Gestionnaires de tickets

Un ticket GitHub ou GitLab par feedback, ouvert, fermé et rouvert avec lui par les hooks de cycle de vie du serveur.

@siteping/integration-issues construit les hooks de cycle de vie de @siteping/server pour que chaque feedback ait son propre ticket sur GitHub ou GitLab :

  • créé → un ticket s'ouvre, avec le label siteping, le message, la page, un lien direct, les éléments annotés, l'environnement du relecteur et les diagnostics ;
  • résolu ou ne sera pas corrigé → le ticket se ferme ; rouvert → il se rouvre ;
  • supprimé → le ticket se ferme avec un commentaire. Si le gestionnaire est injoignable, la suppression est refusée et peut être rejouée.

Aucune colonne n'est ajoutée à la base : la première ligne de chaque ticket est un marqueur caché qui nomme son feedback.

npm i @siteping/integration-issues

Il tourne partout où tourne le serveur (API Fetch uniquement) et prend @siteping/server en dépendance pair ; @siteping/adapter-prisma l'apporte déjà, et transmet hooks comme toutes les autres options du serveur.

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",
  }),
});

Le jeton est un jeton d'accès personnel à granularité fine limité à ce dépôt, avec la permission Issues en Read and write. Son compte doit aussi avoir un accès en écriture au dépôt : GitHub retire sans rien dire les labels d'un nouveau ticket créé par quelqu'un d'autre, et sans son label siteping, SitePing ne retrouve plus le ticket. Dans ce cas, le handler journalise une erreur qui nomme la permission manquante.

Sur GitHub Enterprise Server, passez 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 est l'identifiant numérique ou le chemin complet (groupe/sous-groupe/projet). Le jeton est un jeton d'accès personnel, de projet ou de groupe avec la portée api, pour un membre qui a au moins le rôle Reporter : GitLab ignore les labels posés par les membres Guest, et le handler journalise alors une erreur, comme sur GitHub.

Sur une instance auto-hébergée, passez apiBaseUrl: "https://gitlab.acme.com/api/v4".

Correspondance des statuts

FeedbackTicket GitHubTicket GitLab
open, in_progressRouvertRouvert
resolvedFermé comme terminé (completed)Fermé
wont_fixFermé comme non prévu (not planned)Fermé
SuppriméFermé comme non prévu, avec un commentaireFermé, avec un commentaire

Passer de open à in_progress et inversement n'envoie rien : le ticket est déjà ouvert. Avec syncStatus: false, les tickets sont seulement créés, puis fermés à la suppression.

La synchronisation va dans un seul sens, de SitePing vers le gestionnaire : fermer le ticket sur GitHub ne résout pas le feedback.

Ce que contient un ticket

Le titre est [SitePing] suivi du message sur une ligne, tronqué à 255 caractères. Le corps a ces sections :

  • Message
  • Type
  • Page, résolue par rapport à siteUrl
  • Open in the page : un lien qui ouvre la page avec le feedback mis en avant, une fois l'option deepLink du widget activée
  • Annotations : élément, sélecteur CSS et texte de 10 annotations au plus
  • Author, Viewport, User agent
  • Screenshot, quand la capture a une URL https
  • Console diagnostics et Network diagnostics : les 5 premières entrées de chaque, quand le widget les a capturées

Les champs des annotations sont coupés à 300 caractères et les entrées de diagnostic à 500. Si le corps dépassait encore 60 000 caractères, près de la limite de GitHub de 65 536, ce que seul un feedback fabriqué pour cela atteint, les annotations et les diagnostics sont laissés de côté et une note le signale.

Certaines choses restent dehors :

  • L'e-mail du relecteur, sauf avec includeAuthorEmail: true.
  • Les captures stockées en ligne comme URL data:, ce qui arrive sans stockage des captures. Les gestionnaires ne les affichent pas.
  • Les fils de discussion.

