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
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,36 @@ claude.ai-hosted prototype, and why it is being replaced, is described in

## What v0 does

It lists the Workstream board's "Needs human" items by priority. An item
shows its Why, links, description, gist and latest comments, rendered
from markdown and sanitized. You answer with a tap on one of the options
the bot offered (parsed from Why), free text, or both. On an issue or PR
the answer is a comment by you starting with `/answer` (or `/answer B`);
on a draft item it is a receipt gist plus a marked section in the draft
body, both readable by anyone while the board is public. The queue is polled every 30 seconds with ETags while the tab
is visible.
One ranked queue of everything waiting on you: the bot's open draft PRs
in cgwalters-forge that you haven't approved or sent back at their
current head, and the Workstream board's "Needs human" items (questions,
and other actions) and Draft items (gists to read). P0 comes first
(the board's Priority; a PR takes its board item's), then the oldest.

- **A forge PR** opens a review pane: the description (without bot-pr's
meta section), CI checks, every commit with its full message, and the
diff per file, foldable. **Approve** submits an approving review of the
head you were shown, which is what `bot-pr promote` acts on; if the
head moved meanwhile, nothing is sent. A checkbox adds the `/draft`
line that asks promote for a draft upstream PR. **Request changes** and
**Comment** submit reviews with your text.
- **A board item** shows its Why, links, description, gist and latest
comments, rendered from markdown and sanitized. You answer with a tap
on one of the options the bot offered (parsed from Why), free text, or
both. On an issue or PR the answer is a comment by you starting with
`/answer` (or `/answer B`); on a draft item it is a receipt gist plus a
marked section in the draft body, both readable by anyone while the
board is public.

Keys: `j`/`k` move, `o` opens, `u` goes back, `r` reloads; in a PR,
`j`/`k` step through files, `x` folds one, `a` approves (after a
confirmation) and `c` jumps to the review text; `?` lists them. Your
text never goes out with a line the bot would read as a command
(`/promote`, `/draft`, `/ready`, `/answer`).

The board is polled every 30 seconds with ETags while the tab is
visible, the forge's PR search every minute, and a PR's reviews only
when it changed.

The app contains no data: everything is fetched in your browser with your
token, from `api.github.com` only.
Expand Down
15 changes: 13 additions & 2 deletions src/github/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,19 @@ export function nextLink(link: string | null): string | undefined {
return undefined;
}

/** A message, or an error entry's message (GitHub sends strings or objects). */
function errorText(e: unknown): string {
if (typeof e === "string") return e;
const m = (e as { message?: unknown } | null)?.message;
return typeof m === "string" ? m : "";
}

async function errorMessage(res: Response, what: string): Promise<string> {
let detail = "";
try {
const body = (await res.json()) as { message?: unknown };
if (typeof body.message === "string") detail = `: ${body.message}`;
const body = (await res.json()) as { message?: unknown; errors?: unknown };
const parts = [body.message, ...(Array.isArray(body.errors) ? body.errors : [])].map(errorText).filter(Boolean);
if (parts.length) detail = `: ${parts.join("; ")}`;
} catch {
// Not JSON; the status is enough.
}
Expand Down Expand Up @@ -84,6 +92,9 @@ export class GitHub {
}

#noteRate(res: Response): void {
// Search and GraphQL have budgets of their own; track the core one.
const resource = res.headers.get("x-ratelimit-resource");
if (resource !== null && resource !== "core") return;
const limit = Number(res.headers.get("x-ratelimit-limit"));
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
const reset = Number(res.headers.get("x-ratelimit-reset"));
Expand Down
6 changes: 3 additions & 3 deletions src/github/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type RawItem,
queueItems,
} from "./board.ts";
import { BOARD_NUMBER, BOARD_OWNER, NEEDS_HUMAN, OPERATOR, PAGE_SIZE, RECENT_COMMENTS } from "./config.ts";
import { BOARD_NUMBER, BOARD_OWNER, OPERATOR, PAGE_SIZE, QUEUE_STATUSES, RECENT_COMMENTS } from "./config.ts";
import { checkReceipt, RECEIPT_FILE, type RawReceiptGist, type ReceiptCheck } from "./receipt.ts";

const PROJECT = `/users/${BOARD_OWNER}/projectsV2/${BOARD_NUMBER}`;
Expand All @@ -25,14 +25,14 @@ export interface Queue {
changed: boolean;
}

