From 45046a5133936008337aa7e85200ab0ca5f3a2d2 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:30:40 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20server-side=20GitHub=20backf?= =?UTF-8?q?ill=20=E2=80=94=20admin-gated=20route=20+=20My=20Work=20sync=20?= =?UTF-8?q?button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only server-side GitHub backfill that populates the events store from GitHub REST using the worker's own GITHUB_SERVICE_TOKEN (no webhook secret). Reuses the pure eventsFromDelivery() derivation and writes through the single ingestEvent gate fn with the admin principal as writer; PR-summary and issue-progress projections mirror the webhook. Exposed via POST /admin/backfill (session- + admin-gated) and a My Work "Sync GitHub" button shown only when /auth/me returns admin:true. Co-Authored-By: Claude Fable 5 --- src/auth/principal.ts | 10 ++ src/auth/routes.ts | 3 +- src/env.ts | 1 + src/routes.ts | 15 ++- src/tools/backfill.ts | 220 ++++++++++++++++++++++++++++++++++++ test/admin-route.test.ts | 44 ++++++++ test/backfill.test.ts | 134 ++++++++++++++++++++++ test/env.d.ts | 1 + test/render.mywork.test.ts | 18 ++- test/render.roadmap.test.ts | 6 +- test/render.triage.test.ts | 2 +- vitest.config.ts | 1 + web/src/api.ts | 8 +- web/src/main.ts | 14 ++- web/src/render.ts | 10 +- wrangler.toml | 2 + 16 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 src/tools/backfill.ts create mode 100644 test/admin-route.test.ts create mode 100644 test/backfill.test.ts diff --git a/src/auth/principal.ts b/src/auth/principal.ts index 15c6166..3abe623 100644 --- a/src/auth/principal.ts +++ b/src/auth/principal.ts @@ -9,6 +9,16 @@ export interface Principal { export type AppEnv = { Bindings: Env; Variables: { principal: Principal } }; +/** + * Is this login an admin? ADMIN_LOGINS is a comma-separated allowlist of GitHub + * logins permitted to run admin actions (e.g. the server-side backfill). An + * absent/empty var means nobody is an admin — fails closed. + */ +export function isAdmin(env: Env, login: string): boolean { + const allow = (env.ADMIN_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean); + return allow.includes(login); +} + // The only routes reachable without a session. Everything else is gated. const PUBLIC_PATHS = new Set(["/auth/login", "/auth/callback"]); diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 0fb6a1e..167de48 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { setCookie, getCookie, deleteCookie } from "hono/cookie"; import type { AppEnv } from "./principal"; +import { isAdmin } from "./principal"; import { pkce, randomToken, hmacSeal, hmacUnseal } from "./crypto"; import { buildAuthorizeUrl, exchangeCode, getUser, isActiveOrgMember } from "./github"; import { createSession, setSessionCookie, readSessionCookie, deleteSession, clearSessionCookie } from "./session"; @@ -73,7 +74,7 @@ authApp.get("/callback", async (c) => { authApp.get("/me", async (c) => { const login = c.get("principal").login; const row = await first<{ name: string | null; avatar_url: string | null }>(c.env.DB, `SELECT name, avatar_url FROM users WHERE github_login = ?`, login); - return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG }); + return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG, admin: isAdmin(c.env, login) }); }); // GATED (by sessionGate in src/routes.ts): revoke this session. diff --git a/src/env.ts b/src/env.ts index 30755f8..1bbe49c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,4 +9,5 @@ export interface Env { DEV_LOGIN?: string; // LOCAL DEV ONLY (set in .dev.vars): bypass OAuth, act as this seeded user. Never set in prod. AI?: Ai; // Workers AI binding: capture-time completed-PR summaries only, never at render. GITHUB_SERVICE_TOKEN?: string; // app-level token for the scheduled progress-cache recompute backstop; absent → scheduled() no-ops + ADMIN_LOGINS?: string; // comma-separated GitHub logins allowed to run admin actions (e.g. the server-side backfill) } diff --git a/src/routes.ts b/src/routes.ts index 3a3fb58..731527a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; import { IngestPayload } from "@shared/contract"; import type { AppEnv } from "./auth/principal"; -import { sessionGate } from "./auth/principal"; +import { sessionGate, isAdmin } from "./auth/principal"; import { authApp } from "./auth/routes"; import { consume } from "./consumer"; +import { runBackfill } from "./tools/backfill"; import { get_doc, list_docs, get_feed, query, list_needs_triage, list_adrs, list_milestone_proposals, list_proposals } from "./tools/reads"; import { promote_doc, ratify_adr, promote_milestone_proposal, reject_milestone_proposal, complete_milestone, reject_doc_version, reject_adr, resolve_triage, assign_triage, type AssignType } from "./tools/writes"; import { get_plan } from "./tools/plan"; @@ -219,6 +220,18 @@ app.post("/milestone-proposals/:id/reject", async (c) => { } }); +// ADMIN action (session-gated + admin-gated): server-side GitHub backfill. +// A computed/authored direct writer in the promote class — humans (admins) +// trigger it — but every captured event still funnels through the ingestEvent +// gate fn. Non-admins get 403; a missing service token/repo → 503 with the error. +app.post("/admin/backfill", async (c) => { + const login = c.get("principal").login; + if (!isAdmin(c.env, login)) return c.json({ error: "admin only" }, 403); + const res = await runBackfill(c.env, login); + if (!res.ok) return c.json({ error: res.error }, 503); + return c.json(res); +}); + // Human confirmation (session-gated): flip a live milestone to 'done'. app.post("/milestones/:id/complete", async (c) => { const id = Number(c.req.param("id")); diff --git a/src/tools/backfill.ts b/src/tools/backfill.ts new file mode 100644 index 0000000..914f975 --- /dev/null +++ b/src/tools/backfill.ts @@ -0,0 +1,220 @@ +import type { Env } from "../env"; +import { nowIso } from "../db"; +import { ingestEvent } from "../consumer"; +import { eventsFromDelivery } from "../webhook"; +import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize"; +import { applyEventProgress } from "./progress"; + +// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs +// (which signs synthetic webhook deliveries with the webhook secret), this runs +// INSIDE the Worker with GITHUB_SERVICE_TOKEN — the same token the scheduled() +// progress recompute uses — so it fetches GitHub REST directly, no webhook secret. +// +// It reconstructs the SAME deliveries the webhook would have received, reuses the +// PURE eventsFromDelivery() derivation (never duplicated here), post-maps each +// event's provenance to "backfill", and writes through the ONE gate fn ingestEvent +// — but with the ADMIN principal as the writer (an authenticated identity), not +// the fixed "github-webhook" string. Downstream projections (PR summaries, issue +// progress) mirror handleGithubWebhook, hung off newly-written events only. + +const GH_API = "application/vnd.github+json"; +const USER_AGENT = "canopy"; +const DAYS_BACK = 14; + +export interface BackfillResult { + ok: boolean; + error?: string; + captured: number; + unchanged: number; + prs: number; + issues: number; +} + +// Minimal typed views over the GitHub REST list items — only the fields the +// delivery synthesizers below read are modeled; everything else is ignored. +interface GhUserLite { + login: string; +} +interface GhMilestoneLite { + number: number; + open_issues?: number; + closed_issues?: number; +} +interface GhPrListItem { + number: number; + title: string; + body: string | null; + html_url: string; + merged_at: string | null; + closed_at: string | null; + updated_at: string; + user: GhUserLite; + milestone?: GhMilestoneLite | null; +} +interface GhIssueListItem { + number: number; + title: string; + html_url: string; + state: string; + updated_at: string; + user: GhUserLite; + assignees?: GhUserLite[]; + assignee?: GhUserLite | null; + labels?: (string | { name: string })[]; + milestone?: GhMilestoneLite | null; + pull_request?: unknown; // present only when the "issue" is really a PR +} + +// The `rel="next"` URL from a GitHub Link header, or null when there is no next page. +function nextLink(res: Response): string | null { + const link = res.headers.get("link"); + const next = link?.split(",").find((part) => part.includes('rel="next"')); + return next ? (next.match(/<([^>]+)>/)?.[1] ?? null) : null; +} + +// Synthesize the delivery bodies eventsFromDelivery reads — SAME raw slice shapes +// as scripts/backfill-events.mjs (test/fixtures/*.json). PR list items carry no +// `merged` boolean (that's single-PR-fetch only), so derive it from merged_at. +function prClosedDelivery(pr: GhPrListItem) { + return { + action: "closed", + number: pr.number, + pull_request: { + number: pr.number, + title: pr.title, + body: pr.body, + html_url: pr.html_url, + merged: pr.merged_at != null, + merged_at: pr.merged_at, + closed_at: pr.closed_at, + user: { login: pr.user.login }, + milestone: pr.milestone + ? { number: pr.milestone.number, open_issues: pr.milestone.open_issues, closed_issues: pr.milestone.closed_issues } + : null, + }, + }; +} + +function issueDelivery(issue: GhIssueListItem) { + const assignee = issue.assignees?.[0] ?? issue.assignee ?? null; + const action = assignee ? "assigned" : "opened"; + return { + action, + ...(assignee ? { assignee: { login: assignee.login } } : {}), + issue: { + number: issue.number, + title: issue.title, + html_url: issue.html_url, + state: issue.state, + updated_at: issue.updated_at, + user: { login: issue.user.login }, + assignees: (issue.assignees ?? []).map((a) => ({ login: a.login })), + labels: issue.labels ?? [], + milestone: issue.milestone + ? { number: issue.milestone.number, open_issues: issue.milestone.open_issues, closed_issues: issue.milestone.closed_issues } + : null, + }, + }; +} + +export async function runBackfill( + env: Env, + principalLogin: string, + opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string } +): Promise { + const token = env.GITHUB_SERVICE_TOKEN; + const repo = env.GITHUB_REPO; + if (!token || !repo) { + return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 }; + } + + const doFetch = opts?.fetchImpl ?? fetch; + const summarizer = opts?.summarizer ?? (env.AI ? workersAiSummarizer(env.AI) : null); + const headers = { + authorization: `Bearer ${token}`, + accept: GH_API, + "user-agent": USER_AGENT, + "x-github-api-version": "2022-11-28", + }; + const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000; + + // (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop + // paginating at the first item whose updated_at predates the cutoff. + const prList: GhPrListItem[] = []; + { + let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`; + let done = false; + while (url && !done) { + const res: Response = await doFetch(url, { headers }); + if (!res.ok) break; + const page = (await res.json()) as GhPrListItem[]; + for (const pr of page) { + if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) { + done = true; + break; + } + prList.push(pr); + } + url = done ? null : nextLink(res); + } + } + + // (b) All open issues, paginated. The issues endpoint also returns PRs — those + // carry a `pull_request` field and are not our surface, so skip them. + const issueList: GhIssueListItem[] = []; + { + let url: string | null = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`; + while (url) { + const res: Response = await doFetch(url, { headers }); + if (!res.ok) break; + const page = (await res.json()) as GhIssueListItem[]; + for (const issue of page) { + if (issue.pull_request) continue; + issueList.push(issue); + } + url = nextLink(res); + } + } + + let captured = 0; + let unchanged = 0; + + for (const pr of prList) { + const payload = prClosedDelivery(pr); + for (const base of eventsFromDelivery("pull_request", payload)) { + const ev = { ...base, provenance: "backfill" as const }; + const res = await ingestEvent(env.DB, ev, principalLogin); + if (res.outcome === "written") { + captured++; + // Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and + // store a capture-time summary (storePrSummary never throws). + const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } }; + await storePrSummary(env.DB, summarizer, { + semantic_key: ev.semantic_key, + pr_number: parsed.pr.number, + title: parsed.pr.title, + body: parsed.pr.body ?? "", + }); + } else { + unchanged++; + } + } + } + + for (const issue of issueList) { + const payload = issueDelivery(issue); + for (const base of eventsFromDelivery("issues", payload)) { + const ev = { ...base, provenance: "backfill" as const }; + const res = await ingestEvent(env.DB, ev, principalLogin); + if (res.outcome === "written") { + captured++; + // Mirror handleGithubWebhook's progress seam for newly-written issues. + await applyEventProgress(env.DB, payload); + } else { + unchanged++; + } + } + } + + return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length }; +} diff --git a/test/admin-route.test.ts b/test/admin-route.test.ts new file mode 100644 index 0000000..92b6238 --- /dev/null +++ b/test/admin-route.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { app } from "../src/routes"; +import { createSession } from "../src/auth/session"; +import { hmacSeal } from "../src/auth/crypto"; + +// Seal a session cookie for `login` (mirrors test/dashboard-route.test.ts:9-15). +async function cookieFor(login: string): Promise { + await env.DB.prepare( + `INSERT OR IGNORE INTO users (github_login, name, created_at) VALUES (?, ?, ?)` + ).bind(login, login, "2026-01-01T00:00:00Z").run(); + const { id } = await createSession(env.DB, login); + return `session=${await hmacSeal(id, "test-cookie-secret")}`; +} + +describe("POST /admin/backfill (session- + admin-gated)", () => { + it("401s without a session", async () => { + const res = await app.request("/admin/backfill", { method: "POST" }, env); + expect(res.status).toBe(401); + }); + + it("403s for a non-admin principal", async () => { + const res = await app.request( + "/admin/backfill", + { method: "POST", headers: { cookie: await cookieFor("not-admin") } }, + env + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "admin only" }); + }); + + it("passes the admin gate and 503s when the service token is unset (proves wiring, no network)", async () => { + // ADMIN_LOGINS binds "admin-user" in vitest.config.ts, so this login clears + // isAdmin. GITHUB_SERVICE_TOKEN is a secret never set in tests, so runBackfill + // returns ok:false BEFORE any GitHub fetch → 503 with the config error. + const res = await app.request( + "/admin/backfill", + { method: "POST", headers: { cookie: await cookieFor("admin-user") } }, + env + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "service token or repo not configured" }); + }); +}); diff --git a/test/backfill.test.ts b/test/backfill.test.ts new file mode 100644 index 0000000..17294bf --- /dev/null +++ b/test/backfill.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { all, first } from "../src/db"; +import { runBackfill } from "../src/tools/backfill"; +import type { Env } from "../src/env"; +import type { Summarizer } from "../src/tools/summarize"; +import type { EventRow, PrSummaryRow } from "@shared/rows"; + +// Fixed "now" so the 14-day window is deterministic. threeDaysAgo is inside the +// window; twentyDaysAgo is outside it (must be excluded — and, being sorted +// updated-desc, must also stop pagination). +const NOW = "2026-07-01T00:00:00Z"; +const threeDaysAgo = "2026-06-28T00:00:00Z"; +const twentyDaysAgo = "2026-06-11T00:00:00Z"; + +// A Response-level fetch stub (the pool exports no fetch mock) — mirrors +// test/roadmap.test.ts / test/progress.test.ts. Routes by path substring: the +// pulls list vs the issues list. +function stubFetch(prs: unknown[], issues: unknown[]): typeof fetch { + return (async (url: string | URL | Request) => { + const u = String(url); + const body = u.includes("/pulls") ? prs : u.includes("/issues") ? issues : []; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; +} + +// Deterministic summarizer stub — never touches Workers AI. +const summarizer: Summarizer = { model: "test-model", summarize: async () => "AI summary" }; + +function envWith(overrides: Partial = {}): Env { + return { ...(env as unknown as Env), GITHUB_SERVICE_TOKEN: "svc-token", GITHUB_REPO: "o/r", ...overrides }; +} + +const mergedPr = { + number: 10, + title: "Add feature", + body: "This PR adds a feature.", + html_url: "https://github.com/o/r/pull/10", + merged_at: threeDaysAgo, // merged → derived merged:true from merged_at != null + closed_at: threeDaysAgo, + updated_at: threeDaysAgo, + user: { login: "octocat" }, + milestone: null, +}; +const oldPr = { + number: 5, + title: "Old PR", + body: "old", + html_url: "https://github.com/o/r/pull/5", + merged_at: twentyDaysAgo, + closed_at: twentyDaysAgo, + updated_at: twentyDaysAgo, // predates the cutoff → excluded (and stops pagination) + user: { login: "octocat" }, + milestone: null, +}; +const openIssue = { + number: 20, + title: "Fix bug", + html_url: "https://github.com/o/r/issues/20", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + assignees: [{ login: "octocat" }], // has an assignee → "assigned" + labels: ["bug"], + milestone: null, +}; +const prAsIssue = { + number: 21, + title: "A PR the issues endpoint also returned", + html_url: "https://github.com/o/r/pull/21", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + pull_request: { url: "https://api.github.com/repos/o/r/pulls/21" }, // → skipped + assignees: [], + labels: [], + milestone: null, +}; + +describe("runBackfill", () => { + it("captures in-window closed PRs + open issues as backfill events written by the admin principal", async () => { + const res = await runBackfill(envWith(), "admin-user", { + fetchImpl: stubFetch([mergedPr, oldPr], [openIssue, prAsIssue]), + summarizer, + now: NOW, + }); + + expect(res.ok).toBe(true); + expect(res.prs).toBe(1); // oldPr excluded by the 14-day window + expect(res.issues).toBe(1); // prAsIssue excluded (pull_request present) + expect(res.captured).toBe(2); + expect(res.unchanged).toBe(0); + + const events = await all(env.DB, `SELECT * FROM events ORDER BY ref_number`); + expect(events).toHaveLength(2); + for (const ev of events) { + expect(ev.provenance).toBe("backfill"); // provenance post-mapped from "webhook" + expect(ev.recorded_by).toBe("admin-user"); // writer is the ADMIN principal, not "github-webhook" + } + // The merged PR was captured as pr_merged (merged_at != null). + const pr = events.find((e) => e.ref_number === 10)!; + expect(pr.event_type).toBe("pr_merged"); + expect(pr.subject_login).toBe("octocat"); + + // The PR summary projection ran for the newly-written PR event. + const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); + expect(summary).toBeTruthy(); + expect(summary?.summary).toBe("AI summary"); + }); + + it("is idempotent — a second run over the same GitHub state writes nothing new", async () => { + const fetchImpl = stubFetch([mergedPr], [openIssue]); + const first = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); + expect(first.captured).toBe(2); + + const second = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); + expect(second.ok).toBe(true); + expect(second.captured).toBe(0); + expect(second.unchanged).toBe(2); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(2); // INSERT OR IGNORE on semantic_key + }); + + it("returns {ok:false} (no throw, no writes) when the service token is missing", async () => { + const res = await runBackfill(envWith({ GITHUB_SERVICE_TOKEN: undefined }), "admin-user", { + fetchImpl: stubFetch([mergedPr], [openIssue]), + summarizer, + now: NOW, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("service token or repo"); + expect(res).toMatchObject({ captured: 0, unchanged: 0, prs: 0, issues: 0 }); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(0); + }); +}); diff --git a/test/env.d.ts b/test/env.d.ts index e097c21..c070f80 100644 --- a/test/env.d.ts +++ b/test/env.d.ts @@ -16,6 +16,7 @@ declare global { GITHUB_WEBHOOK_SECRET?: string; GITHUB_REPO?: string; GITHUB_SERVICE_TOKEN?: string; + ADMIN_LOGINS?: string; } } } diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts index c0ec34c..59b2cec 100644 --- a/test/render.mywork.test.ts +++ b/test/render.mywork.test.ts @@ -140,13 +140,13 @@ describe("todoCard", () => { // ── full render() — My Work screen composition ────────────────────────────── describe("render() — My Work screen", () => { - function stateWithDashboard(data: DashboardData): ReturnType { + function stateWithDashboard(data: DashboardData, admin = false): ReturnType { const s = initialState(); return { ...s, view: "app", screen: "mywork", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin }, mywork: { status: "ok", data }, }; } @@ -203,4 +203,18 @@ describe("render() — My Work screen", () => { expect(html).toContain("Previous activity"); expect(html).toContain("To-do"); }); + + // ── admin-only Sync GitHub button (server-side backfill trigger) ──────────── + it("renders the Sync GitHub backfill button for an admin me", () => { + const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false }; + const html = render(stateWithDashboard(data, true)); + expect(html).toContain('data-act="adminBackfill"'); + expect(html).toContain("Sync GitHub"); + }); + + it("does NOT render the Sync GitHub button for a non-admin me", () => { + const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false }; + const html = render(stateWithDashboard(data, false)); + expect(html).not.toContain('data-act="adminBackfill"'); + }); }); diff --git a/test/render.roadmap.test.ts b/test/render.roadmap.test.ts index 23babe2..f669d5a 100644 --- a/test/render.roadmap.test.ts +++ b/test/render.roadmap.test.ts @@ -72,7 +72,7 @@ function stateWithPlan(plan: PlanView, tab: "narrative" | "timeline"): ReturnTyp view: "app", screen: "roadmap", roadmapTab: tab, - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, roadmap: { status: "ok", data: plan }, }; } @@ -190,7 +190,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => { ...s, view: "app", screen: "search", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, searchResults: { status: "ok", data: { @@ -215,7 +215,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => { ...s, view: "app", screen: "search", - me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" }, + me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false }, searchResults: { status: "ok", data: { diff --git a/test/render.triage.test.ts b/test/render.triage.test.ts index 5f337bf..f4521b4 100644 --- a/test/render.triage.test.ts +++ b/test/render.triage.test.ts @@ -392,7 +392,7 @@ describe("XSS: proposal slug in triage list and detail is attribute-escaped", () view: "app", screen: "triage", triageQueue: "proposals", - me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn" }, + me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn", admin: false }, proposals: { status: "ok", data: [proposal] }, selProposal: key, }; diff --git a/vitest.config.ts b/vitest.config.ts index d96e286..b08db02 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ GITHUB_CLIENT_ID: "test-client-id", GITHUB_CLIENT_SECRET: "test-client-secret", GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + ADMIN_LOGINS: "admin-user", // the admin allowlist the admin-gated route + isAdmin() test against DEV_LOGIN: "", // override .dev.vars: tests exercise REAL auth, never the dev bypass }, }, diff --git a/web/src/api.ts b/web/src/api.ts index aea01a8..e12f064 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -119,11 +119,17 @@ export function listMilestoneProposals(): Promise { return getJson<{ proposals: MilestoneProposalRow[] }>("/milestone-proposals").then((r) => r.proposals); } -export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; } +export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; admin: boolean; } export function getMe(): Promise { return getJson("/auth/me"); } +// ADMIN action: trigger the server-side GitHub backfill (admin-only route). The +// worker holds the service token and fetches GitHub directly — no webhook secret. +export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; prs: number; issues: number }> { + return postJson("/admin/backfill", {}); +} + export function getMyDashboard(): Promise { return getJson("/me/dashboard"); } diff --git a/web/src/main.ts b/web/src/main.ts index 9984b7e..ff5f655 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -10,7 +10,7 @@ import { listStagedProposals, listAdrs, listNeedsTriage, listMilestoneProposals, promoteDoc, ratifyAdr, completeMilestone, promoteMilestoneProposal, rejectDoc, rejectAdr, rejectMilestoneProposal, discardTriage, assignTriage, - getMe, logout, mintMcpToken, + getMe, logout, mintMcpToken, adminBackfill, Unauthorized, NotFound, ApiError, } from "./api"; @@ -477,6 +477,18 @@ function dispatch(act: string, arg: string | null, value: string | null): void { }); return; } + // ADMIN action (My Work): trigger the server-side GitHub backfill, then + // refresh My Work so newly-captured PRs/issues surface in the two lists. + case "adminBackfill": { + flash("Syncing GitHub…"); + adminBackfill() + .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged`); loadMyWork(); }) + .catch((e) => { + if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; } + flash(e instanceof ApiError ? e.message : "Sync failed"); + }); + return; + } // ── Phase 3 triage write-back (cookie-authed) ─────────────────────────── // Dismiss = reject. Shared by the Proposals queue (arg "slug@version" → reject // the staged doc version), the Decisions queue (arg id → reject the ADR), and diff --git a/web/src/render.ts b/web/src/render.ts index bc3405b..e8e0872 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -336,6 +336,14 @@ function header(s: AppState): string { ` : ""; + // ADMIN-only, My Work screen: trigger the server-side GitHub backfill. Rendered + // only when /auth/me returned admin:true (outline button, promote-class action). + const myworkControls = s.screen === "mywork" && s.me?.admin + ? `` : ""; + const themeBtn = `