Tout le corps vient de visiteurs anonymes, il est donc cité comme du code. Un message peut contenir @octocat, #12, <!--, un lien Markdown ou une image : rien de tout cela ne notifie quelqu'un, ne pointe vers un autre ticket, ne masque du texte ni ne charge quoi que ce soit. Dans le titre, une espace sans chasse suit chaque signe de référence (@, # et GH-, plus !, &, ~, % et $ sur GitLab) et coupe chaque ://, car GitLab transforme aussi en référence une URL qui pointe vers un ticket ou une merge request. Un SHA de commit seul est laissé tel quel : GitLab le lie toujours s'il désigne un commit du projet lui-même.

Dépôts publics. Un ticket dans un dépôt public est public : le message, la page, le user agent et les diagnostics de chaque feedback deviennent lisibles par tous. Préférez un dépôt privé, et utilisez redact pour les secrets qui finissent dans des URL ou des messages de console.

Options

createIssueTrackerHooks prend :

OptionTypeDéfautCe qu'elle fait
trackerIssueTracker—Obligatoire. createGitHubTracker, createGitLabTracker ou le vôtre
siteUrlstring—Origine du site en recette. Par défaut, le widget enregistre location.pathname comme URL de page : sans siteUrl, les tickets affichent un chemin nu et n'ont pas de lien direct
deepLinkParamstring | false"siteping"Doit correspondre au param de l'option deepLink du widget. false retire le lien
labelsstring[][]Labels en plus. siteping est toujours ajouté
redact(text) => stringaucunAppliqué au message, à l'auteur, aux URL, au user agent, au texte des annotations et aux diagnostics avant qu'ils quittent votre serveur
includeAuthorEmailbooleanfalseAjoute l'e-mail du relecteur à côté de son nom
formatIssue(feedback, defaults) => { title, body }intégréVotre propre titre et votre propre Markdown. Le marqueur reste ajouté en première ligne. Citer le texte des visiteurs est alors à votre charge
syncStatusbooleantrueFerme et rouvre le ticket quand le statut change
deletedCommentText(feedbackId) => stringSitePing feedback `<id>` was deleted.Le commentaire laissé sur le ticket d'un feedback supprimé

createGitHubTracker et createGitLabTracker prennent aussi apiBaseUrl, fetch (pour un proxy ou des tests), timeoutMs (par requête, 5000 par défaut) et maxListedPages (10 par défaut, voir comment les tickets sont retrouvés).

createIssueTrackerHooks renvoie des hooks ordinaires, auxquels vous pouvez ajouter les vôtres :

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

Latence et échecs

  • À la création. onCreated est attendu avant que le POST réponde : le widget attend donc une requête au gestionnaire, au plus timeoutMs (5 secondes par défaut). Si elle échoue (gestionnaire en panne, mauvais jeton, label retiré), le feedback est enregistré quand même et l'erreur part dans le logger du serveur sous la forme [siteping] Hook onCreated failed. Rien ne la rejoue : ce feedback n'a pas de ticket.
  • Au changement de statut. Un échec de synchronisation est journalisé de la même façon, et le nouveau statut est conservé.
  • À la suppression. onDeleting ferme le ticket avant que l'enregistrement parte. Si le gestionnaire échoue, le DELETE répond 502 et garde l'enregistrement : rejouez-le quand le gestionnaire est revenu. Le commentaire n'est laissé qu'une fois, même d'une tentative à l'autre.
  • À la suppression d'un projet entier. Les tickets du projet sont traités l'un après l'autre, avec jusqu'à trois requêtes chacun : fermer le ticket s'il est ouvert, lire ses commentaires, commenter. Un projet qui a beaucoup de tickets peut donc atteindre la limite de débit du gestionnaire (GitHub accepte environ 80 requêtes de création de contenu par minute) ou la durée maximale d'une fonction serverless. Tous les enregistrements sont alors gardés : le DELETE répond 502 quand le gestionnaire refuse une requête, ou par l'erreur de délai de la plateforme (504 sur Vercel, par exemple) quand la fonction est arrêtée. Après une limite de débit, rejouez-le quelques minutes plus tard : un ticket déjà fermé et commenté ne coûte plus qu'une lecture, donc chaque tentative va plus loin. Une limite de durée, elle, ne se contourne pas ainsi : chaque tentative relit les commentaires des tickets déjà traités, elle ne traite donc jamais plus de tickets qu'une exécution n'en laisse le temps, et rejouer la suppression d'un projet plus gros n'aboutit jamais. Supprimez plutôt ses feedbacks un par un, ou faites passer cette suppression par un handler sans ces hooks et fermez ses tickets vous-même.

