Public: anyone can read this; the crew can change it.
A GitHub migration carries each milestone: title, description, due date, state, once #3851
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3829-a-github-migration-carries-each-mileston → main 1 commit fork 2 threads · 2 unresolved
9 files
changed +290
−3
README.md +5
| @@ -287,6 +287,11 @@ new repository. It is the same `migrateRepo` as `/migrate` and | ||
| 287 | 287 | crew's brief says the app lives on it. The web form takes it too (it lists the |
| 288 | 288 | picked repository's branches newest first), as does the API's `branch` field. |
| 289 | 289 | |
| 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 | ||
| 290 | 295 | On a forge made for a client (one that was spawned, signed in by its claim |
| 291 | 296 | link), the home is their apps, one plain sentence each and one more saying |
| 292 | 297 | whether its agents are working, about to start, or waiting: no map, board or |
src/actions/migrate.ts +3 −2
| @@ -12,7 +12,8 @@ | ||
| 12 | 12 | * pasted URL is cloned anonymously: public repositories only. |
| 13 | 13 | * |
| 14 | 14 | * 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 | |
| 16 | 17 | * pulls here, on the branches the clone brought — each opening with a link |
| 17 | 18 | * back to the original; one from a fork has its head under |
| 18 | 19 | * `refs/pull/<n>/head` and waits for a writer to approve it to run. A |
| @@ -486,7 +487,7 @@ async function runMigration( | ||
| 486 | 487 | carried.forkPulls.map((p) => p.number), |
| 487 | 488 | ); |
| 488 | 489 | 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)})`}`; | |
| 490 | 491 | app.bus.append({ |
| 491 | 492 | kind: "repo.imported", |
| 492 | 493 | 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 @@ | ||
| 18 | 18 | * mirror's do — and is returned in `forkPulls`, for the caller to fetch |
| 19 | 19 | * its head into `refs/pull/<n>/head`. |
| 20 | 20 | * |
| 21 | * Milestones come too (`milestones.ts`), once each. | |
| 22 | * | |
| 21 | 23 | * Every issue comes, open and closed; of pulls only the open ones — a |
| 22 | 24 | * closed pull's work is either in the history already or was turned down. |
| 23 | 25 | * Plain data in (a provider, the repository's ids), counts out; no git. |
| 24 | 26 | */ |
| 25 | 27 | |
| 26 | 28 | import type { Database } from "bun:sqlite"; |
| 29 | import { carryMilestone } from "./milestones.ts"; | |
| 27 | 30 | import { importComments, importIssue, importLabels } from "./import.ts"; |
| 28 | 31 | import type { ListIssuesQuery, Provider, RemoteIssue } from "./types.ts"; |
| 29 | 32 | |
| @@ -52,6 +55,8 @@ export interface Carried { | ||
| 52 | 55 | readonly issues: number; |
| 53 | 56 | readonly pulls: number; |
| 54 | 57 | readonly comments: number; |
| 58 | /** Milestones made here: those already carried are not counted. */ | |
| 59 | readonly milestones: number; | |
| 55 | 60 | /** Open pulls whose head branch is in a fork, not in the clone. */ |
| 56 | 61 | readonly forkPulls: readonly ForkPull[]; |
| 57 | 62 | /** The first thing the origin refused, in words; "" when nothing was. */ |
| @@ -90,7 +95,7 @@ export async function carryConversations(input: CarryInput): Promise<Carried> { | ||
| 90 | 95 | const where = originName(provider); |
| 91 | 96 | const ctx = { db, repoId, actorId }; |
| 92 | 97 | 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 }; | |
| 94 | 99 | const forkPulls: ForkPull[] = []; |
| 95 | 100 | |
| 96 | 101 | const labels = await provider.listLabels(fullName); |
| @@ -151,6 +156,17 @@ export async function carryConversations(input: CarryInput): Promise<Carried> { | ||
| 151 | 156 | cursor = listed.value.next; |
| 152 | 157 | } |
| 153 | 158 | } |
| 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 | } | |
| 154 | 170 | return { ...counts, forkPulls, error }; |
| 155 | 171 | } |
| 156 | 172 |
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 | } | |
| 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 { | ||
| 27 | 27 | RemoteReadme, |
| 28 | 28 | RemoteRepo, |
| 29 | 29 | RemoteReview, |
| 30 | RemoteMilestone, | |
| 30 | 31 | RemoteReviewState, |
| 31 | 32 | RemoteTreeEntry, |
| 32 | 33 | } from "../types.ts"; |
| @@ -327,6 +328,33 @@ export class GitHubProvider implements Provider { | ||
| 327 | 328 | return succeed(labels.filter((l) => l.name !== "")); |
| 328 | 329 | } |
| 329 | 330 | |
| 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 | ||
| 330 | 358 | /** |
| 331 | 359 | * One list endpoint serves both kinds. Issues come back whole; pulls are |
| 332 | 360 | * recognised by the `pull_request` key and hydrated one call each, because |
src/origins/types.ts +14
| @@ -89,6 +89,16 @@ export interface RemoteLabel { | ||
| 89 | 89 | readonly color: string; |
| 90 | 90 | } |
| 91 | 91 | |
| 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 | ||
| 92 | 102 | export interface RemoteComment { |
| 93 | 103 | readonly remoteId: string; |
| 94 | 104 | readonly author: string; |
| @@ -302,6 +312,10 @@ export interface Provider { | ||
| 302 | 312 | getPullDiff(fullName: string, number: number): Promise<ProviderResult<Diff>>; |
| 303 | 313 | |
| 304 | 314 | 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[]>>; | |
| 305 | 319 | listIssues( |
| 306 | 320 | fullName: string, |
| 307 | 321 | query: ListIssuesQuery, |
test/fake-origin.ts +25
| @@ -123,6 +123,15 @@ export interface FakeLabel { | ||
| 123 | 123 | color: string; |
| 124 | 124 | } |
| 125 | 125 | |
| 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 | ||
| 126 | 135 | export interface FakeBranch { |
| 127 | 136 | name: string; |
| 128 | 137 | sha: string; |
| @@ -157,6 +166,8 @@ export interface FakeState { | ||
| 157 | 166 | token: string; |
| 158 | 167 | repos: FakeRepo[]; |
| 159 | 168 | labels: Map<string, FakeLabel[]>; |
| 169 | /** GitHub milestones, keyed `owner/name`. */ | |
| 170 | milestones: Map<string, FakeMilestone[]>; | |
| 160 | 171 | issues: Map<string, FakeIssue[]>; |
| 161 | 172 | comments: Map<string, FakeComment[]>; |
| 162 | 173 | reviews: Map<string, FakeReview[]>; |
| @@ -217,6 +228,7 @@ export function newFakeState( | ||
| 217 | 228 | token, |
| 218 | 229 | repos: [], |
| 219 | 230 | labels: new Map(), |
| 231 | milestones: new Map(), | |
| 220 | 232 | issues: new Map(), |
| 221 | 233 | comments: new Map(), |
| 222 | 234 | reviews: new Map(), |
| @@ -546,6 +558,19 @@ function route( | ||
| 546 | 558 | return jsonResponse(labels); |
| 547 | 559 | } |
| 548 | 560 | |
| 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 | ||
| 549 | 574 | // ---- issues and pulls ---- |
| 550 | 575 | const issues = state.issues.get(fullName) ?? []; |
| 551 | 576 |
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 | }); |
new above
offline
dueOf has no production caller; only the test uses it. Wire it to a surface in the follow-up, or drop it until then.