Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,33 @@ awaits a decision, and any other whose issue, pull request, or quest is still
observed, so finished work stays set aside. Retrying a failed planning
interview keeps its `plans/<id>` target.

A pull request someone holds is in progress with no actions, and names its
holder: the author of a draft without a `## Waiting` (or older `## Decisions
needed`) section, or the `quest:*` label a session holds it with. Such a draft
quiet for a week becomes a "quiet draft" decision offering to take it over
with an iterate job, or to skip it until it changes; a labeled hold is never
offered, since its session or job releases it. Every other open pull request
offers merge, iterate, and skip, and a draft handed back with `## Waiting`
also offers "Mark ready for review" (`POST
/projects/github/<owner>/<repo>/pulls/<n>/ready` with the `head` on screen),
which the App does once no job of that pull request is active.

Each project's "Start ready quests on your runners" box opts the viewer in to
starting its ready quests without a click, optionally only in some milestones
(`POST /projects/github/<owner>/<repo>/autostart`, stored in `autostart`;
`src/jobs/autostart.ts`). Whenever a dispatch pass leaves one of the
opted-in maintainer's runners with a free slot and a usable account, their
hub starts the next unclaimed ready quest in `quest unblocked` order,
round-robin across their opted-in projects, as an ordinary start job they
requested, after the same current GitHub checks as their own start. A quest
that already had a job, or that they skipped in the inbox at its current
text, waits for them, so a skipped, stopped, or failed start never reruns on
its own. The scheduled sweep wakes every opted-in hub, so quests that became
ready since the last runner event start too. Losing the maintainer role ends
the opt-in. A start job's agent first checks the quest is still worth
starting, and asks one decision when in doubt: start, skip, deprioritize to a
later milestone, or delete.

