@cesar-richard/git-connector-sdk
v2.6.0
Published
TypeScript SDK for the git-connector v1 API (work items + iterations aggregated from GitHub/GitLab). Version published on npm tracks server releases.
Readme
@cesar-richard/git-connector-sdk
TypeScript SDK for git-connector — a server that aggregates GitHub + GitLab activity (PRs, MRs, issues, commits, comments, reviews, CI runs) and exposes a unified, ticket-correlated read API.
This SDK is a thin, fully-typed wrapper around the server's /v1/* HTTP API, generated from its OpenAPI spec via openapi-fetch.
Version policy: the SDK version always tracks the git-connector server release it was generated against. Pinning the same major+minor as your server is recommended.
Install
npm install @cesar-richard/git-connector-sdk
# or
pnpm add @cesar-richard/git-connector-sdk
# or
bun add @cesar-richard/git-connector-sdkQuick start
import { createGitConnectorClient } from "@cesar-richard/git-connector-sdk";
const client = createGitConnectorClient({
baseUrl: "https://git-connector.example.com",
token: process.env.GIT_CONNECTOR_TOKEN!, // gck_…
});
const { data, error } = await client.GET("/v1/work-items", {
params: { query: { state: "open", iteration: "current" } },
});
if (error) throw new Error(`git-connector error: ${JSON.stringify(error)}`);
for (const item of data.items) {
console.log(`${item.source}/${item.projectKey}#${item.number} — ${item.title}`);
for (const pr of item.linkedActivities) {
console.log(` ↳ ${pr.type.toUpperCase()} ${pr.url} (${pr.state})`);
}
}The client is fully typed: request params, query strings, and response bodies are all derived from the server's OpenAPI schema. Hover any field in your editor to see its shape.
Authentication
The SDK sends Authorization: Bearer <token> on every request. Mint a token from the git-connector control UI or via:
curl -X POST https://git-connector.example.com/admin/api-keys \
-H "Content-Type: application/json" \
-d '{"name":"my-integration"}'
# → { "token": "gck_…", "id": 12, "name": "my-integration", "createdAt": "…" }Cookie auth (same-origin web UIs)
When you're calling from the same origin as the git-connector deployment (e.g. its own SPA), you can omit token entirely — the server accepts the gck_session httpOnly cookie minted by the OIDC /auth/callback flow:
const client = createGitConnectorClient({
baseUrl: window.location.origin,
// Custom fetch so the browser attaches the gck_session cookie.
fetch: (input, init) =>
globalThis.fetch(input, { ...init, credentials: "include" }),
});External SDK consumers (cross-origin scripts, CI integrations) should keep passing a Bearer token.
Tokens with the gck_ prefix are immutable once minted; revoke and reissue via the same endpoint.
Activity tracking / CRA workflow
The /v1/* surface is designed to power activity reports (CRA, billable-time sheets, "what did I do this month"). The primitives below let you slice your data per-user, per-day, per-type.
What did I do today?
const today = new Date().toISOString().slice(0, 10);
const { data } = await client.GET("/v1/events", {
params: { query: { from: today, to: today, user: "cesar" } },
});
for (const e of data.events) {
console.log(`[${e.day}] ${e.type} — ${e.title} (${e.repo})`);
}List my open PRs / MRs
const { data } = await client.GET("/v1/activities", {
params: {
query: { author: "cesar", state: "open,draft", type: "pr,mr" },
},
});
for (const a of data.items) {
console.log(`${a.source}/${a.repo}#${a.number} — ${a.title} (${a.state})`);
}List issues I opened
const { data } = await client.GET("/v1/work-items", {
params: { query: { author: "cesar", state: "open" } },
});Incremental polling (every 5 minutes)
Use ?since=<ISO> to fetch only events newer than your last cursor:
let cursor = "2026-05-18T00:00:00Z";
const { data } = await client.GET("/v1/events", {
params: {
query: { from: "2026-05-01", to: "2026-05-31", since: cursor },
},
});
if (data.events.length > 0) {
cursor = data.events[data.events.length - 1].occurredAt;
}Monthly CRA: count events per day
const { data } = await client.GET("/v1/events", {
params: {
query: {
from: "2026-05-01",
to: "2026-05-31",
user: "cesar",
type: "commit,review,comment,state-transition",
},
},
});
const byDay: Record<string, number> = {};
for (const e of data.events) {
byDay[e.day] = (byDay[e.day] ?? 0) + 1;
}Reliability of the author field
author (on Activity and Event) is the GitHub or GitLab account login (e.g. cesar, never César Richard). If the underlying provider payload doesn't expose a login — rare cases like a git commit by an email not linked to any account — author is null. The git-config commit-author name is never used for author; it lives in meta.commitAuthorName when available, for display purposes only.
Filters like ?author=cesar (on /v1/activities and /v1/work-items) and ?user=cesar (on /v1/events) match the login exactly (case-insensitive). They do NOT fuzzy-match display names. This is a deliberate design choice for auditable activity tracking.
GitLab resolver: commits ingested via polling are resolved server-side by mapping commit.author_email → GL user login via the GitLab Users API. The result is cached (default TTL 90 days). If the email cannot be resolved (anonymous commit, email not associated with any GL user, private email on a self-hosted instance), author stays null and the git-config name lives in meta.commitAuthorName. Webhook-ingested GL commits use the pusher's username directly (no resolver needed).
Structured author access via authorResolved: commit events also carry an authorResolved blob with {login, name, email} (each nullable) — a single typed accessor that aggregates everything we know about the author:
for (const e of data.events) {
if (e.type !== "commit") continue;
const { login, name, email } = e.authorResolved ?? {};
console.log(`${login ?? "anonymous"} (${name ?? "?"} <${email ?? "?"}>)`);
}Available on every commit event regardless of provider or ingestion path. event.author is always equal to event.authorResolved?.login.
User resolution on work-items (assigneesResolved, authorResolved, reviewerResolved)
Beyond commit events, /v1/work-items exposes resolved user identities on
work-item assignees, linked-activity authors, and reviewers. Each is a
ResolvedUser shape:
type ResolvedUser = {
id: string; // "gl:<user_id>" or "gh:<login>" — stable across login renames
login: string;
name: string | null;
email: string | null;
};The id is stable: two references to the same GitLab user retain the same
gl:<user_id> even after the user's username changes (a common pattern in
self-hosted GitLab where matricule-based logins get rewritten to friendly
names later). GitHub usernames don't typically change in a way that affects
this, so id is gh:<login> for GitHub users.
const { data } = await client.GET("/v1/work-items");
for (const wi of data.items) {
for (const a of wi.assigneesResolved ?? []) {
console.log(`${wi.title} assigned to ${a.name ?? a.login} (id=${a.id})`);
}
for (const pr of wi.linkedActivities) {
if (pr.authorResolved) {
console.log(`PR ${pr.url} by ${pr.authorResolved.name ?? pr.authorResolved.login}`);
}
for (const rev of pr.reviews.entries) {
console.log(` review by ${rev.reviewerResolved?.name ?? rev.reviewer}`);
}
}
}Link traceability (linkedActivities[].linkSource)
Each entry in linkedActivities carries an optional linkSource object with the
raw hintSource persisted on the link row and a human-readable kind derived
from it (body-ref, title-ref, pr-comment-ref, issue-comment-ref,
unknown). Consumers can use this to surface WHY a link was created — useful
for debugging missing or unexpected links in the Explorer UI.
for (const pr of wi.linkedActivities) {
if (pr.linkSource) {
console.log(` ↳ ${pr.url} linked via ${pr.linkSource.kind} (${pr.linkSource.hintSource})`);
}
}GitLab resolution uses a 90-day cache (configurable via GITLAB_USER_CACHE_TTL_DAYS).
Issue ↔ PR/MR link detection patterns
WorkItem.linkedActivities is populated by scanning PR/MR bodies, commit
messages, notes, AND issue comments for references to PR/MR numbers or URLs.
The recognized patterns are:
Canonical (in PR/MR body / commits / notes — forward direction):
Fixes #1234,Fix #1234,Closes #1234,Close #1234,Resolves #1234,Resolve #1234(English short refs).https://github.com/<owner>/<repo>/issues/<n>orhttps://<gitlab>/.../-/issues/<n>(full URLs).<owner>/<repo>#<n>(cross-repo GitHub).<alias>#<n>and<alias>!<n>(project-alias cross-repo).- Short refs
#<n>and!<n>(resolved against the active provider/project).
Issue-comment reverse direction (hint_source="issue_comment"):
The above PR/MR URLs and short refs are all recognized in issue comments as evidence that the cited PR/MR is linked to the issue.
Additionally, the following explicit phrase prefixes are recognized, case-insensitively, to catch informal "this issue was delivered" comments:
| Phrase | Example | Behavior |
|---|---|---|
| Fixed by …, Fixed in … | Fixed by 1214, Fixed by https://…/pull/1214 | Treat the cited ref as a linked PR/MR |
| Fixes by …, Fix by … | Fix by #99 | Same |
| Resolved by …, Resolves by …, Resolve by … | Resolved by 42 | Same |
| Closed by …, Closes by …, Close by … | Closed by !99 | Same |
| FR: Corrigé par …, Corrigée par …, Corrige par … | Corrigé par 42 | Same |
| FR: Résolu par …, Resolu par … | Résolu par !99 | Same |
| FR: Fermé par …, Ferme par … | Fermé par #1214 | Same |
| FR: … dans <n> variant | Corrigé dans 42 | Same |
The phrase prefix lets the SDK detect refs even when the number is bare
(no # or ! prefix), e.g. Fixed by 1214 instead of Fixed by #1214.
Code blocks (fenced ``` and inline `) are stripped before scanning.
Correlation confidence & billing flags (attributable, resolves)
Every linkedActivities[] entry carries billing-grade correlation metadata so a
client app can decide, auditably, what to impute and how to display issue
status. All fields are optional (only present once an entity has been
(re)synced under scoring algo corr-2026.06).
| field | type | meaning |
|---|---|---|
| linkKind | "reference" \| "resolution" \| "reverse_comment" | how the link reads: a mention, an explicit closure, or a link discovered from the issue's own comments |
| confidence | number \| null | [0,1]. Driven by the form of the evidence: canonical URL 0.95 > cross-ref (group/repo#N) 0.85 > bare short ref 0.78; −0.13 in a title, +0.07 for a resolution verb; ambiguous heuristic-resolved refs capped at 0.60 |
| status | "confirmed" \| "candidate" | confirmed when confidence ≥ 0.75 and unambiguous (or confirmed via bidirectional evidence) |
| attributable | boolean | the flag to use for imputation / billing. true ⇔ status==='confirmed', any linkKind. A PR that merely references an issue (preparatory work) — or is only linked from the issue's comments — still counts as contribution |
| resolves | boolean | true ⇔ confirmed && linkKind==='resolution'. Use for issue status, never for billing |
| evidence | { field, text, matchStart, matchEnd } \| null | the matched snippet + offsets, for human audit |
Migration note (was
billable): the short-livedbillablefield (SDK1.57.0) was removed. It conflated two questions. Replace it as follows:
- imputation / "who worked on this issue" → use
attributable(NOT the oldbillable, which only fired on closures and missed preparatory work and reverse-linked PRs);- "what closed the issue" / status → use
resolves(this is what the oldbillableactually computed).
const { data } = await client.GET("/v1/work-items", {
params: { query: { state: "open" } },
});
for (const wi of data.items) {
// Everyone who contributed to this issue (for a CRA / billing sheet):
const contributors = new Set(
wi.linkedActivities
.filter((pr) => pr.attributable)
.map((pr) => pr.authorResolved?.login ?? pr.author)
.filter(Boolean),
);
// Is the issue actually resolved by a merged PR?
const resolved = wi.linkedActivities.some((pr) => pr.resolves && pr.isMerged);
console.log(`${wi.title}: worked on by [${[...contributors].join(", ")}]`, { resolved });
}Two PRs in different repos (e.g. a GitHub PR for terraform groundwork + a GitLab
MR for the feature) that both reference the same issue both show up as
attributable linked activities on that one work item — that's how cross-repo,
cross-provider work is consolidated: the issue is the pivot.
PR review status (reviewStatus)
Each LinkedActivity carries an optional reviewStatus field with 8 possible
values, computed server-side from the PR's state, draft flag, requested
reviewers, and review history:
| Value | Meaning |
|---|---|
| draft | PR is a draft — not yet ready for review |
| open-no-review | Open, no reviewer requested |
| open-awaiting-my-review | Open, you (the viewer) are a requested reviewer |
| open-awaiting-other-review | Open, waiting on someone else's review |
| open-changes-requested | Open, at least one reviewer requested changes (and that verdict hasn't been superseded by an approval) |
| open-approved | Open and approved, no outstanding change requests |
| merged | Merged |
| closed | Closed without merging |
Pass ?viewer=<login> (case-insensitive) on GET /v1/work-items or
GET /v1/work-items/{source}/{projectKey}/{number} to drive the
open-awaiting-my-review vs open-awaiting-other-review split. Without
viewer, awaiting-review PRs always show as open-awaiting-other-review.
const { data } = await client.GET("/v1/work-items", {
params: { query: { state: "open", viewer: "cesar" } },
});
for (const wi of data.items) {
for (const pr of wi.linkedActivities) {
if (pr.reviewStatus === "open-awaiting-my-review") {
console.log(`Needs your review: ${pr.url}`);
}
}
}API surface
The /v1/* API exposes these resources: work items, iterations, work-item details, the unified events stream, raw activities, deliverables, and per-project delivery rules.
GET /v1/work-items — list aggregated work items
Issues from GitHub + GitLab, with linked PRs/MRs resolved server-side via ticket-reference correlation. Filter by state, iteration (id or "current"), assignee, label, source.
const { data } = await client.GET("/v1/work-items", {
params: {
query: {
state: "open",
iteration: "current",
assignee: "alice",
},
},
});
// data: { items: WorkItem[]; total: number; limit: number }Each WorkItem includes its linkedActivities (PRs/MRs with full enrichment: reviews summary, unresolved threads, volume, status history, reviewRequestedAt, mergedAt).
GET /v1/work-items/{source}/{projectKey}/{number} — detail
const { data } = await client.GET(
"/v1/work-items/{source}/{projectKey}/{number}",
{ params: { path: { source: "gitlab", projectKey: "acme/ops", number: 42 } } },
);GET /v1/iterations — list iterations
const { data } = await client.GET("/v1/iterations");
// data: IterationWithCount[] — each carries workItemCount across all linked WorkItemsGET /v1/events — unified activity stream
Daily-bucketed (Paris-TZ) chronological event stream. Mixes commits, comments, reviews, state-transitions, and CI runs across both providers. Cursor-paginated.
const { data } = await client.GET("/v1/events", {
params: {
query: {
from: "2026-05-01",
to: "2026-05-17",
type: "ci-run,review", // CSV
user: "alice",
limit: "200",
},
},
});
// data: { events: GitEvent[]; total: number; nextCursor: string | null }
// Paginate:
if (data.nextCursor) {
const { data: next } = await client.GET("/v1/events", {
params: { query: { from: "2026-05-01", to: "2026-05-17", cursor: data.nextCursor } },
});
}Event types in the stream:
| type | Source | parentActivityId | Notable meta keys |
|--------|--------|--------------------|---------------------|
| commit | Default branch | null | fullMessage |
| comment | Issue/PR/MR notes | resolves to PR/MR/issue | body |
| review | GH PR review / GL MR approval | resolves to PR/MR | state, reviewer |
| state-transition | Derived from activity state diff | resolves to PR/MR/issue | from, to |
| ci-run | GH Actions job / GL pipeline job | resolves to PR/MR via head SHA | runId, jobName, conclusion, durationSec, sha, branch |
GET /v1/deliverables — classified deliverables of a window
Issues classified as delivered, in_progress, or not_delivered over a
time window, driven by a per-project label convention (see delivery-rules below).
not_delivered issues are always dropped from the response.
Query params:
| param | required | meaning |
|-------|----------|---------|
| from | yes | Window start. ISO date (YYYY-MM-DD) or datetime. |
| to | yes | Window end. ISO date (inclusive end-of-day) or datetime. |
| projectKey | no | CSV of project keys to restrict to. |
| author | no | CSV of author login aliases (case-insensitive). When set, only issues attributable to one of the aliases are returned and attribution is populated. When absent, every window deliverable is returned with attribution: null. |
| source | no | github or gitlab. |
Each DeliverableIssue carries deliveryState, deliveredAt,
deliveryReason (a typed { kind, ruleLabel, transitionAt, transitionBy }
explaining WHY the classification fired — read deliveryReason.ruleLabel
to get the scoped label that triggered it),
attribution ({ isAssignee, hasOwnCommit, isMergedPRAuthor, result } with
result precedence assigned > committer > pr_author > none), linkedActivities,
and an evidence blob (deliveryTransitions, consideredCommits, consideredPRs)
for human audit.
// Monthly CRA: who delivered what this month, attributed to me.
const { data } = await client.GET("/v1/deliverables", {
params: {
query: {
from: "2026-05-01",
to: "2026-05-31",
projectKey: "id/cohort360/gestion-de-projet",
author: "cesar-richard,crichard",
},
},
});
// data.issues: DeliverableIssue[] — deliveryState, deliveredAt, attribution, evidence
for (const issue of data.issues) {
console.log(
`${issue.projectKey}#${issue.number} — ${issue.deliveryState} ` +
`(${issue.attribution?.result ?? "n/a"}) @ ${issue.deliveredAt ?? "?"}`,
);
}When author is omitted you get the full set of window deliverables with
attribution: null — useful for a team-wide "what shipped this month" view.
GET / PATCH /v1/projects/{source}/{projectKey}/delivery-rules — label convention
The classification above is configurable per project. deliveryLabels are the
scoped labels whose "added" transition marks an issue delivered (e.g. the
GitLab scoped label =::Pushed en dev); openedLabels mark in_progress. When
no rule row exists (or deliveryLabels is empty) the endpoint falls back to a
native closed-state heuristic.
// Read the current convention (empty defaults when unset).
const { data: rules } = await client.GET(
"/v1/projects/{source}/{projectKey}/delivery-rules",
{ params: { path: { source: "gitlab", projectKey: "acme/ops" } } },
);
// { source, projectKey, deliveryLabels, openedLabels, updatedAt }
// PATCH is partial — omitted keys keep their stored value.
await client.PATCH("/v1/projects/{source}/{projectKey}/delivery-rules", {
params: { path: { source: "gitlab", projectKey: "acme/ops" } },
body: { deliveryLabels: ["=::Pushed en dev"], openedLabels: ["=::En cours"] },
});projectKey segments containing / must be URL-encoded (e.g. acme%2Fops).
Schema as a separate import
If you need the raw OpenAPI types without the client (e.g. for codegen or for non-openapi-fetch consumers), import directly:
import type { components } from "@cesar-richard/git-connector-sdk/schema";
type WorkItem = components["schemas"]["WorkItem"];
type GitEvent = components["schemas"]["GitEvent"];The full OpenAPI document is also published in the package — see openapi.json for the contract.
Error handling
openapi-fetch never throws on HTTP-level errors — it returns { data, error, response }. Always check error before using data:
const { data, error, response } = await client.GET("/v1/work-items");
if (error) {
if (response.status === 401) throw new Error("Missing/invalid gck_ token");
if (response.status === 403) throw new Error("Token revoked");
throw new Error(`git-connector ${response.status}: ${JSON.stringify(error)}`);
}
// data is non-null and fully typed from hereNetwork errors (DNS, timeout, connection refused) reject the promise as usual.
Custom fetch
Pass your own fetch implementation to wire up retries, telemetry, or a non-global fetch (e.g. inside Cloudflare Workers):
import { fetch as undiciFetch } from "undici";
const client = createGitConnectorClient({
baseUrl: "https://git-connector.example.com",
token: process.env.GIT_CONNECTOR_TOKEN!,
fetch: undiciFetch,
});Listing the labels of a project
GET /v1/projects/{source}/{projectKey}/labels returns the live label set
of a GitLab project or GitHub repository, including scope computed
server-side for GitLab scoped labels (<scope>::<value>). Cached in-memory
for 15 minutes (configurable via LABELS_CACHE_TTL_MS).
const { data, error } = await client.GET(
"/v1/projects/{source}/{projectKey}/labels",
{ params: { path: { source: "gitlab", projectKey: "group/sub/project" } } },
);
if (error) throw error;
for (const label of data.labels) {
console.log(label.scope ?? "(no scope)", label.name, label.color);
}The endpoint returns 404 when the project does not exist on the upstream
provider, 403 when the configured provider token lacks access, and 502
on upstream unreachable (e.g. expired GitLab NSC cookie).
Compatibility
- Node ≥ 18 (uses global
fetch). - Bun ≥ 1.0.
- Modern browsers (no Node-only APIs).
- ESM only.
User identities
Group multiple (provider, login) aliases under one canonical identity. Filter
any ?author-supporting endpoint by identity instead of login.
// List identities (paginated, with optional search)
const { data } = await client.GET("/v1/identities", {
params: { query: { limit: 50, q: "thomas" } },
});
// Create one with initial aliases
const { data: created } = await client.POST("/v1/identities", {
body: {
displayName: "Thomas",
aliases: [
{ provider: "github", login: "tfi90" },
{ provider: "gitlab", login: "70666666" },
],
},
});
// Attach an alias later — moves it from another identity if needed
await client.POST("/v1/identities/{id}/aliases", {
params: { path: { id: created.id } },
body: { provider: "github", login: "anotherlogin" },
});
// Filter work items / events / activities / deliverables by identity
const { data: workItems } = await client.GET("/v1/work-items", {
params: { query: { identity: String(created.id), withIdentity: "1" } },
});
// Each work item now carries `authorIdentity: { id, displayName } | null`.?identity and ?author are mutually exclusive: passing both returns 400.
?withIdentity=1 opts into the authorIdentity field on each item.
Auto-discovery + enrichment (server-side)
The server runs a nightly cron (3 AM UTC) + a post-sync event hook that:
- Scans
git_events+git_activitiesfor any(provider, login)not yet attached to an identity → creates one identity per login withdisplay_name = login(fallback) anddisplay_name_source = "auto". - Calls the provider's user-API (GitLab
users?username=..., GitHubusers.getByUsername) to enrichdisplay_name,email,avatar_url. - Caches both positive AND negative results (
enriched_atISO timestamp, 7-day TTL). - Never touches identities edited manually (
display_name_source = "manual").
Identities created via POST /v1/identities or whose displayName was set
via PATCH keep display_name_source = "manual" automatically.
Rendering deliverable narratives
/v1/deliverables now ships every field needed to render a rich per-topic
summary without a second API call. Each DeliverableIssue exposes:
- The full topic context:
body,state,assignees+assigneesResolved,iteration,milestone,labels,statusHistory,createdAt,updatedAt. - A typed
deliveryReason: { kind, ruleLabel, transitionAt, transitionBy }digest explaining WHY the classification fired.kindis one ofrule_label_added,closed_in_window,in_progress_opened_label_active,in_progress_delivered_after_window,in_progress_fallback_open. UsedeliveryReason.ruleLabelto display the scoped label that triggered the state (replaces the legacydeliveryLabelfield). - A typed array of linked PR/MRs with stable cross-endpoint ids and the names of requested reviewers:
LinkedActivity {
id: string; // "gitlab:org/proj:mr:42"
source: "github" | "gitlab";
externalId: string; // "42"
number: number; // 42
type: "pr" | "mr";
title: string;
url: string;
state: "open" | "closed" | "merged" | "draft" | "unknown";
isMerged: boolean;
isDraft: boolean;
awaitingReview: boolean;
requestedReviewers: string[]; // logins
requestedReviewersResolved?: ResolvedUser[]; // with name/email
// ...mergedAt, reviews, volume, etc.
}Deduplicating activities
LinkedActivity.id is stable across endpoints — the same id format is
used by Activity.id from /v1/activities. Dedupe by id, never by URL:
const linkedIds = new Set(
deliverables.issues.flatMap(i => i.linkedActivities.map(la => la.id)),
);
const orphans = rawActivities.filter(a => !linkedIds.has(a.id));Rendering a topic narrative
function renderLinkedActivity(la: LinkedActivity): string {
const num = `#${la.number}`;
if (la.isMerged) {
const when = la.mergedAt
? `mergée le ${new Date(la.mergedAt).toLocaleDateString()}`
: "mergée";
return `${num} '${la.title}' ${when}`;
}
if (la.isDraft) return `${num} '${la.title}' en draft`;
if (la.awaitingReview && la.requestedReviewers.length > 0) {
const named = (la.requestedReviewersResolved ?? []).map(
r => r.name ?? r.login,
);
const unnamed = la.requestedReviewers.filter(
l => !(la.requestedReviewersResolved ?? []).some(r => r.login === l),
);
const all = [...named, ...unnamed].slice(0, 3).join(", ");
return `${num} '${la.title}' en attente de review de ${all}`;
}
return `${num} '${la.title}' ${la.state}`;
}
function renderTopic(issue: DeliverableIssue): string {
const num = `#${issue.number}`;
const owner =
issue.assigneesResolved?.[0]?.name ?? issue.assignees[0] ?? "personne";
const iter = issue.iteration?.title ?? "hors itération";
const linked = issue.linkedActivities;
const merged = linked.filter(la => la.isMerged).length;
const open = linked.filter(la => la.state === "open").length;
// Use deliveryReason.ruleLabel + kind to phrase the classification.
const reason = issue.deliveryReason.ruleLabel
? ` (via label '${issue.deliveryReason.ruleLabel}')`
: issue.deliveryReason.kind === "closed_in_window"
? " (fermée dans la fenêtre)"
: "";
const head =
`Le sujet '${issue.title}' (${num}), assigné à ${owner}, ` +
`dans ${iter}, est ${issue.deliveryState}${reason} ` +
`avec ${linked.length} PR liées (${merged} mergées, ${open} en cours)`;
const details = linked.map(renderLinkedActivity).join("\n - ");
return `${head}:\n - ${details}`;
}Sample output:
Le sujet 'Refactor auth' (#42), assigné à Bob Smith, dans Sprint-25, est in_progress (via label '=::En cours') avec 3 PR liées (2 mergées, 1 en cours):
- #18 'Initial refactor' mergée le 2026-05-15
- #21 'Tests' mergée le 2026-05-20
- #24 'Polish' en attente de review de Alice Doe, Carol RoeDeduplicating orphans (CRA "non rattachés")
Each Activity and LinkedActivity exposes linkedIssueRefs: IssueRef[] —
the issues this PR/MR is linked to in either direction:
- forward: the PR/MR mentions the issue (body/notes URL/short-ref/header).
- reverse: an issue's body/notes mentions this PR/MR.
The field is window-independent (derived from git_activity_links),
unlike linkedActivities[].id which is scoped to the issues a given query
returns.
Use it to dedupe a CRA "orphans" section:
const acts = await client.GET("/v1/activities", {
params: { query: { updatedSince: monthStart, updatedBefore: monthEnd } },
});
const orphans = acts.data.items.filter(
(a) => (a.type === "pr" || a.type === "mr") && a.linkedIssueRefs.length === 0,
);
// A PR/MR linked to ANY issue (even one outside the queried window) is NOT
// an orphan. The dashboard can choose to render it under its linked issue
// when the issue is a deliverable of the month, or omit it otherwise.Related
- Server / control UI: git-connector
- OpenAPI spec: ships with this package as
openapi.json— generated server-side, the SDK types derive from it viaopenapi-typescript. - Releases: SDK and server release in lockstep via semantic-release on every merge to
main.
