Issue trackers

One GitHub or GitLab issue per feedback, opened, closed and reopened along with it through the server's lifecycle hooks.

@siteping/integration-issues builds the lifecycle hooks of @siteping/server so that every feedback gets its own issue on GitHub or GitLab:

  • created → an issue opens, labelled siteping, with the message, the page, a deep link, the annotated elements, the reviewer's environment and diagnostics;
  • resolved or won't fix → the issue closes; reopened → it reopens;
  • deleted → the issue closes with a comment. If the tracker is unreachable, the delete is refused and can be retried.

No database column is added: the first line of each issue is a hidden marker naming its feedback.

npm i @siteping/integration-issues

It runs wherever the server runs (Fetch API only) and takes @siteping/server as a peer dependency; @siteping/adapter-prisma already brings it, and passes hooks through like every other server option.

GitHub

// app/api/siteping/route.ts
import { createSitepingHandler } from "@siteping/server";
import { createIssueTrackerHooks } from "@siteping/integration-issues";
import { createGitHubTracker } from "@siteping/integration-issues/github";
import { store } from "@/lib/siteping-store";

export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
  store,
  apiKey: process.env.SITEPING_API_KEY,
  hooks: createIssueTrackerHooks({
    tracker: createGitHubTracker({ repository: "acme/site", token: process.env.GITHUB_TOKEN! }),
    siteUrl: "https://acme.com",
  }),
});

The token is a fine-grained personal access token limited to that repository, with the Issues permission set to Read and write. Its account also needs write access to the repository: GitHub silently drops the labels of a new issue created by anyone else, and without its siteping label SitePing cannot find the issue again. When that happens the handler logs an error that names the missing permission.

On GitHub Enterprise Server, pass apiBaseUrl: "https://github.acme.com/api/v3".

GitLab

import { createGitLabTracker } from "@siteping/integration-issues/gitlab";

createIssueTrackerHooks({
  tracker: createGitLabTracker({ project: "acme/site", token: process.env.GITLAB_TOKEN! }),
  siteUrl: "https://acme.com",
});

project is the numeric id or the full path (group/subgroup/project). The token is a personal, project or group access token with the api scope, for a member with at least the Reporter role: GitLab ignores labels set by Guest members, and the handler then logs an error, as on GitHub.

On a self-managed instance, pass apiBaseUrl: "https://gitlab.acme.com/api/v4".

Status mapping

FeedbackGitHub issueGitLab issue
open, in_progressReopenedReopened
resolvedClosed as completedClosed
wont_fixClosed as not plannedClosed
DeletedClosed as not planned, with a commentClosed, with a comment

Moving between open and in_progress sends nothing: the issue is already open. With syncStatus: false, issues are only created and closed on delete.

The sync runs one way, from SitePing to the tracker: closing the issue on GitHub does not resolve the feedback.

What an issue contains

The title is [SitePing] followed by the message on one line, truncated to 255 characters. The body has these sections:

  • Message
  • Type
  • Page, resolved against siteUrl
  • Open in the page: a link that opens the page with the feedback focused, once the widget's deepLink option is on
  • Annotations: element, CSS selector and text of up to 10 annotations
  • Author, Viewport, User agent
  • Screenshot, when it has an https URL
  • Console diagnostics and Network diagnostics: the first 5 entries of each, when the widget captured them

Annotation fields are cut at 300 characters and diagnostic entries at 500. If the body would still pass 60,000 characters, close to GitHub's limit of 65,536, which only a crafted feedback reaches, the annotations and diagnostics are left out and a note says so.

Some things are left out:

  • The reviewer's email, unless you set includeAuthorEmail: true.
  • Screenshots stored inline as data: URLs, which is what happens without a screenshot storage. Trackers do not render them.
  • Discussion threads.

Everything in the body comes from anonymous visitors, so it is quoted as code. A message can contain @octocat, #12, <!--, a Markdown link or an image, and none of it notifies anyone, links to another issue, hides text or loads anything. In the title, a zero-width space follows each reference sigil (@, # and GH-, plus GitLab's !, &, ~, % and $) and splits each ://, since GitLab also turns a URL pointing at an issue or merge request into a reference. A bare commit SHA is left as typed: GitLab still links it when it names one of the project's own commits.

Public repositories. An issue in a public repository is public: every feedback's message, page, user agent and diagnostics become readable by anyone. Prefer a private repository, and use redact for secrets that end up in URLs or console messages.

Options

createIssueTrackerHooks takes:

