-
Notifications
You must be signed in to change notification settings - Fork 1
Admin server-side backfill (My Work sync button) + admin-gated update_plan #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BackfillResult> { | ||
| 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); | ||
| } | ||
| } | ||
|
Comment on lines
+147
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Non-2xx GitHub responses are silently swallowed and reported as success. When 🛡️ Proposed fix: surface fetch failures instead of silently truncating while (url && !done) {
const res: Response = await doFetch(url, { headers });
- if (!res.ok) break;
+ if (!res.ok) {
+ return { ok: false, error: `GitHub pulls fetch failed: ${res.status}`, captured, unchanged, prs: prList.length, issues: 0 };
+ }
const page = (await res.json()) as GhPrListItem[];(similarly for the issues loop) Also applies to: 166-177 🤖 Prompt for AI Agents |
||
|
|
||
| // (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); | ||
| } | ||
| } | ||
|
Comment on lines
+164
to
+177
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: As of July 3, 2026, the Cloudflare Workers Paid (Standard) plan features the following limits for subrequests and CPU time: Subrequest Limits The default limit for subrequests per invocation is 10,000 [1][2]. This limit can be increased up to 10 million per invocation by configuring the limit in your Worker's Wrangler configuration file [1][3]. CPU Time Limits The maximum CPU time allowed per HTTP request invocation is 5 minutes (300,000 ms) [1][4]. By default, this is set to 30 seconds, but you can adjust this limit up to the 5-minute maximum via your Wrangler configuration or the Cloudflare dashboard [1][5]. For Cron Triggers or Queue Consumer invocations, the maximum CPU time is 15 minutes [4][5]. (Note that for Cron Triggers specifically, the available CPU time may depend on the interval, with 30 seconds for intervals less than 1 hour and 15 minutes for intervals of 1 hour or more [1][6].) You can manage these settings to prevent excessive usage or to accommodate CPU-intensive tasks through your Worker's settings in the Cloudflare dashboard or within your Wrangler configuration file [4][5]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline src/tools/backfill.ts --view expanded || true
printf '\n---\n'
sed -n '1,260p' src/tools/backfill.ts | cat -nRepository: SaplingLearn/canopy Length of output: 1924 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Files:\n'
git ls-files 'src/tools/backfill.ts' 'src/tools/*' | sed -n '1,50p'
printf '\n--- src/tools/backfill.ts ---\n'
sed -n '1,260p' src/tools/backfill.ts | cat -n
printf '\n--- search for issue pagination helpers ---\n'
rg -n "nextLink|pull_request|issues\\?state=open|updated_at" src/tools -SRepository: SaplingLearn/canopy Length of output: 1924 Cap open-issue pagination in backfill 🤖 Prompt for AI Agents |
||
|
|
||
| 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 }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
No error boundary around
runBackfill.runBackfillcan throw (see linked review comment insrc/tools/backfill.tson unguardedingestEvent/storePrSummarycalls). Since there's no try/catch here, an unexpected exception will bubble up as an unhandled rejection rather than the documented{ error }JSON envelope used for the config-missing case, breaking the route's error contract for admins.🛡️ Proposed fix
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); + try { + const res = await runBackfill(c.env, login); + if (!res.ok) return c.json({ error: res.error }, 503); + return c.json(res); + } catch (err) { + return c.json({ error: "backfill failed" }, 502); + } });📝 Committable suggestion
🤖 Prompt for AI Agents