Public: anyone can read this; the crew can change it.
One-click GitHub: Connect GitHub, pick a repository, the crew makes it run here - issues and open pull requests come too #3289
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3264-one-click-github-connect-github-pick-a-r → main 4 commits fork 6 threads · 6 unresolved
docs/ORIGINS.md +49 −4
| @@ -460,6 +460,50 @@ repository), the agent — is refused in words before the repository exists; | ||
| 460 | 460 | what is left for the job is the network. A clone that fails removes the |
| 461 | 461 | empty repository it made, so the name is free to try again. |
| 462 | 462 | |
| 463 | **One click from GitHub.** `/migrate` carries its own "Your accounts" | |
| 464 | card: **Connect GitHub** runs the device flow above (no token pasted; it | |
| 465 | needs the forge's GitHub application, `FORGE_GITHUB_CLIENT_ID`) and comes | |
| 466 | back to `/migrate`, and each connected account has a **Disconnect** that | |
| 467 | comes back too. The return is the fixed word `then=migrate`, never a path a | |
| 468 | form can choose. Every repository the account can see (owned and its orgs') | |
| 469 | is a row — name, a `private` mark, its size as the origin reports it (KiB, | |
| 470 | shown in KiB/MiB/GiB), its description — filtered by `?q=` on the server and | |
| 471 | live as you type. A row's button is the form's submitter (`name="pick"`), so | |
| 472 | one click migrates it with the destination and crew fields as they stand; | |
| 473 | the first agent is picked by default, "Nobody" is one choice away. | |
| 474 | ||
| 475 | **The branch the app lives on.** A fork often keeps the upstream's tree on | |
| 476 | `main` and the person's app on a branch of their own, so the source's default | |
| 477 | is not always the app. A row's **Branches** link (`/migrate?source=…&branches=1`) draws | |
| 478 | that repository's branches, each with its last commit — subject, author, | |
| 479 | date — read through the origin's API (the first 30 branches' commits; the | |
| 480 | rest by name), remembered a minute per account and repository so a reload or | |
| 481 | a refused form spends nothing at the origin; a plain visit asks for none of it. The one selected is the branch whose last commit is the | |
| 482 | person's and newest (an account login the origin resolved, a GitHub noreply | |
| 483 | address with their login, their forge email, or an author name that is one | |
| 484 | of their logins), else the source's default. A one-click row with no branch | |
| 485 | chosen gets the same rule in the job, over the clone's own tips | |
| 486 | (`git for-each-ref`) and the origin's logins (`origins/work-branch.ts`). | |
| 487 | That branch becomes the default branch here, the job says which and why, | |
| 488 | and the crew's brief names it: start from it, open the pull against it. | |
| 489 | ||
| 490 | **The conversations come too** — from a connected origin, not a pasted URL | |
| 491 | (`origins/carry.ts`). Labels; every issue, open and closed, with its comments | |
| 492 | and labels, under its original number; and the open pull requests as pulls | |
| 493 | here, on the branches the clone brought — native ones (`origin_head_repo` | |
| 494 | cleared), so they review and merge like any other. A pull whose head lived | |
| 495 | in a fork is a stranger's code: its head is fetched into `refs/pull/<n>/head` | |
| 496 | (the forge's own namespace — no push event, no branch the clone brought is | |
| 497 | touched), it keeps `origin_head_repo`, and its check and preview wait for a | |
| 498 | writer's **Approve to run here**, exactly as a mirror's do. The job's message | |
| 499 | says how many wait. Closed pulls stay behind: their work is in | |
| 500 | the history or was turned down. Each body opens with | |
| 501 | `> Migrated from [owner/name#n](<original>) by @login on GitHub.` — the link | |
| 502 | only when it is http(s), the login reduced to login characters — and the | |
| 503 | author also travels in `origin_author`, so every page marks it as theirs on | |
| 504 | the origin. One `repo.imported` event (`via: "migrate"`) says so, not one per | |
| 505 | row. A refusal part-way keeps what arrived and says so in the job's detail. | |
| 506 | ||
| 463 | 507 | **Put a crew on it.** Name an agent and, after the clone, the same job puts |
| 464 | 508 | it on the crew, makes the repository an app, sets it autonomous when asked, |
| 465 | 509 | and `tell`s the crew one thing: make it run here. The brief is the |
| @@ -486,8 +530,9 @@ the person still presses Migrate (POST, same-origin checked). | ||
| 486 | 530 | |
| 487 | 531 | The clone runs as a job in the mirror's registry (`origins/jobs.ts`, kind |
| 488 | 532 | `migrate`, owned by whoever started it — anyone else's is `not_found`). |
| 489 | `/migrate/jobs/:job` refreshes until it stops, then lands on the repository | |
| 490 | with what happened as the flash. | |
| 533 | `/migrate/jobs/:job` follows its stages — clone, issues, pull requests, the | |
| 534 | app and the crew's first run — and then lands on the repository, which is | |
| 535 | the app's page, with what happened as the flash. | |
| 491 | 536 | |
| 492 | 537 | ## API |
| 493 | 538 | |
| @@ -536,7 +581,7 @@ GET /api/v1/user/pins PUT/DELETE /api/v1/user/pins/:owner/:name | ||
| 536 | 581 | |
| 537 | 582 | POST /api/v1/migrate {source: {url} | {originId, fullName}, |
| 538 | 583 | owner?, name?, visibility?, |
| 539 | crew?: {agent, autonomous}} | |
| 584 | branch?, crew?: {agent, autonomous}} | |
| 540 | 585 | → 202 {repo, job} |
| 541 | 586 | GET /api/v1/migrate/:job poll one migration (its starter only) |
| 542 | 587 | GET /api/v1/migrate/:job/stream SSE: migrate.<stage>, ends on done |
| @@ -551,7 +596,7 @@ credential, which is the same authority as spending it. Repo-level sync and | ||
| 551 | 596 | pins take the ordinary `rw` floor; so does a migration from a URL, while one |
| 552 | 597 | from an origin takes `admin` — it spends the origin's credential. Its |
| 553 | 598 | refusals are 422 `{ error, message }`: `invalid_url`, `no_such_origin`, |
| 554 | `unreachable`, `no_such_agent`, and the new-repository codes (`name_taken`, | |
| 599 | `unreachable`, `no_such_agent`, `invalid_branch`, and the new-repository codes (`name_taken`, | |
| 555 | 600 | `invalid_name`, `forbidden_owner`). |
| 556 | 601 | |
| 557 | 602 | A contribute answers 201 with |
src/actions/migrate.ts +203 −19
| @@ -11,6 +11,13 @@ | ||
| 11 | 11 | * environment, scoped to the origin's host — never argv, never the URL. A |
| 12 | 12 | * pasted URL is cloned anonymously: public repositories only. |
| 13 | 13 | * |
| 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 | |
| 16 | * pulls here, on the branches the clone brought — each opening with a link | |
| 17 | * back to the original; one from a fork has its head under | |
| 18 | * `refs/pull/<n>/head` and waits for a writer to approve it to run. A | |
| 19 | * pasted URL brings the history alone. | |
| 20 | * | |
| 14 | 21 | * The clone runs as a job (`origins/jobs.ts`, the mirror's registry), so a |
| 15 | 22 | * page can watch it. With a crew asked for, the same job then puts the |
| 16 | 23 | * agent on the crew, makes the repository an app, and tells the crew one |
| @@ -30,9 +37,16 @@ import { isSafeRef } from "../git/guard.ts"; | ||
| 30 | 37 | import { repoDir } from "../git/repos.ts"; |
| 31 | 38 | import { type Resolve, vetHost } from "../kernel/outbound.ts"; |
| 32 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 40 | import { carryConversations, plural } from "../origins/carry.ts"; | |
| 33 | 41 | import { providerForOrigin } from "../origins/credential.ts"; |
| 34 | 42 | import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts"; |
| 35 | 43 | import { MIRROR_REFSPECS, snapshotRefs } from "../origins/git-mirror.ts"; |
| 44 | import { | |
| 45 | localTips, | |
| 46 | pickWorkBranch, | |
| 47 | remoteTips, | |
| 48 | type Who, | |
| 49 | } from "../origins/work-branch.ts"; | |
| 36 | 50 | import { |
| 37 | 51 | advance, |
| 38 | 52 | createJob, |
| @@ -40,6 +54,7 @@ import { | ||
| 40 | 54 | runningJob, |
| 41 | 55 | type MirrorJob, |
| 42 | 56 | } from "../origins/jobs.ts"; |
| 57 | import type { Provider } from "../origins/types.ts"; | |
| 43 | 58 | import { requireUser } from "./access.ts"; |
| 44 | 59 | import { enableApp } from "./apps.ts"; |
| 45 | 60 | import { addToCrew } from "./crew.ts"; |
| @@ -70,6 +85,12 @@ export interface MigrateInput { | ||
| 70 | 85 | readonly name?: string; |
| 71 | 86 | /** @default "private" */ |
| 72 | 87 | readonly visibility?: "public" | "private"; |
| 88 | /** | |
| 89 | * The branch the app lives on — the default branch here and the one the | |
| 90 | * crew is pointed at. Left out, the branch with the person's newest | |
| 91 | * commits (`origins/work-branch.ts`), else the source's default. | |
| 92 | */ | |
| 93 | readonly branch?: string; | |
| 73 | 94 | /** Put an agent on it: the crew makes it run here. */ |
| 74 | 95 | readonly crew?: { readonly agent: string; readonly autonomous: boolean }; |
| 75 | 96 | /** Name → addresses, for the private-address check. Tests inject it. */ |
| @@ -89,6 +110,7 @@ export type MigrateError = | ||
| 89 | 110 | | "private_host" |
| 90 | 111 | | "no_such_origin" |
| 91 | 112 | | "unreachable" |
| 113 | | "invalid_branch" | |
| 92 | 114 | | "no_such_agent"; |
| 93 | 115 | |
| 94 | 116 | /** What the clone reads from, resolved: where, and how to authenticate. */ |
| @@ -100,6 +122,18 @@ interface Resolved { | ||
| 100 | 122 | readonly name: string; |
| 101 | 123 | readonly description: string; |
| 102 | 124 | readonly authEnv: () => Promise<Record<string, string>>; |
| 125 | /** A connected origin's adapter: what issues and pulls are read through. */ | |
| 126 | readonly provider?: Provider; | |
| 127 | /** The person, as the source's commits may name them. */ | |
| 128 | readonly who: Who; | |
| 129 | } | |
| 130 | ||
| 131 | /** Why the work branch is the one it is: said in the job and the brief. */ | |
| 132 | type WorkReason = "picked" | "newest" | "default"; | |
| 133 | ||
| 134 | interface WorkBranch { | |
| 135 | readonly name: string; | |
| 136 | readonly reason: WorkReason; | |
| 103 | 137 | } |
| 104 | 138 | |
| 105 | 139 | /** |
| @@ -121,6 +155,8 @@ export async function migrateRepo( | ||
| 121 | 155 | if (!source.ok) return source; |
| 122 | 156 | if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent)) |
| 123 | 157 | return err("no_such_agent"); |
| 158 | const branch = (input.branch ?? "").trim(); | |
| 159 | if (branch !== "" && !isSafeRef(branch)) return err("invalid_branch"); | |
| 124 | 160 | |
| 125 | 161 | // A double-click joins the migration already under way rather than |
| 126 | 162 | // failing the second one on the name the first just took. |
| @@ -150,7 +186,11 @@ export async function migrateRepo( | ||
| 150 | 186 | actorId: user.value.id, |
| 151 | 187 | }); |
| 152 | 188 | advance(job.id, { stage: "creating", repo }); |
| 153 | void runMigration(app, user.value, repo, source.value, input.crew, job.id); | |
| 189 | void runMigration(app, user.value, repo, source.value, { | |
| 190 | branch: branch === "" ? undefined : branch, | |
| 191 | crew: input.crew, | |
| 192 | jobId: job.id, | |
| 193 | }); | |
| 154 | 194 | return ok({ repo, job: getJob(job.id) ?? job }); |
| 155 | 195 | } |
| 156 | 196 | |
| @@ -188,6 +228,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories | ||
| 188 | 228 | return "The origin could not be reached. Try again, or reconnect it under Settings → Origins."; |
| 189 | 229 | case "no_such_agent": |
| 190 | 230 | return "That agent is not on this forge."; |
| 231 | case "invalid_branch": | |
| 232 | return "That is not a branch name git would take."; | |
| 191 | 233 | case "name_taken": |
| 192 | 234 | return "That owner already has a repository by that name. Pick another name."; |
| 193 | 235 | case "invalid_name": |
| @@ -354,6 +396,7 @@ async function resolveSource( | ||
| 354 | 396 | name: nameFromUrl(url), |
| 355 | 397 | description: "", |
| 356 | 398 | authEnv: () => Promise.resolve({}), |
| 399 | who: { logins: [user.username], emails: [user.email] }, | |
| 357 | 400 | }); |
| 358 | 401 | } |
| 359 | 402 | const origin = getOriginFor(app.db, user.id, source.originId); |
| @@ -376,6 +419,8 @@ async function resolveSource( | ||
| 376 | 419 | // Only the header: the origin's credential reaches this one git |
| 377 | 420 | // invocation scoped to its host, and the repository never learns it. |
| 378 | 421 | authEnv: () => provider.value.gitAuthEnv(url), |
| 422 | provider: provider.value, | |
| 423 | who: { logins: [origin.login, user.username], emails: [user.email] }, | |
| 379 | 424 | }); |
| 380 | 425 | } |
| 381 | 426 | |
| @@ -385,9 +430,13 @@ async function runMigration( | ||
| 385 | 430 | user: User, |
| 386 | 431 | repo: Repo, |
| 387 | 432 | source: Resolved, |
| 388 | crew: MigrateInput["crew"], | |
| 389 | jobId: string, | |
| 433 | asked: { | |
| 434 | readonly branch: string | undefined; | |
| 435 | readonly crew: MigrateInput["crew"]; | |
| 436 | readonly jobId: string; | |
| 437 | }, | |
| 390 | 438 | ): Promise<void> { |
| 439 | const { crew, jobId } = asked; | |
| 391 | 440 | const fail = async (error: string): Promise<void> => { |
| 392 | 441 | // An empty repository nobody asked for is clutter, and it holds the |
| 393 | 442 | // name a retry wants: a clone that did not happen leaves nothing. |
| @@ -398,23 +447,65 @@ async function runMigration( | ||
| 398 | 447 | advance(jobId, { stage: "fetching", detail: "every branch and tag" }); |
| 399 | 448 | const cloned = await cloneInto(app, repo, source); |
| 400 | 449 | if (!cloned.ok) return await fail(cloned.error); |
| 401 | storageStale(app, repo.ownerId); | |
| 402 | 450 | let current = getRepoById(app.db, repo.id) ?? repo; |
| 403 | 451 | |
| 404 | // The source's default branch is kept; a source that never said which | |
| 405 | // keeps the forge's own, or the branch it plainly meant. | |
| 406 | const branches = [...cloned.value.refs.keys()] | |
| 452 | // The conversations, when an origin can be asked for them. A refusal | |
| 453 | // part-way keeps what arrived and says so; the history is the move. | |
| 454 | let conversations = ""; | |
| 455 | const refs = cloned.value.refs; | |
| 456 | if (source.provider !== undefined) { | |
| 457 | advance(jobId, { stage: "issues", detail: "reading issues" }); | |
| 458 | const carried = await carryConversations({ | |
| 459 | db: app.db, | |
| 460 | provider: source.provider, | |
| 461 | fullName: source.label, | |
| 462 | repoId: current.id, | |
| 463 | actorId: user.id, | |
| 464 | report: (stage, detail) => advance(jobId, { stage, detail }), | |
| 465 | }); | |
| 466 | // A fork's head is fetched where the forge keeps pull heads, not as | |
| 467 | // a branch: no push event runs it, and no branch the clone brought | |
| 468 | // is overwritten. Its pull waits for a writer to approve it to run. | |
| 469 | await fetchForkHeads( | |
| 470 | app, | |
| 471 | current, | |
| 472 | source, | |
| 473 | carried.forkPulls.map((p) => p.number), | |
| 474 | ); | |
| 475 | const forks = carried.forkPulls.length; | |
| 476 | 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)})`}`; | |
| 477 | app.bus.append({ | |
| 478 | kind: "repo.imported", | |
| 479 | repoId: current.id, | |
| 480 | actorId: user.id, | |
| 481 | payload: { | |
| 482 | fullName: source.label, | |
| 483 | issues: carried.issues, | |
| 484 | pulls: carried.pulls, | |
| 485 | comments: carried.comments, | |
| 486 | via: "migrate", | |
| 487 | }, | |
| 488 | }); | |
| 489 | } | |
| 490 | storageStale(app, repo.ownerId); | |
| 491 | ||
| 492 | // The default branch here is the work branch: the one picked, else | |
| 493 | // the one with the person's newest commits, else the source's default | |
| 494 | // — and a source that never said which keeps the forge's own, or the | |
| 495 | // branch it plainly meant. | |
| 496 | const branches = [...refs.keys()] | |
| 407 | 497 | .filter((ref) => ref.startsWith("refs/heads/")) |
| 408 | 498 | .map((ref) => ref.slice("refs/heads/".length)); |
| 409 | const wanted = [ | |
| 499 | const fallback = [ | |
| 410 | 500 | cloned.value.head, |
| 411 | 501 | current.defaultBranch, |
| 412 | 502 | "master", |
| 413 | 503 | ...branches.sort(), |
| 414 | 504 | ].find((b) => b !== undefined && branches.includes(b)); |
| 415 | if (wanted !== undefined && wanted !== current.defaultBranch) { | |
| 505 | const work = await workBranch(app, current, source, asked.branch, branches, fallback); | |
| 506 | if (work !== undefined && work.name !== current.defaultBranch) { | |
| 416 | 507 | const moved = await updateRepoSettings(app, user, current, { |
| 417 | defaultBranch: wanted, | |
| 508 | defaultBranch: work.name, | |
| 418 | 509 | }); |
| 419 | 510 | if (moved.ok) current = moved.value; |
| 420 | 511 | } |
| @@ -422,13 +513,13 @@ async function runMigration( | ||
| 422 | 513 | // The history arrived without a push, so nothing downstream heard it. |
| 423 | 514 | // One event, shaped like a push, says so — the feed, webhooks and the |
| 424 | 515 | // default branch's check see what landed. |
| 425 | if (cloned.value.refs.size > 0) { | |
| 516 | if (refs.size > 0) { | |
| 426 | 517 | app.bus.append({ |
| 427 | 518 | kind: "push", |
| 428 | 519 | repoId: current.id, |
| 429 | 520 | actorId: user.id, |
| 430 | 521 | payload: { |
| 431 | updates: [...cloned.value.refs].map(([ref, newOid]) => ({ | |
| 522 | updates: [...refs].map(([ref, newOid]) => ({ | |
| 432 | 523 | ref, |
| 433 | 524 | oldOid: "0".repeat(40), |
| 434 | 525 | newOid, |
| @@ -440,10 +531,12 @@ async function runMigration( | ||
| 440 | 531 | } |
| 441 | 532 | |
| 442 | 533 | const where = `${current.ownerName}/${current.name}`; |
| 443 | let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(cloned.value.refs.size - branches.length, "tag")}`; | |
| 534 | let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(refs.size - branches.length, "tag")}${conversations}`; | |
| 535 | if (work !== undefined) | |
| 536 | detail += `. Working on ${work.name}, ${reasonWords(work.reason)}${asked.branch !== undefined && work.reason !== "picked" ? ` (${asked.branch} did not come with the clone)` : ""}`; | |
| 444 | 537 | if (crew !== undefined) { |
| 445 | 538 | advance(jobId, { stage: "crew", detail: `putting ${crew.agent} on it` }); |
| 446 | detail += `. ${await putCrewOn(app, user, current, source, crew)}`; | |
| 539 | detail += `. ${await putCrewOn(app, user, current, source, crew, work)}`; | |
| 447 | 540 | } |
| 448 | 541 | advance(jobId, { |
| 449 | 542 | stage: "done", |
| @@ -518,6 +611,86 @@ async function cloneInto( | ||
| 518 | 611 | return ok({ head, refs }); |
| 519 | 612 | } |
| 520 | 613 | |
| 614 | /** | |
| 615 | * Which branch the app lives on. A picked branch that arrived wins; else | |
| 616 | * the newest branch tip that is the person's — by the clone's authors, and | |
| 617 | * by the origin's account logins when there is an origin to ask — else the | |
| 618 | * fallback. | |
| 619 | */ | |
| 620 | async function workBranch( | |
| 621 | app: App, | |
| 622 | repo: Repo, | |
| 623 | source: Resolved, | |
| 624 | picked: string | undefined, | |
| 625 | branches: readonly string[], | |
| 626 | fallback: string | undefined, | |
| 627 | ): Promise<WorkBranch | undefined> { | |
| 628 | if (picked !== undefined && branches.includes(picked)) | |
| 629 | return { name: picked, reason: "picked" }; | |
| 630 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 631 | let tips = await localTips(dir, source.who); | |
| 632 | if (source.provider !== undefined) { | |
| 633 | const remote = await remoteTips(source.provider, source.label, source.who); | |
| 634 | const theirs = new Set( | |
| 635 | (remote ?? []).filter((t) => t.mine).map((t) => `${t.name}\0${t.oid}`), | |
| 636 | ); | |
| 637 | tips = tips.map((t) => | |
| 638 | theirs.has(`${t.name}\0${t.oid}`) ? { ...t, mine: true } : t, | |
| 639 | ); | |
| 640 | } | |
| 641 | const name = pickWorkBranch(tips, fallback); | |
| 642 | if (name === undefined) return undefined; | |
| 643 | return { | |
| 644 | name, | |
| 645 | reason: tips.some((t) => t.mine && t.name === name) ? "newest" : "default", | |
| 646 | }; | |
| 647 | } | |
| 648 | ||
| 649 | function reasonWords(reason: WorkReason): string { | |
| 650 | switch (reason) { | |
| 651 | case "picked": | |
| 652 | return "the branch picked"; | |
| 653 | case "newest": | |
| 654 | return "the branch with your newest commits"; | |
| 655 | case "default": | |
| 656 | return "the source's default branch"; | |
| 657 | } | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Bring the heads of pulls opened from forks: the origin serves each as | |
| 662 | * `refs/pull/<n>/head` (GitHub and Forgejo alike), and it lands under the | |
| 663 | * same name here — the forge's own namespace for pull heads, which a push | |
| 664 | * cannot write and the clone never brings. One fetch; a fetch that fails | |
| 665 | * leaves those pulls showing the head the origin reported, still waiting. | |
| 666 | */ | |
| 667 | async function fetchForkHeads( | |
| 668 | app: App, | |
| 669 | repo: Repo, | |
| 670 | source: Resolved, | |
| 671 | numbers: readonly number[], | |
| 672 | ): Promise<void> { | |
| 673 | if (numbers.length === 0) return; | |
| 674 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 675 | const authEnv = await source.authEnv(); | |
| 676 | await runGitCapture( | |
| 677 | [ | |
| 678 | "fetch", | |
| 679 | "--no-write-fetch-head", | |
| 680 | "--no-recurse-submodules", | |
| 681 | "--quiet", | |
| 682 | "--", | |
| 683 | source.url, | |
| 684 | ...numbers.map((n) => `+refs/pull/${n}/head:refs/pull/${n}/head`), | |
| 685 | ], | |
| 686 | { | |
| 687 | cwd: dir, | |
| 688 | env: remoteGitEnv(authEnv, undefined, ALLOWED_PROTOCOL), | |
| 689 | timeoutMs: MIGRATE_TIMEOUT_MS, | |
| 690 | }, | |
| 691 | ); | |
| 692 | } | |
| 693 | ||
| 521 | 694 | /** |
| 522 | 695 | * The crew half: the agent joins, the repository becomes an app, autonomy |
| 523 | 696 | * is set when asked, and the crew is told the one thing it is there for. |
| @@ -529,6 +702,7 @@ async function putCrewOn( | ||
| 529 | 702 | repo: Repo, |
| 530 | 703 | source: Resolved, |
| 531 | 704 | crew: NonNullable<MigrateInput["crew"]>, |
| 705 | work: WorkBranch | undefined, | |
| 532 | 706 | ): Promise<string> { |
| 533 | 707 | const said: string[] = []; |
| 534 | 708 | const added = addToCrew(app, user, repo, crew.agent); |
| @@ -547,7 +721,7 @@ async function putCrewOn( | ||
| 547 | 721 | }); |
| 548 | 722 | if (!set.ok) said.push("Autonomy could not be turned on."); |
| 549 | 723 | } |
| 550 | const told = await tell(app, user, repo, importerBrief(repo, source.label)); | |
| 724 | const told = await tell(app, user, repo, importerBrief(repo, source.label, work)); | |
| 551 | 725 | said.unshift(told.ok ? toldWords(told.value) : "The crew could not be told."); |
| 552 | 726 | return said.join(" "); |
| 553 | 727 | } |
| @@ -557,11 +731,24 @@ async function putCrewOn( | ||
| 557 | 731 | * contract (docs/APPS.md), from the other side: someone else's project, |
| 558 | 732 | * made to run here with the smallest diff that does it. |
| 559 | 733 | */ |
| 560 | export function importerBrief(repo: Repo, from: string): string { | |
| 734 | export function importerBrief( | |
| 735 | repo: Repo, | |
| 736 | from: string, | |
| 737 | work?: WorkBranch, | |
| 738 | ): string { | |
| 739 | const branch = work?.name ?? repo.defaultBranch; | |
| 740 | const why = | |
| 741 | work?.reason === "picked" | |
| 742 | ? "the branch the person who migrated it picked" | |
| 743 | : work?.reason === "newest" | |
| 744 | ? "the branch with the newest commits of the person who migrated it" | |
| 745 | : "the source's default branch"; | |
| 561 | 746 | return `Make ${repo.name} run here |
| 562 | 747 | |
| 563 | 748 | ${repo.name} was migrated to this forge from ${from} — every branch and tag, its history intact. It is someone else's project. Make it run here as an app without changing what it does. |
| 564 | 749 | |
| 750 | The app is on the branch \`${branch}\` — ${why}, and the default branch here. Start from \`${branch}\` and open your pull against it; the other branches are history, not the app. | |
| 751 | ||
| 565 | 752 | Adapt, don't rewrite: |
| 566 | 753 | |
| 567 | 754 | - Read before you write: the language, how it starts today, how it reads its port and configuration, where it keeps state, how it installs and tests. |
| @@ -576,6 +763,3 @@ Adapt, don't rewrite: | ||
| 576 | 763 | Done means the pull's preview starts, answers its \`health\` path, and does what the original did.`; |
| 577 | 764 | } |
| 578 | 765 | |
| 579 | function plural(n: number, one: string, many = `${one}s`): string { | |
| 580 | return `${n} ${n === 1 ? one : many}`; | |
| 581 | } |
src/core/migrations/20261020180000-origin-repo-size.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // The migrate picker shows each repository's size before it is cloned | |
| 2 | // (#3264). Both forges report `size` in KiB on a listing; additive, and 0 | |
| 3 | // reads as "the origin did not say". | |
| 4 | export default ` | |
| 5 | ALTER TABLE origin_repo ADD COLUMN size_kb INTEGER NOT NULL DEFAULT 0; | |
| 6 | `; |
src/core/origins.ts +8 −2
| @@ -323,6 +323,8 @@ export interface OriginRepoRow { | ||
| 323 | 323 | readonly openIssues: number; |
| 324 | 324 | readonly openPulls: number; |
| 325 | 325 | readonly openIssuesIncludingPulls: boolean; |
| 326 | /** KiB, as the origin last reported it; 0 when unknown. */ | |
| 327 | readonly sizeKb: number; | |
| 326 | 328 | } |
| 327 | 329 | |
| 328 | 330 | const REPO_SELECT = ` |
| @@ -348,6 +350,7 @@ interface RawOriginRepo { | ||
| 348 | 350 | open_issues: number; |
| 349 | 351 | open_pulls: number; |
| 350 | 352 | counts_include_pulls: number; |
| 353 | size_kb: number; | |
| 351 | 354 | mirrored_id: string | null; |
| 352 | 355 | } |
| 353 | 356 | |
| @@ -369,6 +372,7 @@ const toOriginRepo = (row: RawOriginRepo): OriginRepoRow => ({ | ||
| 369 | 372 | openIssues: row.open_issues, |
| 370 | 373 | openPulls: row.open_pulls, |
| 371 | 374 | openIssuesIncludingPulls: row.counts_include_pulls === 1, |
| 375 | sizeKb: row.size_kb, | |
| 372 | 376 | }); |
| 373 | 377 | |
| 374 | 378 | /** |
| @@ -379,8 +383,8 @@ const UPSERT_ORIGIN_REPO = ` | ||
| 379 | 383 | INSERT INTO origin_repo |
| 380 | 384 | (origin_id, remote_id, full_name, description, private, fork, |
| 381 | 385 | default_branch, clone_url, html_url, pushed_at, can_push, seen_at, |
| 382 | open_issues, open_pulls, counts_include_pulls, fork_of) | |
| 383 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) | |
| 386 | open_issues, open_pulls, counts_include_pulls, fork_of, size_kb) | |
| 387 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) | |
| 384 | 388 | ON CONFLICT (origin_id, remote_id) DO UPDATE SET |
| 385 | 389 | full_name = excluded.full_name, |
| 386 | 390 | description = excluded.description, |
| @@ -395,6 +399,7 @@ const UPSERT_ORIGIN_REPO = ` | ||
| 395 | 399 | open_issues = excluded.open_issues, |
| 396 | 400 | open_pulls = excluded.open_pulls, |
| 397 | 401 | counts_include_pulls = excluded.counts_include_pulls, |
| 402 | size_kb = excluded.size_kb, | |
| 398 | 403 | -- A listing that does not report the parent must not forget one we |
| 399 | 404 | -- already know: listRepos omits it on both forges, getRepo states it. |
| 400 | 405 | fork_of = COALESCE(excluded.fork_of, origin_repo.fork_of)`; |
| @@ -421,6 +426,7 @@ const upsertArgs = ( | ||
| 421 | 426 | repo.openPulls, |
| 422 | 427 | repo.openIssuesIncludesPulls ? 1 : 0, |
| 423 | 428 | repo.parent, |
| 429 | Number.isSafeInteger(repo.sizeKb) && repo.sizeKb > 0 ? repo.sizeKb : 0, | |
| 424 | 430 | ]; |
| 425 | 431 | |
| 426 | 432 | /** |
src/http/api.ts +3 −1
| @@ -5003,7 +5003,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5003 | 5003 | // |
| 5004 | 5004 | // A repository brought here for good: `source` is `{ originId, fullName }` |
| 5005 | 5005 | // (one of your origins, its credential in the environment) or `{ url }` |
| 5006 | // (public, anonymous). Refusals are words before anything exists; the | |
| 5006 | // (public, anonymous); `branch` is the one the app lives on (left out, | |
| 5007 | // the one with your newest commits). Refusals are words before anything exists; the | |
| 5007 | 5008 | // clone itself is the job this answers with, watched at /migrate/:job. |
| 5008 | 5009 | |
| 5009 | 5010 | router.add("POST", "/api/v1/migrate", async (ctx) => { |
| @@ -5039,6 +5040,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5039 | 5040 | source, |
| 5040 | 5041 | ...(typeof input.owner === "string" ? { owner: input.owner } : {}), |
| 5041 | 5042 | ...(typeof input.name === "string" ? { name: input.name } : {}), |
| 5043 | ...(typeof input.branch === "string" ? { branch: input.branch } : {}), | |
| 5042 | 5044 | ...(input.visibility === "public" || input.visibility === "private" |
| 5043 | 5045 | ? { visibility: input.visibility } |
| 5044 | 5046 | : {}), |
src/origins/carry.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * Carry: a migrated repository's conversations, brought along once. | |
| 3 | * | |
| 4 | * A mirror keeps importing; a migration imports one time and then the | |
| 5 | * forge is where the conversation lives. So the rows are written by the | |
| 6 | * mirror's own upserts (`import.ts` — the same clamps, the origin's | |
| 7 | * numbers, `issue_seq` dragged past them, the real author in | |
| 8 | * `origin_author` so every page says "by @x on GitHub") with two | |
| 9 | * differences that make them native: | |
| 10 | * | |
| 11 | * - every issue and pull starts with a line naming where it came from and | |
| 12 | * linking the original, so the trail back survives the origin; and | |
| 13 | * - a pull is a pull *here*: `origin_head_repo` is cleared, so it reviews, | |
| 14 | * merges and reverts like one opened on this forge. Its head is the | |
| 15 | * branch the clone already brought. A pull whose head lived in a fork | |
| 16 | * keeps `origin_head_repo` — a stranger's code, whose check and preview | |
| 17 | * wait for a writer's "Approve to run here" (`core/pull_trust.ts`), as a | |
| 18 | * mirror's do — and is returned in `forkPulls`, for the caller to fetch | |
| 19 | * its head into `refs/pull/<n>/head`. | |
| 20 | * | |
| 21 | * Every issue comes, open and closed; of pulls only the open ones — a | |
| 22 | * closed pull's work is either in the history already or was turned down. | |
| 23 | * Plain data in (a provider, the repository's ids), counts out; no git. | |
| 24 | */ | |
| 25 | ||
| 26 | import type { Database } from "bun:sqlite"; | |
| 27 | import { importComments, importIssue, importLabels } from "./import.ts"; | |
| 28 | import type { ListIssuesQuery, Provider, RemoteIssue } from "./types.ts"; | |
| 29 | ||
| 30 | /** Pages of 100 per kind: 10 000 conversations is the ceiling of one move. */ | |
| 31 | export const MAX_PAGES = 100; | |
| 32 | ||
| 33 | export interface CarryInput { | |
| 34 | readonly db: Database; | |
| 35 | readonly provider: Provider; | |
| 36 | /** `owner/name` on the origin. */ | |
| 37 | readonly fullName: string; | |
| 38 | readonly repoId: string; | |
| 39 | /** Who migrated it: the local author of every row, as a mirror's are. */ | |
| 40 | readonly actorId: string; | |
| 41 | /** Progress, as a job's detail line. */ | |
| 42 | readonly report?: (stage: "issues" | "pulls", detail: string) => void; | |
| 43 | } | |
| 44 | ||
| 45 | export interface ForkPull { | |
| 46 | readonly issueId: string; | |
| 47 | readonly number: number; | |
| 48 | } | |
| 49 | ||
| 50 | export interface Carried { | |
| 51 | readonly labels: number; | |
| 52 | readonly issues: number; | |
| 53 | readonly pulls: number; | |
| 54 | readonly comments: number; | |
| 55 | /** Open pulls whose head branch is in a fork, not in the clone. */ | |
| 56 | readonly forkPulls: readonly ForkPull[]; | |
| 57 | /** The first thing the origin refused, in words; "" when nothing was. */ | |
| 58 | readonly error: string; | |
| 59 | } | |
| 60 | ||
| 61 | /** What a person calls the origin: "GitHub", or its host. */ | |
| 62 | export function originName(provider: Pick<Provider, "kind" | "webUrl">): string { | |
| 63 | if (provider.kind === "github") return "GitHub"; | |
| 64 | return URL.parse(provider.webUrl)?.host ?? "the origin"; | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * The line an imported body opens with. The link is only an http(s) URL | |
| 69 | * the origin gave — anything else is named without one — and the author's | |
| 70 | * login is reduced to the characters a login can hold. | |
| 71 | */ | |
| 72 | export function backlink( | |
| 73 | remote: Pick<RemoteIssue, "number" | "author" | "htmlUrl">, | |
| 74 | fullName: string, | |
| 75 | where: string, | |
| 76 | ): string { | |
| 77 | const url = URL.parse(remote.htmlUrl); | |
| 78 | const ref = `${fullName}#${remote.number}`; | |
| 79 | const linked = | |
| 80 | url !== null && (url.protocol === "https:" || url.protocol === "http:") | |
| 81 | ? `[${ref}](${url.href.replace(/[()\s]/g, encodeURIComponent)})` | |
| 82 | : ref; | |
| 83 | const login = remote.author.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64); | |
| 84 | const by = login === "" ? "" : ` by @${login}`; | |
| 85 | return `> Migrated from ${linked}${by} on ${where}.`; | |
| 86 | } | |
| 87 | ||
| 88 | export async function carryConversations(input: CarryInput): Promise<Carried> { | |
| 89 | const { db, provider, fullName, repoId, actorId } = input; | |
| 90 | const where = originName(provider); | |
| 91 | const ctx = { db, repoId, actorId }; | |
| 92 | let error = ""; | |
| 93 | const counts = { labels: 0, issues: 0, pulls: 0, comments: 0 }; | |
| 94 | const forkPulls: ForkPull[] = []; | |
| 95 | ||
| 96 | const labels = await provider.listLabels(fullName); | |
| 97 | if (labels.ok) { | |
| 98 | importLabels(db, repoId, labels.value); | |
| 99 | counts.labels = labels.value.length; | |
| 100 | } else error = labels.error.message; | |
| 101 | ||
| 102 | const passes: [ListIssuesQuery["kind"], ListIssuesQuery["state"]][] = [ | |
| 103 | ["issue", "all"], | |
| 104 | ["pull", "open"], | |
| 105 | ]; | |
| 106 | for (const [kind, state] of passes) { | |
| 107 | let cursor: string | undefined; | |
| 108 | for (let page = 0; page < MAX_PAGES; page++) { | |
| 109 | const listed = await provider.listIssues(fullName, { | |
| 110 | kind, | |
| 111 | state, | |
| 112 | ...(cursor === undefined ? {} : { cursor }), | |
| 113 | }); | |
| 114 | if (!listed.ok) { | |
| 115 | error ||= listed.error.message; | |
| 116 | break; | |
| 117 | } | |
| 118 | for (const remote of listed.value.items) { | |
| 119 | if (!Number.isInteger(remote.number) || remote.number < 1) continue; | |
| 120 | // A listing of issues that slips a pull in (or the reverse) is not | |
| 121 | // trusted to say which it is twice. | |
| 122 | if ((remote.pull !== undefined) !== (kind === "pull")) continue; | |
| 123 | const body = `${backlink(remote, fullName, where)}\n\n${remote.body}`; | |
| 124 | const imported = importIssue(ctx, { ...remote, body }); | |
| 125 | if (remote.pull !== undefined) { | |
| 126 | const sameRepo = | |
| 127 | remote.pull.headRepo === "" || | |
| 128 | remote.pull.headRepo.toLowerCase() === fullName.toLowerCase(); | |
| 129 | if (sameRepo) | |
| 130 | db.query( | |
| 131 | "UPDATE pull SET origin_head_repo = NULL WHERE issue_id = ?1", | |
| 132 | ).run(imported.issueId); | |
| 133 | else forkPulls.push({ issueId: imported.issueId, number: remote.number }); | |
| 134 | counts.pulls++; | |
| 135 | } else counts.issues++; | |
| 136 | ||
| 137 | if (remote.comments > 0) { | |
| 138 | const comments = await provider.listComments(fullName, remote.number); | |
| 139 | if (comments.ok) | |
| 140 | counts.comments += importComments(ctx, imported.issueId, comments.value); | |
| 141 | else error ||= comments.error.message; | |
| 142 | } | |
| 143 | input.report?.( | |
| 144 | kind === "issue" ? "issues" : "pulls", | |
| 145 | kind === "issue" | |
| 146 | ? `${plural(counts.issues, "issue")}, ${plural(counts.comments, "comment")}` | |
| 147 | : plural(counts.pulls, "open pull request"), | |
| 148 | ); | |
| 149 | } | |
| 150 | if (listed.value.next === null) break; | |
| 151 | cursor = listed.value.next; | |
| 152 | } | |
| 153 | } | |
| 154 | return { ...counts, forkPulls, error }; | |
| 155 | } | |
| 156 | ||
| 157 | export function plural(n: number, one: string, many = `${one}s`): string { | |
| 158 | return `${n} ${n === 1 ? one : many}`; | |
| 159 | } |
src/origins/providers/forgejo.ts +1
| @@ -790,6 +790,7 @@ function toRepo(raw: unknown): RemoteRepo { | ||
| 790 | 790 | openIssues: asNumber(field(raw, "open_issues_count")), |
| 791 | 791 | openPulls: asNumber(field(raw, "open_pr_counter")), |
| 792 | 792 | openIssuesIncludesPulls: false, |
| 793 | sizeKb: asNumber(field(raw, "size")), | |
| 793 | 794 | }; |
| 794 | 795 | } |
| 795 | 796 |
src/origins/providers/github.ts +1
| @@ -708,6 +708,7 @@ function toRepo(raw: unknown): RemoteRepo { | ||
| 708 | 708 | openIssues: asNumber(field(raw, "open_issues_count")), |
| 709 | 709 | openPulls: 0, |
| 710 | 710 | openIssuesIncludesPulls: true, |
| 711 | sizeKb: asNumber(field(raw, "size")), | |
| 711 | 712 | }; |
| 712 | 713 | } |
| 713 | 714 |
src/origins/types.ts +2
| @@ -78,6 +78,8 @@ export interface RemoteRepo { | ||
| 78 | 78 | readonly openIssues: number; |
| 79 | 79 | readonly openPulls: number; |
| 80 | 80 | readonly openIssuesIncludesPulls: boolean; |
| 81 | /** On-disk size as the origin reports it, in KiB; 0 when it says nothing. */ | |
| 82 | readonly sizeKb: number; | |
| 81 | 83 | } |
| 82 | 84 | |
| 83 | 85 | export interface RemoteLabel { |
src/origins/work-branch.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | /** | |
| 2 | * The work branch: of a repository's branches, the one its person was | |
| 3 | * working on. A fork keeps the upstream's tree on `main` and the person's | |
| 4 | * app on a branch of their own (`workbench`, four commits ahead), so the | |
| 5 | * source's default branch is not always where the app is. The rule is the | |
| 6 | * one a person would use looking at the list: the branch whose last commit | |
| 7 | * is theirs and newest; when none is theirs, the source's own default. | |
| 8 | * | |
| 9 | * A branch's last commit is read two ways, to the same shape: from a clone | |
| 10 | * already on disk (one `git for-each-ref`, every branch) or from an origin's | |
| 11 | * API before anything is cloned (one listing, then one commit per branch, | |
| 12 | * bounded). Whose a commit is: an account login the origin resolved, or an | |
| 13 | * author email or name that is one of the person's. | |
| 14 | */ | |
| 15 | ||
| 16 | import { runGitCapture } from "../git/env.ts"; | |
| 17 | import { isSafeRef } from "../git/guard.ts"; | |
| 18 | import type { Provider, RemoteCommit } from "./types.ts"; | |
| 19 | ||
| 20 | export interface BranchTip { | |
| 21 | readonly name: string; | |
| 22 | readonly oid: string; | |
| 23 | /** The last commit's author date, ISO 8601; "" when not known. */ | |
| 24 | readonly date: string; | |
| 25 | readonly subject: string; | |
| 26 | /** Who wrote it, as the page shows it: a login, else the author's name. */ | |
| 27 | readonly author: string; | |
| 28 | /** Written by the person migrating. */ | |
| 29 | readonly mine: boolean; | |
| 30 | } | |
| 31 | ||
| 32 | /** The person, as commits may name them. Compared without case. */ | |
| 33 | export interface Who { | |
| 34 | readonly logins: readonly string[]; | |
| 35 | readonly emails: readonly string[]; | |
| 36 | } | |
| 37 | ||
| 38 | /** Branches whose last commit an origin is asked for; past it, names alone. */ | |
| 39 | export const TIP_LOOKUPS = 30; | |
| 40 | ||
| 41 | /** | |
| 42 | * Whether an author is the person: a login the origin resolved, an email | |
| 43 | * of theirs (GitHub's `<id>+<login>@users.noreply.github.com` included), | |
| 44 | * or a name that is one of their logins. | |
| 45 | */ | |
| 46 | export function isMine( | |
| 47 | author: { readonly name: string; readonly email: string; readonly login?: string }, | |
| 48 | who: Who, | |
| 49 | ): boolean { | |
| 50 | const logins = who.logins.map((l) => l.toLowerCase()).filter((l) => l !== ""); | |
| 51 | const emails = who.emails.map((e) => e.toLowerCase()).filter((e) => e !== ""); | |
| 52 | const login = (author.login ?? "").toLowerCase(); | |
| 53 | const email = author.email.toLowerCase().replace(/^<|>$/g, ""); | |
| 54 | const name = author.name.toLowerCase(); | |
| 55 | if (login !== "" && logins.includes(login)) return true; | |
| 56 | if (email !== "" && emails.includes(email)) return true; | |
| 57 | const noreply = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email); | |
| 58 | if (noreply !== null && logins.includes(noreply[1]!)) return true; | |
| 59 | return name !== "" && logins.includes(name); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * The branch to work on: the newest tip that is the person's; else the | |
| 64 | * fallback when it is a branch; else the newest tip of all. ISO dates | |
| 65 | * compare as strings once they share a zone, so they are compared as times. | |
| 66 | */ | |
| 67 | export function pickWorkBranch( | |
| 68 | tips: readonly BranchTip[], | |
| 69 | fallback: string | undefined, | |
| 70 | ): string | undefined { | |
| 71 | const newest = (rows: readonly BranchTip[]): BranchTip | undefined => | |
| 72 | [...rows].sort( | |
| 73 | (a, b) => time(b.date) - time(a.date) || a.name.localeCompare(b.name), | |
| 74 | )[0]; | |
| 75 | const mine = newest(tips.filter((t) => t.mine)); | |
| 76 | if (mine !== undefined) return mine.name; | |
| 77 | if (fallback !== undefined && tips.some((t) => t.name === fallback)) | |
| 78 | return fallback; | |
| 79 | return newest(tips)?.name; | |
| 80 | } | |
| 81 | ||
| 82 | const time = (iso: string): number => { | |
| 83 | const t = Date.parse(iso); | |
| 84 | return Number.isFinite(t) ? t : 0; | |
| 85 | }; | |
| 86 | ||
| 87 | /** Every branch's last commit in a repository on disk, newest first. */ | |
| 88 | export async function localTips(dir: string, who: Who): Promise<BranchTip[]> { | |
| 89 | const listed = await runGitCapture( | |
| 90 | [ | |
| 91 | "for-each-ref", | |
| 92 | "--sort=-authordate", | |
| 93 | "--format=%(refname)%00%(objectname)%00%(authordate:iso-strict)%00%(authorname)%00%(authoremail)%00%(contents:subject)", | |
| 94 | "refs/heads/", | |
| 95 | ], | |
| 96 | { cwd: dir }, | |
| 97 | ); | |
| 98 | if (listed.code !== 0) return []; | |
| 99 | const tips: BranchTip[] = []; | |
| 100 | for (const line of listed.stdout.split("\n")) { | |
| 101 | const [ref, oid, date, name, email, subject] = line.split("\0"); | |
| 102 | if (ref === undefined || !ref.startsWith("refs/heads/")) continue; | |
| 103 | const branch = ref.slice("refs/heads/".length); | |
| 104 | tips.push({ | |
| 105 | name: branch, | |
| 106 | oid: oid ?? "", | |
| 107 | date: date ?? "", | |
| 108 | subject: subject ?? "", | |
| 109 | author: name ?? "", | |
| 110 | mine: isMine({ name: name ?? "", email: email ?? "" }, who), | |
| 111 | }); | |
| 112 | } | |
| 113 | return tips; | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Every branch's last commit as an origin reports it, before any clone — | |
| 118 | * what the picker draws. One listing, then the first {@link TIP_LOOKUPS} | |
| 119 | * branches' commits side by side; the rest come back with no commit. | |
| 120 | * `undefined` when the origin will not list. | |
| 121 | */ | |
| 122 | export async function remoteTips( | |
| 123 | provider: Provider, | |
| 124 | fullName: string, | |
| 125 | who: Who, | |
| 126 | ): Promise<BranchTip[] | undefined> { | |
| 127 | const listed = await provider.listBranches(fullName); | |
| 128 | if (!listed.ok) return undefined; | |
| 129 | const branches = listed.value.filter((b) => isSafeRef(b.name)); | |
| 130 | return Promise.all( | |
| 131 | branches.map(async (branch, i): Promise<BranchTip> => { | |
| 132 | const bare = { | |
| 133 | name: branch.name, | |
| 134 | oid: branch.oid, | |
| 135 | date: "", | |
| 136 | subject: "", | |
| 137 | author: "", | |
| 138 | mine: false, | |
| 139 | }; | |
| 140 | if (i >= TIP_LOOKUPS || branch.oid === "") return bare; | |
| 141 | const page = await provider.listCommits(fullName, branch.oid); | |
| 142 | const last: RemoteCommit | undefined = page.ok ? page.value.items[0] : undefined; | |
| 143 | if (last === undefined) return bare; | |
| 144 | return { | |
| 145 | ...bare, | |
| 146 | date: last.author.date, | |
| 147 | subject: last.message.split("\n")[0] ?? "", | |
| 148 | author: last.author.login ?? last.author.name, | |
| 149 | mine: isMine(last.author, who), | |
| 150 | }; | |
| 151 | }), | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | /** How long the picker reuses one repository's tips: a reload, a 422. */ | |
| 156 | export const TIPS_TTL_MS = 60_000; | |
| 157 | const TIPS_KEPT = 64; | |
| 158 | const tipsSeen = new Map<string, { readonly at: number; readonly tips: BranchTip[] }>(); | |
| 159 | ||
| 160 | /** | |
| 161 | * {@link remoteTips} through a short memory, keyed by origin and repository | |
| 162 | * — an origin belongs to one person, so the key is theirs alone. A reload | |
| 163 | * of the picker costs no requests to the origin, nor its rate limit; a | |
| 164 | * refusal is not remembered. Past {@link TIPS_KEPT} keys the oldest goes. | |
| 165 | */ | |
| 166 | export async function cachedRemoteTips( | |
| 167 | originId: string, | |
| 168 | provider: Provider, | |
| 169 | fullName: string, | |
| 170 | who: Who, | |
| 171 | now: number = Date.now(), | |
| 172 | ): Promise<BranchTip[] | undefined> { | |
| 173 | const key = `${originId}\0${fullName}`; | |
| 174 | const seen = tipsSeen.get(key); | |
| 175 | if (seen !== undefined && now - seen.at < TIPS_TTL_MS) return seen.tips; | |
| 176 | const tips = await remoteTips(provider, fullName, who); | |
| 177 | if (tips === undefined) return undefined; | |
| 178 | tipsSeen.delete(key); | |
| 179 | tipsSeen.set(key, { at: now, tips }); | |
| 180 | while (tipsSeen.size > TIPS_KEPT) { | |
| 181 | const oldest = tipsSeen.keys().next().value; | |
| 182 | if (oldest === undefined) break; | |
| 183 | tipsSeen.delete(oldest); | |
| 184 | } | |
| 185 | return tips; | |
| 186 | } |
src/web/pages/migrate.ts +350 −59
| @@ -4,6 +4,15 @@ | ||
| 4 | 4 | * pasted URL, lands under an owner and a name you choose, and — when you |
| 5 | 5 | * put a crew on it — becomes an app the crew is already making run. |
| 6 | 6 | * |
| 7 | * GitHub connects from here too ("Connect GitHub" runs the origins' | |
| 8 | * device flow and comes back), and is disconnected from here. Each of your | |
| 9 | * repositories there is a row; one click on a row migrates it with the crew | |
| 10 | * picked below — issues and open pull requests included. | |
| 11 | * | |
| 12 | * A row's Branches link draws that repository's branches with their last | |
| 13 | * commits and selects the one the app most likely lives on — the person's | |
| 14 | * newest (`origins/work-branch.ts`); the Migrate button carries the choice. | |
| 15 | * | |
| 7 | 16 | * Every mutation is `migrateRepo`; the page only puts its answer into words. |
| 8 | 17 | */ |
| 9 | 18 | |
| @@ -17,7 +26,13 @@ import { | ||
| 17 | 26 | } from "../../actions/migrate.ts"; |
| 18 | 27 | import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts"; |
| 19 | 28 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 20 | import type { OriginRepoRow } from "../../core/origins.ts"; | |
| 29 | import { getOriginFor, type OriginRepoRow } from "../../core/origins.ts"; | |
| 30 | import { providerForOrigin } from "../../origins/credential.ts"; | |
| 31 | import { | |
| 32 | cachedRemoteTips, | |
| 33 | pickWorkBranch, | |
| 34 | type BranchTip, | |
| 35 | } from "../../origins/work-branch.ts"; | |
| 21 | 36 | import { scopeAllows } from "../../core/tokens.ts"; |
| 22 | 37 | import type { Ctx } from "../../http/context.ts"; |
| 23 | 38 | import type { MirrorJob, MirrorStage } from "../../origins/jobs.ts"; |
| @@ -25,44 +40,309 @@ import { notFoundPage, pageHead, signInFirst } from "../chrome.ts"; | ||
| 25 | 40 | import { field, readForm } from "../form.ts"; |
| 26 | 41 | import { html, raw, type Html } from "../html.ts"; |
| 27 | 42 | import { flashTo, page, redirect } from "../layout.ts"; |
| 28 | import { btn, card, icon, selectField, field as formField } from "../ui.ts"; | |
| 43 | import { | |
| 44 | badge, | |
| 45 | btn, | |
| 46 | card, | |
| 47 | icon, | |
| 48 | row, | |
| 49 | selectField, | |
| 50 | field as formField, | |
| 51 | } from "../ui.ts"; | |
| 29 | 52 | import { prefill, originHost } from "./migrate-prefill.ts"; |
| 30 | import { hostLabel } from "./origins.ts"; | |
| 53 | import { githubConnectable, hostLabel } from "./origins.ts"; | |
| 31 | 54 | |
| 32 | 55 | interface MigrateValues { |
| 33 | 56 | source: string; |
| 57 | /** The picker's filter: rows whose name or description holds it. */ | |
| 58 | q: string; | |
| 34 | 59 | url: string; |
| 35 | 60 | owner: string; |
| 36 | 61 | name: string; |
| 37 | 62 | visibility: string; |
| 38 | 63 | agent: string; |
| 39 | 64 | autonomous: boolean; |
| 65 | /** The branch asked for; "" lets the work-branch rule choose. */ | |
| 66 | branch: string; | |
| 67 | /** Draw the picked repository's branches: Branches was pressed, or one was chosen. */ | |
| 68 | showBranches: boolean; | |
| 69 | } | |
| 70 | ||
| 71 | /** The picked repository's branches, and the one selected. */ | |
| 72 | interface BranchChoices { | |
| 73 | readonly fullName: string; | |
| 74 | readonly sourceDefault: string; | |
| 75 | readonly tips: readonly BranchTip[]; | |
| 76 | readonly selected: string | undefined; | |
| 40 | 77 | } |
| 41 | 78 | |
| 42 | 79 | /** An origin's repositories, for the picker; an origin that cannot answer is left out. */ |
| 43 | 80 | interface OriginChoices { |
| 44 | 81 | readonly label: string; |
| 45 | 82 | readonly id: string; |
| 83 | readonly kind: string; | |
| 46 | 84 | readonly host: string; |
| 47 | 85 | readonly repos: readonly OriginRepoRow[]; |
| 48 | 86 | } |
| 49 | 87 | |
| 88 | /** Every origin you connected; one that cannot list has no rows. */ | |
| 50 | 89 | async function originChoices(ctx: Ctx): Promise<OriginChoices[]> { |
| 51 | 90 | const user = ctx.auth.user!; |
| 52 | 91 | const out: OriginChoices[] = []; |
| 53 | 92 | for (const origin of listUserOrigins(ctx.app, user)) { |
| 54 | 93 | const listed = await listRemoteRepos(ctx.app, user, origin.id); |
| 55 | if (!listed.ok || listed.value.length === 0) continue; | |
| 56 | 94 | out.push({ |
| 57 | 95 | id: origin.id, |
| 96 | kind: origin.kind, | |
| 58 | 97 | host: originHost(origin.webUrl), |
| 59 | 98 | label: `${origin.login} @ ${hostLabel(origin.webUrl)}`, |
| 60 | repos: listed.value, | |
| 99 | repos: listed.ok ? listed.value : [], | |
| 61 | 100 | }); |
| 62 | 101 | } |
| 63 | 102 | return out; |
| 64 | 103 | } |
| 65 | 104 | |
| 105 | /** | |
| 106 | * The branches of the repository `source` names, when it is one of the | |
| 107 | * picker's rows: read through its origin, with the selection the work-branch | |
| 108 | * rule makes — or the branch asked for, when it is one of them. | |
| 109 | */ | |
| 110 | async function branchChoices( | |
| 111 | ctx: Ctx, | |
| 112 | origins: readonly OriginChoices[], | |
| 113 | source: string, | |
| 114 | asked: string, | |
| 115 | ): Promise<BranchChoices | undefined> { | |
| 116 | const picked = source === "" ? undefined : parsePick(source); | |
outdated
This runs on every GET and every 422 re-render: one branch listing plus up to 30 commits requests to the origin. Cache it, or ask only when Branches is pressed, so the page stays fast and the GitHub rate limit is not spent on reloads. |
||
| 117 | if (picked === undefined || "url" in picked) return undefined; | |
| 118 | const row = origins | |
| 119 | .find((o) => o.id === picked.originId) | |
| 120 | ?.repos.find((r) => r.fullName === picked.fullName); | |
| 121 | const user = ctx.auth.user!; | |
| 122 | const origin = getOriginFor(ctx.app.db, user.id, picked.originId); | |
| 123 | if (row === undefined || origin === undefined) return undefined; | |
| 124 | const provider = providerForOrigin(ctx.app, origin); | |
| 125 | if (!provider.ok) return undefined; | |
| 126 | const tips = await cachedRemoteTips(origin.id, provider.value, row.fullName, { | |
| 127 | logins: [origin.login, user.username], | |
| 128 | emails: [user.email], | |
| 129 | }); | |
| 130 | if (tips === undefined || tips.length === 0) return undefined; | |
| 131 | return { | |
| 132 | fullName: row.fullName, | |
| 133 | sourceDefault: row.defaultBranch, | |
| 134 | tips, | |
| 135 | selected: tips.some((t) => t.name === asked) | |
| 136 | ? asked | |
| 137 | : pickWorkBranch(tips, row.defaultBranch), | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * One radio per branch, newest first: its last commit's subject, author | |
| 143 | * and date (UTC), the source's default marked, the person's own marked. | |
| 144 | */ | |
| 145 | function branchList(choices: BranchChoices): Html { | |
| 146 | const rows = [...choices.tips].sort( | |
| 147 | (a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0), | |
| 148 | ); | |
| 149 | return html`<div class="field" id="migrate-branches"> | |
| 150 | <label>Branch the app lives on — ${choices.fullName}</label> | |
| 151 | <ul class="list"> | |
| 152 | ${rows.map((tip) => | |
| 153 | row({ | |
| 154 | data: { branch: tip.name }, | |
| 155 | title: html`<label class="check"> | |
| 156 | <input | |
| 157 | type="radio" | |
| 158 | name="branch" | |
| 159 | value="${tip.name}" | |
| 160 | ${tip.name === choices.selected ? raw("checked") : null} | |
| 161 | /> | |
| 162 | <span class="mono">${tip.name}</span> | |
| 163 | </label>`, | |
| 164 | ...(tip.date === "" | |
| 165 | ? {} | |
| 166 | : { | |
| 167 | meta: html`${tip.subject} · ${tip.author} · | |
| 168 | <time datetime="${tip.date}">${tip.date.slice(0, 10)}</time>`, | |
| 169 | }), | |
| 170 | trail: html`${tip.name === choices.sourceDefault | |
| 171 | ? badge("default there") | |
| 172 | : null}${tip.mine ? badge("yours") : null}`, | |
| 173 | }), | |
| 174 | )} | |
| 175 | </ul> | |
| 176 | <span class="hint" | |
| 177 | >Every branch comes; this one becomes the default branch here, and the | |
| 178 | crew is told the app is on it. Selected: the branch with your newest | |
| 179 | commits, else the origin's default.</span | |
| 180 | > | |
| 181 | </div>`; | |
| 182 | } | |
| 183 | ||
| 184 | /** Rows drawn per origin; past it, the filter narrows. */ | |
| 185 | export const PICKER_ROWS = 200; | |
| 186 | ||
| 187 | /** | |
| 188 | * A size an origin reported in KiB, in the unit that keeps it short: | |
| 189 | * 1 KiB = 1024 bytes, 1 MiB = 1024 KiB. 0 is "not reported" — GitHub says | |
| 190 | * 0 for an empty repository too — so it draws nothing. | |
| 191 | */ | |
| 192 | export function formatKib(kib: number): string { | |
| 193 | if (!Number.isFinite(kib) || kib <= 0) return ""; | |
| 194 | if (kib < 1024) return `${Math.round(kib)} KiB`; | |
| 195 | const mib = kib / 1024; | |
| 196 | if (mib < 1024) return `${mib < 10 ? mib.toFixed(1) : Math.round(mib)} MiB`; | |
| 197 | const gib = mib / 1024; | |
| 198 | return `${gib < 10 ? gib.toFixed(1) : Math.round(gib)} GiB`; | |
| 199 | } | |
| 200 | ||
| 201 | /** Whether a row answers the filter: name or description, any case. */ | |
| 202 | export function matchesFilter( | |
| 203 | repo: Pick<OriginRepoRow, "fullName" | "description">, | |
| 204 | q: string, | |
| 205 | ): boolean { | |
| 206 | const needle = q.trim().toLowerCase(); | |
| 207 | return ( | |
| 208 | needle === "" || | |
| 209 | repo.fullName.toLowerCase().includes(needle) || | |
| 210 | repo.description.toLowerCase().includes(needle) | |
| 211 | ); | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * The accounts half, outside the migrate form (forms do not nest): each | |
| 216 | * connected origin with a Disconnect, and Connect GitHub when there is no | |
| 217 | * GitHub account yet. Both come back here (`then=migrate`). | |
| 218 | */ | |
| 219 | function accounts(ctx: Ctx, origins: readonly OriginChoices[]): Html { | |
| 220 | const hasGithub = origins.some((o) => o.kind === "github"); | |
| 221 | const connect = hasGithub | |
| 222 | ? null | |
| 223 | : githubConnectable(ctx) | |
outdated
This is a second |
||
| 224 | ? html`<form method="post" action="/settings/origins/oauth" class="bar"> | |
| 225 | <input type="hidden" name="kind" value="github" /> | |
| 226 | <input type="hidden" name="then" value="migrate" /> | |
| 227 | ${btn("Connect GitHub", { variant: "outline", icon: "github", type: "submit" })} | |
| 228 | <span class="muted small" | |
| 229 | >A short code you type at github.com — no token to paste. Your | |
| 230 | private repositories and their issues come too.</span | |
| 231 | > | |
| 232 | </form>` | |
| 233 | : html`<p class="muted small"> | |
| 234 | ${icon("github")} Connect GitHub needs this forge's GitHub | |
| 235 | application, set up once under | |
| 236 | <a href="/settings/origins">Settings → Origins</a>; a token pasted | |
| 237 | there works until then. | |
| 238 | </p>`; | |
| 239 | return card( | |
| 240 | html`${origins.length === 0 | |
| 241 | ? null | |
| 242 | : html`<ul class="list" id="migrate-accounts"> | |
| 243 | ${origins.map((origin) => | |
| 244 | row({ | |
| 245 | lead: icon(origin.kind === "github" ? "github" : "globe"), | |
| 246 | title: origin.label, | |
| 247 | meta: html`${origin.repos.length} repositories`, | |
| 248 | trail: html`<form | |
| 249 | method="post" | |
| 250 | action="/settings/origins/${origin.id}/delete" | |
| 251 | > | |
| 252 | <input type="hidden" name="then" value="migrate" /> | |
| 253 | ${btn("Disconnect", { size: "sm", variant: "ghost", type: "submit" })} | |
| 254 | </form>`, | |
| 255 | }), | |
| 256 | )} | |
| 257 | </ul>`} | |
| 258 | ${connect}`, | |
| 259 | { | |
| 260 | head: html`${icon("globe")} | |
| 261 | <h2>Your accounts</h2>`, | |
| 262 | }, | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * One row per repository: name, private mark, size, description, and the | |
| 268 | * button that migrates it. The buttons are the form's submitters | |
| 269 | * (`name="pick"`), so one click carries the destination and crew fields | |
| 270 | * below with it. | |
| 271 | */ | |
| 272 | function picker( | |
| 273 | origins: readonly OriginChoices[], | |
| 274 | values: MigrateValues, | |
| 275 | withCrew: boolean, | |
| 276 | branches: BranchChoices | undefined, | |
| 277 | ): Html { | |
| 278 | const listed = origins.filter((o) => o.repos.length > 0); | |
| 279 | if (listed.length === 0) | |
| 280 | return html`<p class="muted small mb"> | |
| 281 | ${icon("globe")} Connect an account above — or another forge under | |
| 282 | <a href="/settings/origins">Settings → Origins</a> — to pick from your | |
| 283 | repositories there, private ones included. | |
| 284 | </p>`; | |
| 285 | return html`<div class="field" id="migrate-picker"> | |
| 286 | <label for="f-q">Your repositories</label> | |
| 287 | <input | |
| 288 | class="input" | |
| 289 | type="search" | |
| 290 | id="f-q" | |
| 291 | name="q" | |
| 292 | form="migrate-search" | |
| 293 | value="${values.q}" | |
| 294 | placeholder="Filter by name or description" | |
| 295 | autocomplete="off" | |
| 296 | /> | |
| 297 | ${values.source === "" | |
| 298 | ? null | |
| 299 | : html`<input type="hidden" name="source" value="${values.source}" />`} | |
| 300 | ${listed.map((origin) => { | |
| 301 | const rows = origin.repos.filter((r) => matchesFilter(r, values.q)); | |
| 302 | const shown = rows.slice(0, PICKER_ROWS); | |
| 303 | return html`<h4 class="mt small muted">${origin.label}</h4> | |
| 304 | <ul class="list" data-pick-rows> | |
| 305 | ${shown.map((remote) => { | |
| 306 | const value = pickValue(origin.id, remote.fullName); | |
outdated
|
||
| 307 | const size = formatKib(remote.sizeKb); | |
| 308 | const branches = `/migrate?${new URLSearchParams({ | |
| 309 | source: value, | |
| 310 | branches: "1", | |
| 311 | ...(values.q === "" ? {} : { q: values.q }), | |
| 312 | })}#migrate-branches`; | |
| 313 | return row({ | |
| 314 | data: { | |
| 315 | q: `${remote.fullName} ${remote.description}`.toLowerCase(), | |
| 316 | }, | |
| 317 | current: values.source === value, | |
| 318 | title: html`<span class="mono">${remote.fullName}</span> | |
| 319 | ${remote.private ? badge("private") : null}`, | |
| 320 | meta: html`${size === "" ? null : html`${size} · `}${remote.description}`, | |
| 321 | trail: html`${btn("Branches", { href: branches, size: "sm", variant: "ghost" })} | |
| 322 | ${btn(withCrew ? "Migrate with the crew" : "Migrate", { | |
| 323 | size: "sm", | |
| 324 | variant: "outline", | |
| 325 | type: "submit", | |
| 326 | name: "pick", | |
| 327 | value, | |
| 328 | })}`, | |
| 329 | }); | |
| 330 | })} | |
| 331 | </ul> | |
| 332 | ${rows.length > shown.length | |
outdated
This button's markup is hand-written. Use |
||
| 333 | ? html`<p class="muted small"> | |
| 334 | ${rows.length - shown.length} more — filter to narrow. | |
| 335 | </p>` | |
| 336 | : null}`; | |
| 337 | })} | |
| 338 | ${branches === undefined ? null : branchList(branches)} | |
| 339 | <span class="hint" | |
| 340 | >Cloned with your account's credential, so private repositories come | |
| 341 | too — with their issues, comments, labels and open pull requests.</span | |
| 342 | > | |
| 343 | </div>`; | |
| 344 | } | |
| 345 | ||
| 66 | 346 | /** `<origin id>/<owner>/<name>` — an id never holds a slash, so the first one splits. */ |
| 67 | 347 | const pickValue = (originId: string, fullName: string): string => |
| 68 | 348 | `${originId}/${fullName}`; |
| @@ -77,6 +357,7 @@ function migrateForm( | ||
| 77 | 357 | ctx: Ctx, |
| 78 | 358 | values: MigrateValues, |
| 79 | 359 | origins: readonly OriginChoices[], |
| 360 | branches: BranchChoices | undefined, | |
| 80 | 361 | error?: string, |
| 81 | 362 | unmatched: readonly string[] = [], |
| 82 | 363 | ): Html { |
| @@ -88,63 +369,38 @@ function migrateForm( | ||
| 88 | 369 | const agents = listAllAgents(ctx.app, user); |
| 89 | 370 | const crew = agents.ok ? agents.value : []; |
| 90 | 371 | |
| 91 | const picker = | |
| 92 | origins.length === 0 | |
| 93 | ? html`<p class="muted small mb"> | |
| 94 | ${icon("globe")} Connect an origin under | |
| 95 | <a href="/settings/origins">Settings → Origins</a> to pick from your | |
| 96 | repositories there, private ones included. | |
| 97 | </p>` | |
| 98 | : html`<div class="field"> | |
| 99 | <label for="f-source">From one of your origins</label> | |
| 100 | <select class="input" id="f-source" name="source"> | |
| 101 | <option value="" ${values.source === "" ? raw("selected") : null}> | |
| 102 | — none, I'll paste a URL — | |
| 103 | </option> | |
| 104 | ${origins.map( | |
| 105 | (origin) => | |
| 106 | html`<optgroup label="${origin.label}"> | |
| 107 | ${origin.repos.map((remote) => { | |
| 108 | const value = pickValue(origin.id, remote.fullName); | |
| 109 | return html`<option | |
| 110 | value="${value}" | |
| 111 | data-name="${remote.fullName.split("/").pop() ?? ""}" | |
| 112 | ${values.source === value ? raw("selected") : null} | |
| 113 | > | |
| 114 | ${remote.fullName}${remote.private ? " (private)" : ""} | |
| 115 | </option>`; | |
| 116 | })} | |
| 117 | </optgroup>`, | |
| 118 | )} | |
| 119 | </select> | |
| 120 | <span class="hint" | |
| 121 | >Cloned with your origin's credential, so private repositories come | |
| 122 | too.</span | |
| 123 | > | |
| 124 | </div>`; | |
| 125 | ||
| 126 | 372 | return html`<div class="narrow-wide"> |
| 127 | 373 | ${pageHead("Migrate a repository", { |
| 128 | 374 | sub: "A one-time clone of the whole history — every branch, every tag — into a repository that lives here from now on. Nothing is written back to where it came from.", |
| 129 | 375 | })} |
| 376 | ${ctx.url.searchParams.get("connected") === "1" | |
| 377 | ? html`<p class="muted small mb" id="migrate-connected"> | |
| 378 | ${icon("check")} Connected. Pick a repository below — one click | |
| 379 | migrates it. | |
| 380 | </p>` | |
| 381 | : null} | |
| 130 | 382 | ${unmatched.length === 0 |
| 131 | 383 | ? null |
| 132 | 384 | : html`<p class="muted small mb" id="prefill-unmatched"> |
| 133 | 385 | ${icon("globe")} Ignored, nothing here matches: |
| 134 | 386 | ${unmatched.join(", ")} |
| 135 | 387 | </p>`} |
| 388 | ${accounts(ctx, origins)} | |
| 389 | <form method="get" action="/migrate" id="migrate-search"></form> | |
| 136 | 390 | ${card( |
| 137 | 391 | html`<form method="post" action="/migrate"> |
| 138 | 392 | <h3 class="mb">Source</h3> |
| 139 | ${picker} | |
| 393 | ${picker(origins, values, crew.length > 0, branches)} | |
| 140 | 394 | ${formField({ |
| 141 | label: origins.length === 0 ? "Repository URL" : "Or a URL", | |
| 395 | label: origins.every((o) => o.repos.length === 0) | |
| 396 | ? "Repository URL" | |
| 397 | : "Or a URL", | |
| 142 | 398 | name: "url", |
| 143 | 399 | value: values.url, |
| 144 | 400 | placeholder: "https://github.com/owner/name", |
| 145 | 401 | autocomplete: "off", |
| 146 | 402 | mono: true, |
| 147 | autofocus: origins.length === 0, | |
| 403 | autofocus: origins.every((o) => o.repos.length === 0), | |
| 148 | 404 | hint: "Any public repository over https, http or git. It is cloned anonymously.", |
| 149 | 405 | })} |
| 150 | 406 | <h3 class="mb mt">Destination</h3> |
| @@ -241,7 +497,13 @@ const migrateJs = /* js */ ` | ||
| 241 | 497 | (function () { |
| 242 | 498 | var name = document.getElementById('f-name'); |
| 243 | 499 | var url = document.getElementById('f-url'); |
| 244 | var source = document.getElementById('f-source'); | |
| 500 | var q = document.getElementById('f-q'); | |
| 501 | if (q) q.addEventListener('input', function () { | |
| 502 | var needle = q.value.trim().toLowerCase(); | |
| 503 | var rows = document.querySelectorAll('[data-pick-rows] [data-q]'); | |
| 504 | for (var i = 0; i < rows.length; i++) | |
| 505 | rows[i].hidden = needle !== '' && rows[i].getAttribute('data-q').indexOf(needle) < 0; | |
| 506 | }); | |
| 245 | 507 | if (!name) return; |
| 246 | 508 | var touched = name.value.trim() !== ''; |
| 247 | 509 | name.addEventListener('input', function () { touched = true; }); |
| @@ -251,29 +513,30 @@ const migrateJs = /* js */ ` | ||
| 251 | 513 | } |
| 252 | 514 | function follow() { |
| 253 | 515 | if (touched) return; |
| 254 | var picked = source && source.selectedIndex > 0 | |
| 255 | ? source.options[source.selectedIndex].getAttribute('data-name') || '' | |
| 256 | : ''; | |
| 257 | name.value = picked !== '' ? picked : url ? fromUrl(url.value) : ''; | |
| 516 | name.value = url ? fromUrl(url.value) : ''; | |
| 258 | 517 | } |
| 259 | 518 | if (url) url.addEventListener('input', follow); |
| 260 | if (source) source.addEventListener('change', follow); | |
| 261 | 519 | })(); |
| 262 | 520 | `; |
| 263 | 521 | |
| 264 | function show( | |
| 522 | async function show( | |
| 265 | 523 | ctx: Ctx, |
| 266 | 524 | values: MigrateValues, |
| 267 | 525 | origins: readonly OriginChoices[], |
| 268 | 526 | error?: string, |
| 269 | 527 | status = 200, |
| 270 | 528 | unmatched: readonly string[] = [], |
| 271 | ): Response { | |
| 529 | ): Promise<Response> { | |
| 530 | // Asked for only: each draw is a listing and a commit per branch at the | |
| 531 | // origin (remembered a minute), so a plain visit costs none of it. | |
| 532 | const branches = values.showBranches | |
| 533 | ? await branchChoices(ctx, origins, values.source, values.branch) | |
| 534 | : undefined; | |
| 272 | 535 | return page( |
| 273 | 536 | { |
| 274 | 537 | ctx, |
| 275 | 538 | title: "Migrate", |
| 276 | body: migrateForm(ctx, values, origins, error, unmatched), | |
| 539 | body: migrateForm(ctx, values, origins, branches, error, unmatched), | |
| 277 | 540 | scriptJs: migrateJs, |
| 278 | 541 | }, |
| 279 | 542 | status, |
| @@ -286,10 +549,11 @@ export async function migratePage(ctx: Ctx): Promise<Response> { | ||
| 286 | 549 | const q = ctx.url.searchParams; |
| 287 | 550 | const origins = await originChoices(ctx); |
| 288 | 551 | const agents = listAllAgents(ctx.app, ctx.auth.user); |
| 552 | const names = agents.ok ? agents.value.map((a) => a.user.username) : []; | |
| 289 | 553 | const linked = prefill( |
| 290 | 554 | { from: q.get("from"), crew: q.get("crew") }, |
| 291 | 555 | origins, |
| 292 | agents.ok ? agents.value.map((a) => a.user.username) : [], | |
| 556 | names, | |
| 293 | 557 | ); |
| 294 | 558 | return show( |
| 295 | 559 | ctx, |
| @@ -297,12 +561,17 @@ export async function migratePage(ctx: Ctx): Promise<Response> { | ||
| 297 | 561 | source: linked.repo |
| 298 | 562 | ? pickValue(linked.repo.originId, linked.repo.fullName) |
| 299 | 563 | : (q.get("source") ?? ""), |
| 564 | q: (q.get("q") ?? "").slice(0, 100), | |
| 300 | 565 | url: q.get("url") ?? "", |
| 301 | 566 | owner: q.get("owner") ?? ctx.auth.user.username, |
| 302 | 567 | name: q.get("name") ?? (linked.repo?.fullName.split("/").pop() ?? ""), |
| 303 | 568 | visibility: "private", |
| 304 | agent: linked.agent ?? "", | |
| 569 | // One click on a row is "with the crew": the first agent is ready | |
| 570 | // unless the link named one; "Nobody" is one choice away. | |
| 571 | agent: linked.agent ?? names[0] ?? "", | |
| 305 | 572 | autonomous: false, |
| 573 | branch: (q.get("branch") ?? "").slice(0, 300), | |
| 574 | showBranches: q.get("branches") === "1" || (q.get("branch") ?? "") !== "", | |
| 306 | 575 | }, |
| 307 | 576 | origins, |
| 308 | 577 | undefined, |
| @@ -317,14 +586,24 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 317 | 586 | const form = await readForm(ctx, "rw"); |
| 318 | 587 | if (form instanceof Response) return form; |
| 319 | 588 | const values: MigrateValues = { |
| 320 | source: field(form, "source"), | |
| 589 | // A row's button names its repository; it wins over a prefilled pick. | |
| 590 | source: field(form, "pick") || field(form, "source"), | |
| 591 | q: "", | |
| 321 | 592 | url: field(form, "url").trim(), |
| 322 | 593 | owner: field(form, "owner"), |
| 323 | 594 | name: field(form, "name").trim(), |
| 324 | 595 | visibility: field(form, "visibility") === "public" ? "public" : "private", |
| 325 | 596 | agent: field(form, "agent"), |
| 326 | 597 | autonomous: field(form, "autonomous") !== "", |
| 598 | // A branch belongs to the repository its list was drawn for: another | |
| 599 | // row's button migrates that row by the rule, not by this choice. | |
| 600 | branch: | |
| 601 | field(form, "pick") === "" || field(form, "pick") === field(form, "source") | |
| 602 | ? field(form, "branch").trim() | |
| 603 | : "", | |
| 604 | showBranches: false, | |
| 327 | 605 | }; |
| 606 | values.showBranches = values.branch !== ""; | |
| 328 | 607 | const again = async (message: string): Promise<Response> => |
| 329 | 608 | show(ctx, values, await originChoices(ctx), message, 422); |
| 330 | 609 | |
| @@ -348,6 +627,9 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 348 | 627 | owner: values.owner, |
| 349 | 628 | name: values.name, |
| 350 | 629 | visibility: values.visibility === "public" ? "public" : "private", |
| 630 | ...(picked === undefined || values.branch === "" | |
| 631 | ? {} | |
| 632 | : { branch: values.branch }), | |
| 351 | 633 | ...(values.agent === "" |
| 352 | 634 | ? {} |
| 353 | 635 | : { crew: { agent: values.agent, autonomous: values.autonomous } }), |
| @@ -365,14 +647,23 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 365 | 647 | const STAGES: { name: MirrorStage; label: string }[] = [ |
| 366 | 648 | { name: "creating", label: "Creating the repository" }, |
| 367 | 649 | { name: "fetching", label: "Cloning every branch and tag" }, |
| 368 | { name: "crew", label: "Putting the crew on it" }, | |
| 650 | { name: "issues", label: "Bringing issues, comments and labels" }, | |
| 651 | { name: "pulls", label: "Bringing open pull requests and their branches" }, | |
| 652 | { name: "crew", label: "Making it an app and starting the crew's first run" }, | |
| 369 | 653 | { name: "done", label: "Done" }, |
| 370 | 654 | ]; |
| 371 | 655 | |
| 656 | /** A pasted URL brings history alone: no conversation rows to wait on. */ | |
| 657 | const stagesFor = (job: MirrorJob): typeof STAGES => | |
| 658 | job.originId === "" | |
| 659 | ? STAGES.filter((s) => s.name !== "issues" && s.name !== "pulls") | |
| 660 | : STAGES; | |
| 661 | ||
| 372 | 662 | function stageRows(job: MirrorJob): Html { |
| 373 | const at = STAGES.findIndex((s) => s.name === job.stage); | |
| 663 | const stages = stagesFor(job); | |
| 664 | const at = stages.findIndex((s) => s.name === job.stage); | |
| 374 | 665 | return html`<ol class="stages"> |
| 375 | ${STAGES.map((stage, i) => { | |
| 666 | ${stages.map((stage, i) => { | |
| 376 | 667 | const state = |
| 377 | 668 | job.stage === "failed" |
| 378 | 669 | ? "todo" |
| @@ -403,7 +694,7 @@ const migrateJobJs = /* js */ ` | ||
| 403 | 694 | var box = document.getElementById('migrate-job'); |
| 404 | 695 | var job = box && box.getAttribute('data-job'); |
| 405 | 696 | if (!job || typeof EventSource === 'undefined') return; |
| 406 | var order = ['creating', 'fetching', 'crew', 'done']; | |
| 697 | var order = ['creating', 'fetching', 'issues', 'pulls', 'crew', 'done']; | |
| 407 | 698 | var source = new EventSource('/api/v1/migrate/' + encodeURIComponent(job) + '/stream'); |
| 408 | 699 | function patch(e) { |
| 409 | 700 | var data; |
src/web/pages/origins.ts +39 −14
| @@ -374,6 +374,14 @@ const blankValues = (): ConnectValues => ({ | ||
| 374 | 374 | * the reader to find out on GitHub's site what to put in it, which is the |
| 375 | 375 | * kind of dead end that sends someone back to pasting a token forever. |
| 376 | 376 | */ |
| 377 | /** Whether "Connect GitHub" has a client id to run the device flow with. */ | |
|
||
| 378 | export function githubConnectable(ctx: Ctx): boolean { | |
| 379 | return ( | |
| 380 | (process.env.FORGE_GITHUB_CLIENT_ID ?? "").trim() !== "" || | |
| 381 | listOriginApps(ctx.app, ctx.auth.user!).some((a) => a.kind === "github") | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 377 | 385 | function githubSection(configured: boolean, callback: string): Html { |
| 378 | 386 | if (!configured) { |
| 379 | 387 | const back = |
| @@ -617,9 +625,7 @@ function originsView(ctx: Ctx, values: ConnectValues): Html { | ||
| 617 | 625 | const origins = listUserOrigins(ctx.app, user); |
| 618 | 626 | const mirrors = mirrorsByOrigin(ctx.app.db, user.id); |
| 619 | 627 | const apps = listOriginApps(ctx.app, user); |
| 620 | const githubConfigured = | |
| 621 | (process.env.FORGE_GITHUB_CLIENT_ID ?? "").trim() !== "" || | |
| 622 | apps.some((a) => a.kind === "github"); | |
| 628 | const githubConfigured = githubConnectable(ctx); | |
| 623 | 629 | const matched = |
| 624 | 630 | values.host === "" |
| 625 | 631 | ? undefined |
| @@ -830,6 +836,16 @@ function connectMessage(error: string): string { | ||
| 830 | 836 | } |
| 831 | 837 | |
| 832 | 838 | // POST /settings/origins/oauth |
| 839 | /** | |
| 840 | * Where a connect or a disconnect started, when it was not here: only | |
| 841 | * `/migrate`, named by a fixed word — never a path a form can choose. | |
| 842 | */ | |
| 843 | type Then = "/migrate" | undefined; | |
| 844 | const thenOf = (value: string | null): Then => | |
| 845 | value === "migrate" ? "/migrate" : undefined; | |
| 846 | const thenQuery = (then: Then): string => | |
| 847 | then === undefined ? "" : "then=migrate"; | |
| 848 | ||
| 833 | 849 | export async function originOAuthStart(ctx: Ctx): Promise<Response> { |
| 834 | 850 | const denied = accountOnly(ctx); |
| 835 | 851 | if (denied) return denied; |
| @@ -837,6 +853,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 837 | 853 | if (form instanceof Response) return form; |
| 838 | 854 | const kind = field(form, "kind"); |
| 839 | 855 | const host = field(form, "host").trim(); |
| 856 | const then = thenOf(field(form, "then")); | |
| 840 | 857 | |
| 841 | 858 | const back = callbackUri(ctx); |
| 842 | 859 | if (back === "" && kind !== "github") { |
| @@ -869,11 +886,12 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 869 | 886 | : connectMessage(started.error.error); |
| 870 | 887 | return shell(ctx, originsView(ctx, values), 422); |
| 871 | 888 | } |
| 872 | const to = | |
| 873 | started.value.flow === "code" | |
| 874 | ? `?to=${encodeURIComponent(started.value.url)}` | |
| 875 | : ""; | |
| 876 | return redirect(`/settings/origins/flows/${started.value.flowId}${to}`); | |
| 889 | const query = new URLSearchParams( | |
| 890 | started.value.flow === "code" ? { to: started.value.url } : {}, | |
| 891 | ); | |
| 892 | if (then !== undefined) query.set("then", "migrate"); | |
| 893 | const qs = query.size === 0 ? "" : `?${query}`; | |
| 894 | return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`); | |
| 877 | 895 | } |
| 878 | 896 | |
| 879 | 897 | /** |
| @@ -959,14 +977,16 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 959 | 977 | if (denied) return denied; |
| 960 | 978 | const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? ""); |
| 961 | 979 | if (!flow) return shell(ctx, notFoundNote(), 404); |
| 980 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 962 | 981 | |
| 963 | if (flow.status === "done") return redirect("/settings/origins?connected=1"); | |
| 982 | if (flow.status === "done") | |
| 983 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 964 | 984 | if (flow.status === "failed") |
| 965 | 985 | return redirect( |
| 966 | 986 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`, |
| 967 | 987 | ); |
| 968 | 988 | |
| 969 | const pollAction = `/settings/origins/flows/${flow.id}/poll`; | |
| 989 | const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`; | |
| 970 | 990 | const authorize = |
| 971 | 991 | flow.flow === "code" |
| 972 | 992 | ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl) |
| @@ -1152,13 +1172,17 @@ export async function originFlowPoll(ctx: Ctx): Promise<Response> { | ||
| 1152 | 1172 | : redirect("/settings/origins?error=state"); |
| 1153 | 1173 | } |
| 1154 | 1174 | const { status, detail } = polled.value; |
| 1175 | const then = thenOf(ctx.url.searchParams.get("then")); | |
| 1155 | 1176 | if (wantsJson) return Response.json({ status, detail }); |
| 1156 | if (status === "done") return redirect("/settings/origins?connected=1"); | |
| 1177 | if (status === "done") | |
| 1178 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 1157 | 1179 | if (status === "failed") |
| 1158 | 1180 | return redirect( |
| 1159 | 1181 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`, |
| 1160 | 1182 | ); |
| 1161 | return redirect(`/settings/origins/flows/${ctx.params.id ?? ""}`); | |
| 1183 | return redirect( | |
| 1184 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`, | |
| 1185 | ); | |
| 1162 | 1186 | } |
| 1163 | 1187 | |
| 1164 | 1188 | // ---- one origin ---- |
| @@ -1200,12 +1224,13 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> { | ||
| 1200 | 1224 | ctx.auth.user, |
| 1201 | 1225 | ctx.params.id ?? "", |
| 1202 | 1226 | ); |
| 1227 | const back = thenOf(field(form, "then")) ?? "/settings/origins"; | |
| 1203 | 1228 | if (!gone.ok) |
| 1204 | return flashTo(ctx, "/settings/origins", { | |
| 1229 | return flashTo(ctx, back, { | |
| 1205 | 1230 | tone: "danger", |
| 1206 | 1231 | message: "No such origin.", |
| 1207 | 1232 | }); |
| 1208 | return flashTo(ctx, "/settings/origins", { | |
| 1233 | return flashTo(ctx, back, { | |
| 1209 | 1234 | message: gone.value.revokedUpstream |
| 1210 | 1235 | ? "Disconnected, and the grant was revoked upstream." |
| 1211 | 1236 | : "Disconnected. Revoke the token on the origin too if you want it dead there.", |
src/web/ui.ts +9 −1
| @@ -829,6 +829,11 @@ export interface RowOptions { | ||
| 829 | 829 | /** The row joins `j`/`k` keyboard traversal. */ |
| 830 | 830 | nav?: boolean; |
| 831 | 831 | titleAttr?: string; |
| 832 | /** `data-*` hooks on the row's `<li>` (a script's filter, a test's | |
| 833 | * handle). Keys are lower-case words and dashes; others are dropped. */ | |
| 834 | data?: Readonly<Record<string, string>>; | |
| 835 | /** The row is the one chosen (`aria-current="true"`). */ | |
| 836 | current?: boolean; | |
| 832 | 837 | } |
| 833 | 838 | |
| 834 | 839 | /** One list row. `<a>` when it navigates, `<div>` when it holds its own |
| @@ -851,7 +856,10 @@ export function row(o: RowOptions): Html { | ||
| 851 | 856 | const attrs = html`class="row" |
| 852 | 857 | ${o.state ? html`data-state="${o.state}"` : null} |
| 853 | 858 | ${o.nav ? raw('data-nav tabindex="-1"') : null}`; |
| 854 | return html`<li> | |
| 859 | const data = Object.entries(o.data ?? {}) | |
| 860 | .filter(([key]) => /^[a-z][a-z-]*$/.test(key)) | |
| 861 | .map(([key, value]) => html` data-${raw(key)}="${value}"`); | |
| 862 | return html`<li${data}${o.current ? raw(' aria-current="true"') : null}> | |
| 855 | 863 | ${o.href !== undefined |
| 856 | 864 | ? html`<a href="${o.href}" ${attrs}>${inner}</a>` |
| 857 | 865 | : html`<div ${attrs}>${inner}</div>`} |
test/fake-origin.ts +6 −1
| @@ -51,6 +51,8 @@ export interface FakeRepo { | ||
| 51 | 51 | openIssues?: number; |
| 52 | 52 | /** Forgejo only — GitHub has no separate pull counter. */ |
| 53 | 53 | openPulls?: number; |
| 54 | /** KiB, as both forges report `size`. */ | |
| 55 | sizeKb?: number; | |
| 54 | 56 | } |
| 55 | 57 | |
| 56 | 58 | export interface FakeIssue { |
| @@ -64,6 +66,8 @@ export interface FakeIssue { | ||
| 64 | 66 | createdAt?: string; |
| 65 | 67 | updatedAt?: string; |
| 66 | 68 | closedAt?: string | null; |
| 69 | /** What the listing says `comments` is; the thread itself is `comments`. */ | |
| 70 | commentCount?: number; | |
| 67 | 71 | /** Present for a pull request. */ |
| 68 | 72 | pull?: { |
| 69 | 73 | base: string; |
| @@ -936,6 +940,7 @@ function repoJson(state: FakeState, repo: FakeRepo): Record<string, unknown> { | ||
| 936 | 940 | ssh_url: "", |
| 937 | 941 | html_url: repo.htmlUrl ?? `https://example.invalid/${repo.fullName}`, |
| 938 | 942 | permissions: { push: repo.push ?? true, admin: repo.admin ?? true }, |
| 943 | size: repo.sizeKb ?? 0, | |
| 939 | 944 | // Both forges report the parent here and nowhere else, which is why the |
| 940 | 945 | // picker's cache must keep the one it has when a listing omits it. |
| 941 | 946 | ...(repo.parent === undefined |
| @@ -975,7 +980,7 @@ function issueJson( | ||
| 975 | 980 | updated_at: issue.updatedAt ?? issue.createdAt ?? new Date().toISOString(), |
| 976 | 981 | closed_at: issue.closedAt ?? null, |
| 977 | 982 | html_url: `${url.origin}/${issue.number}`, |
| 978 | comments: 0, | |
| 983 | comments: issue.commentCount ?? 0, | |
| 979 | 984 | }; |
| 980 | 985 | if (issue.pull === undefined) return common; |
| 981 | 986 | // GitHub marks a pull by the mere presence of this key; Forgejo puts the |
test/migrate.test.ts +494 −5
| @@ -23,6 +23,13 @@ import { | ||
| 23 | 23 | migrateUrl, |
| 24 | 24 | } from "../src/actions/migrate.ts"; |
| 25 | 25 | import { createAgent, listCrew } from "../src/actions/crew.ts"; |
| 26 | import { connectOrigin } from "../src/actions/origins.ts"; | |
| 27 | import { getPull, pullStatus } from "../src/core/pulls.ts"; | |
| 28 | import { pullMayRun } from "../src/core/pull_trust.ts"; | |
| 29 | import { approveHere } from "../src/actions/pulls.ts"; | |
| 30 | import { labelsFor, listComments } from "../src/core/issues.ts"; | |
| 31 | import { backlink } from "../src/origins/carry.ts"; | |
| 32 | import { formatKib, matchesFilter } from "../src/web/pages/migrate.ts"; | |
| 26 | 33 | import { |
| 27 | 34 | commitToBranch, |
| 28 | 35 | createRepo, |
| @@ -39,7 +46,12 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 39 | 46 | import { createSession } from "../src/core/sessions.ts"; |
| 40 | 47 | import { advance, createJob, settled } from "../src/origins/jobs.ts"; |
| 41 | 48 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 42 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 49 | import { | |
| 50 | newFakeOAuth, | |
| 51 | newFakeState, | |
| 52 | serveFakeForge, | |
| 53 | type FakeForge, | |
| 54 | } from "./fake-origin.ts"; | |
| 43 | 55 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 44 | 56 | import { prefill } from "../src/web/pages/migrate-prefill.ts"; |
| 45 | 57 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -98,6 +110,7 @@ describe("migrate", () => { | ||
| 98 | 110 | let bobToken = ""; |
| 99 | 111 | let fixtureUrl = ""; |
| 100 | 112 | let originId = ""; |
| 113 | let octo: User; | |
| 101 | 114 | // octo's credential at the "upstream": a token, since a password would |
| 102 | 115 | // cost an argon2 verify on every git request. |
| 103 | 116 | let upstreamSecret = ""; |
| @@ -185,7 +198,7 @@ describe("migrate", () => { | ||
| 185 | 198 | // request here rides a session or a token. |
| 186 | 199 | alice = await person("alice", true); |
| 187 | 200 | bob = await person("bob"); |
| 188 | const octo = await person("octo"); | |
| 201 | octo = await person("octo"); | |
| 189 | 202 | upstreamSecret = createToken(app.db, octo.id, "upstream", "rw").token; |
| 190 | 203 | aliceCookie = cookieOf(alice); |
| 191 | 204 | bobCookie = cookieOf(bob); |
| @@ -517,6 +530,479 @@ describe("migrate", () => { | ||
| 517 | 530 | ); |
| 518 | 531 | }); |
| 519 | 532 | |
| 533 | test("one click from GitHub: the repository, 3 issues, 1 open pull request and a crew run on the app", async () => { | |
| 534 | // Upstream: a private repository with a branch an open pull stands on. | |
| 535 | const made = await createRepo(app, octo, { | |
| 536 | name: "gadgets", | |
| 537 | visibility: "private", | |
| 538 | }); | |
| 539 | if (!made.ok) throw new Error(made.error); | |
| 540 | for (const [branch, path] of [ | |
| 541 | ["main", "README.md"], | |
| 542 | ["fix-typo", "typo.txt"], | |
| 543 | ] as const) { | |
| 544 | const done = await commitToBranch(app, octo, made.value, { | |
| 545 | branch, | |
| 546 | message: `on ${branch}`, | |
| 547 | files: [{ path, content: "x\n" }], | |
| 548 | }); | |
| 549 | if (!done.ok) throw new Error(String(done.error)); | |
| 550 | } | |
| 551 | const upstreamDir = repoDir(app.reposRoot, "octo", "gadgets"); | |
| 552 | const headSha = (await git(["rev-parse", "fix-typo"], upstreamDir)).out; | |
| 553 | ||
| 554 | // A fake GitHub API in front of it, seeded: 3 issues (one closed, one | |
| 555 | // with a comment), 1 open pull request, and 1 closed one that stays. | |
| 556 | const state = newFakeState("github", "octo", upstreamSecret); | |
| 557 | state.repos.push({ | |
| 558 | id: 501, | |
| 559 | fullName: "octo/gadgets", | |
| 560 | description: "small machines", | |
| 561 | cloneUrl: `${base}/octo/gadgets.git`, | |
| 562 | htmlUrl: "https://github.com/octo/gadgets", | |
| 563 | defaultBranch: "main", | |
| 564 | private: true, | |
| 565 | sizeKb: 2048, | |
| 566 | }); | |
| 567 | state.labels.set("octo/gadgets", [{ id: 9, name: "bug", color: "d73a4a" }]); | |
| 568 | state.issues.set("octo/gadgets", [ | |
| 569 | { id: 601, number: 1, title: "Crashes on start", body: "Stack trace.", user: "mona", labels: ["bug"], commentCount: 1 }, | |
| 570 | { id: 602, number: 2, title: "An old thing", user: "hubot", state: "closed", closedAt: "2026-01-02T00:00:00Z" }, | |
| 571 | { id: 603, number: 3, title: "Write the docs", user: "mona" }, | |
| 572 | { | |
| 573 | id: 604, | |
| 574 | number: 4, | |
| 575 | title: "Fix the typo", | |
| 576 | body: "One letter.", | |
| 577 | user: "octocat", | |
| 578 | pull: { base: "main", head: "fix-typo", headRepo: "octo/gadgets", headSha }, | |
| 579 | }, | |
| 580 | { | |
| 581 | id: 605, | |
| 582 | number: 5, | |
| 583 | title: "Turned down", | |
| 584 | user: "octocat", | |
| 585 | state: "closed", | |
| 586 | pull: { base: "main", head: "nope", headRepo: "octo/gadgets" }, | |
| 587 | }, | |
| 588 | ]); | |
| 589 | state.comments.set("octo/gadgets", [ | |
| 590 | { id: 701, issueNumber: 1, body: "Same here.", user: "hubot" }, | |
| 591 | ]); | |
| 592 | const github = serveFakeForge(state); | |
| 593 | try { | |
| 594 | forgetProviders(dataDir); | |
| 595 | const connected = await connectOrigin(app, alice, { | |
| 596 | host: github.url, | |
| 597 | kind: "github", | |
| 598 | token: upstreamSecret, | |
| 599 | }); | |
| 600 | if (!connected.ok) throw new Error(connected.error); | |
| 601 | const agent = await createAgent(app, alice, { | |
| 602 | username: "mover", | |
| 603 | model: "", | |
| 604 | brief: "Moves things in.", | |
| 605 | }); | |
| 606 | expect(agent.ok).toBe(true); | |
| 607 | ||
| 608 | // The picker: a row, marked private, sized, with its one button — | |
| 609 | // and the account it came through can be disconnected from here. | |
| 610 | const html = await (await browser(aliceCookie, "/migrate")).text(); | |
| 611 | const row = new RegExp(`name="pick"\\s+value="(${connected.value.id}/octo/gadgets)"`).exec(html); | |
| 612 | expect(row).not.toBeNull(); | |
| 613 | expect(html).toContain("2.0 MiB"); | |
| 614 | expect(html).toContain("Migrate with the crew"); | |
| 615 | expect(html).toContain(`action="/settings/origins/${connected.value.id}/delete"`); | |
| 616 | expect(html).toContain('name="then" value="migrate"'); | |
| 617 | ||
| 618 | // The filter narrows the rows on the server too (no script needed). | |
| 619 | const filtered = await (await browser(aliceCookie, "/migrate?q=nothing-like-it")).text(); | |
| 620 | expect(filtered).not.toContain('value="' + row![1] + '"'); | |
| 621 | ||
| 622 | // One click: the row's button and the form's fields as they stood. | |
| 623 | const posted = await browser(aliceCookie, "/migrate", { | |
| 624 | pick: row![1]!, | |
| 625 | url: "", | |
| 626 | owner: "alice", | |
| 627 | name: "", | |
| 628 | visibility: "private", | |
| 629 | agent: "mover", | |
| 630 | }); | |
| 631 | expect(posted.status).toBe(303); | |
| 632 | const where = posted.headers.get("location")!; | |
| 633 | const job = await settled(where.split("/").pop()!); | |
| 634 | expect(job?.error).toBeUndefined(); | |
| 635 | expect(job?.stage).toBe("done"); | |
| 636 | expect(job?.detail).toContain("3 issues, 1 open pull request, 1 comment"); | |
| 637 | // It ends at the app: the repository's page is the app's. | |
| 638 | const landed = await browser(aliceCookie, where); | |
| 639 | expect(landed.headers.get("location")).toBe("/alice/gadgets"); | |
| 640 | ||
| 641 | const repo = getRepo(app.db, "alice", "gadgets")!; | |
| 642 | expect(repo.origin).toBeNull(); | |
| 643 | expect(getAppByRepo(app.db, repo.id)).toBeDefined(); | |
| 644 | ||
| 645 | // The issues, open and closed, numbered as they were, by their | |
| 646 | // GitHub authors, each linking back. | |
| 647 | const carried = listIssues(app.db, repo.id, { kind: "issue", state: "all" }) | |
| 648 | .filter((i) => i.originAuthor !== null) | |
| 649 | .sort((a, b) => a.number - b.number); | |
| 650 | expect(carried.map((i) => [i.number, i.state, i.originAuthor])).toEqual([ | |
| 651 | [1, "open", "mona"], | |
| 652 | [2, "closed", "hubot"], | |
| 653 | [3, "open", "mona"], | |
| 654 | ]); | |
| 655 | expect(carried[0]!.body).toStartWith( | |
| 656 | "> Migrated from [octo/gadgets#1](", | |
| 657 | ); | |
| 658 | expect(carried[0]!.body).toContain("by @mona on GitHub."); | |
| 659 | expect(carried[0]!.body).toContain("Stack trace."); | |
| 660 | expect(labelsFor(app.db, carried[0]!.id).map((l) => l.name)).toEqual(["bug"]); | |
| 661 | const comments = listComments(app.db, carried[0]!.id); | |
| 662 | expect(comments.map((c) => [c.originAuthor, c.body])).toEqual([ | |
| 663 | ["hubot", "Same here."], | |
| 664 | ]); | |
| 665 | ||
| 666 | // One pull request — the open one — native here, on its branch. | |
| 667 | const pulls = listIssues(app.db, repo.id, { kind: "pull", state: "all" }); | |
| 668 | expect(pulls.map((p) => p.number)).toEqual([4]); | |
| 669 | const pull = getPull(app.db, repo.id, 4)!; | |
| 670 | expect(pull.headBranch).toBe("fix-typo"); | |
| 671 | expect(pull.originHeadRepo).toBeNull(); | |
| 672 | expect(pull.body).toContain("by @octocat on GitHub."); | |
| 673 | const dir = repoDir(app.reposRoot, "alice", "gadgets"); | |
| 674 | expect((await git(["rev-parse", "fix-typo"], dir)).out).toBe(headSha); | |
| 675 | ||
| 676 | // The crew's brief is the next number, assigned to the agent: the | |
| 677 | // durable event the dispatcher (`crew/dispatch.ts`, started by | |
| 678 | // main.ts) queues the first run from. | |
| 679 | const brief = listIssues(app.db, repo.id, { kind: "issue", state: "open" }) | |
| 680 | .find((i) => i.originAuthor === null)!; | |
| 681 | expect(brief.number).toBe(5); | |
| 682 | expect(brief.assigneeName).toBe("mover"); | |
| 683 | const assigned = app.db | |
| 684 | .query<{ payload: string }, [string]>( | |
| 685 | "SELECT payload FROM event WHERE repo_id = ?1 AND kind = 'issue.assigned'", | |
| 686 | ) | |
| 687 | .all(repo.id) | |
| 688 | .map((e) => JSON.parse(e.payload) as { number: number; agent?: boolean }); | |
| 689 | expect(assigned).toContainEqual( | |
| 690 | expect.objectContaining({ number: 5, agent: true }), | |
| 691 | ); | |
| 692 | // And the import said so once, not once per row. | |
| 693 | const imported = app.db | |
| 694 | .query<{ n: number }, [string]>( | |
| 695 | "SELECT count(*) AS n FROM event WHERE repo_id = ?1 AND kind = 'repo.imported'", | |
| 696 | ) | |
| 697 | .get(repo.id); | |
| 698 | expect(imported?.n).toBe(1); | |
| 699 | } finally { | |
| 700 | github.stop(); | |
| 701 | } | |
| 702 | }); | |
| 703 | ||
| 704 | test("a pull opened from a fork arrives with its head under refs/pull/<n>/head, waiting for a writer to let it run", async () => { | |
| 705 | const made = await createRepo(app, octo, { name: "forked-pulls", visibility: "private" }); | |
| 706 | if (!made.ok) throw new Error(made.error); | |
| 707 | for (const branch of ["main", "their-work"]) { | |
| 708 | const done = await commitToBranch(app, octo, made.value, { | |
| 709 | branch, | |
| 710 | message: `on ${branch}`, | |
| 711 | files: [{ path: `${branch}.txt`, content: "x\n" }], | |
| 712 | }); | |
| 713 | if (!done.ok) throw new Error(String(done.error)); | |
| 714 | } | |
| 715 | // The head lives only where GitHub keeps it: refs/pull/<n>/head. | |
| 716 | const upstream = repoDir(app.reposRoot, "octo", "forked-pulls"); | |
| 717 | const sha = (await git(["rev-parse", "their-work"], upstream)).out; | |
| 718 | expect((await git(["update-ref", "refs/pull/6/head", sha], upstream)).code).toBe(0); | |
| 719 | expect((await git(["branch", "-D", "their-work"], upstream)).code).toBe(0); | |
| 720 | // A branch of the source's own that happens to be called pull/6. | |
| 721 | const mainSha = (await git(["rev-parse", "main"], upstream)).out; | |
| 722 | expect((await git(["branch", "pull/6", "main"], upstream)).code).toBe(0); | |
| 723 | ||
| 724 | const state = newFakeState("github", "octo", upstreamSecret); | |
| 725 | state.repos.push({ | |
| 726 | id: 801, | |
| 727 | fullName: "octo/forked-pulls", | |
| 728 | cloneUrl: `${base}/octo/forked-pulls.git`, | |
| 729 | defaultBranch: "main", | |
| 730 | private: true, | |
| 731 | }); | |
| 732 | state.issues.set("octo/forked-pulls", [ | |
| 733 | { | |
| 734 | id: 806, | |
| 735 | number: 6, | |
| 736 | title: "From a fork", | |
| 737 | user: "stranger", | |
| 738 | pull: { base: "main", head: "their-work", headRepo: "stranger/forked-pulls", headSha: sha }, | |
| 739 | }, | |
| 740 | ]); | |
| 741 | const github = serveFakeForge(state); | |
| 742 | try { | |
| 743 | forgetProviders(dataDir); | |
| 744 | const connected = await connectOrigin(app, alice, { | |
| 745 | host: github.url, | |
| 746 | kind: "github", | |
| 747 | token: upstreamSecret, | |
| 748 | }); | |
| 749 | if (!connected.ok) throw new Error(connected.error); | |
| 750 | const job = await migrate(alice, { | |
| 751 | source: { originId: connected.value.id, fullName: "octo/forked-pulls" }, | |
| 752 | }); | |
| 753 | expect(job?.error).toBeUndefined(); | |
| 754 | expect(job?.detail).toContain( | |
| 755 | "1 open pull request (1 from forks, waiting for your approval to run here)", | |
| 756 | ); | |
| 757 | const repo = getRepo(app.db, "alice", "forked-pulls")!; | |
| 758 | const pull = getPull(app.db, repo.id, 6)!; | |
| 759 | // Still the stranger's: the head is where the forge keeps pull heads, | |
| 760 | // the source's own pull/6 branch is untouched, and no push event | |
| 761 | // carried the stranger's head to a check or a preview. | |
| 762 | expect(pull.originHeadRepo).toBe("stranger/forked-pulls"); | |
| 763 | const dir = repoDir(app.reposRoot, "alice", "forked-pulls"); | |
| 764 | expect((await git(["rev-parse", "refs/pull/6/head"], dir)).out).toBe(sha); | |
| 765 | expect((await git(["rev-parse", "refs/heads/pull/6"], dir)).out).toBe(mainSha); | |
| 766 | const pushed = app.db | |
| 767 | .query<{ payload: string }, [string]>( | |
| 768 | "SELECT payload FROM event WHERE repo_id = ?1 AND kind = 'push'", | |
| 769 | ) | |
| 770 | .all(repo.id) | |
| 771 | .map((e) => JSON.stringify(JSON.parse(e.payload))); | |
| 772 | expect(pushed.length).toBe(1); | |
| 773 | expect(pushed[0]).not.toContain(sha); | |
| 774 | expect(pushed[0]).not.toContain("refs/pull/"); | |
| 775 | // It runs only once a writer approves this head. | |
| 776 | const status = await pullStatus(dir, pull); | |
| 777 | expect(status.headOid).toBe(sha); | |
| 778 | expect(pullMayRun(app.db, repo, pull, sha)).toBe(false); | |
| 779 | const approved = await approveHere(app, alice, repo, pull); | |
| 780 | expect(approved.ok).toBe(true); | |
| 781 | expect(pullMayRun(app.db, repo, pull, sha)).toBe(true); | |
| 782 | } finally { | |
| 783 | github.stop(); | |
| 784 | } | |
| 785 | }); | |
| 786 | ||
| 787 | test("the app's branch: the person's newest beats the source's default, by URL and from GitHub, and the brief names it", async () => { | |
| 788 | // trevato/arm's shape: `main` is the upstream's tree (and the newest | |
| 789 | // commit), `workbench` is the person's app, ahead of it. | |
| 790 | const upstreamer = await person("upstreamer"); | |
| 791 | const made = await createRepo(app, upstreamer, { name: "arm", visibility: "public" }); | |
| 792 | if (!made.ok) throw new Error(made.error); | |
| 793 | const put = async (user: User, branch: string, message: string) => { | |
| 794 | const done = await commitToBranch(app, user, made.value, { | |
| 795 | branch, | |
| 796 | message, | |
| 797 | files: [{ path: `${message}.txt`, content: "x\n" }], | |
| 798 | }); | |
| 799 | if (!done.ok) throw new Error(String(done.error)); | |
| 800 | }; | |
| 801 | // Which tip is newer is the rule's own test (work-branch.test.ts); | |
| 802 | // here, whose it is decides. | |
| 803 | await put(upstreamer, "main", "upstream-start"); | |
| 804 | await put(alice, "workbench", "the-app"); | |
| 805 | await put(upstreamer, "main", "upstream-later"); | |
| 806 | const upstream = repoDir(app.reposRoot, "upstreamer", "arm"); | |
| 807 | const mainSha = (await git(["rev-parse", "main"], upstream)).out; | |
| 808 | const workSha = (await git(["rev-parse", "workbench"], upstream)).out; | |
| 809 | const agent = await createAgent(app, alice, { username: "armer", model: "", brief: "Moves things in." }); | |
| 810 | expect(agent.ok).toBe(true); | |
| 811 | const briefOf = (owner: string, name: string): string => | |
| 812 | listIssues(app.db, getRepo(app.db, owner, name)!.id, { kind: "issue", state: "open" }) | |
| 813 | .find((i) => i.title === `Make ${name} run here`)!.body; | |
| 814 | ||
| 815 | // By URL, the clone's own authors: alice's branch, not the source's main. | |
| 816 | const byUrl = await migrate(alice, { | |
| 817 | source: { url: `${base}/upstreamer/arm.git` }, | |
| 818 | name: "arm-url", | |
| 819 | crew: { agent: "armer", autonomous: false }, | |
| 820 | }); | |
| 821 | expect(byUrl?.error).toBeUndefined(); | |
| 822 | expect(byUrl?.detail).toContain("Working on workbench, the branch with your newest commits"); | |
| 823 | expect(getRepo(app.db, "alice", "arm-url")!.defaultBranch).toBe("workbench"); | |
| 824 | expect(briefOf("alice", "arm-url")).toContain("The app is on the branch `workbench`"); | |
| 825 | expect(briefOf("alice", "arm-url")).toContain("open your pull against it"); | |
| 826 | // Someone with no commits there keeps the source's default. | |
| 827 | const theirs = await migrate(bob, { source: { url: `${base}/upstreamer/arm.git` }, name: "arm-bob" }); | |
| 828 | expect(getRepo(app.db, "bob", "arm-bob")!.defaultBranch).toBe("main"); | |
| 829 | expect(theirs?.detail).toContain("Working on main, the source's default branch"); | |
| 830 | // A branch that is not one git would take is refused before anything exists. | |
| 831 | const bad = await migrateRepo(app, alice, { | |
| 832 | source: { url: `${base}/upstreamer/arm.git` }, | |
| 833 | name: "arm-bad", | |
| 834 | branch: "--upload-pack=x", | |
| 835 | }); | |
| 836 | expect(bad.ok ? "" : bad.error).toBe("invalid_branch"); | |
| 837 | expect(getRepo(app.db, "alice", "arm-bad")).toBeUndefined(); | |
| 838 | ||
| 839 | // From GitHub: the origin says whose each last commit is by login. | |
| 840 | const state = newFakeState("github", "octo", upstreamSecret); | |
| 841 | state.repos.push({ | |
| 842 | id: 901, | |
| 843 | fullName: "octo/arm", | |
| 844 | cloneUrl: `${base}/upstreamer/arm.git`, | |
| 845 | defaultBranch: "main", | |
| 846 | private: true, | |
| 847 | }); | |
| 848 | state.branches.set("octo/arm", [ | |
| 849 | { name: "main", sha: mainSha }, | |
| 850 | { name: "workbench", sha: workSha }, | |
| 851 | ]); | |
| 852 | state.commits.set("octo/arm", [ | |
| 853 | { sha: workSha, message: "<b>the app</b>\n\nbody", authorName: "Octo Cat", authorEmail: "[email protected]", login: "octo", date: "2026-10-19T12:00:00Z" }, | |
| 854 | { sha: mainSha, message: "upstream later", authorName: "Up Streamer", login: "upstreamer", date: "2026-10-20T09:00:00Z" }, | |
| 855 | ]); | |
| 856 | const github = serveFakeForge(state); | |
| 857 | try { | |
| 858 | forgetProviders(dataDir); | |
| 859 | const connected = await connectOrigin(app, alice, { host: github.url, kind: "github", token: upstreamSecret }); | |
| 860 | if (!connected.ok) throw new Error(connected.error); | |
| 861 | const value = `${connected.value.id}/octo/arm`; | |
| 862 | ||
| 863 | // The row links to its branches; the list shows each last commit, | |
| 864 | // escaped, newest first, and selects the person's. | |
| 865 | const listing = await (await browser(aliceCookie, "/migrate")).text(); | |
| 866 | expect(listing).not.toContain('id="migrate-branches"'); | |
| 867 | expect(listing).toContain(`href="/migrate?source=${encodeURIComponent(value)}&branches=1#migrate-branches"`); | |
| 868 | // A plain visit with the row selected asks the origin for nothing. | |
| 869 | const asking = () => state.requests.filter((r) => r.path.endsWith("/branches") || r.path.endsWith("/commits")).length; | |
| 870 | const before = asking(); | |
| 871 | const plain = await (await browser(aliceCookie, `/migrate?${new URLSearchParams({ source: value })}`)).text(); | |
| 872 | expect(plain).not.toContain('id="migrate-branches"'); | |
| 873 | expect(asking()).toBe(before); | |
| 874 | const html = await (await browser(aliceCookie, `/migrate?${new URLSearchParams({ source: value, branches: "1" })}`)).text(); | |
| 875 | // One listing and one commit per branch; a reload within the minute costs none. | |
| 876 | expect(asking()).toBe(before + 3); | |
| 877 | await browser(aliceCookie, `/migrate?${new URLSearchParams({ source: value, branches: "1" })}`); | |
| 878 | expect(asking()).toBe(before + 3); | |
| 879 | const rows = [...html.matchAll(/data-branch="([^"]+)"/g)].map((m) => m[1]); | |
| 880 | expect(rows).toEqual(["main", "workbench"]); | |
| 881 | expect(html).toMatch(/value="workbench"\s+checked/); | |
| 882 | expect(html).not.toMatch(/value="main"\s+checked/); | |
| 883 | expect(html).toContain("<b>the app</b>"); | |
| 884 | expect(html).not.toContain("<b>the app</b>"); | |
| 885 | expect(html).toMatch(/octo ·\s+<time datetime="2026-10-19T12:00:00(\.000)?Z">2026-10-19<\/time>/); | |
| 886 | expect(html).toContain("default there"); | |
| 887 | // Asked for, another branch is the one selected. | |
| 888 | const asked = await (await browser(aliceCookie, `/migrate?${new URLSearchParams({ source: value, branch: "main" })}`)).text(); | |
| 889 | expect(asked).toMatch(/value="main"\s+checked/); | |
| 890 | ||
| 891 | // The Migrate button carries the branch chosen. | |
| 892 | const chosen = await browser(aliceCookie, "/migrate", { | |
| 893 | source: value, branch: "main", url: "", owner: "alice", name: "arm-main", visibility: "private", agent: "armer", | |
| 894 | }); | |
| 895 | expect(chosen.status).toBe(303); | |
| 896 | const chosenJob = await settled(chosen.headers.get("location")!.split("/").pop()!); | |
| 897 | expect(chosenJob?.detail).toContain("Working on main, the branch picked"); | |
| 898 | expect(getRepo(app.db, "alice", "arm-main")!.defaultBranch).toBe("main"); | |
| 899 | expect(briefOf("alice", "arm-main")).toContain("The app is on the branch `main` — the branch the person who migrated it picked"); | |
| 900 | ||
| 901 | // Another row's button ignores a branch drawn for a different row: | |
| 902 | // one click migrates by the rule — here, by the origin's login alone, | |
| 903 | // since the clone's author (alice) is not the person connected (octo). | |
| 904 | const oneClick = await browser(aliceCookie, "/migrate", { | |
| 905 | pick: value, source: "elsewhere/x/y", branch: "main", url: "", owner: "alice", name: "arm-click", visibility: "private", agent: "armer", | |
| 906 | }); | |
| 907 | expect(oneClick.status).toBe(303); | |
| 908 | const clickJob = await settled(oneClick.headers.get("location")!.split("/").pop()!); | |
| 909 | expect(clickJob?.error).toBeUndefined(); | |
| 910 | expect(getRepo(app.db, "alice", "arm-click")!.defaultBranch).toBe("workbench"); | |
| 911 | expect(briefOf("alice", "arm-click")).toContain("The app is on the branch `workbench` — the branch with the newest commits of the person who migrated it"); | |
| 912 | } finally { | |
| 913 | github.stop(); | |
| 914 | } | |
| 915 | }, 30_000); | |
| 916 | ||
| 917 | test("Connect GitHub from /migrate comes back to /migrate, and so does Disconnect; nothing else can be named", async () => { | |
| 918 | const state = newFakeState("github", "bobby", "bob-gh-token"); | |
| 919 | const oauth = newFakeOAuth("migrate-client"); | |
| 920 | state.oauth = oauth; | |
| 921 | const github = serveFakeForge(state); | |
| 922 | process.env.FORGE_GITHUB_CLIENT_ID = "migrate-client"; | |
| 923 | try { | |
| 924 | forgetProviders(dataDir); | |
| 925 | const page = await (await browser(bobCookie, "/migrate")).text(); | |
| 926 | expect(page).toContain('action="/settings/origins/oauth"'); | |
| 927 | expect(page).toContain("Connect GitHub"); | |
| 928 | // One filled button on the page: Migrate. Connect is an outline. | |
| 929 | expect(page.match(/data-variant="primary"/g)?.length).toBe(1); | |
| 930 | expect(page).toMatch(/data-variant="outline"[^>]*>\s*(<svg[\s\S]*?<\/svg>)?\s*Connect GitHub/); | |
| 931 | ||
| 932 | const started = await browser(bobCookie, "/settings/origins/oauth", { | |
| 933 | kind: "github", | |
| 934 | host: github.url, | |
| 935 | then: "migrate", | |
| 936 | }); | |
| 937 | expect(started.status).toBe(303); | |
| 938 | const flowPage = started.headers.get("location")!; | |
| 939 | expect(flowPage).toMatch(/^\/settings\/origins\/flows\/[^?]+\?then=migrate$/); | |
| 940 | const waiting = await (await browser(bobCookie, flowPage)).text(); | |
| 941 | expect(waiting).toContain("WXYZ-1234"); | |
| 942 | expect(waiting).toContain("/poll?then=migrate"); | |
| 943 | ||
| 944 | oauth.approved = true; | |
| 945 | const flowId = flowPage.split("/").pop()!.split("?")[0]!; | |
| 946 | const polled = await browser( | |
| 947 | bobCookie, | |
| 948 | `/settings/origins/flows/${flowId}/poll?then=migrate`, | |
| 949 | {}, | |
| 950 | ); | |
| 951 | expect(polled.headers.get("location")).toBe("/migrate?connected=1"); | |
| 952 | const origin = listOrigins(app.db, bob.id).find((o) => o.kind === "github")!; | |
| 953 | expect(origin).toBeDefined(); | |
| 954 | ||
| 955 | // Connected: the page lists the account and offers Disconnect, not Connect. | |
| 956 | const after = await (await browser(bobCookie, "/migrate?connected=1")).text(); | |
| 957 | expect(after).toContain('id="migrate-connected"'); | |
| 958 | expect(after).toContain(`action="/settings/origins/${origin.id}/delete"`); | |
| 959 | expect(after).not.toContain('action="/settings/origins/oauth"'); | |
| 960 | ||
| 961 | // `then` is a word, not an address: anything else goes home. | |
| 962 | const steered = await browser(bobCookie, "/settings/origins/oauth", { | |
| 963 | kind: "github", | |
| 964 | host: github.url, | |
| 965 | then: "https://evil.test/", | |
| 966 | }); | |
| 967 | expect(steered.headers.get("location")).not.toContain("then="); | |
| 968 | ||
| 969 | const gone = await browser(bobCookie, `/settings/origins/${origin.id}/delete`, { | |
| 970 | then: "migrate", | |
| 971 | }); | |
| 972 | expect(gone.status).toBe(303); | |
| 973 | expect(gone.headers.get("location")).toBe("/migrate"); | |
| 974 | expect(listOrigins(app.db, bob.id).some((o) => o.id === origin.id)).toBe(false); | |
| 975 | } finally { | |
| 976 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 977 | github.stop(); | |
| 978 | } | |
| 979 | }); | |
| 980 | ||
| 981 | test("the picker's pieces: sizes in binary units, a filter on name or description, a backlink that cannot be steered", () => { | |
| 982 | expect(formatKib(0)).toBe(""); | |
| 983 | expect(formatKib(512)).toBe("512 KiB"); | |
| 984 | expect(formatKib(1536)).toBe("1.5 MiB"); | |
| 985 | expect(formatKib(20 * 1024)).toBe("20 MiB"); | |
| 986 | expect(formatKib(3 * 1024 * 1024)).toBe("3.0 GiB"); | |
| 987 | expect(matchesFilter({ fullName: "octo/Gadgets", description: "" }, "gadg")).toBe(true); | |
| 988 | expect(matchesFilter({ fullName: "octo/x", description: "Small machines" }, "MACHINE")).toBe(true); | |
| 989 | expect(matchesFilter({ fullName: "octo/x", description: "" }, "y")).toBe(false); | |
| 990 | ||
| 991 | expect( | |
| 992 | backlink({ number: 7, author: "mona", htmlUrl: "https://github.com/o/r/issues/7" }, "o/r", "GitHub"), | |
| 993 | ).toBe("> Migrated from [o/r#7](https://github.com/o/r/issues/7) by @mona on GitHub."); | |
| 994 | // A link that is not http(s) is named, not linked; a login cannot | |
| 995 | // smuggle markdown or a mention of someone else. | |
| 996 | const hostile = backlink( | |
| 997 | { number: 7, author: "x](javascript:alert(1)) @admin", htmlUrl: "javascript:alert(1)" }, | |
| 998 | "o/r", | |
| 999 | "GitHub", | |
| 1000 | ); | |
| 1001 | expect(hostile).not.toContain("javascript:"); | |
| 1002 | expect(hostile).not.toContain("]("); | |
| 1003 | expect(hostile).toBe("> Migrated from o/r#7 by @xjavascriptalert1admin on GitHub."); | |
| 1004 | }); | |
| 1005 | ||
| 520 | 1006 | test("the page: the form, the plus menu, the job, and who may see it", async () => { |
| 521 | 1007 | const anonymous = await browser("", "/migrate"); |
| 522 | 1008 | expect(anonymous.status).toBe(303); |
| @@ -525,7 +1011,8 @@ describe("migrate", () => { | ||
| 525 | 1011 | const html = await (await browser(aliceCookie, "/migrate")).text(); |
| 526 | 1012 | expect(html).toContain('action="/migrate"'); |
| 527 | 1013 | expect(html).toContain('name="url"'); |
| 528 | expect(html).toContain('name="source"'); | |
| 1014 | // Each repository is a row whose button is the form's submitter. | |
| 1015 | expect(html).toMatch(/name="pick"\s+value="[^"]+\/octo\/secret-widgets"/); | |
| 529 | 1016 | expect(html).toContain("octo/secret-widgets"); |
| 530 | 1017 | expect(html).toContain("Put a crew on it"); |
| 531 | 1018 | expect(html).toContain('name="agent"'); |
| @@ -627,7 +1114,8 @@ describe("migrate", () => { | ||
| 627 | 1114 | const hit = await get( |
| 628 | 1115 | new URLSearchParams({ from: `${host}/octo/secret-widgets`, crew: "porter" }).toString(), |
| 629 | 1116 | ); |
| 630 | expect(hit).toMatch(/<option\s+value="[^"]+\/octo\/secret-widgets"[^>]*selected/); | |
| 1117 | expect(hit).toMatch(/name="source" value="[^"]+\/octo\/secret-widgets"/); | |
| 1118 | expect(hit).toMatch(/aria-current="true"/); | |
| 631 | 1119 | expect(hit).toMatch(/<option[^>]*value="porter"[^>]*selected|selected[^>]*value="porter"/); |
| 632 | 1120 | expect(hit).not.toContain("prefill-unmatched"); |
| 633 | 1121 | |
| @@ -641,7 +1129,8 @@ describe("migrate", () => { | ||
| 641 | 1129 | expect(miss).toContain("from=other.example/octo/secret-widgets"); |
| 642 | 1130 | expect(miss).not.toContain("<script>alert(1)"); |
| 643 | 1131 | expect(miss).toContain("<script>"); |
| 644 | expect(miss).not.toMatch(/<option[^>]*value="[^"]+"[^>]*selected[^>]*>\s*octo/); | |
| 1132 | expect(miss).not.toContain('name="source" value='); | |
| 1133 | expect(miss).not.toContain('aria-current="true"'); | |
| 645 | 1134 | |
| 646 | 1135 | // A link starts nothing: no repository, no job. |
| 647 | 1136 | expect(getRepo(app.db, "alice", "secret-widgets")?.id).toBe(already); |
test/work-branch.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The work-branch rule, by hand: whose a commit is, and which branch wins. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { | |
| 7 | cachedRemoteTips, | |
| 8 | isMine, | |
| 9 | pickWorkBranch, | |
| 10 | TIPS_TTL_MS, | |
| 11 | type BranchTip, | |
| 12 | } from "../src/origins/work-branch.ts"; | |
| 13 | import type { Provider } from "../src/origins/types.ts"; | |
| 14 | ||
| 15 | const who = { logins: ["Trevato"], emails: ["[email protected]"] }; | |
| 16 | ||
| 17 | const tip = (name: string, date: string, mine: boolean): BranchTip => ({ | |
| 18 | name, | |
| 19 | oid: name, | |
| 20 | date, | |
| 21 | subject: "", | |
| 22 | author: "", | |
| 23 | mine, | |
| 24 | }); | |
| 25 | ||
| 26 | describe("work branch", () => { | |
| 27 | test("a commit is the person's by login, email, GitHub noreply address or name — any case", () => { | |
| 28 | expect(isMine({ name: "x", email: "", login: "trevato" }, who)).toBe(true); | |
| 29 | expect(isMine({ name: "x", email: "[email protected]" }, who)).toBe(true); | |
| 30 | expect(isMine({ name: "x", email: "[email protected]" }, who)).toBe(true); | |
| 31 | expect(isMine({ name: "x", email: "[email protected]" }, who)).toBe(true); | |
| 32 | expect(isMine({ name: "TREVATO", email: "[email protected]" }, who)).toBe(true); | |
| 33 | expect(isMine({ name: "someone", email: "[email protected]", login: "someone" }, who)).toBe(false); | |
| 34 | // A noreply address for a login that merely contains theirs is not theirs. | |
| 35 | expect(isMine({ name: "x", email: "[email protected]" }, who)).toBe(false); | |
| 36 | expect(isMine({ name: "", email: "" }, { logins: [""], emails: [""] })).toBe(false); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the person's newest tip wins over a newer one that is not theirs, then the fallback, then the newest", () => { | |
| 40 | const tips = [ | |
| 41 | tip("main", "2026-10-20T09:00:00Z", false), | |
| 42 | tip("workbench", "2026-10-19T12:00:00Z", true), | |
| 43 | tip("old-mine", "2026-01-01T00:00:00Z", true), | |
| 44 | ]; | |
| 45 | expect(pickWorkBranch(tips, "main")).toBe("workbench"); | |
| 46 | // Dates compare as times, not strings: +02:00 is two hours earlier. | |
| 47 | expect( | |
| 48 | pickWorkBranch( | |
| 49 | [tip("a", "2026-10-20T10:00:00+02:00", true), tip("b", "2026-10-20T09:00:00Z", true)], | |
| 50 | undefined, | |
| 51 | ), | |
| 52 | ).toBe("b"); | |
| 53 | const none = tips.map((t) => ({ ...t, mine: false })); | |
| 54 | expect(pickWorkBranch(none, "old-mine")).toBe("old-mine"); | |
| 55 | expect(pickWorkBranch(none, "gone")).toBe("main"); | |
| 56 | expect(pickWorkBranch([], "main")).toBeUndefined(); | |
| 57 | }); | |
| 58 | }); | |
| 59 | ||
| 60 | describe("work branch tips, remembered", () => { | |
| 61 | test("a minute per origin and repository; a refusal is not remembered", async () => { | |
| 62 | let asked = 0; | |
| 63 | let refuse = false; | |
| 64 | const provider = { | |
| 65 | listBranches: async () => { | |
| 66 | asked++; | |
| 67 | return refuse | |
| 68 | ? { ok: false as const, error: { error: "unreachable" as const, message: "down" } } | |
| 69 | : { ok: true as const, value: [{ name: "main", oid: "a".repeat(40) }] }; | |
| 70 | }, | |
| 71 | listCommits: async () => ({ | |
| 72 | ok: true as const, | |
| 73 | value: { | |
| 74 | items: [{ oid: "a".repeat(40), message: "one\n\ntwo", author: { name: "x", email: "", login: "trevato", date: "2026-10-20T00:00:00Z" }, parents: [] }], | |
| 75 | next: null, | |
| 76 | }, | |
| 77 | }), | |
| 78 | } as unknown as Provider; | |
| 79 | const t0 = 1_000_000; | |
| 80 | const first = await cachedRemoteTips("o1", provider, "a/b", who, t0); | |
| 81 | expect(first?.[0]).toMatchObject({ name: "main", subject: "one", author: "trevato", mine: true }); | |
| 82 | await cachedRemoteTips("o1", provider, "a/b", who, t0 + TIPS_TTL_MS - 1); | |
| 83 | expect(asked).toBe(1); | |
| 84 | // Another account's origin is its own entry. | |
| 85 | await cachedRemoteTips("o2", provider, "a/b", who, t0); | |
| 86 | expect(asked).toBe(2); | |
| 87 | await cachedRemoteTips("o1", provider, "a/b", who, t0 + TIPS_TTL_MS); | |
| 88 | expect(asked).toBe(3); | |
| 89 | refuse = true; | |
| 90 | expect(await cachedRemoteTips("o3", provider, "a/b", who, t0)).toBeUndefined(); | |
| 91 | expect(await cachedRemoteTips("o3", provider, "a/b", who, t0 + 1)).toBeUndefined(); | |
| 92 | expect(asked).toBe(5); | |
| 93 | }); | |
| 94 | }); |
A stranger's fork head becomes a native branch and pull here, and the push event then runs checks and previews on it. Consider keeping fork pulls read-only until the owner adopts one.