Beside the decisions (below them on a phone), an Agents card shows each job
on the maintainer's projects (`src/inbox/activity.ts`): every target's latest
job while it is queued, running, or waiting, and an ended one while the pull
Expand Down
5 changes: 4 additions & 1 deletion cloudflare/dev/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type Issue = {
body?: string;
comments?: Comment[];
closed?: boolean;
/** When GitHub last saw activity on it; a fixed past date when absent. */
updatedAt?: string;
};
export type Thread = { path: string; line: number | null; resolved?: boolean; outdated?: boolean; comments: Comment[] };
export type Pull = Issue & {
Expand Down Expand Up @@ -208,7 +210,7 @@ function entity(repo: Repo, item: Issue) {
number: item.number,
title: item.title,
state: (item as Pull).merged ? "MERGED" : item.closed ? "CLOSED" : "OPEN",
updatedAt,
updatedAt: item.updatedAt ?? updatedAt,
closedAt: item.closed ? updatedAt : null,
author: { login: item.author },
comments: { totalCount: item.comments?.length ?? 0 },
Expand Down Expand Up @@ -712,6 +714,7 @@ export function fakeGitHub(world: World) {
mergeable_state: pull.mergeableState ?? (pull.merge === "DIRTY" ? "dirty" : "clean"),
title: pull.title,
body: pull.body ?? "",
labels: (pull.labels ?? []).map((name) => ({ name })),
draft: !!pull.draft,
user: { login: pull.author },
created_at: new Date(pull.createdAt ?? Date.parse(updatedAt)).toISOString(),
Expand Down
4 changes: 3 additions & 1 deletion cloudflare/dev/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function world(): World {
review: "CHANGES_REQUESTED",
reviews: [{ state: "CHANGES_REQUESTED", author: "reviewer" }],
},
// A draft its starter left quiet for weeks: offered for takeover.
{
number: 15,
title: "Try a shared build cache",
Expand All @@ -124,7 +125,7 @@ export function world(): World {
checks: null,
review: "REVIEW_REQUIRED",
},
// A claim its starter still holds: work in progress, not a decision.
// A claim its starter still holds and touched just now: work in progress, not a decision.
{
number: 17,
title: "Draft the theme picker",
Expand All @@ -134,6 +135,7 @@ export function world(): World {
review: null,
draft: true,
body: "Started on the picker.",
updatedAt: new Date().toISOString(),
},
// A draft handed back with decisions: it waits on the maintainer.
{
Expand Down
14 changes: 14 additions & 0 deletions cloudflare/migrations/0023_autostart.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- A maintainer's opt-in to start a project's ready quests on their own
-- runners whenever one of their job slots frees, within the listed milestone
-- directories (a JSON array), or every milestone when NULL. It lasts only as
-- long as the membership: a lost maintainer role, a disconnect, or a deleted
-- account ends it, so regaining access never resumes starts unasked.
CREATE TABLE autostart (
repository INTEGER NOT NULL,
user TEXT NOT NULL,
milestones TEXT,
createdAt INTEGER NOT NULL,
PRIMARY KEY(repository, user),
FOREIGN KEY(repository, user) REFERENCES github_project_members(repository, user) ON DELETE CASCADE
);
CREATE INDEX autostart_user ON autostart(user);
39 changes: 39 additions & 0 deletions cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
heldToken,
check,
isQuest,
handedBack,
parse,
planInit,
ready,
Expand Down Expand Up @@ -1120,6 +1121,44 @@ export class GitHubService {
return (await this.#signed(token, repo, pull, revision)) || (await this.#approved(token, repo, pull, revision));
}

/**
* Mark a draft ready for review at the exact head the maintainer saw, as
* the App, accepting it as handed back. GitHub's own rules still decide
* whether and how it merges. A draft its starter still holds, or that a
* session holds with a `quest:*` label, is theirs to mark, as the inbox
* offers it.
*/
markReady(repo: GitHubRepository, userId: string, pull: number, head: string) {
const permissions = { pull_requests: "write" } as const;
return this.#authorized(repo, userId, permissions, async (token, current) => {
this.#public(current);
const live = await this.#api<{
state: string;
draft: boolean;
node_id: string;
body: string | null;
labels: { name: string }[];
head: { sha: string };
}>(token, `${repositoryPath(repo)}/pulls/${number(pull)}`);
if (live.state !== "open" || live.head.sha !== sha(head))
throw new GitHubError("The pull request's head moved since you looked; review it again", 409);
if (live.draft) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Read live, since a stale form may follow a starter resuming the draft.
if (!handedBack(live.body ?? "") || live.labels.some((label) => isHoldLabel(label.name)))
throw new GitHubError(
"Only a draft handed back with ## Waiting and no hold can be marked ready",
409,
);
const result = await this.#api<{ errors?: unknown[] }>(token, "/graphql", "POST", {
query: "mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { clientMutationId } }",
variables: { id: live.node_id },
});
if (result.errors?.length) throw new GitHubError("Could not mark the pull request ready for review");
}
await this.#invalidate(repo);
});
}

/**
* Approve one exact open pull request head for host runners, as the
* maintainer `userId` saw it. The approval lapses when the head moves, and
Expand Down
71 changes: 51 additions & 20 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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";
import { type Autostart, digest } from "../jobs/autostart";
import * as autostart from "../jobs/autostart";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { hub } from "../jobs/routes";
import * as store from "../jobs/store";
Expand All @@ -20,11 +22,11 @@ export type Read = { repository: number; item: string; seen: string | null; skip
/**
* What the item asks of the maintainer: answer an agent, pick an account for
* a limited job, approve another maintainer's answer to their own job, trust
* a pull request head no runner may run yet, retry a failure, or start work
* on an issue, PR, or ready quest. `null` is work in progress, which only
* informs.
* a pull request head no runner may run yet, retry a failure, start work
* on an issue, PR, or ready quest, or take over a draft its starter left
* quiet for `STALE`. `null` is work in progress, which only informs.
*/
export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start";
export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start" | "takeover";
export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell";

export type Item = {
Expand All @@ -42,6 +44,8 @@ export type Item = {
/** The job's open question, or its answer awaiting approval, and its prompt when an agent asked it. */
question: QuestionView | null;
prompt: Prompt | null;
/** Who holds a PR nobody may act on: its draft's author, or the hold label a session holds it with. */
holder: string | null;
/** A "trust" decision's exact head, and GitHub's view of its commits beyond the default branch. */
head: { sha: string; compare: string } | null;
need: Need | null;
Expand All @@ -54,14 +58,13 @@ export type Item = {
};

/** One project's place in the inbox, or why it is unavailable. */
export type ProjectView = { project: Enrollment; state: ProjectState | null; error: string | null };

// FNV-1a: a short, stable version for a quest's text.
function digest(text: string) {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
return (hash >>> 0).toString(16);
}
export type ProjectView = {
project: Enrollment;
state: ProjectState | null;
error: string | null;
/** The viewer's opt-in to start this project's ready quests on their runners. */
autostart?: Autostart | null;
};

function kindOf(target: string): Kind {
if (target.startsWith("issues/")) return "issue";
Expand Down Expand Up @@ -145,13 +148,31 @@ export function entities(project: Enrollment, state: ProjectState | null) {
}

/**
* A PR whose observed draft its starter still holds, or that a session holds
* with a hold label, such as a merge job whose landing is queued, so nothing
* waits on the maintainer.
* How long a draft its starter holds can stay quiet before the maintainer is
* offered to take it over. A week outlasts a weekend away from a local
* session while still surfacing abandoned claims.
*/
export const STALE = 7 * 24 * 60 * 60 * 1000;

/**
* Who holds a PR, so nothing waits on the maintainer: the hold label a
* session holds it with, such as a merge job whose landing is queued, or the
* author of an observed draft they have not handed back. Null when free.
*/
function held(entity: Pick<Item, "kind" | "activity">) {
function holder(entity: Pick<Item, "kind" | "activity">) {
const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null;
return !!pull && (owned(pull) || !!pull.hold);
if (pull?.hold) return pull.hold;
if (pull && owned(pull)) return pull.author?.login ?? "its starter";
return null;
}

/**
* Whether a held draft has been quiet past `STALE`. Only a starter's draft is
* offered: a hold label names a live session or job, which releases its own.
*/
function abandoned(entity: Pick<Item, "kind" | "activity">, now: number) {
const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null;
return !!pull && !pull.hold && owned(pull) && Date.parse(pull.updatedAt) < now - STALE;
}

/** What an item is called and links to when nothing observed names its target. */
Expand Down Expand Up @@ -180,6 +201,7 @@ export function decisions(
jobs: JobView[],
reads: Read[],
user: string,
now = Date.now(),
): Item[] {
const { project } = view;
const latest = new Map<string, JobView>();
Expand All @@ -196,6 +218,7 @@ export function decisions(
let question: QuestionView | null = null;
let head: Item["head"] = null;
let ended = false;
const holds = entity ? holder(entity) : null;
if (job?.state === "queued" && store.onPull(job.workflow) && !job.trusted) {
// Its head has commits no current maintainer signed, so no runner takes it until one approves the head.
const sha = job.head ?? job.revision;
Expand All @@ -217,10 +240,13 @@ export function decisions(
} else if (entity) {
// Starting new work on a paused project would be refused, so it is not offered.
if (project.state !== "enabled") continue;
// A draft its starter still holds is their work in progress until they hand it back.
if (!held(entity)) {
// A draft its starter still holds is their work in progress until they hand it back or leave it.
if (!holds) {
need = "start";
ended = !!job && !changed;
} else if (abandoned(entity, now)) {
need = "takeover";
ended = !!job && !changed;
}
} else continue;
const read = reads.find((r) => r.repository === project.id && r.item === target);
Expand All @@ -239,6 +265,7 @@ export function decisions(
question,
prompt,
head,
holder: holds,
need,
version,
unread: need !== null && read?.seen !== version,
Expand Down Expand Up @@ -323,7 +350,11 @@ export async function load(env: Env, user: string) {
const views = await Promise.all(
(await github.projects(user)).map(async (project): Promise<ProjectView | null> => {
try {
return { project, state: await github.projectState(project, user), error: null };
const [state, scope] = await Promise.all([
github.projectState(project, user),
autostart.read(env.DB, project.id, user),
]);
return { project, state, error: null, autostart: scope };
} catch (error) {
if (error instanceof NotMaintainer) return null;
if (!(error instanceof GitHubError)) throw error;
Expand Down
Loading