Skip to content
Merged
31 changes: 18 additions & 13 deletions cloudflare/src/board/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,20 @@
// status and progress. Readiness comes from the shared core, so the board
// always agrees with `quest unblocked`.

import { type Doc, ROOT, blockers, children, comparePaths, entries, isEpic, parse, permanent, ready } from "quest/core";
import {
type Doc,
ROOT,
blockers,
branchQuest,
children,
comparePaths,
entries,
isEpic,
parse,
permanent,
questBranch,
ready,
} from "quest/core";
import type { Size } from "../layout";
import type { Change, Completed, Project } from "./project";

Expand Down Expand Up @@ -66,18 +79,14 @@ export function title(text: string): { size: Size | null; name: string } {
return match ? { size: match[1] as Size, name: match[2] } : { size: null, name: text };
}

/**
* 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}`;
}

/** A quest's page: its branch name, so an epic's ends in `/README`. The root's is the board. */
export function questHref(project: Project, path: string): string {
if (path === ROOT) return boardHref(project);
const page = path.replace(/\/README\.md$/, "").replace(/\.md$/, "");
return `${boardHref(project)}/${encodePath(page)}`;
return `${boardHref(project)}/${encodePath(questBranch(path))}`;
}

/**
Expand All @@ -98,13 +107,9 @@ export function milestoneAnchor(milestone: Milestone): string {
return milestone.path.split("/")[1] ?? "unsorted";
}

/** The document a page path names: `quest/m0/board`, `quest/m0/epic`, or `quest/m0/epic/README`. */
/** The document a page path names, as its branch does: `quest/m0/board` or `quest/m0/epic/README`. */
export function findDoc(board: Board, page: string): Doc | null {
for (const path of [`${page}.md`, `${page}/README.md`]) {
const doc = board.docs.get(path);
if (doc) return doc;
}
return null;
return board.docs.get(branchQuest(page)) ?? null;
}

/** The first paragraph of a section, as plain words. */
Expand Down
12 changes: 7 additions & 5 deletions cloudflare/src/github/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { handedBack, parse, ready } from "quest/core";
import { branchQuest, handedBack, parse, ready } from "quest/core";
import type { Project, Snapshot } from "../board/project";
import { GitHubError, type GitHubRepository, ProjectChanged } from "./service";
import { positiveId } from "./webhook";
Expand Down Expand Up @@ -132,11 +132,13 @@ function next<T>(connection: Connection<T> | undefined, cursor: Cursor) {
return connection.pageInfo.endCursor;
}

/** A branch convention is evidence of association, never execution authority. */
/**
* The quest `branch` is named for, as `quest start` names it. A branch
* convention is evidence of association, never execution authority.
*/
export function associatedQuest(branch: string, paths: Set<string>) {
const exact = `${branch}.md`;
const epic = `${branch}/README.md`;
return paths.has(exact) ? exact : paths.has(epic) ? epic : null;
const path = branchQuest(branch);
return paths.has(path) ? path : null;
}

function validate(kind: "issue" | "pull", item: Entity) {
Expand Down
6 changes: 3 additions & 3 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// are one item, so updates, reconnects, and duplicate webhooks coalesce instead
// of adding prompts. Agent text and GitHub data stay untrusted display data.

import { parse } from "quest/core";
import { parse, questBranch } from "quest/core";
import { encodePath } from "../board/model";
import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service";
import { type Activity, type ProjectState, type Pull, owned, readState } from "../github/state";
Expand Down Expand Up @@ -64,9 +64,9 @@ function kindOf(target: string): Kind {
return target === "devshell" ? "devshell" : "quest";
}

/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */
/** A quest's page on the project's board: its branch name, so an epic's ends in `/README`. */
export function questHref(project: Enrollment, path: string) {
return `${projectURL(project.owner, project.name)}/board/${encodePath(path.replace(/(\/README)?\.md$/, ""))}`;
return `${projectURL(project.owner, project.name)}/board/${encodePath(questBranch(path))}`;
}

export type Entity = Pick<Item, "kind" | "title" | "href" | "activity" | "version" | "at">;
Expand Down
3 changes: 2 additions & 1 deletion cloudflare/src/jobs/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Context, Hono } from "hono";
import { questBranch } from "quest/core";
import { createAuth } from "../auth";
import * as push from "../push/notify";
import { GitHubError, GitHubService, refName, type Stopped } from "../github/service";
Expand Down Expand Up @@ -192,7 +193,7 @@ function resolve(workflow: unknown, target: unknown, state: ProjectState, failed
if (workflow === "start") {
if (typeof target !== "string" || !startable(state, target, failed && continued(state, failed)))
throw new JobError("Only ready, unclaimed quests on the default branch can start");
const branch = target.replace(/(\/README)?\.md$/, "");
const branch = questBranch(target);
Comment thread
kixelated marked this conversation as resolved.
Resolved
if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name");
return { workflow, target, revision: commit, branch, trusted: true };
}
Expand Down
12 changes: 9 additions & 3 deletions cloudflare/test/board.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { finished } from "../scripts/history";
import { render } from "../src/board/markdown";
import { type Board, blobHref, findGroup, only, questHref, readBoard, trail } from "../src/board/model";
import { type Board, blobHref, findDoc, findGroup, only, questHref, readBoard, trail } from "../src/board/model";
import type { Project } from "../src/board/project";

const docs: Record<string, string> = {
Expand Down Expand Up @@ -86,11 +86,17 @@ describe("board model", () => {
expect(items[0].kind === "epic" && items[0].epic.items.length).toBe(1);
});

it("names an epic's page by its branch, not its directory", () => {
expect(questHref(project(), "quest/m0/epic/README.md")).toBe("/repos/demo/quest/m0/epic/README");
expect(findDoc(board, "quest/m0/epic/README")?.path).toBe("quest/m0/epic/README.md");
expect(findDoc(board, "quest/m0/epic")).toBeNull();
});

it("encodes each board and forge path segment", () => {
const source = project();
const path = "quest/m0/an epic#/name#?%.md";
expect(questHref(source, path)).toBe("/repos/demo/quest/m0/an%20epic%23/name%23%3F%25");
expect(questHref(source, "quest/m0/an epic#/README.md")).toBe("/repos/demo/quest/m0/an%20epic%23");
expect(questHref(source, "quest/m0/an epic#/README.md")).toBe("/repos/demo/quest/m0/an%20epic%23/README");
expect(blobHref(source, path)).toBe(
`https://example.com/acme/demo/blob/${source.commit}/quest/m0/an%20epic%23/name%23%3F%25.md`,
);
Expand Down Expand Up @@ -140,7 +146,7 @@ describe("quest markdown", () => {
"## Plan\n\n[epic](/quest/m0/epic/README.md#goal) [ready](epic/ready.md) [docs](../../docs/a.md) [web](https://example.com)\n",
known,
);
expect(plan.html).toContain('href="/repos/demo/quest/m0/epic#goal"');
expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/README#goal"');
expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/ready"');
expect(plan.html).toContain(`href="https://example.com/acme/demo/blob/${project().commit}/docs/a.md"`);
expect(plan.html).toContain('href="https://example.com"');
Expand Down
4 changes: 3 additions & 1 deletion cloudflare/test/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,9 @@ describe("decision inbox", () => {
it("encodes quest paths in board links", () => {
const enrolled = { owner: "upstream", name: "project" } as Parameters<typeof questHref>[0];
expect(questHref(enrolled, "quest/a#b?.md")).toBe("/projects/github/upstream/project/board/quest/a%23b%3F");
expect(questHref(enrolled, "quest/epic/README.md")).toBe("/projects/github/upstream/project/board/quest/epic");
expect(questHref(enrolled, "quest/epic/README.md")).toBe(
"/projects/github/upstream/project/board/quest/epic/README",
);
});

it("skips an item until it changes", async () => {
Expand Down
30 changes: 30 additions & 0 deletions cloudflare/test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,36 @@ describe("job routes", () => {
);
});

it("starts an epic on its README branch while a child's branch is claimed", async () => {
// The epic's last listed child merged, leaving its own work; another quest under it is claimed.
const documents = [
{
path: "quest/README.md",
content: "# Project\n\n## Goal\n\nBuild it.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n",
},
{
path: "quest/m0/README.md",
content: "# First\n\n## Goal\n\nShip.\n\n## Required\n\n- [Epic](/quest/m0/epic/README.md)\n",
},
{ path: "quest/m0/epic/README.md", content: "# [S] Epic\n\n## Goal\n\nThe epic's own work.\n" },
{ path: "quest/m0/epic/child.md", content: "# [S] Child\n\n## Goal\n\nClaimed.\n" },
];
await db
.prepare(
"UPDATE github_project_state SET snapshot=json_set(snapshot,'$.documents',json(?)),scan=json_set(scan,'$.occupancy.claims',json(?))",
)
.bind(JSON.stringify(documents), JSON.stringify(["quest/m0/epic/child"]))
.run();
expect((await post("", { workflow: "start", target: "quest/m0/epic/child.md" })).status).toBe(409);
expect((await post("", { workflow: "start", target: "quest/m0/epic/README.md" })).status).toBe(201);
const [job] = await store.listJobs(db, 10);
expect(job).toMatchObject({
workflow: "start",
target: "quest/m0/epic/README.md",
branch: "quest/m0/epic/README",
});
});

it("starts work while history is still paging, but not before a refresh reads a new pull request head", async () => {
// A long history needs many refreshes; open PRs and claims are already current.
await db
Expand Down
4 changes: 2 additions & 2 deletions cloudflare/test/map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("quest map", () => {

it("draws epics and the milestone's own quests as paths in priority order", () => {
expect(region.paths.map((path) => path.title)).toEqual(["An epic", "Main path"]);
expect(region.paths[0].href).toBe("/repos/demo/quest/m0/epic");
expect(region.paths[0].href).toBe("/repos/demo/quest/m0/epic/README");
});

it("orders each path by what it requires, then by priority", () => {
Expand All @@ -58,7 +58,7 @@ describe("quest map", () => {

it("links to the board without a web address", () => {
const [offline] = chart(readBoard({ ...project, web: null }));
expect(offline.paths[0].href).toBe("/repos/demo/quest/m0/epic");
expect(offline.paths[0].href).toBe("/repos/demo/quest/m0/epic/README");
expect(offline.paths[1].waypoints[0].href).toBe("/repos/demo/quest/m0/solo");
});
});
Expand Down
8 changes: 7 additions & 1 deletion cloudflare/test/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { app } from "../src/index";
import { readBoard } from "../src/board/model";
import { type ActivityPage, RETENTION, readActivity } from "../src/github/state";
import { type ActivityPage, associatedQuest, RETENTION, readActivity } from "../src/github/state";
import { GitHubService } from "../src/github/service";
import { privateKey } from "./github-key";
import { identity, oauth, origin } from "./login-fixtures";
Expand Down Expand Up @@ -739,6 +739,12 @@ function history(): ActivityPage {
const event = { installation: { id: 5 }, repository: { id: 10, default_branch: "trunk" } };

describe("enrolled project observations", () => {
it("maps a branch to the quest it names, an epic by its README branch", () => {
const paths = new Set(["quest/epic/README.md", "quest/epic/child.md"]);
expect(associatedQuest("quest/epic/README", paths)).toBe("quest/epic/README.md");
expect(associatedQuest("quest/epic/child", paths)).toBe("quest/epic/child.md");
expect(associatedQuest("quest/epic", paths)).toBeNull();
});
async function enrolled() {
await github().service.setContributions(repo, "enrollment-test", "enabled");
}
Expand Down
1 change: 0 additions & 1 deletion quest/m0/kix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ operator-paid-hosting decisions:
- [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest
- [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR
- [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID
- [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest/<path>/README` branch
- [Runner](/quest/m0/kix/runner.md) - `quest runner` on Linux and macOS with Nix devShells, accounts, and the Claude Code adapter
- [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners
- [opencode adapter](/quest/m0/kix/opencode.md) - OpenCode Go, ChatGPT plans, and API-key providers through opencode
Expand Down
26 changes: 0 additions & 26 deletions quest/m0/kix/epic-branches.md

This file was deleted.

5 changes: 0 additions & 5 deletions src/cli/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ import {
} from "./github";
import { type Snapshot, snapshot } from "./tree";

/** A quest's branch: its path without `.md`. */
export function questBranch(path: string): string {
return path.replace(/\.md$/, "");
}

/** An open PR, from any head repository, as the claim checks read it. */
export interface OpenPull {
number: number;
Expand Down
9 changes: 3 additions & 6 deletions src/cli/landed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { spawnSync } from "node:child_process";

import { type Finding } from "../core";
import { type Finding, branchQuest } from "../core";

/**
* A finding when `branch` (default: the current branch) is a quest's branch
Expand Down Expand Up @@ -38,13 +38,10 @@ export function landed(root: string, base: string, branch?: string): Finding[] {
];
}

/**
* The quest file `branch` names, if it existed at `commit`: the branch plus
* `.md`, so an epic's branch ends in `/README`.
*/
/** The quest file `branch` names, if it existed at `commit`. */
function questAt(root: string, commit: string, branch: string): string | null {
if (!branch.startsWith("quest/") || branch.endsWith("/")) return null;
const path = `${branch}.md`;
const path = branchQuest(branch);
return git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0 ? path : null;
}

Expand Down
3 changes: 2 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import {
formatFinding,
label,
lookup,
questBranch,
renderBlocker,
unblocked,
} from "../core";
import { AgentInputError, HARNESSES, type Launch, processDependencies, runAgent, stopAgent, stopAll } from "./agent";
import { held, questBranch, reach, target, upstream, upstreamHead } from "./claims";
import { held, reach, target, upstream, upstreamHead } from "./claims";
import { gate } from "./gate";
import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUrl } from "./github";
import { landed } from "./landed";
Expand Down
4 changes: 2 additions & 2 deletions src/cli/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { execFileSync } from "node:child_process";
import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { join, posix, relative, resolve, sep } from "node:path";

import { check, formatFinding, isQuest, move, normalize, permanent, relocate, remove } from "../core";
import { type ClaimDependencies, claims, questBranch } from "./claims";
import { check, formatFinding, isQuest, move, normalize, permanent, questBranch, relocate, remove } from "../core";
import { type ClaimDependencies, claims } from "./claims";
import { exists, load, sources, under } from "./tree";

/** What a command changed, and what it left for the caller to look at. */
Expand Down
2 changes: 1 addition & 1 deletion src/cli/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";

import { questBranch } from "../core";
import {
type ClaimDependencies,
type OpenPull,
Expand All @@ -15,7 +16,6 @@ import {
openPullRequests,
pullsFrom,
remoteBranches,
questBranch,
repositoryPath,
upstream,
upstreamHead,
Expand Down
14 changes: 14 additions & 0 deletions src/core/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ export function isQuest(path: string): boolean {
return name.endsWith(".md") && !NOT_QUESTS.includes(name);
}

/**
* A quest's branch: its path without `.md`, so an epic's is
* `quest/<path>/README`. The directory form would collide with its children's
* branches, since git cannot hold `quest/m0/foo` beside `quest/m0/foo/bar`.
*/
export function questBranch(path: string): string {
return path.replace(/\.md$/, "");
}

/** The quest file a branch names, whether or not it exists: the branch plus `.md`. */
export function branchQuest(branch: string): string {
return `${branch}.md`;
}

/** Whether the document has this exact `## ` heading. */
export function has(doc: Doc, heading: string): boolean {
return doc.headings.some((h) => h.text === heading);
Expand Down
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
type Heading,
type Link,
type Position,
branchQuest,
children,
entries,
has,
Expand All @@ -20,6 +21,7 @@ export {
owner,
parse,
permanent,
questBranch,
resolve,
rooted,
} from "./doc";
Expand Down
Loading
Loading