/** Read the items needing a human, conditionally: 304s cost nothing. */
/** Read the items needing a human or ready for review, conditionally: 304s cost nothing. */
export async function loadQueue(gh: GitHub): Promise<Queue> {
const project = await gh.get<{ public?: boolean }>(PROJECT);
const fields = await gh.getAll<RawField>(`${PROJECT}/fields?per_page=${PAGE_SIZE}`);
const ids = fieldIds(fields.data).join(",");
// The server-side filter keeps the poll to one page; queueItems filters
// again, in case the filter syntax ever stops matching.
const q = encodeURIComponent(`status:"${NEEDS_HUMAN}"`);
const q = encodeURIComponent(`status:${QUEUE_STATUSES.map((s) => `"${s}"`).join(",")}`);
const items = await gh.getAll<RawItem>(`${PROJECT}/items?per_page=${PAGE_SIZE}&fields=${ids}&q=${q}`);
return {
items: queueItems(items.data),
Expand Down
33 changes: 7 additions & 26 deletions src/github/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// synthetic payloads.

import { AnswerError, type Option, parseOptions, questionId, withoutDraftSection } from "../answer.ts";
import { FIELD, NEEDS_HUMAN } from "./config.ts";
import { FIELD, QUEUE_STATUSES } from "./config.ts";

/** The subset of a project field the app uses. */
export interface RawField {
Expand Down Expand Up @@ -42,6 +42,7 @@ export interface RawItem {
content_type: string;
content?: RawContent | null;
fields?: RawFieldValue[];
created_at?: string;
updated_at?: string;
archived_at?: string | null;
}
Expand Down Expand Up @@ -78,6 +79,8 @@ export interface Item {
org?: string;
branch: string[];
gist: string[];
/** When the item was added to the board. */
createdAt?: string;
updatedAt?: string;
}

Expand Down Expand Up @@ -159,6 +162,7 @@ export function parseItem(raw: RawItem): Item {
opt("status", fields.get(FIELD.status));
opt("priority", fields.get(FIELD.priority));
opt("org", fields.get(FIELD.org));
opt("createdAt", raw.created_at);
opt("updatedAt", c.updated_at ?? raw.updated_at);
if (kind === "draft") {
opt("draftId", c.node_id);
Expand All @@ -171,35 +175,12 @@ export function parseItem(raw: RawItem): Item {
return item;
}

/** The queue: unarchived items needing a human. */
/** The board's part of the queue: unarchived items needing a human, or Draft (ready for review). */
export function queueItems(raw: readonly RawItem[]): Item[] {
return raw
.filter((r) => !r.archived_at)
.map(parseItem)
.filter((i) => i.status === NEEDS_HUMAN);
}

export interface PriorityGroup {
priority: string;
items: Item[];
}

/** Group items by priority in PRIORITY_ORDER, keeping board order within one. */
export function groupByPriority(items: readonly Item[]): PriorityGroup[] {
const groups = new Map<string, Item[]>();
for (const item of items) {
const key = item.priority ?? NO_PRIORITY;
const list = groups.get(key) ?? [];
list.push(item);
groups.set(key, list);
}
const rank = (p: string) => {
const i = PRIORITY_ORDER.indexOf(p);
return i < 0 ? (p === NO_PRIORITY ? PRIORITY_ORDER.length + 1 : PRIORITY_ORDER.length) : i;
};
return [...groups.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([priority, list]) => ({ priority, items: list }));
.filter((i) => i.status !== undefined && QUEUE_STATUSES.includes(i.status));
}

/** The question an item asks, as the bot wrote it. */
Expand Down
23 changes: 22 additions & 1 deletion src/github/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ export const BOARD_URL = `https://github.com/users/${BOARD_OWNER}/projects/${BOA
/** The only login whose answers the bot acts on. */
export const OPERATOR = "cgwalters";

/** The Status value that puts an item in the queue. */
/** The Status of an item blocked on his decision or action. */
export const NEEDS_HUMAN = "Needs human";
/** The Status of an item ready for his review (a forge PR or a gist). */
export const DRAFT = "Draft";
/** The Statuses that put an item in the queue (the board's "Needs cgwalters" view). */
export const QUEUE_STATUSES: readonly string[] = [NEEDS_HUMAN, DRAFT];

/** The organization holding the forks where the bot proposes draft PRs. */
export const FORGE_ORG = "cgwalters-forge";
/** The bot's login: the forge PRs listed are the ones it opened. */
export const BOT_LOGIN = "cgwalters-bot";

/** Board fields the app reads, by name; their ids are looked up at runtime. */
export const FIELD = {
Expand All @@ -33,6 +42,14 @@ export const HOME_OWNERS: readonly string[] = ["cgwalters-forge", "cgwalters-bot

/** Poll every this many ms while the tab is visible. */
export const POLL_INTERVAL_MS = 30_000;
/** Poll the forge's PR search this often: search has its own, smaller budget. */
export const FORGE_POLL_INTERVAL_MS = 60_000;
/** ... and at most this often when asked to refresh (r, or after a review). */
export const FORGE_MIN_INTERVAL_MS = 10_000;
/** Parallel requests when refreshing PR verdicts. */
export const FETCH_CONCURRENCY = 6;
/** A file's diff starts collapsed above this many lines. */
export const DIFF_COLLAPSE_LINES = 300;
/** Poll this many times slower when the rate budget runs low. */
export const POLL_BACKOFF_FACTOR = 4;
/** Below this fraction of the hourly budget, back off. */
Expand All @@ -43,6 +60,10 @@ export const PAGE_SIZE = 100;
/** Comments shown in the item view. */
export const RECENT_COMMENTS = 5;


/** localStorage key for the chosen theme (auto, light, dark). */
export const THEME_KEY = "review.theme";

/** Storage key for the pasted token (sessionStorage, or localStorage if remembered). */
export const TOKEN_KEY = "review.token";

Expand Down
Loading
Loading