Public: anyone can read this; the crew can change it.

A pure read lists open crew pulls with no push for 72 hours, oldest first #6484

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6470-a-pure-read-lists-open-crew-pulls-with-n → main 1 commit fork

  • Merged. scout/6470-a-pure-read-lists-open-crew-pulls-with-n was merged into main as fd7026f.

Review

Sign in to review this change.
2 files changed +153 −0
src/flow/idle_pulls.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * Idle pulls (#6470): open, unmerged pulls opened by a crew agent whose head
3 * has not moved for 72 h. A pull "moves" at its newest `pull.pushed` event, or
4 * at its opening when it has none. Oldest first, at most 20. Nothing shows it yet.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { closesFromBody } from "../core/pulls.ts";
9
10 const HOUR_MS = 3_600_000;
11 const IDLE_HOURS = 72;
12 const LIMIT = 20;
13
14 export interface IdlePull {
15 readonly pull: number;
16 readonly repo: string;
17 readonly issue: number | null;
18 readonly hours: number;
19 }
20
21 /** `publicOnly` reads public repositories alone, as `readOldestApproved` does. */
22 export function readIdlePulls(db: Database, now = new Date(), publicOnly = false): IdlePull[] {
23 const cutoff = new Date(now.getTime() - IDLE_HOURS * HOUR_MS).toISOString();
24 const rows = db
25 .query<{ number: number; name: string; moved_at: string; body: string }, [number, string, number]>(
26 `SELECT i.number AS number, r.name AS name, m.at AS moved_at, i.body AS body
27 FROM pull p
28 JOIN issue i ON i.id = p.issue_id
29 JOIN repo r ON r.id = i.repo_id
30 JOIN user u ON u.id = i.author_id AND u.is_agent = 1
31 JOIN (SELECT p2.issue_id AS issue_id,
32 COALESCE((SELECT MAX(e.created_at) FROM event e
33 WHERE e.kind = 'pull.pushed' AND e.repo_id = i2.repo_id
34 AND json_extract(e.payload, '$.number') = i2.number), i2.created_at) AS at
35 FROM pull p2 JOIN issue i2 ON i2.id = p2.issue_id) m ON m.issue_id = i.id
36 WHERE (?1 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL
37 AND m.at <= ?2
38 ORDER BY m.at ASC, i.number ASC
39 LIMIT ?3`,
40 )
41 .all(publicOnly ? 1 : 0, cutoff, LIMIT);
42 return rows.map((r) => ({
43 pull: r.number,
44 repo: r.name,
45 issue: closesFromBody(r.body)[0] ?? null,
46 hours: Math.max(0, (now.getTime() - Date.parse(r.moved_at)) / HOUR_MS),
47 }));
48 }
test/flow-idle-pulls.test.ts added +105
@@ -0,0 +1,105 @@
1 /** Idle pulls (#6470). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { readIdlePulls } from "../src/flow/idle_pulls.ts";
9
10 const NOW = new Date("2026-10-26T12:00:00.000Z");
11 const ago = (h: number): string => new Date(NOW.getTime() - h * 3_600_000).toISOString();
12
13 let dir: string;
14 let app: App;
15 let agent: string;
16 let person: string;
17 let n = 100;
18
19 beforeAll(async () => {
20 dir = await mkdtemp(join(tmpdir(), "idle-pulls-"));
21 app = createApp({ dataDir: dir, port: 0 });
22 const mk = async (username: string, first: boolean): Promise<string> => {
23 const u = await createUser(app.db, {
24 origin: "seed",
25 trusted: true,
26 username,
27 email: `${username}@forge.local`,
28 password: "a-good-passphrase",
29 adminIfFirst: first,
30 });
31 if (!u.ok) throw new Error(u.error);
32 return u.value.id;
33 };
34 person = await mk("alice", true);
35 agent = await mk("scout", false);
36 app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [agent]);
37 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'pub', 'public')", [person]);
38 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('priv', ?1, 'priv', 'private')", [person]);
39 });
40 afterAll(async () => {
41 app.close?.();
42 await rm(dir, { recursive: true, force: true });
43 });
44 const clear = (): void => {
45 app.db.run("DELETE FROM issue");
46 app.db.run("DELETE FROM event");
47 };
48
49 function pull(opts: { openedH: number; pushedH?: number[]; repo?: string; by?: string; merged?: boolean; body?: string }): number {
50 n++;
51 const repo = opts.repo ?? "pub";
52 app.db.run(
53 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 't', ?4, ?5, 'open', ?6)",
54 [`p${n}`, repo, n, opts.body ?? "", opts.by ?? agent, ago(opts.openedH)],
55 );
56 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', 'h', ?3)", [
57 `p${n}`,
58 `b${n}`,
59 opts.merged ? ago(1) : null,
60 ]);
61 (opts.pushedH ?? []).forEach((h, i) =>
62 app.db.run("INSERT INTO event (id, kind, repo_id, payload, created_at) VALUES (?1, 'pull.pushed', ?2, ?3, ?4)", [
63 `e${n}-${i}`,
64 repo,
65 JSON.stringify({ number: n }),
66 ago(h),
67 ]),
68 );
69 return n;
70 }
71
72 describe("readIdlePulls", () => {
73 test("returns only the idle open crew pull", () => {
74 clear();
75 const idle = pull({ openedH: 200, pushedH: [150, 80], body: "Closes #7" });
76 pull({ openedH: 200, pushedH: [150, 1] });
77 pull({ openedH: 200, merged: true });
78 pull({ openedH: 200, by: person });
79 const rows = readIdlePulls(app.db, NOW, false);
80 expect(rows).toEqual([{ pull: idle, repo: "pub", issue: 7, hours: 80 }]);
81 });
82
83 test("a pull with no push is idle from its opening, and 72 h is the line", () => {
84 clear();
85 const a = pull({ openedH: 72 });
86 pull({ openedH: 71 });
87 expect(readIdlePulls(app.db, NOW, false).map((r) => r.pull)).toEqual([a]);
88 });
89
90 test("oldest first, at most 20", () => {
91 clear();
92 for (let i = 0; i < 25; i++) pull({ openedH: 100 + i });
93 const rows = readIdlePulls(app.db, NOW, false);
94 expect(rows).toHaveLength(20);
95 expect(rows[0]!.hours).toBeGreaterThan(rows[19]!.hours);
96 });
97
98 test("publicOnly leaves out a private repository's pull", () => {
99 clear();
100 const pub = pull({ openedH: 100 });
101 const priv = pull({ openedH: 200, repo: "priv" });
102 expect(readIdlePulls(app.db, NOW, false).map((r) => r.pull)).toEqual([priv, pub]);
103 expect(readIdlePulls(app.db, NOW, true).map((r) => r.pull)).toEqual([pub]);
104 });
105 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs