diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md
index 56bd651f..cf5eacd9 100644
--- a/cloudflare/GITHUB.md
+++ b/cloudflare/GITHUB.md
@@ -203,6 +203,66 @@ See the official [GitHub license API](https://docs.github.com/en/rest/licenses/l
[GraphQL blobs](https://docs.github.com/en/graphql/reference/git#blob), and
[App metadata API](https://docs.github.com/en/rest/apps/apps#get-the-authenticated-app).
-The project-state quest owns ongoing snapshots, branches, PRs, and event
-reconciliation; this setup preview does not reserve or launch ready quests.
+The setup preview does not reserve or launch ready quests.
The jobs/funding quests own live eligibility checks and contribution execution.
+
+## Project state and activity
+
+An enrolled project's setup page links to `/projects/github///state`.
+A maintainer explicitly refreshes observations with its same-origin form. The
+service rereads the default branch at an exact commit using the preview's shared
+snapshot reader and validates the Quest tree with the pure core. The authenticated
+board and nested quest pages use the existing `Project` seam. Source links retain
+the exact commit; live project state never replaces GitHub as authority.
+
+Each refresh reconciles one page of native issues, **all** PRs (including human
+contributions and closed/merged PRs), and `quest/` branches. Independent opaque
+cursors persist across requests. Continue reconciliation until all connections
+finish; candidates remain empty while a scan is incomplete or a webhook has
+invalidated it. The next completed scan also detects deleted entities and removed
+claims. Refreshing a completed scan starts again from the authoritative entity
+connections, so missed webhooks and later changes to old entities are recovered.
+This deliberately avoids GitHub's bounded, delayed activity feed; GitHub does
+[not automatically redeliver failed webhooks](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries).
+There is no scheduled background poll in this stage.
+
+A single GraphQL activity query reads up to 50 issues, 50 PRs and 100 branch
+claims, keeping external request count independent of entity/file counts. Bulk
+D1 writes use `json_each`, avoiding a query per entity or SQLite's parameter
+limit. A revision comparison commits each page and cursor atomically; concurrent
+refreshes or an intervening webhook cannot publish over a newer observation.
+A webhook restarts an unfinished scan on the next refresh. Installation tokens
+remain read-only, repository-scoped, and revoked after use.
+
+Durable activity items use the GitHub repository ID, entity kind and full database
+ID. GitHub's GraphQL `fullDatabaseId` strings and webhook numeric IDs map to the
+same stored text key. Duplicate delivery IDs, redelivery with new IDs, later
+pushes/comments/reviews, and reconciliation all update the same item. Webhooks
+retain identity pointers only, never issue/PR bodies. Authoritative API reads add
+escaped titles, state, comment/review counts, current PR head, that head's check
+rollup, aggregate review decision, and up to 20 latest reviewer records with an
+explicit pagination flag. Raw bodies and individual check logs are not cached.
+Unavailable/deleted entities retain their notification identity and are marked
+missing rather than disappearing or presenting old state as current.
+
+A PR associates with a quest only when its head branch exactly matches the quest's
+path without `.md` (or an epic's `/README.md`). An unrelated human PR remains in
+activity without a guessed association. Open associated PRs and observed quest
+branch claims exclude that quest from candidate data; branch claims display as
+Claimed on the board. Runner/run associations belong to job coordination.
+
+Every cached read and refresh proves current maintainer authority, live repository
+identity/public visibility, installation access, and enrollment for that
+installation. Refresh rechecks those boundaries after provider reads. Paused
+projects remain readable but have no candidates. Disconnect cascades cached
+snapshots and activity; explicitly enrolling a replacement installation clears
+prior observations. No positive authority cache is created. Candidate data is an
+observation tied to its commit, **never execution permission**: job coordination
+must validate current eligibility, fresh revision, locks, and PRs before a start.
+Review/check data is similarly descriptive and cannot approve a merge.
+
+The `/data` endpoint exposes the same authenticated snapshot/activity with
+`Cache-Control: no-store` for future decision and job consumers. The activity
+page is a secondary read view; the durable decision inbox and its unread/answer
+state belong to the prompts workflow. Neither incoming activity nor refreshing
+starts a model, creates a quest/job, spends usage, or writes to GitHub.
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 55d297d4..be6246e5 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -26,8 +26,8 @@ renders the repository's `docs/*.md` into `build/docs.json`, so the Markdown
stays the single source and its relative links keep working on GitHub: links to
other docs become `/docs/` and other repository paths point at GitHub. It
also snapshots this repository's `quest/` tree and finished quests into
-`build/board.json`, which is the board's only project until the GitHub project
-state integration supplies enrolled repositories. A shallow clone has no
+`build/board.json`, which supplies the public board. Enrolled repositories use authenticated
+GitHub snapshots through the same board model. A shallow clone has no
history, so its board shows no finished work. It also copies `design/theme/` and `src/site.css` into `build/public/`, which the
Worker serves as static assets (`/theme/theme.css`, `/theme/logo.svg`,
`/theme/og.png`, and `/site.css`). `build/` is ignored by Git.
@@ -65,7 +65,7 @@ The passkey relying party is the hostname of `AUTH_URL`, and its exact origin
is the only one accepted. Changing the hostname orphans existing passkeys;
people enroll new ones after signing in with a provider.
-GitHub remains the planned authority for enrolled project state. The Worker
+GitHub is authoritative for enrolled project state. The Worker
has no Durable Objects; D1 holds accounts, sessions, and GitHub App state.
`cloudflare/` is a Bun workspace of the repository root, so the Worker
diff --git a/cloudflare/migrations/0006_project_state.sql b/cloudflare/migrations/0006_project_state.sql
new file mode 100644
index 00000000..252807f0
--- /dev/null
+++ b/cloudflare/migrations/0006_project_state.sql
@@ -0,0 +1,24 @@
+-- Cached observations never grant authority. Disconnect removes their retention.
+CREATE TABLE github_project_state (
+ repository INTEGER PRIMARY KEY REFERENCES github_projects(id) ON DELETE CASCADE,
+ revision INTEGER NOT NULL DEFAULT 0,
+ epoch TEXT NOT NULL,
+ invalidated INTEGER NOT NULL DEFAULT 0 CHECK(invalidated IN (0,1)),
+ snapshot TEXT,
+ scan TEXT,
+ observedAt INTEGER
+);
+-- One durable notification per provider entity, independent of delivery IDs.
+-- Webhooks keep only identity pointers; authenticated reconciliation fills data.
+CREATE TABLE github_project_activity (
+ repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK(kind IN ('issue','pull')),
+ id TEXT NOT NULL,
+ number INTEGER NOT NULL,
+ firstSeen INTEGER NOT NULL,
+ changedAt INTEGER NOT NULL,
+ data TEXT,
+ seenIn TEXT,
+ missing INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(repository,kind,id)
+);
diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts
index 39a5a146..6119b22f 100644
--- a/cloudflare/src/board/model.ts
+++ b/cloudflare/src/board/model.ts
@@ -10,7 +10,12 @@ import type { Change, Completed, Project } from "./project";
* A quest's most advanced state, per design/theme.md: Ready to turn in, then
* Available or Requires.
*/
-export type Status = { kind: "turn-in"; change: Change } | { kind: "available" } | { kind: "blocked"; requires: Ref[] };
+export type Status =
+ | { kind: "turn-in"; change: Change }
+ | { kind: "available" }
+ | { kind: "pending" }
+ | { kind: "accepted" }
+ | { kind: "blocked"; requires: Ref[] };
/** A link to another quest, or a blocker's own words when it names none. */
export type Ref = { title: string; href: string | null };
@@ -64,10 +69,14 @@ export function title(text: string): { size: Size | null; name: string } {
* A quest's page: its path without `.md`, like its branch name. A README is
* its directory's page.
*/
+export function boardHref(project: Project): string {
+ return project.href ?? `/repos/${project.name}`;
+}
+
export function questHref(project: Project, path: string): string {
- if (path === ROOT) return `/repos/${project.name}`;
+ if (path === ROOT) return boardHref(project);
const page = path.replace(/\/README\.md$/, "").replace(/\.md$/, "");
- return `/repos/${project.name}/${encodePath(page)}`;
+ return `${boardHref(project)}/${encodePath(page)}`;
}
/**
@@ -114,6 +123,9 @@ export function readBoard(project: Project): Board {
const docs = new Map(parsed.map((doc) => [doc.path, doc]));
const contents = new Map(sorted.map(({ path, content }) => [path, content]));
const open = new Set(ready(parsed));
+ // Observations narrow the tree's ready quests; until they are complete, an
+ // unseen change or claim may already occupy one.
+ const free = project.available === undefined ? open : new Set(project.available ?? []);
const changes = new Map(project.changes.map((change) => [change.quest, change]));
const name = (path: string) => {
@@ -124,7 +136,9 @@ export function readBoard(project: Project): Board {
const status = (doc: Doc): Status => {
const change = changes.get(doc.path);
if (change) return { kind: "turn-in", change };
- if (open.has(doc.path)) return { kind: "available" };
+ if (project.claims?.includes(doc.path)) return { kind: "accepted" };
+ if (free.has(doc.path)) return { kind: "available" };
+ if (open.has(doc.path)) return { kind: "pending" };
const requires = blockers(parsed, doc).map((blocker) =>
blocker.path === null
? { title: blocker.text, href: null }
@@ -214,7 +228,7 @@ export function readBoard(project: Project): Board {
}
const all = milestones.flatMap((milestone) => flatten(milestone.items));
- const counts = { "turn-in": 0, available: 0, blocked: 0 };
+ const counts = { "turn-in": 0, available: 0, pending: 0, blocked: 0, accepted: 0 };
for (const q of all) counts[q.status.kind]++;
const milestoneOf = (path: string) => milestones.find((c) => c.number !== "" && path.startsWith(`${dir(c.path)}/`));
diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx
index 6120e645..847786cb 100644
--- a/cloudflare/src/board/pages.tsx
+++ b/cloudflare/src/board/pages.tsx
@@ -5,6 +5,7 @@ import { type Crumb, Difficulty, Marker, Paste, SignIn, Token, type User } from
import { render } from "./markdown";
import {
type Board,
+ boardHref,
type Milestone,
milestoneAnchor,
blobHref,
@@ -27,8 +28,12 @@ function label(status: Status, limit = Infinity): string {
switch (status.kind) {
case "turn-in":
return "Ready to turn in";
+ case "accepted":
+ return "Claimed";
case "available":
return "Available";
+ case "pending":
+ return "Availability pending";
case "blocked": {
const titles = status.requires.map((ref) => ref.title);
const shown = titles.slice(0, limit).join(", ");
@@ -156,13 +161,15 @@ function FinishedList(props: { finished: Finished[] }) {
/** The statuses a visitor can filter the board by, in the order a contributor asks about them. */
export const SHOWN: [Status["kind"], string][] = [
["available", "Available"],
+ ["pending", "Availability pending"],
+ ["accepted", "Claimed"],
["turn-in", "Ready to turn in"],
["blocked", "Requires others"],
];
// Counts by status, which double as filters and as the legend for the markers.
function Tally(props: { board: Board; show: Status["kind"] | null }) {
- const base = `/repos/${props.board.project.name}`;
+ const base = boardHref(props.board.project);
return (
+ {project.available === null && (
+
+ Reconciliation with GitHub is incomplete, so availability is pending until every open change and
+ quest branch is observed.
+
+ Reconciliation with GitHub is incomplete, so an open change or quest branch may already claim this
+ quest. Check again once every page is observed.
+
+
+ );
if (status.kind === "blocked") {
return (
This quest opens once {status.requires.length === 1 ? "the quest" : "everything"} it requires is
- complete. Until then, pick an available{" "}
+ complete. Until then, pick an available{" "}
quest.
@@ -435,7 +469,7 @@ export function QuestPage(props: { board: Board; doc: Doc; content: string; user
)}
- Back to the quest log
+ Back to the quest log
{markdown && (
<>
{" · "}
diff --git a/cloudflare/src/board/project.ts b/cloudflare/src/board/project.ts
index 3f6823ba..3f04d6a4 100644
--- a/cloudflare/src/board/project.ts
+++ b/cloudflare/src/board/project.ts
@@ -36,11 +36,20 @@ export type Issue = { path: string; title: string };
export type Project = Snapshot & {
/** The project's name in URLs: `/repos/`. */
name: string;
+ /** An authenticated board may live under project setup. */
+ href?: string;
/** How the project names itself, such as `kixelated/quest`. */
title: string;
/** Where the repository's other files can be read, or `null` if nowhere public. */
web: string | null;
changes: Change[];
+ /** Observed branch claims reserve work without implying a finished PR. */
+ claims?: string[];
+ /**
+ * Quests observations show free to start, or `null` while reconciliation is
+ * incomplete. Absent when the quest tree alone decides.
+ */
+ available?: string[] | null;
issues: Issue[];
};
diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts
index 7e493c58..02fa8098 100644
--- a/cloudflare/src/github/service.ts
+++ b/cloudflare/src/github/service.ts
@@ -2,6 +2,7 @@ import { createHash, createPrivateKey, sign } from "node:crypto";
import { check, isQuest, parse, ready } from "quest/core";
import { osiApproved } from "../licenses";
import { positiveId } from "./webhook";
+import { activityQuery, type ActivityPage, type ProjectState, type Scan, reconcile, readState } from "./state";
export interface GitHubRepository {
owner: string;
@@ -306,12 +307,7 @@ export class GitHubService {
});
}
- async #preview(
- token: string,
- repo: GitHubRepository,
- current: Repository,
- installation: number,
- ): Promise {
+ async #snapshot(token: string, repo: GitHubRepository, current: Repository) {
this.#public(current);
if (!current.default_branch) throw new GitHubError("Repository default branch unavailable");
const path = repositoryPath(repo);
@@ -349,6 +345,7 @@ export class GitHubService {
});
if (result.errors?.length || !result.data?.repository) throw new GitHubError("Quest content unavailable");
const blobs = result.data.repository;
+ const files: { path: string; content: string }[] = [];
const documents = entries.map((entry, i) => {
const blob = blobs[`q${i}`];
if (
@@ -364,25 +361,127 @@ export class GitHubService {
const content = Buffer.from(blob.text, "utf8");
const oid = createHash("sha1").update(`blob ${content.length}\0`).update(content).digest("hex");
if (oid !== entry.sha) throw new GitHubError("Quest content unavailable");
+ files.push({ path: entry.path, content: blob.text });
return parse(entry.path, blob.text);
});
const paths = new Set(root.tree.map((entry) => entry.path));
if (check(documents, (path) => paths.has(path)).length)
throw new GitHubError("Fix the quest tree with quest check before enabling contributions");
const available = new Set(ready(documents));
+ return {
+ commit: commit.sha,
+ date: new Date().toISOString().slice(0, 10),
+ documents: files,
+ completed: [],
+ ready: documents
+ .filter((doc) => available.has(doc.path))
+ .map((doc) => ({ path: doc.path, title: doc.title?.text ?? doc.path })),
+ };
+ }
+
+ async #preview(
+ token: string,
+ repo: GitHubRepository,
+ current: Repository,
+ installation: number,
+ ): Promise {
+ const snapshot = await this.#snapshot(token, repo, current);
return {
repository: repo,
branch: current.default_branch,
- commit: commit.sha,
+ commit: snapshot.commit,
license: current.license,
licenseError: await this.#license(current),
- quests: documents
- .filter((doc) => available.has(doc.path))
- .map((doc) => ({ path: doc.path, title: doc.title?.text ?? doc.path })),
+ quests: snapshot.ready,
state: await this.#state(repo, installation),
};
}
+ async #observations(repo: GitHubRepository, installation: number) {
+ const result = await readState(this.#env.DB, repo);
+ const state = await this.#state(repo, installation);
+ await this.#gate(installation);
+ if (state === null) throw new GitHubError("Project enrollment changed");
+ if (result && state !== "enabled") result.available = [];
+ return result;
+ }
+
+ /** Observations only. Neither a refresh nor an arriving entity launches work. */
+ projectState(repo: GitHubRepository, userId: string, refresh = false): Promise {
+ return this.#authorized(
+ repo,
+ userId,
+ refresh
+ ? { contents: "read", issues: "read", pull_requests: "read", checks: "read", statuses: "read" }
+ : {},
+ async (token, current, installation) => {
+ this.#public(current);
+ if ((await this.#state(repo, installation)) === null)
+ throw new GitHubError("Project is not enrolled for this installation");
+ if (!refresh) return this.#observations(repo, installation);
+ await this.#env.DB.prepare("INSERT OR IGNORE INTO github_project_state(repository,epoch) VALUES(?,?)")
+ .bind(repo.id, crypto.randomUUID())
+ .run();
+ const row = await this.#env.DB.prepare(
+ "SELECT revision,scan,invalidated,epoch FROM github_project_state WHERE repository=?",
+ )
+ .bind(repo.id)
+ .first<{ revision: number; scan: string | null; invalidated: number; epoch: string }>();
+ const previous: Scan | null = row?.scan ? JSON.parse(row.scan) : null;
+ const scan: Scan =
+ previous && !previous.complete && row?.invalidated !== 1
+ ? previous
+ : {
+ id: crypto.randomUUID(),
+ issues: null,
+ pulls: null,
+ refs: null,
+ claims: [],
+ complete: false,
+ };
+ const snapshot = await this.#snapshot(token, repo, current);
+ const response = await this.#api<{ data?: { repository: ActivityPage | null }; errors?: unknown[] }>(
+ token,
+ "/graphql",
+ "POST",
+ {
+ query: activityQuery(scan),
+ variables: {
+ owner: repo.owner,
+ name: repo.name,
+ issues: scan.issues || null,
+ pulls: scan.pulls || null,
+ refs: scan.refs || null,
+ },
+ },
+ );
+ if (response.errors?.length || !response.data?.repository)
+ throw new GitHubError("Project activity unavailable");
+ // A rename, transfer, visibility change, revocation, or lost
+ // maintainer role during the scan must not publish fresh observations.
+ const latest = await this.#api(token, repositoryPath(repo));
+ this.#repository(repo, latest);
+ this.#public(latest);
+ if (latest.default_branch !== current.default_branch)
+ throw new GitHubError("Default branch changed; refresh again");
+ await this.#maintainer(token, repo, userId);
+ await this.#gate(installation);
+ if ((await this.#state(repo, installation)) === null)
+ throw new GitHubError("Project enrollment changed");
+ await reconcile(
+ this.#env.DB,
+ repo,
+ snapshot,
+ scan,
+ response.data.repository,
+ row!.revision,
+ row!.epoch,
+ );
+ return this.#observations(repo, installation);
+ },
+ );
+ }
+
previewProject(repo: GitHubRepository, userId: string) {
return this.#authorized(repo, userId, { contents: "read" }, (token, current, installation) =>
this.#preview(token, repo, current, installation),
@@ -422,12 +521,18 @@ export class GitHubService {
.bind(Date.now(), repo.id)
.run();
} else {
- await this.#env.DB.prepare(
- `INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(?,?,?,?,'enabled',?,?)
+ await this.#env.DB.batch([
+ this.#env.DB.prepare(
+ "DELETE FROM github_project_state WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?)",
+ ).bind(repo.id, repo.id, installation),
+ this.#env.DB.prepare(
+ "DELETE FROM github_project_activity WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?)",
+ ).bind(repo.id, repo.id, installation),
+ this.#env.DB.prepare(
+ `INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(?,?,?,?,'enabled',?,?)
ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,name=excluded.name,installation=excluded.installation,state=excluded.state,updatedAt=excluded.updatedAt`,
- )
- .bind(repo.id, repo.owner, repo.name, installation, userId, Date.now())
- .run();
+ ).bind(repo.id, repo.owner, repo.name, installation, userId, Date.now()),
+ ]);
}
},
state !== "enabled",
diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts
new file mode 100644
index 00000000..33b78c7e
--- /dev/null
+++ b/cloudflare/src/github/state.ts
@@ -0,0 +1,248 @@
+import { parse, ready } from "quest/core";
+import type { Project, Snapshot } from "../board/project";
+import { GitHubError, type GitHubRepository } from "./service";
+import { positiveId } from "./webhook";
+
+export type Scan = {
+ id: string;
+ issues: string | null | false;
+ pulls: string | null | false;
+ refs: string | null | false;
+ claims: string[];
+ complete: boolean;
+};
+type Connection = { nodes: T[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } };
+type Entity = {
+ databaseId: string;
+ number: number;
+ title: string;
+ state: string;
+ updatedAt: string;
+ author: { login: string } | null;
+ comments: { totalCount: number };
+};
+export type Pull = Entity & {
+ headRefName: string;
+ headRefOid: string;
+ isDraft: boolean;
+ reviewDecision: string | null;
+ reviews: { totalCount: number };
+ latestReviews: {
+ nodes: { state: string; author: { login: string } | null; commit: { oid: string } | null }[];
+ pageInfo: { hasNextPage: boolean };
+ };
+ commits: { nodes: { commit: { oid: string; statusCheckRollup: { state: string } | null } }[] };
+};
+export type ActivityPage = {
+ issues?: Connection;
+ pullRequests?: Connection;
+ refs?: Connection<{ name: string; prefix: string }>;
+};
+export type Activity = {
+ kind: "issue" | "pull";
+ id: string;
+ number: number;
+ firstSeen: number;
+ changedAt: number;
+ data: Entity | Pull | null;
+ missing: number;
+};
+export type ProjectState = {
+ project: Project;
+ activity: Activity[];
+ claims: string[];
+ available: string[];
+ complete: boolean;
+ observedAt: number;
+ revision: number;
+};
+
+const entityFields = "databaseId:fullDatabaseId number title state updatedAt author { login } comments { totalCount }";
+// Cursor variables are declared only for unfinished connections (GraphQL rejects
+// unused variables). Fixed page sizes bound subrequests, independent of tree size.
+export function activityQuery(scan: Scan) {
+ const fields: string[] = [];
+ const variables = ["$owner: String!", "$name: String!"];
+ if (scan.issues !== false) {
+ variables.push("$issues: String");
+ fields.push(
+ `issues(first:50,after:$issues,orderBy:{field:CREATED_AT,direction:ASC}) { nodes { ${entityFields} } pageInfo { hasNextPage endCursor } }`,
+ );
+ }
+ if (scan.pulls !== false) {
+ variables.push("$pulls: String");
+ fields.push(
+ `pullRequests(first:50,after:$pulls,orderBy:{field:CREATED_AT,direction:ASC}) { nodes { ${entityFields} headRefName headRefOid isDraft reviewDecision reviews { totalCount } latestReviews(first:20) { nodes { state author {login} commit {oid} } pageInfo {hasNextPage} } commits(last:1) { nodes { commit { oid statusCheckRollup { state } } } } } pageInfo { hasNextPage endCursor } }`,
+ );
+ }
+ if (scan.refs !== false) {
+ variables.push("$refs: String");
+ fields.push(
+ 'refs(refPrefix:"refs/heads/quest/",first:100,after:$refs) { nodes { name prefix } pageInfo { hasNextPage endCursor } }',
+ );
+ }
+ return `query(${variables.join(",")}) { repository(owner:$owner,name:$name) { ${fields.join("\n")} } }`;
+}
+
+function next(connection: Connection | undefined, cursor: Scan["issues"]) {
+ if (cursor === false) return false;
+ if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo)
+ throw new GitHubError("Incomplete project activity");
+ if (!connection.pageInfo.hasNextPage) return false;
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor)
+ throw new GitHubError("Invalid project activity cursor");
+ return connection.pageInfo.endCursor;
+}
+
+/** A branch convention is evidence of association, never execution authority. */
+export function associatedQuest(branch: string, paths: Set) {
+ const exact = `${branch}.md`;
+ const epic = `${branch}/README.md`;
+ return paths.has(exact) ? exact : paths.has(epic) ? epic : null;
+}
+
+export async function reconcile(
+ db: D1Database,
+ repo: GitHubRepository,
+ snapshot: Snapshot,
+ scan: Scan,
+ page: ActivityPage,
+ revision: number,
+ epoch: string,
+) {
+ const now = Date.now();
+ const updated: Scan = {
+ id: scan.id,
+ issues: next(page.issues, scan.issues),
+ pulls: next(page.pullRequests, scan.pulls),
+ refs: next(page.refs, scan.refs),
+ claims: [...scan.claims],
+ complete: false,
+ };
+ if (scan.refs !== false)
+ for (const ref of page.refs!.nodes) {
+ if (typeof ref.name !== "string" || ref.prefix !== "refs/heads/quest/")
+ throw new GitHubError("Invalid branch observation");
+ // Ref.name is relative to the requested prefix.
+ updated.claims.push(`quest/${ref.name}`);
+ }
+ updated.claims = [...new Set(updated.claims)];
+ updated.complete = updated.issues === false && updated.pulls === false && updated.refs === false;
+ const entities: { kind: string; item: Entity }[] = [];
+ for (const [kind, connection] of [
+ ["issue", page.issues],
+ ["pull", page.pullRequests],
+ ] as const) {
+ if (scan[kind === "issue" ? "issues" : "pulls"] === false) continue;
+ for (const item of connection!.nodes) {
+ if (
+ typeof item.databaseId !== "string" ||
+ !/^[1-9]\d*$/.test(item.databaseId) ||
+ !positiveId(item.number) ||
+ typeof item.title !== "string" ||
+ !Number.isFinite(Date.parse(item.updatedAt))
+ )
+ throw new GitHubError("Invalid entity observation");
+ if (kind === "pull") {
+ const pull = item as Pull;
+ if (
+ !/^[a-f0-9]{40}$/.test(pull.headRefOid) ||
+ pull.commits.nodes.at(-1)?.commit.oid !== pull.headRefOid
+ )
+ throw new GitHubError("Pull request head changed; refresh again");
+ }
+ entities.push({ kind, item });
+ }
+ }
+ // json_each keeps the transaction to three statements and a few bindings,
+ // within D1's free query budget and SQLite's bound-parameter limit.
+ const statements = [
+ db
+ .prepare(
+ `INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn)
+ SELECT ?,json_extract(value,'$.kind'),json_extract(value,'$.item.databaseId'),json_extract(value,'$.item.number'),?,?,json_extract(value,'$.item'),?
+ FROM json_each(?) WHERE EXISTS(SELECT 1 FROM github_project_state WHERE repository=? AND revision=? AND epoch=?)
+ ON CONFLICT(repository,kind,id) DO UPDATE SET number=excluded.number,changedAt=CASE WHEN github_project_activity.data IS NOT excluded.data THEN excluded.changedAt ELSE github_project_activity.changedAt END,data=excluded.data,seenIn=excluded.seenIn,missing=0`,
+ )
+ .bind(repo.id, now, now, scan.id, JSON.stringify(entities), repo.id, revision, epoch),
+ db
+ .prepare(
+ "UPDATE github_project_activity SET missing=1 WHERE repository=? AND (seenIn IS NULL OR seenIn<>?) AND ?=1 AND EXISTS(SELECT 1 FROM github_project_state WHERE repository=? AND revision=? AND epoch=?)",
+ )
+ .bind(repo.id, scan.id, updated.complete ? 1 : 0, repo.id, revision, epoch),
+ db
+ .prepare(
+ "UPDATE github_project_state SET snapshot=?,scan=?,observedAt=?,invalidated=?,revision=revision+1 WHERE repository=? AND revision=? AND epoch=?",
+ )
+ .bind(JSON.stringify(snapshot), JSON.stringify(updated), now, 0, repo.id, revision, epoch),
+ ];
+ const result = await db.batch(statements);
+ if (result.at(-1)!.meta.changes !== 1) throw new GitHubError("Project changed during refresh; refresh again");
+}
+
+export async function readState(db: D1Database, repo: GitHubRepository): Promise {
+ const records = await db.batch([
+ db
+ .prepare(
+ "SELECT snapshot,scan,observedAt,revision,invalidated FROM github_project_state WHERE repository=?",
+ )
+ .bind(repo.id),
+ db
+ .prepare(
+ "SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity WHERE repository=? ORDER BY firstSeen DESC,id DESC",
+ )
+ .bind(repo.id),
+ ]);
+ const row = records[0]!.results[0] as
+ | { snapshot: string | null; scan: string | null; observedAt: number; revision: number; invalidated: number }
+ | undefined;
+ if (!row?.snapshot || !row.scan) return null;
+ const snapshot: Snapshot = JSON.parse(row.snapshot);
+ const scan: Scan = JSON.parse(row.scan);
+ const rows = records[1]!.results as (Omit & { data: string | null })[];
+ const activity: Activity[] = rows.map((item) => ({ ...item, data: item.data ? JSON.parse(item.data) : null }));
+ const paths = new Set(snapshot.documents.map((d) => d.path));
+ const changes = activity
+ .filter((item) => item.kind === "pull" && !item.missing && item.data?.state === "OPEN")
+ .flatMap((item) => {
+ const pull = item.data as Pull;
+ const quest = associatedQuest(pull.headRefName, paths);
+ return quest
+ ? [
+ {
+ quest,
+ author: pull.author?.login ?? "GitHub contributor",
+ href: `https://github.com/${repo.owner}/${repo.name}/pull/${pull.number}`,
+ },
+ ]
+ : [];
+ });
+ const claims = scan.claims.map((ref) => associatedQuest(ref, paths)).filter((q): q is string => q !== null);
+ const complete = scan.complete && row.invalidated === 0;
+ const occupied = new Set([...claims, ...changes.map((c) => c.quest)]);
+ const available = complete
+ ? ready(snapshot.documents.map((d) => parse(d.path, d.content))).filter((path) => !occupied.has(path))
+ : [];
+ const project: Project = {
+ ...snapshot,
+ name: `github/${repo.owner}/${repo.name}`,
+ href: `/projects/github/${repo.owner}/${repo.name}/board`,
+ title: `${repo.owner}/${repo.name}`,
+ web: `https://github.com/${repo.owner}/${repo.name}`,
+ changes,
+ claims,
+ issues: activity
+ .filter((item) => item.kind === "issue" && !item.missing && item.data?.state === "OPEN")
+ .map((item) => ({ path: `issues/${item.number}`, title: item.data!.title })),
+ available: complete ? available : null,
+ };
+ return {
+ project,
+ activity,
+ claims,
+ available,
+ complete,
+ observedAt: row.observedAt,
+ revision: row.revision,
+ };
+}
diff --git a/cloudflare/src/github/webhook.ts b/cloudflare/src/github/webhook.ts
index 18a422a8..717653d1 100644
--- a/cloudflare/src/github/webhook.ts
+++ b/cloudflare/src/github/webhook.ts
@@ -110,6 +110,43 @@ githubWebhooks.post("/webhooks/github", async (c) => {
).bind(`repositories_${action}`, installation, delivery, digest, event),
);
}
+ if (observed.has(event)) {
+ const pending = "EXISTS(SELECT 1 FROM github_deliveries WHERE id=? AND digest=? AND event=? AND processed=0)";
+ const enrolled = "EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation=?)";
+ // Only identity pointers are retained from untrusted webhook content.
+ // Different delivery IDs and later updates coalesce on the provider ID.
+ const pull = payload.pull_request;
+ const issue = payload.issue;
+ const kind = pull ? "pull" : issue && !issue.pull_request ? "issue" : null;
+ const entity = pull ?? issue;
+ if (kind && positiveId(entity.id) && positiveId(entity.number)) {
+ statements.push(
+ c.env.DB.prepare(
+ `INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt)
+ SELECT ?,?,?,?,?,? WHERE ${pending} AND ${enrolled}
+ ON CONFLICT(repository,kind,id) DO UPDATE SET changedAt=excluded.changedAt`,
+ ).bind(
+ repository,
+ kind,
+ String(entity.id),
+ entity.number,
+ now,
+ now,
+ delivery,
+ digest,
+ event,
+ repository,
+ installation,
+ ),
+ );
+ }
+ statements.push(
+ c.env.DB.prepare(
+ `UPDATE github_project_state SET invalidated=1,revision=revision+1 WHERE repository=? AND ${pending} AND ${enrolled}`,
+ ).bind(repository, delivery, digest, event, repository, installation),
+ );
+ }
+
statements.push(
c.env.DB.prepare("UPDATE github_deliveries SET processed=1 WHERE id=? AND digest=? AND event=?").bind(
delivery,
diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx
index c67a1d72..30a08a08 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -10,7 +10,7 @@ import { type Project, findProject, home } from "./board/project";
import { DocIndex, DocPage, findDoc } from "./docs";
import { Home } from "./home";
import { type Enrollment, GitHubError, GitHubService } from "./github/service";
-import { ProjectsPage, ProjectSetupPage, ProjectStopControls, projectURL } from "./projects";
+import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects";
import { githubWebhooks } from "./github/webhook";
import { Layout, type Page, type User } from "./layout";
@@ -180,6 +180,70 @@ app.post("/projects/github/:owner/:repository", async (c) => {
}
});
+// Live enrolled-project observations use the same board model as the bundled
+// repository, but every read rechecks current GitHub maintainer authority.
+async function liveProject(c: Context<{ Bindings: Env }>, refresh = false) {
+ const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
+ if (!session) throw new GitHubError("Sign in required");
+ const github = new GitHubService(c.env);
+ const repo = await github.enrolledProject(c.req.param("owner")!, c.req.param("repository")!, session.user.id);
+ if (!repo) throw new GitHubError("Project is not enrolled");
+ return github.projectState(repo, session.user.id, refresh);
+}
+app.get("/projects/github/:owner/:repository/state", async (c) => {
+ const state = await liveProject(c);
+ return page(
+ c,
+ { title: "Project activity", description: "Observed GitHub project state." },
+ ,
+ );
+});
+app.get("/projects/github/:owner/:repository/data", async (c) => {
+ c.header("Cache-Control", "no-store");
+ return c.json(await liveProject(c));
+});
+app.post("/projects/github/:owner/:repository/refresh", async (c) => {
+ await liveProject(c, true);
+ return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303);
+});
+app.get("/projects/github/:owner/:repository/board", async (c) => {
+ const state = await liveProject(c);
+ if (!state) return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303);
+ return page(
+ c,
+ { title: `${state.project.title} quest log`, description: "Pinned repository quest tree." },
+ kind === c.req.query("show"))?.[0] ?? null}
+ />,
+ );
+});
+app.get("/projects/github/:owner/:repository/board/quest/*", async (c) => {
+ const state = await liveProject(c);
+ if (!state) return c.notFound();
+ let segments: string[];
+ try {
+ segments = new URL(c.req.url).pathname.split("/").slice(6).map(decodeURIComponent);
+ } catch {
+ return c.notFound();
+ }
+ if (segments.some((segment) => segment.includes("/"))) return c.notFound();
+ const board = readBoard(state.project);
+ const doc = findQuest(board, segments.join("/"));
+ if (!doc) return c.notFound();
+ if (doc.path === "quest/README.md") return c.redirect(state.project.href!);
+ const content = state.project.documents.find((d) => d.path === doc.path)!.content;
+ return page(
+ c,
+ {
+ title: doc.title?.text ?? doc.path,
+ description: "A pinned repository quest.",
+ crumbs: questCrumbs(board, doc),
+ },
+ (user) => ,
+ );
+});
+
// The quest board: a project's milestones, then each quest's page at its path
// without `.md`, the way its branch is named.
app.get("/repos/:repository", (c) => {
@@ -362,6 +426,7 @@ app.post("/passkeys/delete", async (c) => {
return redirect(response, "/account");
});
app.onError((error, c) => {
+ if (error instanceof GitHubError) return c.json({ error: error.message }, 403);
console.error({ event: "request_failed", path: c.req.path, name: error.name });
return c.json({ error: "Request failed" }, 500);
});
diff --git a/cloudflare/src/layout.tsx b/cloudflare/src/layout.tsx
index 1eb41deb..4da78ca8 100644
--- a/cloudflare/src/layout.tsx
+++ b/cloudflare/src/layout.tsx
@@ -184,12 +184,12 @@ export function Token() {
}
// A quest's status, per the glossary in design/theme.md.
-export type Status = "available" | "blocked" | "accepted" | "turn-in";
+export type Status = "available" | "pending" | "blocked" | "accepted" | "turn-in";
// A status glyph: a yellow `!` for Available, a grey one for Requires, and a
-// yellow `?` for Ready to turn in. Accepted has none. Always pair it with its label.
+// yellow `?` for Ready to turn in. Accepted and Pending have none. Always pair it with its label.
export function Marker(props: { status: Status }) {
- if (props.status === "accepted") return ;
+ if (props.status === "accepted" || props.status === "pending") return ;
return {props.status === "turn-in" ? : };
}
diff --git a/cloudflare/src/map.tsx b/cloudflare/src/map.tsx
index b7244023..ce687949 100644
--- a/cloudflare/src/map.tsx
+++ b/cloudflare/src/map.tsx
@@ -78,8 +78,12 @@ export function chart(board: Board): Region[] {
/** A waypoint's status in the format's own terms. */
function text(status: Status): string {
switch (status.kind) {
+ case "accepted":
+ return "claimed";
case "available":
return "ready";
+ case "pending":
+ return "pending";
case "blocked":
return "blocked";
case "turn-in":
diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx
index bd713c3b..bcfc03ed 100644
--- a/cloudflare/src/projects.tsx
+++ b/cloudflare/src/projects.tsx
@@ -1,3 +1,4 @@
+import type { ProjectState, Pull } from "./github/state";
import type { Enrollment, ProjectPreview } from "./github/service";
import { Difficulty, Marker } from "./layout";
import { title } from "./board/model";
@@ -175,6 +176,11 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E
From {preview.branch} at {preview.commit.slice(0, 7)}
. This preview does not start work.
@@ -206,3 +212,90 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E