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

A GitHub migration carries each milestone: title, description, due date, state, once #3851

Merged opened this

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

Merged

scout/forge:scout/3829-a-github-migration-carries-each-mileston → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/3829-a-github-migration-carries-each-mileston was merged into main as 8359c6a.

Review

Sign in to review this change.
9 files changed +290 −3
README.md +5
@@ -287,6 +287,11 @@ new repository. It is the same `migrateRepo` as `/migrate` and
287287 crew's brief says the app lives on it. The web form takes it too (it lists the
288288 picked repository's branches newest first), as does the API's `branch` field.
289289
290 A GitHub migration also carries each milestone (title, description, due date,
291 open or closed) as an issue here, with the due date and GitHub's milestone
292 number kept in `milestone_origin`; carrying again makes no duplicates. Which
293 issues belong to a milestone is not carried yet.
294
290295 On a forge made for a client (one that was spawned, signed in by its claim
291296 link), the home is their apps, one plain sentence each and one more saying
292297 whether its agents are working, about to start, or waiting: no map, board or
src/actions/migrate.ts +3 −2
@@ -12,7 +12,8 @@
1212 * pasted URL is cloned anonymously: public repositories only.
1313 *
1414 * From a connected origin the conversations come too (`origins/carry.ts`):
15 * every issue with its comments and labels, and the open pull requests as
15 * every issue with its comments and labels, each milestone (title,
16 * description, due date, state) and the open pull requests as
1617 * pulls here, on the branches the clone brought — each opening with a link
1718 * back to the original; one from a fork has its head under
1819 * `refs/pull/<n>/head` and waits for a writer to approve it to run. A
@@ -486,7 +487,7 @@ async function runMigration(
486487 carried.forkPulls.map((p) => p.number),
487488 );
488489 const forks = carried.forkPulls.length;
489 conversations = `; ${plural(carried.issues, "issue")}, ${plural(carried.pulls, "open pull request")}${forks === 0 ? "" : ` (${forks} from forks, waiting for your approval to run here)`}, ${plural(carried.comments, "comment")}${carried.error === "" ? "" : ` (the origin stopped part-way: ${redact(carried.error)})`}`;
490 conversations = `; ${plural(carried.issues, "issue")}, ${plural(carried.pulls, "open pull request")}${forks === 0 ? "" : ` (${forks} from forks, waiting for your approval to run here)`}, ${plural(carried.comments, "comment")}${carried.milestones === 0 ? "" : `, ${plural(carried.milestones, "milestone")}`}${carried.error === "" ? "" : ` (the origin stopped part-way: ${redact(carried.error)})`}`;
490491 app.bus.append({
491492 kind: "repo.imported",
492493 repoId: current.id,
src/core/migrations/20261022090000-milestone-origin.ts added +13
@@ -0,0 +1,13 @@
1 // A milestone carried from an origin (#3829, `origins/milestones.ts`): the
2 // origin's own number kept beside the issue that stands for it, so carrying
3 // twice finds it instead of making another, and the due date the origin
4 // gave it. Additive.
5 export default `
6 CREATE TABLE IF NOT EXISTS milestone_origin (
7 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
8 origin_number INTEGER NOT NULL,
9 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
10 due_on TEXT,
11 PRIMARY KEY (repo_id, origin_number)
12 );
13 `;
src/origins/carry.ts +17 −1
@@ -18,12 +18,15 @@
1818 * mirror's do — and is returned in `forkPulls`, for the caller to fetch
1919 * its head into `refs/pull/<n>/head`.
2020 *
21 * Milestones come too (`milestones.ts`), once each.
22 *
2123 * Every issue comes, open and closed; of pulls only the open ones — a
2224 * closed pull's work is either in the history already or was turned down.
2325 * Plain data in (a provider, the repository's ids), counts out; no git.
2426 */
2527
2628 import type { Database } from "bun:sqlite";
29 import { carryMilestone } from "./milestones.ts";
2730 import { importComments, importIssue, importLabels } from "./import.ts";
2831 import type { ListIssuesQuery, Provider, RemoteIssue } from "./types.ts";
2932
@@ -52,6 +55,8 @@ export interface Carried {
5255 readonly issues: number;
5356 readonly pulls: number;
5457 readonly comments: number;
58 /** Milestones made here: those already carried are not counted. */
59 readonly milestones: number;
5560 /** Open pulls whose head branch is in a fork, not in the clone. */
5661 readonly forkPulls: readonly ForkPull[];
5762 /** The first thing the origin refused, in words; "" when nothing was. */
@@ -90,7 +95,7 @@ export async function carryConversations(input: CarryInput): Promise<Carried> {
9095 const where = originName(provider);
9196 const ctx = { db, repoId, actorId };
9297 let error = "";
93 const counts = { labels: 0, issues: 0, pulls: 0, comments: 0 };
98 const counts = { labels: 0, issues: 0, pulls: 0, comments: 0, milestones: 0 };
9499 const forkPulls: ForkPull[] = [];
95100
96101 const labels = await provider.listLabels(fullName);
@@ -151,6 +156,17 @@ export async function carryConversations(input: CarryInput): Promise<Carried> {
151156 cursor = listed.value.next;
152157 }
153158 }
159
160 // After the issues, so a milestone's number here is past every number the
161 // origin gave a conversation.
162 if (provider.listMilestones !== undefined) {
163 const listed = await provider.listMilestones(fullName);
164 if (listed.ok) {
165 for (const remote of listed.value)
166 if (carryMilestone(db, repoId, actorId, remote)?.created)
167 counts.milestones++;
168 } else error ||= listed.error.message;
169 }
154170 return { ...counts, forkPulls, error };
155171 }
156172
src/origins/milestones.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * Milestones from an origin, carried once.
3 *
4 * A milestone here is an issue that waits for others (`core/milestones.ts`),
5 * so a GitHub milestone arrives as an issue: its title, its description as
6 * the body, closed when the milestone is. The due date has no column on an
7 * issue; it is kept in `milestone_origin` beside the origin's own number,
8 * which is also how a second carry finds the milestone it made the first
9 * time and leaves it alone — what a person has changed since is theirs.
10 *
11 * Which issues belong to which milestone is not carried here.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import { createIssue, MAX_BODY, MAX_TITLE, setIssueState } from "../core/issues.ts";
16 import type { RemoteMilestone } from "./types.ts";
17
18 /** Control bytes out, length capped: an origin does not get to write a novel. */
19 const clean = (text: string, limit: number): string =>
20 // eslint-disable-next-line no-control-regex
21 text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "").slice(0, limit);
22
23 export interface CarriedMilestone {
24 readonly issueId: string;
25 readonly created: boolean;
26 }

dueOf has no production caller; only the test uses it. Wire it to a surface in the follow-up, or drop it until then.

27
28 /** The milestone's due date as kept, or null when it has none. */
29 export function dueOf(db: Database, repoId: string, originNumber: number): string | null {
30 return (
31 db
32 .query<{ due_on: string | null }, [string, number]>(
33 "SELECT due_on FROM milestone_origin WHERE repo_id = ?1 AND origin_number = ?2",
34 )
35 .get(repoId, originNumber)?.due_on ?? null
36 );
37 }
38
39 /** Make the milestone here, unless it already is. Nothing is thrown at an origin's odd data: it is clamped. */
40 export function carryMilestone(
41 db: Database,
42 repoId: string,
43 actorId: string,
44 remote: RemoteMilestone,
45 ): CarriedMilestone | undefined {
46 if (!Number.isInteger(remote.number) || remote.number < 1) return undefined;
47 const known = db
48 .query<{ issue_id: string }, [string, number]>(
49 "SELECT issue_id FROM milestone_origin WHERE repo_id = ?1 AND origin_number = ?2",
50 )
51 .get(repoId, remote.number);
52 if (known !== null) return { issueId: known.issue_id, created: false };
53
54 const title = clean(remote.title.replace(/[\r\n\t]+/g, " "), MAX_TITLE).trim();
55 const made = createIssue(db, {
56 repoId,
57 authorId: actorId,
58 title: title === "" ? `Milestone ${remote.number}` : title,

createIssue, setIssueState and the milestone_origin insert are three writes with no transaction. Wrap them in db.transaction so a failure part-way cannot leave an issue the next carry will duplicate.

59 body: clean(remote.description, MAX_BODY),
60 });
61 if (!made.ok) return undefined;
62 if (remote.state === "closed") setIssueState(db, made.value.id, "closed", "done");
63 db.query(
64 `INSERT INTO milestone_origin (repo_id, origin_number, issue_id, due_on)
65 VALUES (?1, ?2, ?3, ?4)`,
66 ).run(repoId, remote.number, made.value.id, remote.dueOn);
67 return { issueId: made.value.id, created: true };
68 }
src/origins/providers/github.ts +28
@@ -27,6 +27,7 @@ import type {
2727 RemoteReadme,
2828 RemoteRepo,
2929 RemoteReview,
30 RemoteMilestone,
3031 RemoteReviewState,
3132 RemoteTreeEntry,
3233 } from "../types.ts";
@@ -327,6 +328,33 @@ export class GitHubProvider implements Provider {
327328 return succeed(labels.filter((l) => l.name !== ""));
328329 }
329330
331 async listMilestones(
332 fullName: string,
333 ): Promise<ProviderResult<readonly RemoteMilestone[]>> {
334 const named = splitFullName(fullName);
335 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
336 const milestones: RemoteMilestone[] = [];
337 for (let page = 1; page <= MAX_INTERNAL_PAGES; page++) {
338 const result = await this.api.call({
339 path: `/repos/${seg(named.owner)}/${seg(named.name)}/milestones`,
340 query: { state: "all", per_page: PER_PAGE, page },
341 });
342 if (!result.ok) return result;
343 const batch = asArray(result.value.data);
344 for (const raw of batch) {
345 milestones.push({
346 number: asNumber(field(raw, "number")),
347 title: asString(field(raw, "title")),
348 description: asString(field(raw, "description")),
349 dueOn: asTime(field(raw, "due_on")),
350 state: asState(field(raw, "state")),
351 });
352 }
353 if (batch.length < PER_PAGE) break;
354 }
355 return succeed(milestones.filter((m) => m.number > 0));
356 }
357
330358 /**
331359 * One list endpoint serves both kinds. Issues come back whole; pulls are
332360 * recognised by the `pull_request` key and hydrated one call each, because
src/origins/types.ts +14
@@ -89,6 +89,16 @@ export interface RemoteLabel {
8989 readonly color: string;
9090 }
9191
92 export interface RemoteMilestone {
93 /** The origin's number for it, per repository: what a second carry matches on. */
94 readonly number: number;
95 readonly title: string;
96 readonly description: string;
97 /** ISO 8601, or null when it has no due date. */
98 readonly dueOn: string | null;
99 readonly state: "open" | "closed";
100 }
101
92102 export interface RemoteComment {
93103 readonly remoteId: string;
94104 readonly author: string;
@@ -302,6 +312,10 @@ export interface Provider {
302312 getPullDiff(fullName: string, number: number): Promise<ProviderResult<Diff>>;
303313
304314 listLabels(fullName: string): Promise<ProviderResult<readonly RemoteLabel[]>>;
315 /** Every milestone, open and closed. Left out by an origin that is not carried from. */
316 listMilestones?(
317 fullName: string,
318 ): Promise<ProviderResult<readonly RemoteMilestone[]>>;
305319 listIssues(
306320 fullName: string,
307321 query: ListIssuesQuery,
test/fake-origin.ts +25
@@ -123,6 +123,15 @@ export interface FakeLabel {
123123 color: string;
124124 }
125125
126 export interface FakeMilestone {
127 number: number;
128 title: string;
129 description?: string;
130 /** ISO 8601, or absent for no due date. */
131 dueOn?: string;
132 state?: "open" | "closed";
133 }
134
126135 export interface FakeBranch {
127136 name: string;
128137 sha: string;
@@ -157,6 +166,8 @@ export interface FakeState {
157166 token: string;
158167 repos: FakeRepo[];
159168 labels: Map<string, FakeLabel[]>;
169 /** GitHub milestones, keyed `owner/name`. */
170 milestones: Map<string, FakeMilestone[]>;
160171 issues: Map<string, FakeIssue[]>;
161172 comments: Map<string, FakeComment[]>;
162173 reviews: Map<string, FakeReview[]>;
@@ -217,6 +228,7 @@ export function newFakeState(
217228 token,
218229 repos: [],
219230 labels: new Map(),
231 milestones: new Map(),
220232 issues: new Map(),
221233 comments: new Map(),
222234 reviews: new Map(),
@@ -546,6 +558,19 @@ function route(
546558 return jsonResponse(labels);
547559 }
548560
561 // ---- milestones ----
562 if (tail === "/milestones" && method === "GET" && state.kind === "github") {
563 return jsonResponse(
564 (state.milestones.get(fullName) ?? []).map((m) => ({
565 number: m.number,
566 title: m.title,
567 description: m.description ?? null,
568 due_on: m.dueOn ?? null,
569 state: m.state ?? "open",
570 })),
571 );
572 }
573
549574 // ---- issues and pulls ----
550575 const issues = state.issues.get(fullName) ?? [];
551576
test/migrate-milestones.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * A GitHub migration carries each milestone (#3829): title, description,
3 * due date and state, once. The origin is the fake GitHub, seeded with two
4 * milestones, one closed; carrying twice into the same repository makes
5 * none more.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { getIssue } from "../src/core/issues.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { carryConversations } from "../src/origins/carry.ts";
16 import { dueOf } from "../src/origins/milestones.ts";
17 import { GitHubProvider } from "../src/origins/providers/github.ts";
18 import { staticCredential } from "../src/origins/types.ts";
19 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 describe("migrate carries milestones", () => {
25 let dataDir: string;
26 let app: App;
27 let github: FakeForge;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-milestones-"));
31 app = createApp({ dataDir, port: 0 });
32 const state = newFakeState("github", "octo", "tok-secret-1234");
33 state.repos.push({
34 id: 1,
35 fullName: "octo/gadgets",
36 cloneUrl: "http://127.0.0.1/none.git",
37 });
38 state.issues.set("octo/gadgets", [
39 { id: 11, number: 1, title: "A conversation", user: "mona" },
40 ]);
41 state.milestones.set("octo/gadgets", [
42 { number: 1, title: "v1", description: "The first cut.", dueOn: "2026-12-01T00:00:00Z" },
43 { number: 2, title: "v0", description: "Done long ago.", dueOn: "2026-01-15T00:00:00Z", state: "closed" },
44 { number: 3, title: "Someday" },
45 ]);
46 github = serveFakeForge(state);
47 });
48 afterAll(async () => {
49 github.stop();
50 await rm(dataDir, { recursive: true, force: true });
51 });
52
53 test("both milestones arrive with state and due date, and a second carry makes no more", async () => {
54 const made = await createUser(app.db, {
55 origin: "seed",
56 trusted: true,
57 username: "alice",
58 email: "[email protected]",
59 adminIfFirst: true,
60 });
61 if (!made.ok) throw new Error(made.error);
62 const repo = await createRepo(app, made.value, { name: "gadgets" });
63 if (!repo.ok) throw new Error(String(repo.error));
64 const provider = new GitHubProvider(
65 staticCredential({
66 kind: "github",
67 apiUrl: github.apiUrl,
68 webUrl: github.url,
69 token: "tok-secret-1234",
70 }),
71 );
72 const carry = () =>
73 carryConversations({
74 db: app.db,
75 provider,
76 fullName: "octo/gadgets",
77 repoId: repo.value.id,
78 actorId: made.value.id,
79 });
80
81 const first = await carry();
82 expect(first.error).toBe("");
83 expect(first.milestones).toBe(3);
84 const rows = app.db
85 .query<{ origin_number: number; issue_id: string }, [string]>(
86 "SELECT origin_number, issue_id FROM milestone_origin WHERE repo_id = ?1 ORDER BY origin_number",
87 )
88 .all(repo.value.id);
89 expect(rows.map((r) => r.origin_number)).toEqual([1, 2, 3]);
90 const issues = rows.map((r) => {
91 const issue = app.db
92 .query<{ title: string; body: string; state: string }, [string]>(
93 "SELECT title, body, state FROM issue WHERE id = ?1",
94 )
95 .get(r.issue_id)!;
96 return issue;
97 });
98 expect(issues).toEqual([
99 { title: "v1", body: "The first cut.", state: "open" },
100 { title: "v0", body: "Done long ago.", state: "closed" },
101 { title: "Someday", body: "", state: "open" },
102 ]);
103 expect(dueOf(app.db, repo.value.id, 1)).toBe("2026-12-01T00:00:00.000Z");
104 expect(dueOf(app.db, repo.value.id, 2)).toBe("2026-01-15T00:00:00.000Z");
105 expect(dueOf(app.db, repo.value.id, 3)).toBeNull();
106 // Their numbers sit past the origin's conversation, not on it.
107 expect(getIssue(app.db, repo.value.id, 1)?.title).toBe("A conversation");
108
109 const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM issue").get()!.n;
110 const second = await carry();
111 expect(second.milestones).toBe(0);
112 expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM issue").get()!.n).toBe(before);
113 expect(
114 app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM milestone_origin").get()!.n,
115 ).toBe(3);
116 });
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs