diff --git a/apps/api/src/analytics.test.ts b/apps/api/src/analytics.test.ts index 8eda139..68dbf80 100644 --- a/apps/api/src/analytics.test.ts +++ b/apps/api/src/analytics.test.ts @@ -67,6 +67,7 @@ describe("buildSprintPulse", () => { assert.equal(result.summary.achievements, 2); assert.equal(result.summary.jira, 1); assert.equal(result.summary.bitbucket, 1); + assert.equal(result.summary.github, 0); assert.equal(result.summary.feedback, 1); assert.equal(result.summary.openConcerns, 1); assert.equal(result.summary.staleCitePeople, 1); diff --git a/apps/api/src/analytics.ts b/apps/api/src/analytics.ts index 707d3fa..0430ff1 100644 --- a/apps/api/src/analytics.ts +++ b/apps/api/src/analytics.ts @@ -68,6 +68,7 @@ export function buildSprintPulse(args: { let achievements = 0; let jira = 0; let bitbucket = 0; + let github = 0; let feedback = 0; let documents = 0; let openConcerns = 0; @@ -79,6 +80,7 @@ export function buildSprintPulse(args: { const docIn = p.documents.filter((d) => inRange(d.occurredAt, args.from, args.to)); const jiraN = achIn.filter((a) => a.sourceProvider === "jira").length; const bbN = achIn.filter((a) => a.sourceProvider === "bitbucket").length; + const ghN = achIn.filter((a) => a.sourceProvider === "github").length; const themes: string[] = []; for (const f of fbIn) { for (const t of f.themes) { @@ -109,6 +111,7 @@ export function buildSprintPulse(args: { achievements += achIn.length; jira += jiraN; bitbucket += bbN; + github += ghN; feedback += fbIn.length; documents += docIn.length; openConcerns += p.openConcernsInWindow; @@ -121,6 +124,7 @@ export function buildSprintPulse(args: { achievements: achIn.length, jira: jiraN, bitbucket: bbN, + github: ghN, feedback: fbIn.length, documents: docIn.length, openConcerns: p.openConcernsInWindow, @@ -148,6 +152,7 @@ export function buildSprintPulse(args: { achievements, jira, bitbucket, + github, feedback, documents, openConcerns, diff --git a/apps/api/src/analyticsGather.ts b/apps/api/src/analyticsGather.ts index db5b807..5199259 100644 --- a/apps/api/src/analyticsGather.ts +++ b/apps/api/src/analyticsGather.ts @@ -185,7 +185,7 @@ export function loadPersonTimeline(db: Db, personId: string): PersonTimelineDTO const provider = a.sourceProvider ? ` · ${a.sourceProvider}` : ""; entries.push({ at: a.occurredAt, - kind: a.sourceProvider === "jira" || a.sourceProvider === "bitbucket" ? a.sourceProvider : "achievement", + kind: a.sourceProvider === "jira" || a.sourceProvider === "bitbucket" || a.sourceProvider === "github" ? a.sourceProvider : "achievement", title: a.title + provider, detail: a.description, sourceId: a.id, diff --git a/apps/api/src/bitbucket.ts b/apps/api/src/bitbucket.ts index c6fac80..7d1aa80 100644 --- a/apps/api/src/bitbucket.ts +++ b/apps/api/src/bitbucket.ts @@ -27,6 +27,18 @@ export type BitbucketPullRequest = { url: string; commentCount: number; taskCount: number; + /** Lines added (when diffstat available). */ + linesAdded: number | null; + linesRemoved: number | null; + filesChanged: number | null; + /** Coarse size band — not a performance score. */ + sizeBand: "XS" | "S" | "M" | "L" | "XL" | null; + daysToMerge: number | null; + looksLikeRevert: boolean; + reviewParticipation: { + approvals: number; + reviewers: number; + } | null; }; function authHeader(cfg: BitbucketConfig) { @@ -122,10 +134,90 @@ async function listWorkspaceRepos(cfg: BitbucketConfig, maxRepos = 30): Promise< return repos; } +function sizeBand(files: number | null, churn: number | null): BitbucketPullRequest["sizeBand"] { + const f = files ?? 0; + const c = churn ?? 0; + if (f <= 2 && c <= 40) return "XS"; + if (f <= 8 && c <= 200) return "S"; + if (f <= 20 && c <= 600) return "M"; + if (f <= 40 && c <= 1500) return "L"; + if (f > 0 || c > 0) return "XL"; + return null; +} + +async function enrichPrQuality( + cfg: BitbucketConfig, + repo: { fullName: string; slug: string }, + pr: BitbucketPullRequest, +): Promise { + let linesAdded: number | null = null; + let linesRemoved: number | null = null; + let filesChanged: number | null = null; + let reviewParticipation: BitbucketPullRequest["reviewParticipation"] = null; + try { + const diff = await bbFetch<{ + values?: Array<{ lines_added?: number; lines_removed?: number }>; + size?: number; + }>( + cfg, + `/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests/${pr.id}/diffstat?pagelen=100`, + ); + let add = 0; + let rem = 0; + let files = 0; + for (const row of diff.values ?? []) { + files += 1; + add += row.lines_added ?? 0; + rem += row.lines_removed ?? 0; + } + linesAdded = add; + linesRemoved = rem; + filesChanged = files || diff.size || null; + } catch { + /* optional */ + } + try { + const detail = await bbFetch<{ + participants?: Array<{ role?: string; approved?: boolean }>; + }>( + cfg, + `/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests/${pr.id}`, + ); + const reviewers = (detail.participants ?? []).filter((p) => p.role === "REVIEWER"); + reviewParticipation = { + reviewers: reviewers.length, + approvals: reviewers.filter((p) => p.approved).length, + }; + } catch { + /* optional */ + } + + let daysToMerge: number | null = null; + if (pr.createdOn && pr.mergedOn) { + const a = Date.parse(pr.createdOn); + const b = Date.parse(pr.mergedOn); + if (!Number.isNaN(a) && !Number.isNaN(b) && b >= a) { + daysToMerge = Math.round((b - a) / (1000 * 60 * 60 * 24)); + } + } + const looksLikeRevert = /\brevert\b/i.test(pr.title); + + return { + ...pr, + linesAdded, + linesRemoved, + filesChanged, + sizeBand: sizeBand(filesChanged, (linesAdded ?? 0) + (linesRemoved ?? 0)), + daysToMerge, + looksLikeRevert, + reviewParticipation, + }; +} + export async function bitbucketMergedPrsForAuthor( cfg: BitbucketConfig, authorUuid: string, - opts: { maxRepos?: number; maxPrs?: number } = {}, + opts: { maxRepos?: number; maxPrs?: number; windowStart?: string; windowEnd?: string } = {}, ): Promise { const maxRepos = opts.maxRepos ?? 25; const maxPrs = opts.maxPrs ?? 50; @@ -155,7 +247,12 @@ export async function bitbucketMergedPrsForAuthor( `/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests?q=${encodeURIComponent(q)}&pagelen=50`, ); for (const pr of data.values ?? []) { - results.push({ + const mergedOn = pr.merged_on + ? pr.merged_on.slice(0, 10) + : (pr.updated_on ?? "").slice(0, 10) || null; + if (opts.windowStart && mergedOn && mergedOn < opts.windowStart) continue; + if (opts.windowEnd && mergedOn && mergedOn > opts.windowEnd) continue; + let row: BitbucketPullRequest = { id: pr.id, title: pr.title ?? `PR #${pr.id}`, state: pr.state ?? "MERGED", @@ -164,11 +261,20 @@ export async function bitbucketMergedPrsForAuthor( authorDisplay: pr.author?.display_name ?? null, createdOn: (pr.created_on ?? "").slice(0, 10), updatedOn: (pr.updated_on ?? "").slice(0, 10), - mergedOn: pr.merged_on ? pr.merged_on.slice(0, 10) : (pr.updated_on ?? "").slice(0, 10) || null, + mergedOn, url: pr.links?.html?.href ?? `https://bitbucket.org/${repo.fullName}/pull-requests/${pr.id}`, commentCount: pr.comment_count ?? 0, taskCount: pr.task_count ?? 0, - }); + linesAdded: null, + linesRemoved: null, + filesChanged: null, + sizeBand: null, + daysToMerge: null, + looksLikeRevert: /\brevert\b/i.test(pr.title ?? ""), + reviewParticipation: null, + }; + row = await enrichPrQuality(cfg, repo, row); + results.push(row); if (results.length >= maxPrs) break; } } catch { @@ -179,3 +285,49 @@ export async function bitbucketMergedPrsForAuthor( results.sort((a, b) => (b.mergedOn ?? "").localeCompare(a.mergedOn ?? "")); return results; } + +/** PRs where this user participated as a reviewer (gave reviews). */ +export async function bitbucketReviewsGiven( + cfg: BitbucketConfig, + reviewerUuid: string, + opts: { maxRepos?: number; maxPrs?: number; windowStart?: string; windowEnd?: string } = {}, +): Promise> { + const maxRepos = opts.maxRepos ?? 15; + const maxPrs = opts.maxPrs ?? 30; + const repos = await listWorkspaceRepos(cfg, maxRepos); + const uuid = reviewerUuid.includes("{") ? reviewerUuid : `{${reviewerUuid}}`; + const out: Array<{ id: number; title: string; repoFullName: string; url: string; updatedOn: string }> = []; + for (const repo of repos) { + if (out.length >= maxPrs) break; + const q = `reviewers.uuid="${uuid}" AND state="MERGED"`; + try { + const data = await bbFetch<{ + values?: Array<{ + id: number; + title?: string; + updated_on?: string; + links?: { html?: { href?: string } }; + }>; + }>( + cfg, + `/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests?q=${encodeURIComponent(q)}&pagelen=30`, + ); + for (const pr of data.values ?? []) { + const updatedOn = (pr.updated_on ?? "").slice(0, 10); + if (opts.windowStart && updatedOn && updatedOn < opts.windowStart) continue; + if (opts.windowEnd && updatedOn && updatedOn > opts.windowEnd) continue; + out.push({ + id: pr.id, + title: pr.title ?? `PR #${pr.id}`, + repoFullName: repo.fullName, + url: pr.links?.html?.href ?? `https://bitbucket.org/${repo.fullName}/pull-requests/${pr.id}`, + updatedOn, + }); + if (out.length >= maxPrs) break; + } + } catch { + /* skip */ + } + } + return out; +} diff --git a/apps/api/src/deliveryRollup.test.ts b/apps/api/src/deliveryRollup.test.ts new file mode 100644 index 0000000..b094283 --- /dev/null +++ b/apps/api/src/deliveryRollup.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildDeliveryRollup } from "./deliveryRollup.js"; + +describe("buildDeliveryRollup", () => { + it("aggregates done issues, PRs, cites, and thin-evidence flags", () => { + const rollup = buildDeliveryRollup({ + personId: "p1", + cycleId: "c1", + windowStart: "2026-01-01", + windowEnd: "2026-06-30", + dossierNonIntegrationCount: 0, + achievements: [ + { + id: "ach_1", + title: "[Jira ENG-1] Ship it", + description: null, + occurredAt: "2026-03-01", + sourceProvider: "jira", + externalId: "ENG-1", + externalUrl: "https://x/browse/ENG-1", + externalPayloadJson: JSON.stringify({ + isDone: true, + isInProgress: false, + isBug: false, + storyPoints: 5, + labels: ["reliability"], + epicName: "Platform", + }), + }, + { + id: "ach_2", + title: "[Jira ENG-2] WIP", + description: null, + occurredAt: "2026-03-10", + sourceProvider: "jira", + externalId: "ENG-2", + externalUrl: null, + externalPayloadJson: JSON.stringify({ + isDone: false, + isInProgress: true, + isBug: true, + }), + }, + { + id: "ach_3", + title: "[BB PR #9] Fix", + description: null, + occurredAt: "2026-04-01", + sourceProvider: "bitbucket", + externalId: "acme/repo#9", + externalUrl: null, + externalPayloadJson: JSON.stringify({ sizeBand: "S", looksLikeRevert: false }), + }, + { + id: "ach_4", + title: "[GH review] Other", + description: null, + occurredAt: "2026-04-02", + sourceProvider: "github", + externalId: "review:org/r#1", + externalUrl: null, + externalPayloadJson: JSON.stringify({ kind: "review_given" }), + }, + { + id: "ach_old", + title: "Old", + description: null, + occurredAt: "2025-01-01", + sourceProvider: "jira", + externalId: "ENG-0", + externalUrl: null, + externalPayloadJson: JSON.stringify({ isDone: true }), + }, + ], + }); + + assert.equal(rollup.issuesDone, 1); + assert.equal(rollup.issuesInProgress, 1); + assert.equal(rollup.openBugs, 1); + assert.equal(rollup.storyPointsDone, 5); + assert.equal(rollup.prsMerged, 1); + assert.equal(rollup.reviewsGiven, 1); + assert.equal(rollup.sizeBands.S, 1); + assert.ok(rollup.citeSuggestions.some((c) => c.citeToken === "[ach_1]")); + assert.equal(rollup.flags.thinDeliveryEvidence, "jira_rich_dossier_thin"); + }); + + it("flags dossier-rich empty window", () => { + const rollup = buildDeliveryRollup({ + personId: "p1", + cycleId: "c1", + windowStart: "2026-01-01", + windowEnd: "2026-06-30", + dossierNonIntegrationCount: 5, + achievements: [ + { + id: "ach_old", + title: "Old jira", + description: null, + occurredAt: "2024-01-01", + sourceProvider: "jira", + externalId: "ENG-9", + externalUrl: null, + externalPayloadJson: JSON.stringify({ isDone: true }), + }, + ], + }); + assert.equal(rollup.flags.thinDeliveryEvidence, "dossier_rich_integrations_empty_window"); + }); +}); diff --git a/apps/api/src/deliveryRollup.ts b/apps/api/src/deliveryRollup.ts new file mode 100644 index 0000000..ebe06ea --- /dev/null +++ b/apps/api/src/deliveryRollup.ts @@ -0,0 +1,148 @@ +import type { DeliveryRollupDTO, DeliveryCiteSuggestion } from "@prm/shared"; + +type AchRow = { + id: string; + title: string; + description: string | null; + occurredAt: string; + sourceProvider: string | null; + externalId: string | null; + externalUrl: string | null; + externalPayloadJson: string | null; +}; + +function inWindow(date: string | null | undefined, start: string, end: string) { + if (!date) return false; + const d = date.slice(0, 10); + return d >= start && d <= end; +} + +function parsePayload(raw: string | null): Record { + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +/** Aggregate synced integration achievements into a cycle-window delivery rollup. */ +export function buildDeliveryRollup(args: { + personId: string; + cycleId: string | null; + windowStart: string; + windowEnd: string; + achievements: AchRow[]; + dossierNonIntegrationCount: number; +}): DeliveryRollupDTO { + const { personId, cycleId, windowStart, windowEnd, achievements, dossierNonIntegrationCount } = args; + + let issuesDone = 0; + let issuesInProgress = 0; + let openBugs = 0; + let storyPointsDone = 0; + let storyPointsKnown = false; + let prsMerged = 0; + let reviewsGiven = 0; + let reverts = 0; + let blockedHours = 0; + const sizeBands: Record = {}; + const citeSuggestions: DeliveryCiteSuggestion[] = []; + const themes = new Map(); + + let jiraCount = 0; + let bitbucketCount = 0; + let githubCount = 0; + + for (const ach of achievements) { + const provider = ach.sourceProvider; + if (!provider) continue; + const payload = parsePayload(ach.externalPayloadJson); + const occurred = ach.occurredAt?.slice(0, 10) ?? ""; + const inWin = inWindow(occurred, windowStart, windowEnd); + + if (provider === "jira") { + jiraCount += 1; + if (!inWin) continue; + const isDone = Boolean(payload.isDone) || Boolean(payload.resolved); + const isInProgress = Boolean(payload.isInProgress); + const isBug = Boolean(payload.isBug) || /bug/i.test(String(payload.issueType ?? "")); + const pts = typeof payload.storyPoints === "number" ? payload.storyPoints : null; + if (isDone) { + issuesDone += 1; + if (pts != null) { + storyPointsDone += pts; + storyPointsKnown = true; + } + citeSuggestions.push({ + achievementId: ach.id, + label: ach.externalId ? `Jira ${ach.externalId}` : ach.title.slice(0, 40), + reason: "Done in review window", + citeToken: `[${ach.id}]`, + }); + } else if (isInProgress) { + issuesInProgress += 1; + } + if (isBug && !isDone) openBugs += 1; + if (typeof payload.blockedHours === "number") blockedHours += payload.blockedHours; + for (const label of (payload.labels as string[]) ?? []) { + themes.set(label, (themes.get(label) ?? 0) + 1); + } + if (payload.epicName || payload.epicKey) { + const t = String(payload.epicName ?? payload.epicKey); + themes.set(t, (themes.get(t) ?? 0) + 1); + } + } else if (provider === "bitbucket" || provider === "github") { + if (provider === "bitbucket") bitbucketCount += 1; + else githubCount += 1; + if (String(payload.kind ?? "") === "review_given") { + if (inWin) reviewsGiven += 1; + continue; + } + if (!inWin) continue; + prsMerged += 1; + if (payload.looksLikeRevert) reverts += 1; + const band = typeof payload.sizeBand === "string" ? payload.sizeBand : null; + if (band) sizeBands[band] = (sizeBands[band] ?? 0) + 1; + citeSuggestions.push({ + achievementId: ach.id, + label: ach.title.replace(/^\[(BB|GH)[^\]]*\]\s*/, "").slice(0, 48), + reason: "Merged PR in review window", + citeToken: `[${ach.id}]`, + }); + } + } + + const integrationInWindow = issuesDone + issuesInProgress + prsMerged + openBugs; + const thinDeliveryEvidence = + dossierNonIntegrationCount < 2 && integrationInWindow >= 3 + ? "jira_rich_dossier_thin" + : dossierNonIntegrationCount >= 4 && integrationInWindow === 0 && (jiraCount > 0 || bitbucketCount > 0 || githubCount > 0) + ? "dossier_rich_integrations_empty_window" + : null; + + return { + personId, + cycleId, + windowStart, + windowEnd, + issuesDone, + issuesInProgress, + openBugs, + storyPointsDone: storyPointsKnown ? storyPointsDone : null, + prsMerged, + reviewsGiven, + reverts, + blockedHours: blockedHours > 0 ? Math.round(blockedHours * 10) / 10 : null, + sizeBands, + themes: [...themes.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([theme, count]) => ({ theme, count })), + citeSuggestions: citeSuggestions.slice(0, 12), + flags: { + thinDeliveryEvidence, + integrationCounts: { jira: jiraCount, bitbucket: bitbucketCount, github: githubCount }, + }, + }; +} diff --git a/apps/api/src/faithfulness.test.ts b/apps/api/src/faithfulness.test.ts index 001ae42..ea9cb5b 100644 --- a/apps/api/src/faithfulness.test.ts +++ b/apps/api/src/faithfulness.test.ts @@ -112,6 +112,14 @@ describe("faithfulness lint", () => { assert.ok(!next!.includes("[ach_fake]")); assert.ok(next!.includes("[ach_real]")); }); + it("flags bare delivery claims and Jira keys without ach cites", () => { + const findings = runFaithfulnessLint( + "They shipped ENG-42 and merged three PRs this half without linking evidence.", + ["ach_1"], + ); + assert.ok(findings.some((f) => f.id.startsWith("delivery_claim_"))); + assert.ok(findings.some((f) => f.id.startsWith("jira_key_ENG-42"))); + }); }); describe("faithfulness golden eval set", () => { diff --git a/apps/api/src/faithfulness.ts b/apps/api/src/faithfulness.ts index 2032a29..6ca592f 100644 --- a/apps/api/src/faithfulness.ts +++ b/apps/api/src/faithfulness.ts @@ -147,6 +147,57 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia }); } + // Prefer integration achievement cites for delivery claims (Done-in-window / merged PRs). + const deliveryCite = allowlist.find((id) => id.startsWith("ach_")) ?? pickCiteId(allowlist); + + // Shipping / delivery claims without cites + for (const m of body.matchAll( + /\b(shipped|merged|resolved|closed|delivered|completed)\b[^.!?\n]{0,80}/gi, + )) { + const idx = m.index ?? 0; + const window = body.slice(Math.max(0, idx - 40), idx + (m[0]?.length ?? 0) + 40); + if (/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(window)) continue; + const excerpt = m[0].trim().slice(0, 100); + findings.push({ + id: `delivery_claim_${idx}`, + severity: "info", + category: "invention", + message: `Delivery claim (“${excerpt.slice(0, 40)}…”) lacks a nearby evidence cite — prefer [ach_…] from Done-in-window items.`, + excerpt, + suggestedFix: deliveryCite + ? { + action: "cite", + label: `Cite [${deliveryCite}]`, + citeId: deliveryCite, + newText: `${excerpt.replace(/\.*$/, "")} [${deliveryCite}].`, + } + : undefined, + }); + } + + // Bare Jira keys without achievement ids (keys aren't in the allowlist) + for (const m of body.matchAll(/\b([A-Z][A-Z0-9]+-\d+)\b/g)) { + const key = m[1]!; + const idx = m.index ?? 0; + const nearby = body.slice(Math.max(0, idx - 30), idx + key.length + 30); + if (/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(nearby)) continue; + findings.push({ + id: `jira_key_${key}_${idx}`, + severity: "info", + category: "invention", + message: `Jira key ${key} is not a citable evidence id — sync the ticket and cite [ach_…] instead.`, + excerpt: key, + suggestedFix: deliveryCite + ? { + action: "cite", + label: `Replace with [${deliveryCite}]`, + citeId: deliveryCite, + newText: `[${deliveryCite}]`, + } + : undefined, + }); + } + return findings; } diff --git a/apps/api/src/github.ts b/apps/api/src/github.ts new file mode 100644 index 0000000..42a15c9 --- /dev/null +++ b/apps/api/src/github.ts @@ -0,0 +1,214 @@ +/** GitHub REST helpers (PAT: classic or fine-grained with repo + PR read). */ + +export type GitHubConfig = { + token: string; + /** Optional org login to scope searches; if null, search user-authored PRs across orgs. */ + org?: string | null; + apiBase?: string; +}; + +export type GitHubRemoteUser = { + login: string; + id: number; + name: string | null; + htmlUrl: string; +}; + +export type GitHubPullRequest = { + id: number; + number: number; + title: string; + state: string; + repoFullName: string; + authorLogin: string | null; + createdOn: string; + updatedOn: string; + mergedOn: string | null; + url: string; + additions: number | null; + deletions: number | null; + changedFiles: number | null; + sizeBand: "XS" | "S" | "M" | "L" | "XL" | null; + daysToMerge: number | null; + looksLikeRevert: boolean; + reviewCount: number | null; +}; + +function apiBase(cfg: GitHubConfig) { + return (cfg.apiBase ?? "https://api.github.com").replace(/\/+$/, ""); +} + +async function ghFetch(cfg: GitHubConfig, path: string, init?: RequestInit): Promise { + const url = path.startsWith("http") ? path : `${apiBase(cfg)}${path}`; + const res = await fetch(url, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${cfg.token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`GitHub ${res.status}: ${body.slice(0, 240) || res.statusText}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export async function githubTestConnection(cfg: GitHubConfig) { + const me = await ghFetch<{ login?: string; name?: string; id?: number }>(cfg, "/user"); + return { + ok: true as const, + login: me.login ?? null, + name: me.name ?? null, + id: me.id ?? null, + }; +} + +export async function githubSearchUsers(cfg: GitHubConfig, query: string, max = 20): Promise { + const q = encodeURIComponent(query.trim()); + const data = await ghFetch<{ + items?: Array<{ login: string; id: number; html_url?: string }>; + }>(cfg, `/search/users?q=${q}&per_page=${max}`); + return (data.items ?? []).map((u) => ({ + login: u.login, + id: u.id, + name: null, + htmlUrl: u.html_url ?? `https://github.com/${u.login}`, + })); +} + +function sizeBand(files: number | null, churn: number | null): GitHubPullRequest["sizeBand"] { + const f = files ?? 0; + const c = churn ?? 0; + if (f <= 2 && c <= 40) return "XS"; + if (f <= 8 && c <= 200) return "S"; + if (f <= 20 && c <= 600) return "M"; + if (f <= 40 && c <= 1500) return "L"; + if (f > 0 || c > 0) return "XL"; + return null; +} + +export async function githubMergedPrsForAuthor( + cfg: GitHubConfig, + authorLogin: string, + opts: { maxPrs?: number; windowStart?: string; windowEnd?: string } = {}, +): Promise { + const maxPrs = opts.maxPrs ?? 50; + const parts = [`is:pr`, `is:merged`, `author:${authorLogin}`]; + if (cfg.org) parts.push(`org:${cfg.org}`); + if (opts.windowStart) parts.push(`merged:>=${opts.windowStart}`); + if (opts.windowEnd) parts.push(`merged:<=${opts.windowEnd}`); + const q = encodeURIComponent(parts.join(" ")); + const data = await ghFetch<{ + items?: Array<{ + id: number; + number: number; + title?: string; + html_url?: string; + repository_url?: string; + created_at?: string; + updated_at?: string; + closed_at?: string | null; + pull_request?: { merged_at?: string | null; url?: string }; + user?: { login?: string }; + }>; + }>(cfg, `/search/issues?q=${q}&per_page=${Math.min(maxPrs, 50)}&sort=updated`); + + const out: GitHubPullRequest[] = []; + for (const item of data.items ?? []) { + const repoFullName = (item.repository_url ?? "").replace("https://api.github.com/repos/", ""); + const mergedOn = item.pull_request?.merged_at + ? item.pull_request.merged_at.slice(0, 10) + : item.closed_at + ? item.closed_at.slice(0, 10) + : null; + let additions: number | null = null; + let deletions: number | null = null; + let changedFiles: number | null = null; + let reviewCount: number | null = null; + if (repoFullName && item.number) { + try { + const detail = await ghFetch<{ + additions?: number; + deletions?: number; + changed_files?: number; + }>(cfg, `/repos/${repoFullName}/pulls/${item.number}`); + additions = detail.additions ?? null; + deletions = detail.deletions ?? null; + changedFiles = detail.changed_files ?? null; + } catch { + /* optional */ + } + try { + const reviews = await ghFetch>( + cfg, + `/repos/${repoFullName}/pulls/${item.number}/reviews`, + ); + reviewCount = reviews.length; + } catch { + /* optional */ + } + } + const createdOn = (item.created_at ?? "").slice(0, 10); + let daysToMerge: number | null = null; + if (createdOn && mergedOn) { + const a = Date.parse(createdOn); + const b = Date.parse(mergedOn); + if (!Number.isNaN(a) && !Number.isNaN(b) && b >= a) { + daysToMerge = Math.round((b - a) / (1000 * 60 * 60 * 24)); + } + } + out.push({ + id: item.id, + number: item.number, + title: item.title ?? `PR #${item.number}`, + state: "MERGED", + repoFullName, + authorLogin: item.user?.login ?? authorLogin, + createdOn, + updatedOn: (item.updated_at ?? "").slice(0, 10), + mergedOn, + url: item.html_url ?? `https://github.com/${repoFullName}/pull/${item.number}`, + additions, + deletions, + changedFiles, + sizeBand: sizeBand(changedFiles, (additions ?? 0) + (deletions ?? 0)), + daysToMerge, + looksLikeRevert: /\brevert\b/i.test(item.title ?? ""), + reviewCount, + }); + } + return out; +} + +export async function githubReviewsGiven( + cfg: GitHubConfig, + reviewerLogin: string, + opts: { maxPrs?: number; windowStart?: string; windowEnd?: string } = {}, +): Promise> { + const maxPrs = opts.maxPrs ?? 30; + const parts = [`is:pr`, `is:merged`, `reviewed-by:${reviewerLogin}`]; + if (cfg.org) parts.push(`org:${cfg.org}`); + if (opts.windowStart) parts.push(`merged:>=${opts.windowStart}`); + if (opts.windowEnd) parts.push(`merged:<=${opts.windowEnd}`); + const q = encodeURIComponent(parts.join(" ")); + const data = await ghFetch<{ + items?: Array<{ + number: number; + title?: string; + html_url?: string; + repository_url?: string; + updated_at?: string; + }>; + }>(cfg, `/search/issues?q=${q}&per_page=${Math.min(maxPrs, 50)}`); + return (data.items ?? []).map((item) => ({ + number: item.number, + title: item.title ?? `PR #${item.number}`, + repoFullName: (item.repository_url ?? "").replace("https://api.github.com/repos/", ""), + url: item.html_url ?? "", + updatedOn: (item.updated_at ?? "").slice(0, 10), + })); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4a28272..15047bc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -99,10 +99,13 @@ import { indexReviewFts } from "./reviewSearch.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; import { localPeerSynthesize } from "./peerSynth.js"; import { bitbucketSearchMembers, bitbucketTestConnection } from "./bitbucket.js"; +import { githubSearchUsers, githubTestConnection } from "./github.js"; import { jiraSearchUsers, jiraTestConnection } from "./jira.js"; +import { buildDeliveryRollup } from "./deliveryRollup.js"; import { ensureIntegrationRow, getBitbucketConfig, + getGitHubConfig, getJiraConfig, syncPersonIntegrations, } from "./integrations.js"; @@ -2200,22 +2203,27 @@ function mapExternalLink(row: typeof personExternalLinks.$inferSelect): PersonEx }; } +function isKnownIntegrationProvider(provider: string): provider is IntegrationProvider { + return provider === "jira" || provider === "bitbucket" || provider === "github"; +} + app.get("/api/integrations", (c) => { return c.json({ jira: mapIntegrationSettings("jira"), bitbucket: mapIntegrationSettings("bitbucket"), + github: mapIntegrationSettings("github"), }); }); app.get("/api/integrations/:provider", (c) => { - const provider = c.req.param("provider") as IntegrationProvider; - if (provider !== "jira" && provider !== "bitbucket") return c.json({ error: "Unknown provider" }, 404); + const provider = c.req.param("provider"); + if (!isKnownIntegrationProvider(provider)) return c.json({ error: "Unknown provider" }, 404); return c.json(mapIntegrationSettings(provider)); }); app.put("/api/integrations/:provider", async (c) => { - const provider = c.req.param("provider") as IntegrationProvider; - if (provider !== "jira" && provider !== "bitbucket") return c.json({ error: "Unknown provider" }, 404); + const provider = c.req.param("provider"); + if (!isKnownIntegrationProvider(provider)) return c.json({ error: "Unknown provider" }, 404); const body = await c.req.json<{ enabled?: boolean; baseUrl?: string | null; @@ -2258,7 +2266,7 @@ app.put("/api/integrations/:provider", async (c) => { }); app.post("/api/integrations/:provider/test", async (c) => { - const provider = c.req.param("provider") as IntegrationProvider; + const provider = c.req.param("provider"); try { if (provider === "jira") { const cfg = getJiraConfig(); @@ -2274,6 +2282,12 @@ app.post("/api/integrations/:provider/test", async (c) => { const result = await bitbucketTestConnection(cfg); return c.json(result); } + if (provider === "github") { + const cfg = getGitHubConfig(); + if (!cfg) return c.json({ error: "Enable GitHub and save a personal access token first" }, 400); + const result = await githubTestConnection(cfg); + return c.json(result); + } return c.json({ error: "Unknown provider" }, 404); } catch (e) { return c.json({ error: e instanceof Error ? e.message : "Connection failed" }, 400); @@ -2281,7 +2295,7 @@ app.post("/api/integrations/:provider/test", async (c) => { }); app.get("/api/integrations/:provider/users", async (c) => { - const provider = c.req.param("provider") as IntegrationProvider; + const provider = c.req.param("provider"); const q = (c.req.query("q") ?? "").trim(); if (!q) return c.json({ error: "q required" }, 400); try { @@ -2309,6 +2323,18 @@ app.get("/api/integrations/:provider/users", async (c) => { })); return c.json({ items }); } + if (provider === "github") { + const cfg = getGitHubConfig(); + if (!cfg) return c.json({ error: "GitHub not configured" }, 400); + const users = await githubSearchUsers(cfg, q); + const items: IntegrationRemoteUser[] = users.map((u) => ({ + accountId: String(u.id), + displayName: u.name ?? u.login, + email: null, + username: u.login, + })); + return c.json({ items }); + } return c.json({ error: "Unknown provider" }, 404); } catch (e) { return c.json({ error: e instanceof Error ? e.message : "User search failed" }, 400); @@ -2329,8 +2355,8 @@ app.get("/api/people/:id/external-links", (c) => { app.put("/api/people/:id/external-links/:provider", async (c) => { const personId = c.req.param("id"); - const provider = c.req.param("provider") as IntegrationProvider; - if (provider !== "jira" && provider !== "bitbucket") return c.json({ error: "Unknown provider" }, 404); + const provider = c.req.param("provider"); + if (!isKnownIntegrationProvider(provider)) return c.json({ error: "Unknown provider" }, 404); const body = await c.req.json<{ externalAccountId: string; externalDisplayName?: string | null; @@ -2396,13 +2422,21 @@ app.delete("/api/people/:id/external-links/:provider", (c) => { app.post("/api/people/:id/integrations/sync", async (c) => { const personId = c.req.param("id"); - const body = await c.req.json<{ providers?: IntegrationProvider[] }>().catch(() => ({} as { providers?: IntegrationProvider[] })); + const body = await c.req + .json<{ providers?: IntegrationProvider[]; cycleId?: string; includeComments?: boolean }>() + .catch( + () => + ({} as { providers?: IntegrationProvider[]; cycleId?: string; includeComments?: boolean }), + ); const db = getDb(); if (!db.select().from(people).where(eq(people.id, personId)).all()[0]) { return c.json({ error: "Not found" }, 404); } try { - const result: IntegrationSyncResult = await syncPersonIntegrations(personId, body.providers); + const result: IntegrationSyncResult = await syncPersonIntegrations(personId, body.providers, { + cycleId: body.cycleId, + includeComments: body.includeComments, + }); return c.json(result); } catch (e) { return c.json({ error: e instanceof Error ? e.message : "Sync failed" }, 400); @@ -2411,18 +2445,54 @@ app.post("/api/people/:id/integrations/sync", async (c) => { app.post("/api/integrations/sync-all", async (c) => { const body = await c.req - .json<{ providers?: IntegrationProvider[] }>() - .catch(() => ({} as { providers?: IntegrationProvider[] })); + .json<{ providers?: IntegrationProvider[]; cycleId?: string; includeComments?: boolean }>() + .catch( + () => + ({} as { providers?: IntegrationProvider[]; cycleId?: string; includeComments?: boolean }), + ); const db = getDb(); const links = db.select().from(personExternalLinks).all(); const personIds = [...new Set(links.map((l) => l.personId))]; const results: IntegrationSyncResult[] = []; for (const personId of personIds) { - results.push(await syncPersonIntegrations(personId, body.providers)); + results.push( + await syncPersonIntegrations(personId, body.providers, { + cycleId: body.cycleId, + includeComments: body.includeComments, + }), + ); } return c.json({ count: results.length, results }); }); +app.get("/api/people/:id/delivery-rollup", (c) => { + const personId = c.req.param("id"); + const cycleId = c.req.query("cycleId"); + const db = getDb(); + const person = db.select().from(people).where(eq(people.id, personId)).all()[0]; + if (!person) return c.json({ error: "Not found" }, 404); + const allCycles = db.select().from(cycles).all().sort((a, b) => b.windowEnd.localeCompare(a.windowEnd)); + const cycle = cycleId + ? allCycles.find((x) => x.id === cycleId) + : allCycles.find((x) => x.status !== "closed") ?? allCycles[0]; + if (!cycle) return c.json({ error: "No cycle found" }, 404); + const achRows = db.select().from(achievements).where(eq(achievements.personId, personId)).all(); + const dossierNonIntegrationCount = achRows.filter((a) => !a.sourceProvider).length + + db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, personId)).all().length + + db.select().from(documents).where(eq(documents.personId, personId)).all().length + + db.select().from(goals).where(eq(goals.personId, personId)).all().length; + return c.json( + buildDeliveryRollup({ + personId, + cycleId: cycle.id, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount, + }), + ); +}); + app.post("/api/people/:id/documents", async (c) => { const personId = c.req.param("id"); const db = getDb(); @@ -2745,13 +2815,44 @@ app.post("/api/ai/digest", async (c) => { let outputText: string; let model = "local-grounded"; + const db = getDb(); + const cycle = body.cycleId + ? db.select().from(cycles).where(eq(cycles.id, body.cycleId)).all()[0] + : null; + const achRows = db.select().from(achievements).where(eq(achievements.personId, body.personId)).all(); + const dossierNonIntegrationCount = achRows.filter((a) => !a.sourceProvider).length; + const rollup = cycle + ? buildDeliveryRollup({ + personId: body.personId, + cycleId: cycle.id, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount, + }) + : null; + const citeHint = + rollup && rollup.citeSuggestions.length + ? `\n\nPrefer citing these Done-in-window items when relevant:\n${rollup.citeSuggestions + .map((s) => `- ${s.citeToken} ${s.label} (${s.reason})`) + .join("\n")}` + : ""; + const localLines = [ `Subject: ${ctx.person?.name} — role bar: ${ctx.role?.title ?? "unassigned"}${ctx.next ? ` → next: ${ctx.next.title}` : ""}.`, ctx.role ? `Current-role expectations in play: ${ctx.role.expectations.length} (stable ids).` : "Gap: unassigned role.", + ...(rollup + ? [ + `Delivery rollup (${rollup.windowStart}→${rollup.windowEnd}): ${rollup.issuesDone} issues done, ${rollup.prsMerged} PRs merged, ${rollup.issuesInProgress} in progress` + + (rollup.storyPointsDone != null ? `, ${rollup.storyPointsDone} story points` : "") + + ".", + ] + : []), ...ctx.ach.slice(0, 5).map((item) => `Achievement [${item.id}]: ${item.title} (${item.occurredAt}).`), ...ctx.lines.filter((l) => l.startsWith("Goal") || l.startsWith("Feedback") || l.startsWith("Self") || l.startsWith("Peer") || l.startsWith("Gap") || l.startsWith("Thin")).slice(0, 12), + ...(rollup?.citeSuggestions.slice(0, 5).map((s) => `Suggested cite ${s.citeToken}: ${s.label}`) ?? []), ]; if (body.useCloud !== false && cfg.enabled && (cfg.hasApiKey || cfg.localOnly || cfg.provider === "ollama")) { @@ -2760,7 +2861,7 @@ app.post("/api/ai/digest", async (c) => { { role: "system", content: buildSystemPrompt() }, { role: "user", - content: `Produce an 8–12 bullet evidence digest for the manager writing desk.\nCite ids.\n\nEVIDENCE:\n${ctx.lines.join("\n")}`, + content: `Produce an 8–12 bullet evidence digest for the manager writing desk.\nCite ids like [ach_…]. Prefer Done-in-window delivery items when summarizing shipping work.${citeHint}\n\nEVIDENCE:\n${ctx.lines.join("\n")}`, }, ]); outputText = result.text; @@ -2789,6 +2890,7 @@ app.post("/api/ai/digest", async (c) => { outputText, citations: filtered.citationsUsed, citationsDropped: filtered.citationsDropped, + citeSuggestions: rollup?.citeSuggestions ?? [], dataClassesSent: ctx.dataClassesSent, model, }); @@ -3384,6 +3486,10 @@ app.get("/api/team-performance", (c) => { const priorCycle = allCycles.find((x) => x.windowStart < cycle.windowStart && x.id !== cycle.id) ?? null; + const priorForTrend = allCycles + .filter((x) => x.windowStart < cycle.windowStart && x.id !== cycle.id) + .sort((a, b) => a.windowStart.localeCompare(b.windowStart)) + .slice(-4); const participants = db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all(); const peopleInputs: TeamPerformancePersonInput[] = []; @@ -3392,11 +3498,11 @@ app.get("/api/team-performance", (c) => { const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; if (!person) continue; - const ach = db.select().from(achievements).where(eq(achievements.personId, person.id)).all().length; + const achRows = db.select().from(achievements).where(eq(achievements.personId, person.id)).all(); const fbRows = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all(); const docs = db.select().from(documents).where(eq(documents.personId, person.id)).all().length; const goalsN = db.select().from(goals).where(eq(goals.personId, person.id)).all().length; - const evidenceCount = ach + fbRows.length + docs + goalsN; + const evidenceCount = achRows.length + fbRows.length + docs + goalsN; const themes: string[] = []; for (const f of fbRows) { try { @@ -3482,6 +3588,28 @@ app.get("/api/team-performance", (c) => { } } + const dossierNonIntegrationCount = + achRows.filter((a) => !a.sourceProvider).length + fbRows.length + docs + goalsN; + const rollup = buildDeliveryRollup({ + personId: person.id, + cycleId: cycle.id, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount, + }); + const deliveryTrend = priorForTrend.map((pc) => { + const r = buildDeliveryRollup({ + personId: person.id, + cycleId: pc.id, + windowStart: pc.windowStart.slice(0, 10), + windowEnd: pc.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount, + }); + return r.issuesDone + r.prsMerged; + }); + peopleInputs.push({ personId: person.id, personName: person.name, @@ -3499,6 +3627,10 @@ app.get("/api/team-performance", (c) => { coverageNeed, themes, openConcerns, + deliveryDone: rollup.issuesDone + rollup.prsMerged, + storyPointsDone: rollup.storyPointsDone, + deliveryTrend, + thinDeliveryEvidence: rollup.flags.thinDeliveryEvidence, }); } diff --git a/apps/api/src/integrations.clients.test.ts b/apps/api/src/integrations.clients.test.ts index b3f017c..d2bf3f6 100644 --- a/apps/api/src/integrations.clients.test.ts +++ b/apps/api/src/integrations.clients.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, mock } from "node:test"; -import { jiraSearchIssuesForAssignee, jiraTestConnection } from "../src/jira.js"; -import { bitbucketTestConnection } from "../src/bitbucket.js"; +import { jiraSearchIssuesForAssignee, jiraTestConnection } from "./jira.js"; +import { bitbucketTestConnection } from "./bitbucket.js"; +import { githubMergedPrsForAuthor, githubTestConnection } from "./github.js"; describe("jira client", () => { it("parses search issues from Cloud REST payload", async () => { @@ -14,13 +15,29 @@ describe("jira client", () => { key: "ENG-42", fields: { summary: "Ship reliability fix", - status: { name: "Done" }, + status: { name: "Done", statusCategory: { key: "done", name: "Done" } }, issuetype: { name: "Story" }, priority: { name: "High" }, project: { key: "ENG", name: "Engineering" }, updated: "2026-07-01T12:00:00.000Z", resolutiondate: "2026-06-28T09:00:00.000Z", assignee: { accountId: "abc" }, + reporter: { accountId: "rep", displayName: "Reporter" }, + labels: ["platform"], + customfield_10016: 3, + }, + }, + { + id: "10002", + key: "ENG-99", + fields: { + summary: "dependabot bump", + status: { name: "Done" }, + issuetype: { name: "Sub-task" }, + project: { key: "ENG", name: "Engineering" }, + updated: "2026-07-01T12:00:00.000Z", + assignee: { accountId: "abc" }, + labels: [], }, }, ], @@ -37,6 +54,9 @@ describe("jira client", () => { assert.equal(issues[0]!.key, "ENG-42"); assert.equal(issues[0]!.url, "https://acme.atlassian.net/browse/ENG-42"); assert.equal(issues[0]!.resolved, "2026-06-28"); + assert.equal(issues[0]!.isDone, true); + assert.equal(issues[0]!.storyPoints, 3); + assert.equal(issues[0]!.reporterName, "Reporter"); fetchMock.mock.restore(); }); @@ -84,3 +104,68 @@ describe("bitbucket client", () => { fetchMock.mock.restore(); }); }); + +describe("github client", () => { + it("tests connection against /user", async () => { + const fetchMock = mock.method(globalThis, "fetch", async () => { + return new Response(JSON.stringify({ login: "ada", name: "Ada", id: 42 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const res = await githubTestConnection({ token: "ghp_x" }); + assert.equal(res.ok, true); + assert.equal(res.login, "ada"); + fetchMock.mock.restore(); + }); + + it("maps merged PRs with size bands", async () => { + const fetchMock = mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/search/issues")) { + return new Response( + JSON.stringify({ + items: [ + { + id: 1, + number: 12, + title: "Ship feature", + html_url: "https://github.com/acme/app/pull/12", + repository_url: "https://api.github.com/repos/acme/app", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-10T00:00:00Z", + closed_at: "2026-06-10T00:00:00Z", + pull_request: { merged_at: "2026-06-10T00:00:00Z" }, + user: { login: "ada" }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url.includes("/pulls/12/reviews")) { + return new Response(JSON.stringify([{ id: 1 }, { id: 2 }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("/pulls/12")) { + return new Response( + JSON.stringify({ additions: 20, deletions: 5, changed_files: 3 }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("{}", { status: 404 }); + }); + const prs = await githubMergedPrsForAuthor( + { token: "ghp_x", org: "acme" }, + "ada", + { windowStart: "2026-06-01", windowEnd: "2026-06-30" }, + ); + assert.equal(prs.length, 1); + assert.equal(prs[0]!.sizeBand, "S"); + assert.equal(prs[0]!.daysToMerge, 9); + assert.equal(prs[0]!.reviewCount, 2); + fetchMock.mock.restore(); + }); +}); diff --git a/apps/api/src/integrations.ts b/apps/api/src/integrations.ts index 4d55f55..7a5c4ca 100644 --- a/apps/api/src/integrations.ts +++ b/apps/api/src/integrations.ts @@ -1,7 +1,8 @@ import { and, eq } from "drizzle-orm"; -import { achievements, integrationSettings, personExternalLinks } from "@prm/db"; +import { achievements, cycles, integrationSettings, personExternalLinks } from "@prm/db"; import type { IntegrationProvider, IntegrationSyncResult } from "@prm/shared"; -import { bitbucketMergedPrsForAuthor, type BitbucketConfig } from "./bitbucket.js"; +import { bitbucketMergedPrsForAuthor, bitbucketReviewsGiven, type BitbucketConfig } from "./bitbucket.js"; +import { githubMergedPrsForAuthor, githubReviewsGiven, type GitHubConfig } from "./github.js"; import { jiraSearchIssuesForAssignee, type JiraConfig } from "./jira.js"; import { decryptSecret, @@ -26,7 +27,12 @@ export function ensureIntegrationRow(provider: IntegrationProvider) { .values({ id: provider, enabled: 0, - baseUrl: provider === "jira" ? "https://your-domain.atlassian.net" : null, + baseUrl: + provider === "jira" + ? "https://your-domain.atlassian.net" + : provider === "github" + ? "https://api.github.com" + : null, workspaceSlug: null, authEmail: null, authUsername: null, @@ -63,6 +69,24 @@ export function getBitbucketConfig(): BitbucketConfig | null { return { workspace: row.workspaceSlug, username: row.authUsername, appPassword: token }; } +export function getGitHubConfig(): GitHubConfig | null { + const row = getIntegrationRow("github"); + if (!row?.enabled) return null; + const token = resolveIntegrationToken("github"); + if (!token) return null; + let extra: Record = {}; + try { + extra = JSON.parse(row.extraJson || "{}") as Record; + } catch { + extra = {}; + } + return { + token, + org: typeof extra.org === "string" ? extra.org : row.workspaceSlug, + apiBase: row.baseUrl || "https://api.github.com", + }; +} + function upsertAchievement(input: { personId: string; provider: IntegrationProvider; @@ -117,18 +141,53 @@ function upsertAchievement(input: { return { id: achId, created: true }; } +function resolveCycleWindow(cycleId?: string | null): { + cycleId: string | null; + windowStart: string | null; + windowEnd: string | null; +} { + const db = getDb(); + const cycle = cycleId + ? db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0] + : db + .select() + .from(cycles) + .all() + .filter((c) => c.status !== "closed") + .sort((a, b) => b.windowEnd.localeCompare(a.windowEnd))[0]; + if (!cycle) return { cycleId: null, windowStart: null, windowEnd: null }; + return { cycleId: cycle.id, windowStart: cycle.windowStart.slice(0, 10), windowEnd: cycle.windowEnd.slice(0, 10) }; +} + export async function syncPersonIntegrations( personId: string, providers?: IntegrationProvider[], + opts?: { cycleId?: string; includeComments?: boolean }, ): Promise { const db = getDb(); - const want = providers?.length ? providers : (["jira", "bitbucket"] as IntegrationProvider[]); + const want = providers?.length + ? providers + : (["jira", "bitbucket", "github"] as IntegrationProvider[]); + const window = resolveCycleWindow(opts?.cycleId); const result: IntegrationSyncResult = { personId, + cycleId: window.cycleId, + windowStart: window.windowStart, + windowEnd: window.windowEnd, jira: { imported: 0, updated: 0, skipped: true, error: null }, bitbucket: { imported: 0, updated: 0, skipped: true, error: null }, + github: { imported: 0, updated: 0, skipped: true, error: null }, }; + const jiraExtra = (() => { + try { + return JSON.parse(getIntegrationRow("jira")?.extraJson || "{}") as { includeComments?: boolean }; + } catch { + return {}; + } + })(); + const includeComments = opts?.includeComments ?? Boolean(jiraExtra.includeComments); + if (want.includes("jira")) { result.jira.skipped = false; try { @@ -140,12 +199,26 @@ export async function syncPersonIntegrations( .where(and(eq(personExternalLinks.personId, personId), eq(personExternalLinks.provider, "jira"))) .all()[0]; if (!link) throw new Error("No Jira account linked for this person"); - const issues = await jiraSearchIssuesForAssignee(cfg, link.externalAccountId, { days: 180, maxResults: 50 }); + const issues = await jiraSearchIssuesForAssignee(cfg, link.externalAccountId, { + windowStart: window.windowStart ?? undefined, + windowEnd: window.windowEnd ?? undefined, + days: window.windowStart ? undefined : 180, + maxResults: 80, + includeComments, + includeChangelog: true, + }); for (const issue of issues) { const desc = [ `${issue.issueType} · ${issue.status}` + (issue.priority ? ` · ${issue.priority}` : ""), issue.projectKey ? `Project: ${issue.projectKey} (${issue.projectName})` : null, + issue.storyPoints != null ? `Story points: ${issue.storyPoints}` : null, + issue.epicKey ? `Epic: ${issue.epicKey}${issue.epicName ? ` — ${issue.epicName}` : ""}` : null, + issue.labels.length ? `Labels: ${issue.labels.join(", ")}` : null, + issue.reporterName ? `Reporter: ${issue.reporterName}` : null, + issue.blockedHours != null ? `Blocked ~${issue.blockedHours}h` : null, issue.resolved ? `Resolved: ${issue.resolved}` : `Updated: ${issue.updated}`, + issue.commentHighlights.length ? `Comments:\n- ${issue.commentHighlights.join("\n- ")}` : null, + `Cite as [${issue.key}] via achievement id after sync`, issue.url, ] .filter(Boolean) @@ -186,12 +259,20 @@ export async function syncPersonIntegrations( const prs = await bitbucketMergedPrsForAuthor(cfg, link.externalAccountId, { maxRepos: 25, maxPrs: 50, + windowStart: window.windowStart ?? undefined, + windowEnd: window.windowEnd ?? undefined, }); for (const pr of prs) { const externalId = `${pr.repoFullName}#${pr.id}`; const desc = [ `Merged PR in ${pr.repoFullName}`, + pr.sizeBand ? `Size band: ${pr.sizeBand} (files/LOC bands — not a score)` : null, + pr.daysToMerge != null ? `Time to merge: ${pr.daysToMerge}d` : null, + pr.reviewParticipation + ? `Reviewers: ${pr.reviewParticipation.reviewers} · Approvals: ${pr.reviewParticipation.approvals}` + : null, `Comments: ${pr.commentCount} · Tasks: ${pr.taskCount}`, + pr.looksLikeRevert ? "Looks like a revert" : null, pr.mergedOn ? `Merged: ${pr.mergedOn}` : null, pr.url, ] @@ -210,6 +291,25 @@ export async function syncPersonIntegrations( if (up.created) result.bitbucket.imported += 1; else result.bitbucket.updated += 1; } + const given = await bitbucketReviewsGiven(cfg, link.externalAccountId, { + windowStart: window.windowStart ?? undefined, + windowEnd: window.windowEnd ?? undefined, + }); + for (const rev of given) { + const externalId = `review:${rev.repoFullName}#${rev.id}`; + const up = upsertAchievement({ + personId, + provider: "bitbucket", + externalId, + title: `[BB review] ${rev.title}`, + description: `Reviewed PR in ${rev.repoFullName}\n${rev.url}`, + occurredAt: rev.updatedOn || nowIso().slice(0, 10), + externalUrl: rev.url, + payload: { ...rev, kind: "review_given" }, + }); + if (up.created) result.bitbucket.imported += 1; + else result.bitbucket.updated += 1; + } db.update(integrationSettings) .set({ lastSyncAt: nowIso(), updatedAt: nowIso() }) .where(eq(integrationSettings.id, "bitbucket")) @@ -219,6 +319,77 @@ export async function syncPersonIntegrations( } } + if (want.includes("github")) { + result.github.skipped = false; + try { + const cfg = getGitHubConfig(); + if (!cfg) throw new Error("GitHub is not configured or enabled"); + const link = db + .select() + .from(personExternalLinks) + .where(and(eq(personExternalLinks.personId, personId), eq(personExternalLinks.provider, "github"))) + .all()[0]; + if (!link) throw new Error("No GitHub account linked for this person"); + const login = link.externalUsername || link.externalAccountId; + const prs = await githubMergedPrsForAuthor(cfg, login, { + maxPrs: 50, + windowStart: window.windowStart ?? undefined, + windowEnd: window.windowEnd ?? undefined, + }); + for (const pr of prs) { + const externalId = `${pr.repoFullName}#${pr.number}`; + const desc = [ + `Merged PR in ${pr.repoFullName}`, + pr.sizeBand ? `Size band: ${pr.sizeBand}` : null, + pr.daysToMerge != null ? `Time to merge: ${pr.daysToMerge}d` : null, + pr.reviewCount != null ? `Reviews on PR: ${pr.reviewCount}` : null, + pr.looksLikeRevert ? "Looks like a revert" : null, + pr.mergedOn ? `Merged: ${pr.mergedOn}` : null, + pr.url, + ] + .filter(Boolean) + .join("\n"); + const up = upsertAchievement({ + personId, + provider: "github", + externalId, + title: `[GH PR #${pr.number}] ${pr.title}`, + description: desc, + occurredAt: pr.mergedOn ?? pr.updatedOn ?? nowIso().slice(0, 10), + externalUrl: pr.url, + payload: pr as unknown as Record, + }); + if (up.created) result.github.imported += 1; + else result.github.updated += 1; + } + const given = await githubReviewsGiven(cfg, login, { + windowStart: window.windowStart ?? undefined, + windowEnd: window.windowEnd ?? undefined, + }); + for (const rev of given) { + const externalId = `review:${rev.repoFullName}#${rev.number}`; + const up = upsertAchievement({ + personId, + provider: "github", + externalId, + title: `[GH review] ${rev.title}`, + description: `Reviewed PR in ${rev.repoFullName}\n${rev.url}`, + occurredAt: rev.updatedOn || nowIso().slice(0, 10), + externalUrl: rev.url, + payload: { ...rev, kind: "review_given" }, + }); + if (up.created) result.github.imported += 1; + else result.github.updated += 1; + } + db.update(integrationSettings) + .set({ lastSyncAt: nowIso(), updatedAt: nowIso() }) + .where(eq(integrationSettings.id, "github")) + .run(); + } catch (e) { + result.github.error = e instanceof Error ? e.message : "GitHub sync failed"; + } + } + logActivity("integrations.sync", "person", personId, result as unknown as Record); return result; } diff --git a/apps/api/src/jira.ts b/apps/api/src/jira.ts index 8467775..18d803e 100644 --- a/apps/api/src/jira.ts +++ b/apps/api/src/jira.ts @@ -1,4 +1,4 @@ -/** Thin Jira Cloud REST helpers (Basic auth: email + API token). */ +/** Richer Jira Cloud REST helpers (Basic auth: email + API token). */ export type JiraConfig = { baseUrl: string; @@ -18,14 +18,30 @@ export type JiraIssue = { key: string; summary: string; status: string; + statusCategory: string | null; issueType: string; priority: string | null; projectKey: string; projectName: string; updated: string; resolved: string | null; + created: string | null; url: string; assigneeAccountId: string | null; + reporterAccountId: string | null; + reporterName: string | null; + creatorAccountId: string | null; + creatorName: string | null; + labels: string[]; + epicKey: string | null; + epicName: string | null; + storyPoints: number | null; + commentHighlights: string[]; + blockedHours: number | null; + isBug: boolean; + isDone: boolean; + isInProgress: boolean; + noiseFiltered: boolean; }; function authHeader(cfg: JiraConfig) { @@ -78,50 +94,214 @@ export async function jiraSearchUsers(cfg: JiraConfig, query: string, max = 20): })); } +function extractAdfText(node: unknown, depth = 0): string { + if (depth > 8 || node == null) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map((n) => extractAdfText(n, depth + 1)).join(" "); + if (typeof node === "object") { + const o = node as Record; + if (typeof o.text === "string") return o.text; + if (o.content) return extractAdfText(o.content, depth + 1); + } + return ""; +} + +function parseStoryPoints(fields: Record): number | null { + const candidates = [ + fields.customfield_10016, + fields.customfield_10026, + fields.story_points, + fields.storyPoints, + ]; + for (const v of candidates) { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) return Number(v); + } + return null; +} + +function isNoiseIssue(issueType: string, summary: string, labels: string[]): boolean { + const type = issueType.toLowerCase(); + if (type.includes("sub-task") || type.includes("subtask") || type === "subtask") return true; + if (/\b(bot|dependabot|renovate|automation)\b/i.test(summary)) return true; + if (labels.some((l) => /^(bot|automation|spam)$/i.test(l))) return true; + return false; +} + +function blockedHoursFromChangelog( + histories: Array<{ + created?: string; + items?: Array<{ field?: string; fromString?: string | null; toString?: string | null }>; + }>, +): number | null { + let entered: string | null = null; + let hours = 0; + const sorted = [...histories].sort((a, b) => (a.created ?? "").localeCompare(b.created ?? "")); + for (const h of sorted) { + for (const item of h.items ?? []) { + if (item.field !== "status") continue; + const to = (item.toString ?? "").toLowerCase(); + const from = (item.fromString ?? "").toLowerCase(); + if (to.includes("block") && !entered) entered = h.created ?? null; + if (entered && from.includes("block") && !to.includes("block")) { + const start = Date.parse(entered); + const end = Date.parse(h.created ?? ""); + if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) { + hours += (end - start) / (1000 * 60 * 60); + } + entered = null; + } + } + } + return hours > 0 ? Math.round(hours * 10) / 10 : null; +} + +export type JiraSearchOpts = { + /** Inclusive window start YYYY-MM-DD (preferred over days). */ + windowStart?: string; + windowEnd?: string; + days?: number; + maxResults?: number; + includeComments?: boolean; + includeChangelog?: boolean; +}; + export async function jiraSearchIssuesForAssignee( cfg: JiraConfig, accountId: string, - opts: { days?: number; maxResults?: number } = {}, + opts: JiraSearchOpts = {}, ): Promise { - const days = opts.days ?? 180; - const maxResults = Math.min(opts.maxResults ?? 50, 100); - const jql = `assignee = "${accountId.replace(/"/g, '\\"')}" AND updated >= -${days}d ORDER BY updated DESC`; + const maxResults = Math.min(opts.maxResults ?? 80, 100); + const id = accountId.replace(/"/g, '\\"'); + let jql: string; + if (opts.windowStart && opts.windowEnd) { + jql = `(assignee = "${id}" OR reporter = "${id}" OR creator = "${id}") AND ((resolved >= "${opts.windowStart}" AND resolved <= "${opts.windowEnd}") OR (updated >= "${opts.windowStart}" AND updated <= "${opts.windowEnd}")) ORDER BY updated DESC`; + } else { + const days = opts.days ?? 180; + jql = `(assignee = "${id}" OR reporter = "${id}") AND updated >= -${days}d ORDER BY updated DESC`; + } + + const fields = [ + "summary", + "status", + "issuetype", + "priority", + "project", + "updated", + "created", + "resolutiondate", + "assignee", + "reporter", + "creator", + "labels", + "parent", + "customfield_10016", + "customfield_10026", + "customfield_10014", // epic link (classic) + "comment", + ].join(","); + + const expand: string[] = []; + if (opts.includeChangelog !== false) expand.push("changelog"); + const data = await jiraFetch<{ issues?: Array<{ id: string; key: string; - fields?: { - summary?: string; - status?: { name?: string }; - issuetype?: { name?: string }; - priority?: { name?: string }; - project?: { key?: string; name?: string }; - updated?: string; - resolutiondate?: string; - assignee?: { accountId?: string }; + fields?: Record; + changelog?: { + histories?: Array<{ + created?: string; + items?: Array<{ field?: string; fromString?: string | null; toString?: string | null }>; + }>; }; }>; }>( cfg, - `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=summary,status,issuetype,priority,project,updated,resolutiondate,assignee`, + `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${encodeURIComponent(fields)}${ + expand.length ? `&expand=${expand.join(",")}` : "" + }`, ); const base = normalizeBase(cfg.baseUrl); - return (data.issues ?? []).map((issue) => { - const f = issue.fields ?? {}; - return { + const out: JiraIssue[] = []; + + for (const issue of data.issues ?? []) { + const f = (issue.fields ?? {}) as Record; + const statusObj = f.status as { name?: string; statusCategory?: { name?: string; key?: string } } | undefined; + const status = statusObj?.name ?? "Unknown"; + const statusCategory = statusObj?.statusCategory?.name ?? statusObj?.statusCategory?.key ?? null; + const issueType = + (f.issuetype as { name?: string } | undefined)?.name ?? + (f.issueType as { name?: string } | undefined)?.name ?? + "Issue"; + const labels = Array.isArray(f.labels) ? (f.labels as string[]) : []; + const summary = String(f.summary ?? issue.key); + const noise = isNoiseIssue(issueType, summary, labels); + if (noise) continue; + + const parent = f.parent as { key?: string; fields?: { summary?: string } } | undefined; + const epicLink = typeof f.customfield_10014 === "string" ? f.customfield_10014 : null; + + let commentHighlights: string[] = []; + if (opts.includeComments) { + const comments = (f.comment as { comments?: Array<{ body?: unknown; author?: { displayName?: string } }> }) + ?.comments; + commentHighlights = (comments ?? []) + .slice(-3) + .map((c) => { + const text = extractAdfText(c.body).replace(/\s+/g, " ").trim().slice(0, 180); + const who = c.author?.displayName ?? "Someone"; + return text ? `${who}: ${text}` : ""; + }) + .filter(Boolean); + } + + const cat = (statusCategory ?? "").toLowerCase(); + const statusLower = status.toLowerCase(); + const isDone = + cat.includes("done") || + /^(done|closed|resolved|complete|shipped)$/i.test(statusLower); + const isInProgress = + !isDone && + (cat.includes("progress") || /progress|review|qa|testing|develop/i.test(statusLower)); + const isBug = /bug|defect|incident/i.test(issueType); + + const reporter = f.reporter as { accountId?: string; displayName?: string } | undefined; + const creator = f.creator as { accountId?: string; displayName?: string } | undefined; + const assignee = f.assignee as { accountId?: string } | undefined; + + out.push({ id: issue.id, key: issue.key, - summary: f.summary ?? issue.key, - status: f.status?.name ?? "Unknown", - issueType: f.issuetype?.name ?? "Issue", - priority: f.priority?.name ?? null, - projectKey: f.project?.key ?? "", - projectName: f.project?.name ?? "", - updated: (f.updated ?? "").slice(0, 10) || new Date().toISOString().slice(0, 10), - resolved: f.resolutiondate ? f.resolutiondate.slice(0, 10) : null, + summary, + status, + statusCategory, + issueType, + priority: (f.priority as { name?: string } | undefined)?.name ?? null, + projectKey: (f.project as { key?: string } | undefined)?.key ?? "", + projectName: (f.project as { name?: string } | undefined)?.name ?? "", + updated: String(f.updated ?? "").slice(0, 10) || new Date().toISOString().slice(0, 10), + resolved: f.resolutiondate ? String(f.resolutiondate).slice(0, 10) : null, + created: f.created ? String(f.created).slice(0, 10) : null, url: `${base}/browse/${issue.key}`, - assigneeAccountId: f.assignee?.accountId ?? null, - }; - }); + assigneeAccountId: assignee?.accountId ?? null, + reporterAccountId: reporter?.accountId ?? null, + reporterName: reporter?.displayName ?? null, + creatorAccountId: creator?.accountId ?? null, + creatorName: creator?.displayName ?? null, + labels, + epicKey: epicLink ?? parent?.key ?? null, + epicName: parent?.fields?.summary ?? null, + storyPoints: parseStoryPoints(f), + commentHighlights, + blockedHours: blockedHoursFromChangelog(issue.changelog?.histories ?? []), + isBug, + isDone, + isInProgress, + noiseFiltered: false, + }); + } + + return out; } diff --git a/apps/api/src/teamPerformance.test.ts b/apps/api/src/teamPerformance.test.ts index c79b8e4..ed4e53e 100644 --- a/apps/api/src/teamPerformance.test.ts +++ b/apps/api/src/teamPerformance.test.ts @@ -26,6 +26,10 @@ describe("buildTeamPerformance", () => { coverageNeed: 2, themes: ["career", "Career", "delivery"], openConcerns: 0, + deliveryDone: 4, + storyPointsDone: 13, + deliveryTrend: [2, 3], + thinDeliveryEvidence: null, }, { personId: "p2", @@ -44,6 +48,10 @@ describe("buildTeamPerformance", () => { coverageNeed: 0, themes: ["delivery"], openConcerns: 1, + deliveryDone: 0, + storyPointsDone: null, + deliveryTrend: [], + thinDeliveryEvidence: "jira_rich_dossier_thin", }, { personId: "p3", @@ -62,6 +70,10 @@ describe("buildTeamPerformance", () => { coverageNeed: 1, themes: [], openConcerns: 0, + deliveryDone: 1, + storyPointsDone: null, + deliveryTrend: [1], + thinDeliveryEvidence: null, }, ], }); @@ -92,6 +104,8 @@ describe("buildTeamPerformance", () => { assert.equal(exec?.avg, 3.5); assert.equal(result.people[0].personName, "Ada"); assert.equal(result.people[0].priorOverall, 3); + assert.equal(result.people[0].deliveryDone, 4); + assert.equal(result.people[1].thinDeliveryEvidence, "jira_rich_dossier_thin"); assert.equal(result.priorCycle?.cycleId, "c0"); }); diff --git a/apps/api/src/teamPerformance.ts b/apps/api/src/teamPerformance.ts index 74dbd6b..16c9b17 100644 --- a/apps/api/src/teamPerformance.ts +++ b/apps/api/src/teamPerformance.ts @@ -18,6 +18,10 @@ export type TeamPerformancePersonInput = { coverageNeed: number; themes: string[]; openConcerns: number; + deliveryDone: number; + storyPointsDone: number | null; + deliveryTrend: number[]; + thinDeliveryEvidence: string | null; }; const PROMO_KEYS = ["ready", "not_yet", "too_early", "not_applicable", "unset"] as const; @@ -114,6 +118,10 @@ export function buildTeamPerformance(args: { linkedSources: p.linkedSources, thin: p.thin, managerStatus: p.managerStatus, + deliveryDone: p.deliveryDone, + storyPointsDone: p.storyPointsDone, + deliveryTrend: p.deliveryTrend, + thinDeliveryEvidence: p.thinDeliveryEvidence, }); } diff --git a/apps/ui/src/components/DeliveryRollupPanel.tsx b/apps/ui/src/components/DeliveryRollupPanel.tsx new file mode 100644 index 0000000..e4b3e64 --- /dev/null +++ b/apps/ui/src/components/DeliveryRollupPanel.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import type { DeliveryRollupDTO } from "@prm/shared"; +import { api } from "../lib/api"; + +function thinLabel(flag: DeliveryRollupDTO["flags"]["thinDeliveryEvidence"]) { + if (flag === "jira_rich_dossier_thin") { + return "Thin delivery evidence: integrations look busy, but the dossier is light — add coaching notes or docs."; + } + if (flag === "dossier_rich_integrations_empty_window") { + return "Thin delivery evidence: dossier is rich, but nothing landed in this review window from Jira/PRs."; + } + return null; +} + +export function DeliveryRollupPanel({ + personId, + cycleId, +}: { + personId: string; + cycleId?: string | null; +}) { + const [rollup, setRollup] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!personId) return; + const qs = cycleId ? `?cycleId=${encodeURIComponent(cycleId)}` : ""; + api(`/api/people/${personId}/delivery-rollup${qs}`) + .then((r) => { + setRollup(r); + setError(null); + }) + .catch((e) => { + setRollup(null); + setError(e instanceof Error ? e.message : "Failed to load delivery rollup"); + }); + }, [personId, cycleId]); + + if (error) return

{error}

; + if (!rollup) return

Loading delivery rollup…

; + + const thin = thinLabel(rollup.flags.thinDeliveryEvidence); + const bands = Object.entries(rollup.sizeBands); + + return ( +
+
+

Delivery in window

+ + {rollup.windowStart} → {rollup.windowEnd} + +
+

+ Cycle-scoped rollup from synced Jira / Bitbucket / GitHub — volume and collaboration signals, not a score. +

+ {thin ?

{thin}

: null} +
+
+ {rollup.issuesDone} +
Issues done
+
+
+ {rollup.issuesInProgress} +
In progress
+
+
+ {rollup.storyPointsDone ?? "—"} +
Story points
+
+
+ {rollup.prsMerged} +
PRs merged
+
+
+ {rollup.reviewsGiven} +
Reviews given
+
+
+ {rollup.openBugs} +
Open bugs
+
+
+ {(rollup.blockedHours != null || rollup.reverts > 0 || bands.length > 0) && ( +

+ {rollup.blockedHours != null ? `Blocked ~${rollup.blockedHours}h · ` : ""} + {rollup.reverts > 0 ? `${rollup.reverts} revert-like PR(s) · ` : ""} + {bands.length + ? `PR size bands: ${bands.map(([k, v]) => `${k}×${v}`).join(", ")}` + : null} +

+ )} + {rollup.themes.length > 0 && ( +

+ Themes: {rollup.themes.map((t) => `${t.theme} (${t.count})`).join(", ")} +

+ )} + {rollup.citeSuggestions.length > 0 && ( +
+

+ Suggested cites for AI digests / drafts +

+
    + {rollup.citeSuggestions.map((c) => ( +
  • + {c.citeToken} {c.label}{" "} + ({c.reason}) +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/IntegrationsPanel.tsx b/apps/ui/src/components/IntegrationsPanel.tsx index 7d9076c..d3cdae8 100644 --- a/apps/ui/src/components/IntegrationsPanel.tsx +++ b/apps/ui/src/components/IntegrationsPanel.tsx @@ -5,12 +5,14 @@ import { api } from "../lib/api"; type IntegrationsState = { jira: IntegrationSettingsDTO; bitbucket: IntegrationSettingsDTO; + github: IntegrationSettingsDTO; }; export function IntegrationsPanel() { const [state, setState] = useState(null); const [jiraToken, setJiraToken] = useState(""); const [bbToken, setBbToken] = useState(""); + const [ghToken, setGhToken] = useState(""); const [msg, setMsg] = useState(null); const [busy, setBusy] = useState(false); @@ -24,13 +26,15 @@ export function IntegrationsPanel() { if (!state) return

Loading integrations…

; + const jiraExtra = state.jira.extra ?? {}; + return (

Integrations

- Pull Jira issues and merged Bitbucket PRs into each person’s dossier as achievements (citable - on the writing desk). Tokens are encrypted with the workspace secret — same pattern as AI keys. - Sync is explicit egress to Atlassian cloud. + Pull cycle-scoped Jira issues and merged Bitbucket / GitHub PRs into each person’s dossier as + achievements (citable on the writing desk). Tokens are encrypted with the workspace secret. + Sync is explicit egress — no leaderboards, LOC scores, or Slack mining.

(s ? { ...s, jira: next } : s)); @@ -96,6 +104,24 @@ export function IntegrationsPanel() { placeholder={state.jira.hasToken ? "••••••••" : "Atlassian API token"} />
+
+ +
+
+ {state.bitbucket.lastSyncAt && ( +

Last sync: {state.bitbucket.lastSyncAt}

+ )} + + +
{ + e.preventDefault(); + setBusy(true); + try { + const next = await api("/api/integrations/github", { + method: "PUT", + body: JSON.stringify({ + enabled: state.github.enabled, + baseUrl: state.github.baseUrl || "https://api.github.com", + workspaceSlug: state.github.workspaceSlug, + token: ghToken || undefined, + extra: { + ...(state.github.extra ?? {}), + org: state.github.workspaceSlug || state.github.extra?.org || null, + }, + }), + }); + setState((s) => (s ? { ...s, github: next } : s)); + setGhToken(""); + setMsg("GitHub settings saved"); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Save failed"); + } finally { + setBusy(false); + } + }} + > +

GitHub

+

+ Same pattern as Bitbucket for orgs that aren’t on BB — merged PRs + reviews in the active cycle window. +

+
+ +
+
+ + + setState({ ...state, github: { ...state.github, workspaceSlug: e.target.value } }) + } + placeholder="my-org" + /> +
+
+ + setState({ ...state, github: { ...state.github, baseUrl: e.target.value } })} + placeholder="https://api.github.com" + /> +
+
+ + setGhToken(e.target.value)} + placeholder={state.github.hasToken ? "••••••••" : "ghp_…"} + /> +
+
+ +
- {state.bitbucket.lastSyncAt && ( -

Last sync: {state.bitbucket.lastSyncAt}

+ {state.github.lastSyncAt && ( +

Last sync: {state.github.lastSyncAt}

)}

- Link each direct’s Jira / Bitbucket account on their person page, then sync there or use Sync - all above. + Link each direct’s Jira / Bitbucket / GitHub account on their person page, then sync there or use Sync + all above. Sync uses the active review cycle window.

{msg &&

{msg}

} diff --git a/apps/ui/src/pages/ConsistencyPage.tsx b/apps/ui/src/pages/ConsistencyPage.tsx index 61c27ad..850b591 100644 --- a/apps/ui/src/pages/ConsistencyPage.tsx +++ b/apps/ui/src/pages/ConsistencyPage.tsx @@ -12,6 +12,10 @@ export function ConsistencyPage() { const [error, setError] = useState(null); const [calibMsg, setCalibMsg] = useState(null); const [importJson, setImportJson] = useState(""); + const [exportedBundle, setExportedBundle] = useState(null); + const [directorDecisions, setDirectorDecisions] = useState< + Record + >({}); const cycleId = params.get("cycleId") ?? ""; async function load(cid?: string) { @@ -92,7 +96,8 @@ export function ConsistencyPage() {

Calibration bundles

- Export candidate cards for a director, then import decisions back into draft manager reviews (same cycle id). + Director round-trip: export cards, capture decisions in the form (or paste JSON), then import + back into draft manager reviews for the same cycle.

+ + {exportedBundle && ( +
+

Director decisions

+

+ Capture the room’s calls here, then apply — no hand-editing JSON required. +

+ {exportedBundle.cards.map((card) => { + const row = directorDecisions[card.personId] ?? { overall: "", note: "" }; + return ( +
+
+ {card.personName} +
was {card.overall ?? "—"}
+
+
+ + +
+
+ + + setDirectorDecisions({ + ...directorDecisions, + [card.personId]: { ...row, note: e.target.value }, + }) + } + placeholder="Director rationale" + /> +
+
+ ); + })} + +
+ )} +
- +