Les gestionnaires intégrés lèvent deux erreurs qu'un logger personnalisé peut distinguer : IssueTrackerRequestError pour une requête au gestionnaire qui a échoué (elle porte la méthode, le chemin et le statut, jamais le jeton) et UnlabelledIssueError pour un ticket créé sans son label siteping. Reconnaissez-les avec isIssueTrackerRequestError(error) et isUnlabelledIssueError(error), exportées à côté d'elles, plutôt qu'avec instanceof : en CommonJS, chaque point d'entrée embarque sa propre copie des classes d'erreur.

Comment les tickets sont retrouvés

Chaque corps commence par le marqueur <!-- siteping-feedback {"id":"…","project":"…"} -->, que GitHub et GitLab n'affichent pas. Seule cette première ligne est lue, donc un texte de feedback qui l'imite est ignoré. Modifiez le reste du ticket comme vous voulez, mais gardez cette ligne en tête.

Pour synchroniser un statut ou supprimer un feedback, le gestionnaire est d'abord interrogé par une recherche sur l'identifiant du feedback (l'API de recherche de GitHub, le paramètre search de GitLab). Si la recherche ne trouve rien, les hooks listent les tickets siteping du plus récent au plus ancien. L'index de recherche de GitHub a quelques secondes de retard sur un nouveau ticket, et sa limite est de 30 recherches par minute. La suppression d'un projet entier passe toujours par cette liste.

La liste s'arrête après maxListedPages pages de 100 tickets. Avec la valeur par défaut de 10, le repli ne peut pas atteindre un ticket plus ancien que les 1 000 tickets SitePing les plus récents du dépôt. Augmentez-la pour un historique plus grand.

Seuls les tickets avec le label comptent. Sur GitHub et GitLab, une personne extérieure ne peut pas poser de label sur un ticket, et ne peut donc pas en faire passer un pour un ticket de SitePing.

Autres gestionnaires

Implémentez IssueTracker pour Jira, Linear ou un outil interne. Les hooks gardent tout ce qui ne dépend pas du gestionnaire : le format, le marqueur, et des commentaires de suppression qui ne se répètent pas.

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

const tracker: IssueTracker = {
  name: "Linear",
  // Stockez `body` tel quel : sa première ligne est le marqueur.
  async createIssue({ title, body, labels }) {
    return { key: "ENG-42", url: "https://linear.app/acme/issue/ENG-42" };
  },
  // resolved / wont_fix ferment le ticket ; open / in_progress le rouvrent.
  async updateIssueStatus(reference, status) {},
  async addComment(reference, body) {},
  async listComments(reference) {
    return []; // le texte des commentaires
  },
  // Chaque ticket SitePing dont le corps contient `marker`, fermés compris.
  async findSitepingIssues(marker) {
    return []; // { reference, body, isOpen }
  },
  // Facultatif : une recherche côté gestionnaire par identifiant de feedback, essayée d'abord.
  async searchSitepingIssues(feedbackId) {
    return [];
  },
};

Levez une exception quand un appel échoue, et les règles d'échec ci-dessus s'appliquent. Les gestionnaires qui ne parlent pas Markdown peuvent façonner le corps avec formatIssue.

Synchroniser dans l'autre sens, pour que fermer un ticket résolve son feedback, demande un handler de webhooks du gestionnaire et n'existe pas encore (#318).

Modifier sur GitHub

Sur cette page