diff --git a/apps/api/src/bitbucket.ts b/apps/api/src/bitbucket.ts new file mode 100644 index 0000000..c6fac80 --- /dev/null +++ b/apps/api/src/bitbucket.ts @@ -0,0 +1,181 @@ +/** Thin Bitbucket Cloud REST helpers (Basic auth: username + app password). */ + +export type BitbucketConfig = { + workspace: string; + username: string; + appPassword: string; +}; + +export type BitbucketRemoteUser = { + uuid: string; + displayName: string; + nickname?: string; + accountId?: string; + email?: string; +}; + +export type BitbucketPullRequest = { + id: number; + title: string; + state: string; + repoFullName: string; + authorUuid: string | null; + authorDisplay: string | null; + createdOn: string; + updatedOn: string; + mergedOn: string | null; + url: string; + commentCount: number; + taskCount: number; +}; + +function authHeader(cfg: BitbucketConfig) { + const token = Buffer.from(`${cfg.username}:${cfg.appPassword}`).toString("base64"); + return `Basic ${token}`; +} + +async function bbFetch(cfg: BitbucketConfig, path: string, init?: RequestInit): Promise { + const url = path.startsWith("http") ? path : `https://api.bitbucket.org/2.0${path}`; + const res = await fetch(url, { + ...init, + headers: { + Accept: "application/json", + Authorization: authHeader(cfg), + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Bitbucket ${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 bitbucketTestConnection(cfg: BitbucketConfig) { + const me = await bbFetch<{ display_name?: string; uuid?: string; username?: string }>(cfg, "/user"); + const ws = await bbFetch<{ name?: string; slug?: string }>( + cfg, + `/workspaces/${encodeURIComponent(cfg.workspace)}`, + ); + return { + ok: true as const, + displayName: me.display_name ?? null, + uuid: me.uuid ?? null, + username: me.username ?? cfg.username, + workspaceName: ws.name ?? cfg.workspace, + }; +} + +export async function bitbucketSearchMembers( + cfg: BitbucketConfig, + query: string, + max = 20, +): Promise { + const q = query.trim(); + const data = await bbFetch<{ + values?: Array<{ + user?: { + uuid?: string; + display_name?: string; + nickname?: string; + account_id?: string; + }; + }>; + }>( + cfg, + `/workspaces/${encodeURIComponent(cfg.workspace)}/members?pagelen=50${ + q ? `&q=${encodeURIComponent(`user.display_name~"${q.replace(/"/g, "")}"`)}` : "" + }`, + ); + const out: BitbucketRemoteUser[] = []; + for (const row of data.values ?? []) { + const u = row.user; + if (!u?.uuid) continue; + out.push({ + uuid: u.uuid, + displayName: u.display_name ?? u.nickname ?? u.uuid, + nickname: u.nickname, + accountId: u.account_id, + }); + if (out.length >= max) break; + } + return out; +} + +async function listWorkspaceRepos(cfg: BitbucketConfig, maxRepos = 30): Promise> { + const repos: Array<{ fullName: string; slug: string }> = []; + let url: string | null = + `/repositories/${encodeURIComponent(cfg.workspace)}?pagelen=50&role=member`; + while (url && repos.length < maxRepos) { + const page: { + values?: Array<{ full_name?: string; slug?: string }>; + next?: string; + } = await bbFetch(cfg, url); + for (const r of page.values ?? []) { + if (!r.full_name || !r.slug) continue; + repos.push({ fullName: r.full_name, slug: r.slug }); + if (repos.length >= maxRepos) break; + } + url = page.next ?? null; + } + return repos; +} + +export async function bitbucketMergedPrsForAuthor( + cfg: BitbucketConfig, + authorUuid: string, + opts: { maxRepos?: number; maxPrs?: number } = {}, +): Promise { + const maxRepos = opts.maxRepos ?? 25; + const maxPrs = opts.maxPrs ?? 50; + const repos = await listWorkspaceRepos(cfg, maxRepos); + const results: BitbucketPullRequest[] = []; + const uuid = authorUuid.includes("{") ? authorUuid : `{${authorUuid}}`; + + for (const repo of repos) { + if (results.length >= maxPrs) break; + const q = `state="MERGED" AND author.uuid="${uuid}"`; + try { + const data = await bbFetch<{ + values?: Array<{ + id: number; + title?: string; + state?: string; + created_on?: string; + updated_on?: string; + merged_on?: string | null; + comment_count?: number; + task_count?: number; + links?: { html?: { href?: string } }; + author?: { uuid?: string; display_name?: string }; + }>; + }>( + cfg, + `/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests?q=${encodeURIComponent(q)}&pagelen=50`, + ); + for (const pr of data.values ?? []) { + results.push({ + id: pr.id, + title: pr.title ?? `PR #${pr.id}`, + state: pr.state ?? "MERGED", + repoFullName: repo.fullName, + authorUuid: pr.author?.uuid ?? null, + 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, + url: pr.links?.html?.href ?? `https://bitbucket.org/${repo.fullName}/pull-requests/${pr.id}`, + commentCount: pr.comment_count ?? 0, + taskCount: pr.task_count ?? 0, + }); + if (results.length >= maxPrs) break; + } + } catch { + /* skip repos we can't read */ + } + } + + results.sort((a, b) => (b.mergedOn ?? "").localeCompare(a.mergedOn ?? "")); + return results; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5cb0345..c472e9c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,8 @@ import { roleFrameworks, workspace, concernLogs, + integrationSettings, + personExternalLinks, } from "@prm/db"; import type { BiasFinding, @@ -40,6 +42,11 @@ import type { ConcernLogDTO, CycleStatsDTO, DeadlineNudge, + IntegrationProvider, + IntegrationRemoteUser, + IntegrationSettingsDTO, + IntegrationSyncResult, + PersonExternalLinkDTO, } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; import { seedWorkspace } from "./seed.js"; @@ -82,6 +89,14 @@ import { runBiasToneLint } from "./bias.js"; import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; import { localPeerSynthesize } from "./peerSynth.js"; +import { bitbucketSearchMembers, bitbucketTestConnection } from "./bitbucket.js"; +import { jiraSearchUsers, jiraTestConnection } from "./jira.js"; +import { + ensureIntegrationRow, + getBitbucketConfig, + getJiraConfig, + syncPersonIntegrations, +} from "./integrations.js"; import { buildPersonTimeline, collectRatingChanges, @@ -2094,6 +2109,265 @@ app.put("/api/ai/settings", async (c) => { return c.json({ ok: true }); }); +function mapIntegrationSettings(provider: IntegrationProvider): IntegrationSettingsDTO { + const row = ensureIntegrationRow(provider); + let extra: Record = {}; + try { + extra = JSON.parse(row.extraJson || "{}") as Record; + } catch { + extra = {}; + } + return { + id: provider, + enabled: Boolean(row.enabled), + baseUrl: row.baseUrl, + workspaceSlug: row.workspaceSlug, + authEmail: row.authEmail, + authUsername: row.authUsername, + hasToken: Boolean(row.tokenEncrypted), + extra, + lastSyncAt: row.lastSyncAt, + updatedAt: row.updatedAt, + }; +} + +function mapExternalLink(row: typeof personExternalLinks.$inferSelect): PersonExternalLinkDTO { + return { + id: row.id, + personId: row.personId, + provider: row.provider as IntegrationProvider, + externalAccountId: row.externalAccountId, + externalDisplayName: row.externalDisplayName, + externalEmail: row.externalEmail, + externalUsername: row.externalUsername, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +app.get("/api/integrations", (c) => { + return c.json({ + jira: mapIntegrationSettings("jira"), + bitbucket: mapIntegrationSettings("bitbucket"), + }); +}); + +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); + 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 body = await c.req.json<{ + enabled?: boolean; + baseUrl?: string | null; + workspaceSlug?: string | null; + authEmail?: string | null; + authUsername?: string | null; + token?: string | null; + extra?: Record; + }>(); + const db = getDb(); + const current = ensureIntegrationRow(provider); + const secret = getWorkspaceSecret(); + let tokenEncrypted = current.tokenEncrypted; + if (body.token === null) tokenEncrypted = null; + else if (body.token && secret) tokenEncrypted = encryptSecret(body.token, secret); + else if (body.token && !secret) return c.json({ error: "Workspace secret unavailable — re-unlock" }, 400); + + const extraJson = + body.extra === undefined ? current.extraJson : JSON.stringify(body.extra ?? {}); + const ts = nowIso(); + db.update(integrationSettings) + .set({ + enabled: body.enabled === undefined ? current.enabled : body.enabled ? 1 : 0, + baseUrl: body.baseUrl === undefined ? current.baseUrl : body.baseUrl?.trim() || null, + workspaceSlug: + body.workspaceSlug === undefined ? current.workspaceSlug : body.workspaceSlug?.trim() || null, + authEmail: body.authEmail === undefined ? current.authEmail : body.authEmail?.trim() || null, + authUsername: + body.authUsername === undefined ? current.authUsername : body.authUsername?.trim() || null, + tokenEncrypted, + extraJson, + updatedAt: ts, + }) + .where(eq(integrationSettings.id, provider)) + .run(); + logActivity("integrations.settings.update", "integration", provider, { + enabled: body.enabled, + }); + return c.json(mapIntegrationSettings(provider)); +}); + +app.post("/api/integrations/:provider/test", async (c) => { + const provider = c.req.param("provider") as IntegrationProvider; + try { + if (provider === "jira") { + const cfg = getJiraConfig(); + if (!cfg) return c.json({ error: "Enable Jira and save base URL, email, and API token first" }, 400); + const result = await jiraTestConnection(cfg); + return c.json(result); + } + if (provider === "bitbucket") { + const cfg = getBitbucketConfig(); + if (!cfg) { + return c.json({ error: "Enable Bitbucket and save workspace, username, and app password first" }, 400); + } + const result = await bitbucketTestConnection(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); + } +}); + +app.get("/api/integrations/:provider/users", async (c) => { + const provider = c.req.param("provider") as IntegrationProvider; + const q = (c.req.query("q") ?? "").trim(); + if (!q) return c.json({ error: "q required" }, 400); + try { + if (provider === "jira") { + const cfg = getJiraConfig(); + if (!cfg) return c.json({ error: "Jira not configured" }, 400); + const users = await jiraSearchUsers(cfg, q); + const items: IntegrationRemoteUser[] = users.map((u) => ({ + accountId: u.accountId, + displayName: u.displayName, + email: u.emailAddress ?? null, + username: null, + })); + return c.json({ items }); + } + if (provider === "bitbucket") { + const cfg = getBitbucketConfig(); + if (!cfg) return c.json({ error: "Bitbucket not configured" }, 400); + const users = await bitbucketSearchMembers(cfg, q); + const items: IntegrationRemoteUser[] = users.map((u) => ({ + accountId: u.uuid, + displayName: u.displayName, + email: u.email ?? null, + username: u.nickname ?? null, + })); + 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); + } +}); + +app.get("/api/people/:id/external-links", (c) => { + const personId = c.req.param("id"); + const db = getDb(); + const items = db + .select() + .from(personExternalLinks) + .where(eq(personExternalLinks.personId, personId)) + .all() + .map(mapExternalLink); + return c.json({ items }); +}); + +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 body = await c.req.json<{ + externalAccountId: string; + externalDisplayName?: string | null; + externalEmail?: string | null; + externalUsername?: string | null; + }>(); + if (!body.externalAccountId?.trim()) return c.json({ error: "externalAccountId required" }, 400); + 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 ts = nowIso(); + const existing = db + .select() + .from(personExternalLinks) + .where(and(eq(personExternalLinks.personId, personId), eq(personExternalLinks.provider, provider))) + .all()[0]; + if (existing) { + db.update(personExternalLinks) + .set({ + externalAccountId: body.externalAccountId.trim(), + externalDisplayName: body.externalDisplayName?.trim() || null, + externalEmail: body.externalEmail?.trim() || null, + externalUsername: body.externalUsername?.trim() || null, + updatedAt: ts, + }) + .where(eq(personExternalLinks.id, existing.id)) + .run(); + logActivity("integrations.link.update", "person", personId, { provider }); + const updated = db.select().from(personExternalLinks).where(eq(personExternalLinks.id, existing.id)).all()[0]!; + return c.json(mapExternalLink(updated)); + } + const linkId = id("extlink"); + const row = { + id: linkId, + personId, + provider, + externalAccountId: body.externalAccountId.trim(), + externalDisplayName: body.externalDisplayName?.trim() || null, + externalEmail: body.externalEmail?.trim() || null, + externalUsername: body.externalUsername?.trim() || null, + createdAt: ts, + updatedAt: ts, + }; + db.insert(personExternalLinks).values(row).run(); + logActivity("integrations.link.create", "person", personId, { provider }); + return c.json(mapExternalLink(row)); +}); + +app.delete("/api/people/:id/external-links/:provider", (c) => { + const personId = c.req.param("id"); + const provider = c.req.param("provider") as IntegrationProvider; + const db = getDb(); + const existing = db + .select() + .from(personExternalLinks) + .where(and(eq(personExternalLinks.personId, personId), eq(personExternalLinks.provider, provider))) + .all()[0]; + if (!existing) return c.json({ error: "Not found" }, 404); + db.delete(personExternalLinks).where(eq(personExternalLinks.id, existing.id)).run(); + logActivity("integrations.link.delete", "person", personId, { provider }); + return c.json({ ok: true }); +}); + +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 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); + return c.json(result); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "Sync failed" }, 400); + } +}); + +app.post("/api/integrations/sync-all", async (c) => { + const body = await c.req + .json<{ providers?: IntegrationProvider[] }>() + .catch(() => ({} as { providers?: IntegrationProvider[] })); + 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)); + } + return c.json({ count: results.length, results }); +}); + app.post("/api/people/:id/documents", async (c) => { const personId = c.req.param("id"); const db = getDb(); @@ -2190,7 +2464,13 @@ function buildEvidenceContext(personId: string, cycleId?: string) { if (next) { for (const e of next.expectations) lines.push(`Next expectation [${e.id}] (${e.kind}): ${e.text}`); } - for (const item of ach) lines.push(`Achievement [${item.id}] ${item.occurredAt}: ${item.title}. ${item.description ?? ""}`); + for (const item of ach) { + const link = item.externalUrl ? ` ${item.externalUrl}` : ""; + const src = item.sourceProvider ? ` via ${item.sourceProvider}` : ""; + lines.push( + `Achievement [${item.id}] ${item.occurredAt}${src}: ${item.title}. ${item.description ?? ""}${link}`, + ); + } for (const item of g) lines.push(`Goal [${item.id}] (${item.status}): ${item.title}`); for (const item of fb) { if (!allowPrivateNotes && item.visibility === "em_only") { diff --git a/apps/api/src/integrations.clients.test.ts b/apps/api/src/integrations.clients.test.ts new file mode 100644 index 0000000..b3f017c --- /dev/null +++ b/apps/api/src/integrations.clients.test.ts @@ -0,0 +1,86 @@ +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"; + +describe("jira client", () => { + it("parses search issues from Cloud REST payload", async () => { + const fetchMock = mock.method(globalThis, "fetch", async () => { + return new Response( + JSON.stringify({ + issues: [ + { + id: "10001", + key: "ENG-42", + fields: { + summary: "Ship reliability fix", + status: { 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" }, + }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + const issues = await jiraSearchIssuesForAssignee( + { baseUrl: "https://acme.atlassian.net", email: "em@acme.com", apiToken: "tok" }, + "abc", + ); + assert.equal(issues.length, 1); + 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"); + fetchMock.mock.restore(); + }); + + it("surfaces myself on test connection", async () => { + const fetchMock = mock.method(globalThis, "fetch", async () => { + return new Response(JSON.stringify({ displayName: "EM User", accountId: "acc1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const res = await jiraTestConnection({ + baseUrl: "https://acme.atlassian.net/", + email: "em@acme.com", + apiToken: "tok", + }); + assert.equal(res.ok, true); + assert.equal(res.displayName, "EM User"); + fetchMock.mock.restore(); + }); +}); + +describe("bitbucket client", () => { + it("tests connection against user + workspace", async () => { + const fetchMock = mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/user")) { + return new Response(JSON.stringify({ display_name: "Dev", uuid: "{u1}", username: "dev" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ name: "Acme", slug: "acme" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const res = await bitbucketTestConnection({ + workspace: "acme", + username: "dev", + appPassword: "pw", + }); + assert.equal(res.ok, true); + assert.equal(res.workspaceName, "Acme"); + assert.equal(res.username, "dev"); + fetchMock.mock.restore(); + }); +}); diff --git a/apps/api/src/integrations.ts b/apps/api/src/integrations.ts new file mode 100644 index 0000000..4d55f55 --- /dev/null +++ b/apps/api/src/integrations.ts @@ -0,0 +1,224 @@ +import { and, eq } from "drizzle-orm"; +import { achievements, integrationSettings, personExternalLinks } from "@prm/db"; +import type { IntegrationProvider, IntegrationSyncResult } from "@prm/shared"; +import { bitbucketMergedPrsForAuthor, type BitbucketConfig } from "./bitbucket.js"; +import { jiraSearchIssuesForAssignee, type JiraConfig } from "./jira.js"; +import { + decryptSecret, + getDb, + getWorkspaceSecret, + id, + logActivity, + nowIso, +} from "./store.js"; + +export function getIntegrationRow(provider: IntegrationProvider) { + const db = getDb(); + return db.select().from(integrationSettings).where(eq(integrationSettings.id, provider)).all()[0]; +} + +export function ensureIntegrationRow(provider: IntegrationProvider) { + const db = getDb(); + const existing = getIntegrationRow(provider); + if (existing) return existing; + const ts = nowIso(); + db.insert(integrationSettings) + .values({ + id: provider, + enabled: 0, + baseUrl: provider === "jira" ? "https://your-domain.atlassian.net" : null, + workspaceSlug: null, + authEmail: null, + authUsername: null, + tokenEncrypted: null, + extraJson: "{}", + lastSyncAt: null, + updatedAt: ts, + }) + .run(); + return getIntegrationRow(provider)!; +} + +export function resolveIntegrationToken(provider: IntegrationProvider): string | null { + const row = getIntegrationRow(provider); + if (!row?.tokenEncrypted) return null; + const secret = getWorkspaceSecret(); + if (!secret) throw new Error("Workspace locked — unlock before using integrations"); + return decryptSecret(row.tokenEncrypted, secret); +} + +export function getJiraConfig(): JiraConfig | null { + const row = getIntegrationRow("jira"); + if (!row?.enabled || !row.baseUrl || !row.authEmail) return null; + const token = resolveIntegrationToken("jira"); + if (!token) return null; + return { baseUrl: row.baseUrl, email: row.authEmail, apiToken: token }; +} + +export function getBitbucketConfig(): BitbucketConfig | null { + const row = getIntegrationRow("bitbucket"); + if (!row?.enabled || !row.workspaceSlug || !row.authUsername) return null; + const token = resolveIntegrationToken("bitbucket"); + if (!token) return null; + return { workspace: row.workspaceSlug, username: row.authUsername, appPassword: token }; +} + +function upsertAchievement(input: { + personId: string; + provider: IntegrationProvider; + externalId: string; + title: string; + description: string; + occurredAt: string; + externalUrl: string; + payload: Record; +}) { + const db = getDb(); + const existing = db + .select() + .from(achievements) + .where( + and( + eq(achievements.personId, input.personId), + eq(achievements.sourceProvider, input.provider), + eq(achievements.externalId, input.externalId), + ), + ) + .all()[0]; + const payloadJson = JSON.stringify(input.payload); + if (existing) { + db.update(achievements) + .set({ + title: input.title, + description: input.description, + occurredAt: input.occurredAt, + externalUrl: input.externalUrl, + externalPayloadJson: payloadJson, + }) + .where(eq(achievements.id, existing.id)) + .run(); + return { id: existing.id, created: false }; + } + const achId = id("ach"); + db.insert(achievements) + .values({ + id: achId, + personId: input.personId, + title: input.title, + description: input.description, + occurredAt: input.occurredAt, + createdAt: nowIso(), + sourceProvider: input.provider, + externalId: input.externalId, + externalUrl: input.externalUrl, + externalPayloadJson: payloadJson, + }) + .run(); + return { id: achId, created: true }; +} + +export async function syncPersonIntegrations( + personId: string, + providers?: IntegrationProvider[], +): Promise { + const db = getDb(); + const want = providers?.length ? providers : (["jira", "bitbucket"] as IntegrationProvider[]); + const result: IntegrationSyncResult = { + personId, + jira: { imported: 0, updated: 0, skipped: true, error: null }, + bitbucket: { imported: 0, updated: 0, skipped: true, error: null }, + }; + + if (want.includes("jira")) { + result.jira.skipped = false; + try { + const cfg = getJiraConfig(); + if (!cfg) throw new Error("Jira is not configured or enabled"); + const link = db + .select() + .from(personExternalLinks) + .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 }); + for (const issue of issues) { + const desc = [ + `${issue.issueType} · ${issue.status}` + (issue.priority ? ` · ${issue.priority}` : ""), + issue.projectKey ? `Project: ${issue.projectKey} (${issue.projectName})` : null, + issue.resolved ? `Resolved: ${issue.resolved}` : `Updated: ${issue.updated}`, + issue.url, + ] + .filter(Boolean) + .join("\n"); + const up = upsertAchievement({ + personId, + provider: "jira", + externalId: issue.key, + title: `[Jira ${issue.key}] ${issue.summary}`, + description: desc, + occurredAt: issue.resolved ?? issue.updated, + externalUrl: issue.url, + payload: issue as unknown as Record, + }); + if (up.created) result.jira.imported += 1; + else result.jira.updated += 1; + } + db.update(integrationSettings) + .set({ lastSyncAt: nowIso(), updatedAt: nowIso() }) + .where(eq(integrationSettings.id, "jira")) + .run(); + } catch (e) { + result.jira.error = e instanceof Error ? e.message : "Jira sync failed"; + } + } + + if (want.includes("bitbucket")) { + result.bitbucket.skipped = false; + try { + const cfg = getBitbucketConfig(); + if (!cfg) throw new Error("Bitbucket is not configured or enabled"); + const link = db + .select() + .from(personExternalLinks) + .where(and(eq(personExternalLinks.personId, personId), eq(personExternalLinks.provider, "bitbucket"))) + .all()[0]; + if (!link) throw new Error("No Bitbucket account linked for this person"); + const prs = await bitbucketMergedPrsForAuthor(cfg, link.externalAccountId, { + maxRepos: 25, + maxPrs: 50, + }); + for (const pr of prs) { + const externalId = `${pr.repoFullName}#${pr.id}`; + const desc = [ + `Merged PR in ${pr.repoFullName}`, + `Comments: ${pr.commentCount} · Tasks: ${pr.taskCount}`, + pr.mergedOn ? `Merged: ${pr.mergedOn}` : null, + pr.url, + ] + .filter(Boolean) + .join("\n"); + const up = upsertAchievement({ + personId, + provider: "bitbucket", + externalId, + title: `[BB PR #${pr.id}] ${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.bitbucket.imported += 1; + else result.bitbucket.updated += 1; + } + db.update(integrationSettings) + .set({ lastSyncAt: nowIso(), updatedAt: nowIso() }) + .where(eq(integrationSettings.id, "bitbucket")) + .run(); + } catch (e) { + result.bitbucket.error = e instanceof Error ? e.message : "Bitbucket 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 new file mode 100644 index 0000000..8467775 --- /dev/null +++ b/apps/api/src/jira.ts @@ -0,0 +1,127 @@ +/** Thin Jira Cloud REST helpers (Basic auth: email + API token). */ + +export type JiraConfig = { + baseUrl: string; + email: string; + apiToken: string; +}; + +export type JiraRemoteUser = { + accountId: string; + displayName: string; + emailAddress?: string; + active?: boolean; +}; + +export type JiraIssue = { + id: string; + key: string; + summary: string; + status: string; + issueType: string; + priority: string | null; + projectKey: string; + projectName: string; + updated: string; + resolved: string | null; + url: string; + assigneeAccountId: string | null; +}; + +function authHeader(cfg: JiraConfig) { + const token = Buffer.from(`${cfg.email}:${cfg.apiToken}`).toString("base64"); + return `Basic ${token}`; +} + +function normalizeBase(url: string) { + return url.replace(/\/+$/, ""); +} + +async function jiraFetch(cfg: JiraConfig, path: string, init?: RequestInit): Promise { + const base = normalizeBase(cfg.baseUrl); + const res = await fetch(`${base}${path}`, { + ...init, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: authHeader(cfg), + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Jira ${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 jiraTestConnection(cfg: JiraConfig) { + const me = await jiraFetch<{ displayName?: string; accountId?: string }>(cfg, "/rest/api/3/myself"); + return { + ok: true as const, + displayName: me.displayName ?? null, + accountId: me.accountId ?? null, + }; +} + +export async function jiraSearchUsers(cfg: JiraConfig, query: string, max = 20): Promise { + const q = encodeURIComponent(query.trim()); + const rows = await jiraFetch< + Array<{ accountId: string; displayName: string; emailAddress?: string; active?: boolean }> + >(cfg, `/rest/api/3/user/search?query=${q}&maxResults=${max}`); + return (rows ?? []).map((u) => ({ + accountId: u.accountId, + displayName: u.displayName, + emailAddress: u.emailAddress, + active: u.active, + })); +} + +export async function jiraSearchIssuesForAssignee( + cfg: JiraConfig, + accountId: string, + opts: { days?: number; maxResults?: number } = {}, +): 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 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 }; + }; + }>; + }>( + cfg, + `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=summary,status,issuetype,priority,project,updated,resolutiondate,assignee`, + ); + + const base = normalizeBase(cfg.baseUrl); + return (data.issues ?? []).map((issue) => { + const f = issue.fields ?? {}; + return { + 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, + url: `${base}/browse/${issue.key}`, + assigneeAccountId: f.assignee?.accountId ?? null, + }; + }); +} diff --git a/apps/ui/src/components/IntegrationsPanel.tsx b/apps/ui/src/components/IntegrationsPanel.tsx new file mode 100644 index 0000000..7d9076c --- /dev/null +++ b/apps/ui/src/components/IntegrationsPanel.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } from "react"; +import type { IntegrationSettingsDTO, IntegrationSyncResult } from "@prm/shared"; +import { api } from "../lib/api"; + +type IntegrationsState = { + jira: IntegrationSettingsDTO; + bitbucket: IntegrationSettingsDTO; +}; + +export function IntegrationsPanel() { + const [state, setState] = useState(null); + const [jiraToken, setJiraToken] = useState(""); + const [bbToken, setBbToken] = useState(""); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + + async function load() { + setState(await api("/api/integrations")); + } + + useEffect(() => { + load().catch((e) => setMsg(e instanceof Error ? e.message : "Failed to load integrations")); + }, []); + + if (!state) return

Loading integrations…

; + + 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. +

+ +
{ + e.preventDefault(); + setBusy(true); + try { + const next = await api("/api/integrations/jira", { + method: "PUT", + body: JSON.stringify({ + enabled: state.jira.enabled, + baseUrl: state.jira.baseUrl, + authEmail: state.jira.authEmail, + token: jiraToken || undefined, + }), + }); + setState((s) => (s ? { ...s, jira: next } : s)); + setJiraToken(""); + setMsg("Jira settings saved"); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Save failed"); + } finally { + setBusy(false); + } + }} + > +

Jira Cloud

+
+ +
+
+ + setState({ ...state, jira: { ...state.jira, baseUrl: e.target.value } })} + placeholder="https://your-domain.atlassian.net" + /> +
+
+ + setState({ ...state, jira: { ...state.jira, authEmail: e.target.value } })} + placeholder="you@company.com" + /> +
+
+ + setJiraToken(e.target.value)} + placeholder={state.jira.hasToken ? "••••••••" : "Atlassian API token"} + /> +
+
+ + +
+ {state.jira.lastSyncAt && ( +

Last sync: {state.jira.lastSyncAt}

+ )} + + +
{ + e.preventDefault(); + setBusy(true); + try { + const next = await api("/api/integrations/bitbucket", { + method: "PUT", + body: JSON.stringify({ + enabled: state.bitbucket.enabled, + workspaceSlug: state.bitbucket.workspaceSlug, + authUsername: state.bitbucket.authUsername, + token: bbToken || undefined, + }), + }); + setState((s) => (s ? { ...s, bitbucket: next } : s)); + setBbToken(""); + setMsg("Bitbucket settings saved"); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Save failed"); + } finally { + setBusy(false); + } + }} + > +

Bitbucket Cloud

+
+ +
+
+ + + setState({ + ...state, + bitbucket: { ...state.bitbucket, workspaceSlug: e.target.value }, + }) + } + placeholder="my-workspace" + /> +
+
+ + + setState({ + ...state, + bitbucket: { ...state.bitbucket, authUsername: e.target.value }, + }) + } + placeholder="bb-username" + /> +
+
+ + setBbToken(e.target.value)} + placeholder={state.bitbucket.hasToken ? "••••••••" : "App password"} + /> +
+
+ + + +
+ {state.bitbucket.lastSyncAt && ( +

Last sync: {state.bitbucket.lastSyncAt}

+ )} + + +

+ Link each direct’s Jira / Bitbucket account on their person page, then sync there or use Sync + all above. +

+ {msg &&

{msg}

} +
+ ); +} diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index 40a35d9..cd13559 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -1,6 +1,14 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import type { ConcernLogDTO, CycleDTO, PersonDTO } from "@prm/shared"; +import type { + ConcernLogDTO, + CycleDTO, + IntegrationProvider, + IntegrationRemoteUser, + IntegrationSyncResult, + PersonDTO, + PersonExternalLinkDTO, +} from "@prm/shared"; import { api, getToken } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -10,7 +18,14 @@ export function PersonPage() { const [cycles, setCycles] = useState([]); const [dossier, setDossier] = useState<{ goals: Array<{ id: string; title: string; status: string }>; - achievements: Array<{ id: string; title: string; occurredAt: string; description: string | null }>; + achievements: Array<{ + id: string; + title: string; + occurredAt: string; + description: string | null; + sourceProvider?: string | null; + externalUrl?: string | null; + }>; feedback: Array<{ id: string; fromName: string | null; body: string; kind: string; themes?: string[] }>; documents: Array<{ id: string; title: string; filename: string; visibility: string; occurredAt: string }>; } | null>(null); @@ -29,6 +44,12 @@ export function PersonPage() { const [concernBody, setConcernBody] = useState(""); const [concernExpectation, setConcernExpectation] = useState(""); const [concernPlan, setConcernPlan] = useState(""); + const [extLinks, setExtLinks] = useState([]); + const [linkProvider, setLinkProvider] = useState("jira"); + const [linkQuery, setLinkQuery] = useState(""); + const [linkResults, setLinkResults] = useState([]); + const [linkMsg, setLinkMsg] = useState(null); + const [syncBusy, setSyncBusy] = useState(false); async function load() { if (!id) return; @@ -41,6 +62,12 @@ export function PersonPage() { } catch { setConcerns([]); } + try { + const links = await api<{ items: PersonExternalLinkDTO[] }>(`/api/people/${id}/external-links`); + setExtLinks(links.items); + } catch { + setExtLinks([]); + } } useEffect(() => { @@ -100,12 +127,160 @@ export function PersonPage() { /> )} +
+

Jira & Bitbucket

+

+ Link remote accounts, then pull tickets / merged PRs into achievements. Configure credentials in{" "} + Settings → Integrations. +

+ {extLinks.length === 0 ? ( +

No external accounts linked yet.

+ ) : ( +
    + {extLinks.map((l) => ( +
  • + {l.provider} — {l.externalDisplayName ?? l.externalAccountId} + {l.externalEmail ? · {l.externalEmail} : null} + {l.externalUsername ? · @{l.externalUsername} : null}{" "} + +
  • + ))} +
+ )} +
+
+ + +
+
+ + setLinkQuery(e.target.value)} + placeholder={person.email ?? person.name} + /> +
+ + +
+ {linkResults.length > 0 && ( +
    + {linkResults.map((u) => ( +
  • + + {u.displayName} + {u.email ? · {u.email} : null} + {u.username ? · @{u.username} : null} + + +
  • + ))} +
+ )} + {linkMsg &&

{linkMsg}

} +
+

Achievements

    {dossier.achievements.map((a) => ( -
  • {a.title} ({a.occurredAt})
  • +
  • + {a.title} ({a.occurredAt}) + {a.sourceProvider ? {a.sourceProvider} : null} + {a.externalUrl ? ( + <> + {" "} + + open + + + ) : null} +
  • ))}
Save AI settings} secondary={
+ +

Activity audit

diff --git a/docs/CORE_LOOP_EVAL.md b/docs/CORE_LOOP_EVAL.md index d879609..a481a35 100644 --- a/docs/CORE_LOOP_EVAL.md +++ b/docs/CORE_LOOP_EVAL.md @@ -30,6 +30,7 @@ upload role R&R → tag people → year-round dossier | Subject export | Done | HTML (+ Print→PDF tip); requires finalized/shared; strips `private_notes` / `em_only`; redacts peer names in cites | | Consistency + bias + faithfulness | Partial | Advisory invention checks + golden eval set + cite/soften actions — not fairness certification | | Continuous 1:1 themes | Done | Feedback notes accept comma-separated theme tags; included in AI evidence context | +| Jira + Bitbucket integrations | Done | Settings credentials (encrypted); person account link; sync tickets/merged PRs into achievements | | Decision log + audit pack | Done* | Append-only triggers; verify + HMAC export in Settings. *Tamper-evident / EM-keyed — not external legal custody (TRUST_MODEL) | | Promotion packets | Done | Next-role marks + AI case draft | | Backfill + MCP bulk load | Done | Thin = ach+fb+docs+goals < 2; MCP needs explicit unlock / `PRM_PASSWORD` | diff --git a/docs/product/FEATURES.md b/docs/product/FEATURES.md index fbf14a3..a9a4c70 100644 --- a/docs/product/FEATURES.md +++ b/docs/product/FEATURES.md @@ -73,6 +73,7 @@ Trust boundary: [TRUST_MODEL.md](../architecture/TRUST_MODEL.md). | Meet / transcript → MeetingSummary | P1 | Opt-in; default private-to-EM; notice that “private” ≠ undiscoverable legally | | 1:1 theme tags | P1 | Feedback notes; themes feed AI digests | | Integration signals (GitHub/Linear) | P2 | Optional | +| Jira + Bitbucket sync | P1 | Settings credentials; person account link; tickets/merged PRs → achievements | | Private vs shareable | P0 | Controls **share/export** inclusion; EM always sees workspace data | | Evidence snapshots at finalize | P0 | Cited title/checksum/excerpt frozen into review | | Out-of-window tray | P1 | Don’t silently drop late-uploaded docs | @@ -167,7 +168,8 @@ Detail: [AI_INTEGRATION.md](../ai/AI_INTEGRATION.md). | OS + in-app notifications | P0 | Workbench | | | Document / framework upload | P0 | Workbench | | | Google Drive / Meet file pick or manual transcript | P1 | Workbench | | -| GitHub / Linear | P2 | | | +| GitHub / Linear | P2 | | +| Jira Cloud + Bitbucket Cloud | P1 | Settings → Integrations; person link + sync into dossier achievements | | Slack / email digests | — | Hosted-only / later | | --- diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 680346d..ec9b889 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -147,7 +147,11 @@ CREATE TABLE IF NOT EXISTS achievements ( title TEXT NOT NULL, description TEXT, occurred_at TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + source_provider TEXT, + external_id TEXT, + external_url TEXT, + external_payload_json TEXT ); CREATE TABLE IF NOT EXISTS feedback_items ( @@ -242,6 +246,31 @@ CREATE TABLE IF NOT EXISTS ai_settings ( api_key_encrypted TEXT ); +CREATE TABLE IF NOT EXISTS integration_settings ( + id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + base_url TEXT, + workspace_slug TEXT, + auth_email TEXT, + auth_username TEXT, + token_encrypted TEXT, + extra_json TEXT NOT NULL DEFAULT '{}', + last_sync_at TEXT, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS person_external_links ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + provider TEXT NOT NULL, + external_account_id TEXT NOT NULL, + external_display_name TEXT, + external_email TEXT, + external_username TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS ai_generations ( id TEXT PRIMARY KEY, feature TEXT NOT NULL, @@ -282,6 +311,18 @@ export function openDatabase(dbPath: string): { sqlite: Database.Database; db: D } catch { /* column exists */ } + for (const col of [ + "ALTER TABLE achievements ADD COLUMN source_provider TEXT", + "ALTER TABLE achievements ADD COLUMN external_id TEXT", + "ALTER TABLE achievements ADD COLUMN external_url TEXT", + "ALTER TABLE achievements ADD COLUMN external_payload_json TEXT", + ]) { + try { + sqlite.exec(col); + } catch { + /* column exists */ + } + } sqlite.exec(` CREATE TRIGGER IF NOT EXISTS activity_events_no_update BEFORE UPDATE ON activity_events diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 69b2cb5..eb94411 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -141,6 +141,11 @@ export const achievements = sqliteTable("achievements", { description: text("description"), occurredAt: text("occurred_at").notNull(), createdAt: text("created_at").notNull(), + /** Manual = null; synced integrations set provider id. */ + sourceProvider: text("source_provider"), + externalId: text("external_id"), + externalUrl: text("external_url"), + externalPayloadJson: text("external_payload_json"), }); export const feedbackItems = sqliteTable("feedback_items", { @@ -238,6 +243,33 @@ export const aiSettings = sqliteTable("ai_settings", { apiKeyEncrypted: text("api_key_encrypted"), }); +/** Per-provider BYO integration config (Jira / Bitbucket). Tokens encrypted like AI keys. */ +export const integrationSettings = sqliteTable("integration_settings", { + id: text("id").primaryKey(), // 'jira' | 'bitbucket' + enabled: integer("enabled").notNull().default(0), + baseUrl: text("base_url"), + workspaceSlug: text("workspace_slug"), + authEmail: text("auth_email"), + authUsername: text("auth_username"), + tokenEncrypted: text("token_encrypted"), + extraJson: text("extra_json").notNull().default("{}"), + lastSyncAt: text("last_sync_at"), + updatedAt: text("updated_at").notNull(), +}); + +/** Map a local person to a remote Jira/Bitbucket account. */ +export const personExternalLinks = sqliteTable("person_external_links", { + id: text("id").primaryKey(), + personId: text("person_id").notNull(), + provider: text("provider").notNull(), + externalAccountId: text("external_account_id").notNull(), + externalDisplayName: text("external_display_name"), + externalEmail: text("external_email"), + externalUsername: text("external_username"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + export const aiGenerations = sqliteTable("ai_generations", { id: text("id").primaryKey(), feature: text("feature").notNull(), @@ -284,6 +316,8 @@ export const schema = { promotionCases, concernLogs, aiSettings, + integrationSettings, + personExternalLinks, aiGenerations, activityEvents, }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 507053f..5fb0210 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -252,6 +252,46 @@ export interface DeadlineNudge { mailtoHref: string; } +export type IntegrationProvider = "jira" | "bitbucket"; + +export interface IntegrationSettingsDTO { + id: IntegrationProvider; + enabled: boolean; + baseUrl: string | null; + workspaceSlug: string | null; + authEmail: string | null; + authUsername: string | null; + hasToken: boolean; + extra: Record; + lastSyncAt: string | null; + updatedAt: string; +} + +export interface PersonExternalLinkDTO { + id: string; + personId: string; + provider: IntegrationProvider; + externalAccountId: string; + externalDisplayName: string | null; + externalEmail: string | null; + externalUsername: string | null; + createdAt: string; + updatedAt: string; +} + +export interface IntegrationRemoteUser { + accountId: string; + displayName: string; + email?: string | null; + username?: string | null; +} + +export interface IntegrationSyncResult { + personId: string; + jira: { imported: number; updated: number; skipped: boolean; error: string | null }; + bitbucket: { imported: number; updated: number; skipped: boolean; error: string | null }; +} + export interface DecisionLogEntry { id: string; action: string;