OptionTypeDefaultWhat it does
trackerIssueTracker—Required. createGitHubTracker, createGitLabTracker or your own
siteUrlstring—Origin of the site under review. The widget records location.pathname as the page URL by default: without siteUrl, issues show a bare path and have no deep link
deepLinkParamstring | false"siteping"Must match the widget's deepLink param. false leaves the link out
labelsstring[][]Extra labels. siteping is always added
redact(text) => stringnoneApplied to the message, author, URLs, user agent, annotation text and diagnostics before they leave your server
includeAuthorEmailbooleanfalseAdd the reviewer's email next to their name
formatIssue(feedback, defaults) => { title, body }built-inYour own title and Markdown. The marker is still added as the first line. Quoting visitor text is then up to you
syncStatusbooleantrueClose and reopen the issue when the status changes
deletedCommentText(feedbackId) => stringSitePing feedback `<id>` was deleted.The comment left on the issue of a deleted feedback

createGitHubTracker and createGitLabTracker also take apiBaseUrl, fetch (for a proxy or tests), timeoutMs (per request, default 5000) and maxListedPages (default 10, see how issues are found again).

createIssueTrackerHooks returns plain hooks, so you can add your own next to them:

hooks: {
  ...createIssueTrackerHooks({ tracker }),
  onDeleted: (target) => audit.log("deleted", target),
},

Latency and failures

  • Creating. onCreated is awaited before the POST answers, so the widget waits for one tracker request, at most timeoutMs (5 seconds by default). If it fails (the tracker is down, the token is wrong, or the label was dropped), the feedback is stored anyway and the error goes to the server's logger as [siteping] Hook onCreated failed. Nothing retries it: that feedback has no issue.
  • Changing a status. A failed sync is logged the same way, and the new status is kept.
  • Deleting. onDeleting closes the issue before the record goes. If the tracker fails, the DELETE answers 502 and keeps the record: retry it once the tracker is back. The comment is left once, even across retries.
  • Deleting a whole project. The project's issues are handled one after the other, with up to three requests each: close the issue if it is open, read its comments, comment. A project with many issues can therefore hit the tracker's rate limit (GitHub allows about 80 content-creating requests a minute) or a serverless function's time limit. Every record is then kept: the DELETE answers 502 when the tracker refuses a request, or the platform's own timeout error (504 on Vercel, for example) when the function is stopped. After a rate limit, retry a few minutes later: an issue already closed and commented costs a single read, so each attempt gets further. A time limit is different: every attempt reads the comments of the issues already handled again, so it never handles more issues than one run has time for, and retrying a project past that size never succeeds. Delete its feedback one at a time instead, or run that delete through a handler without these hooks and close its issues yourself.

The built-in trackers throw two errors that a custom logger can tell apart: IssueTrackerRequestError for a failed tracker request (it carries the method, path and status, never the token) and UnlabelledIssueError for an issue created without its siteping label. Match them with isIssueTrackerRequestError(error) and isUnlabelledIssueError(error), exported next to them, rather than instanceof: in CommonJS, each entry point bundles its own copy of the error classes.

How issues are found again

Each body starts with the marker <!-- siteping-feedback {"id":"…","project":"…"} -->, which GitHub and GitLab do not display. Only that first line is read, so feedback text that imitates it is ignored. Edit the rest of the issue as you like, but keep that line first.

To sync a status or delete one feedback, the tracker is first searched for the feedback id (GitHub's search API, GitLab's search parameter). If the search misses, the hooks list the siteping issues newest first. GitHub's search index lags a few seconds behind a new issue, and its rate limit is 30 searches a minute. Deleting a whole project always uses that listing.

The listing stops after maxListedPages pages of 100 issues. With the default of 10, the fallback cannot reach an issue older than the repository's 1,000 newest SitePing issues. Raise it for a larger backlog.

Only labelled issues count. On GitHub and GitLab, an outsider cannot label an issue, so they cannot make one pass for SitePing's.

Other trackers

Implement IssueTracker for Jira, Linear or an internal tool. The hooks keep everything that does not depend on the tracker: the format, the marker, and making deletion comments idempotent.

import type { IssueTracker } from "@siteping/integration-issues";

const tracker: IssueTracker = {
  name: "Linear",
  // Store `body` exactly as given: its first line is the marker.
  async createIssue({ title, body, labels }) {
    return { key: "ENG-42", url: "https://linear.app/acme/issue/ENG-42" };
  },
  // resolved / wont_fix close the issue; open / in_progress reopen it.
  async updateIssueStatus(reference, status) {},
  async addComment(reference, body) {},
  async listComments(reference) {
    return []; // comment bodies
  },
  // Every SitePing issue whose body contains `marker`, closed ones included.
  async findSitepingIssues(marker) {
    return []; // { reference, body, isOpen }
  },
  // Optional: a server-side search by feedback id, tried first.
  async searchSitepingIssues(feedbackId) {
    return [];
  },
};

Throw when a call fails, and the failure rules above apply. Trackers that do not speak Markdown can shape the body with formatIssue.

Syncing the other way, so that closing an issue resolves its feedback, needs a tracker webhook handler and is not built yet (#318).

Edit on GitHub

On this page