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

Merged opened this in answer to

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

Merged

torque/forge:torque/3264-one-click-github-connect-github-pick-a-r → main 4 commits fork 6 threads · 6 unresolved

  • Merged. torque/3264-one-click-github-connect-github-pick-a-r was merged into main as b88d9a7.

Review

Sign in to review this change.
16 files changed +1610 −106
docs/ORIGINS.md +49 −4
@@ -460,6 +460,50 @@ repository), the agent — is refused in words before the repository exists;
460460 what is left for the job is the network. A clone that fails removes the
461461 empty repository it made, so the name is free to try again.
462462
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
463507 **Put a crew on it.** Name an agent and, after the clone, the same job puts
464508 it on the crew, makes the repository an app, sets it autonomous when asked,
465509 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).
486530
487531 The clone runs as a job in the mirror's registry (`origins/jobs.ts`, kind
488532 `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.
491536
492537 ## API
493538
@@ -536,7 +581,7 @@ GET /api/v1/user/pins PUT/DELETE /api/v1/user/pins/:owner/:name
536581
537582 POST /api/v1/migrate {source: {url} | {originId, fullName},
538583 owner?, name?, visibility?,
539 crew?: {agent, autonomous}}
584 branch?, crew?: {agent, autonomous}}
540585 → 202 {repo, job}
541586 GET /api/v1/migrate/:job poll one migration (its starter only)
542587 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
551596 pins take the ordinary `rw` floor; so does a migration from a URL, while one
552597 from an origin takes `admin` — it spends the origin's credential. Its
553598 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`,
555600 `invalid_name`, `forbidden_owner`).
556601
557602 A contribute answers 201 with
src/actions/migrate.ts +203 −19
@@ -11,6 +11,13 @@
1111 * environment, scoped to the origin's host — never argv, never the URL. A
1212 * pasted URL is cloned anonymously: public repositories only.
1313 *
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 *
1421 * The clone runs as a job (`origins/jobs.ts`, the mirror's registry), so a
1522 * page can watch it. With a crew asked for, the same job then puts the
1623 * agent on the crew, makes the repository an app, and tells the crew one
@@ -30,9 +37,16 @@ import { isSafeRef } from "../git/guard.ts";
3037 import { repoDir } from "../git/repos.ts";
3138 import { type Resolve, vetHost } from "../kernel/outbound.ts";
3239 import { err, ok, type Result } from "../kernel/result.ts";
40 import { carryConversations, plural } from "../origins/carry.ts";
3341 import { providerForOrigin } from "../origins/credential.ts";
3442 import { redact, safeRemoteUrl, secretsIn } from "../origins/credentials.ts";
3543 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";
3650 import {
3751 advance,
3852 createJob,
@@ -40,6 +54,7 @@ import {
4054 runningJob,
4155 type MirrorJob,
4256 } from "../origins/jobs.ts";
57 import type { Provider } from "../origins/types.ts";
4358 import { requireUser } from "./access.ts";
4459 import { enableApp } from "./apps.ts";
4560 import { addToCrew } from "./crew.ts";
@@ -70,6 +85,12 @@ export interface MigrateInput {
7085 readonly name?: string;
7186 /** @default "private" */
7287 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;
7394 /** Put an agent on it: the crew makes it run here. */
7495 readonly crew?: { readonly agent: string; readonly autonomous: boolean };
7596 /** Name → addresses, for the private-address check. Tests inject it. */
@@ -89,6 +110,7 @@ export type MigrateError =
89110 | "private_host"
90111 | "no_such_origin"
91112 | "unreachable"
113 | "invalid_branch"
92114 | "no_such_agent";
93115
94116 /** What the clone reads from, resolved: where, and how to authenticate. */
@@ -100,6 +122,18 @@ interface Resolved {
100122 readonly name: string;
101123 readonly description: string;
102124 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;
103137 }
104138
105139 /**
@@ -121,6 +155,8 @@ export async function migrateRepo(
121155 if (!source.ok) return source;
122156 if (input.crew !== undefined && !getAgentByName(app.db, input.crew.agent))
123157 return err("no_such_agent");
158 const branch = (input.branch ?? "").trim();
159 if (branch !== "" && !isSafeRef(branch)) return err("invalid_branch");
124160
125161 // A double-click joins the migration already under way rather than
126162 // failing the second one on the name the first just took.
@@ -150,7 +186,11 @@ export async function migrateRepo(
150186 actorId: user.value.id,
151187 });
152188 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 });
154194 return ok({ repo, job: getJob(job.id) ?? job });
155195 }
156196
@@ -188,6 +228,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories
188228 return "The origin could not be reached. Try again, or reconnect it under Settings → Origins.";
189229 case "no_such_agent":
190230 return "That agent is not on this forge.";
231 case "invalid_branch":
232 return "That is not a branch name git would take.";
191233 case "name_taken":
192234 return "That owner already has a repository by that name. Pick another name.";
193235 case "invalid_name":
@@ -354,6 +396,7 @@ async function resolveSource(
354396 name: nameFromUrl(url),
355397 description: "",
356398 authEnv: () => Promise.resolve({}),
399 who: { logins: [user.username], emails: [user.email] },
357400 });
358401 }
359402 const origin = getOriginFor(app.db, user.id, source.originId);
@@ -376,6 +419,8 @@ async function resolveSource(
376419 // Only the header: the origin's credential reaches this one git
377420 // invocation scoped to its host, and the repository never learns it.
378421 authEnv: () => provider.value.gitAuthEnv(url),
422 provider: provider.value,
423 who: { logins: [origin.login, user.username], emails: [user.email] },
379424 });
380425 }
381426
@@ -385,9 +430,13 @@ async function runMigration(
385430 user: User,
386431 repo: Repo,
387432 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 },
390438 ): Promise<void> {
439 const { crew, jobId } = asked;
391440 const fail = async (error: string): Promise<void> => {
392441 // An empty repository nobody asked for is clutter, and it holds the
393442 // name a retry wants: a clone that did not happen leaves nothing.
@@ -398,23 +447,65 @@ async function runMigration(
398447 advance(jobId, { stage: "fetching", detail: "every branch and tag" });
399448 const cloned = await cloneInto(app, repo, source);
400449 if (!cloned.ok) return await fail(cloned.error);
401 storageStale(app, repo.ownerId);
402450 let current = getRepoById(app.db, repo.id) ?? repo;
403451
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,
outdated

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.

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()]
407497 .filter((ref) => ref.startsWith("refs/heads/"))
408498 .map((ref) => ref.slice("refs/heads/".length));
409 const wanted = [
499 const fallback = [
410500 cloned.value.head,
411501 current.defaultBranch,
412502 "master",
413503 ...branches.sort(),
414504 ].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) {
416507 const moved = await updateRepoSettings(app, user, current, {
417 defaultBranch: wanted,
508 defaultBranch: work.name,
418509 });
419510 if (moved.ok) current = moved.value;
420511 }
@@ -422,13 +513,13 @@ async function runMigration(
422513 // The history arrived without a push, so nothing downstream heard it.
423514 // One event, shaped like a push, says so — the feed, webhooks and the
424515 // default branch's check see what landed.
425 if (cloned.value.refs.size > 0) {
516 if (refs.size > 0) {
426517 app.bus.append({
427518 kind: "push",
428519 repoId: current.id,
429520 actorId: user.id,
430521 payload: {
431 updates: [...cloned.value.refs].map(([ref, newOid]) => ({
522 updates: [...refs].map(([ref, newOid]) => ({
432523 ref,
433524 oldOid: "0".repeat(40),
434525 newOid,
@@ -440,10 +531,12 @@ async function runMigration(
440531 }
441532
442533 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)` : ""}`;
444537 if (crew !== undefined) {
445538 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)}`;
447540 }
448541 advance(jobId, {
449542 stage: "done",
@@ -518,6 +611,86 @@ async function cloneInto(
518611 return ok({ head, refs });
519612 }
520613
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
521694 /**
522695 * The crew half: the agent joins, the repository becomes an app, autonomy
523696 * is set when asked, and the crew is told the one thing it is there for.
@@ -529,6 +702,7 @@ async function putCrewOn(
529702 repo: Repo,
530703 source: Resolved,
531704 crew: NonNullable<MigrateInput["crew"]>,
705 work: WorkBranch | undefined,
532706 ): Promise<string> {
533707 const said: string[] = [];
534708 const added = addToCrew(app, user, repo, crew.agent);
@@ -547,7 +721,7 @@ async function putCrewOn(
547721 });
548722 if (!set.ok) said.push("Autonomy could not be turned on.");
549723 }
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));
551725 said.unshift(told.ok ? toldWords(told.value) : "The crew could not be told.");
552726 return said.join(" ");
553727 }
@@ -557,11 +731,24 @@ async function putCrewOn(
557731 * contract (docs/APPS.md), from the other side: someone else's project,
558732 * made to run here with the smallest diff that does it.
559733 */
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";
561746 return `Make ${repo.name} run here
562747
563748 ${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.
564749
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
565752 Adapt, don't rewrite:
566753
567754 - 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:
576763 Done means the pull's preview starts, answers its \`health\` path, and does what the original did.`;
577764 }
578765
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 {
323323 readonly openIssues: number;
324324 readonly openPulls: number;
325325 readonly openIssuesIncludingPulls: boolean;
326 /** KiB, as the origin last reported it; 0 when unknown. */
327 readonly sizeKb: number;
326328 }
327329
328330 const REPO_SELECT = `
@@ -348,6 +350,7 @@ interface RawOriginRepo {
348350 open_issues: number;
349351 open_pulls: number;
350352 counts_include_pulls: number;
353 size_kb: number;
351354 mirrored_id: string | null;
352355 }
353356
@@ -369,6 +372,7 @@ const toOriginRepo = (row: RawOriginRepo): OriginRepoRow => ({
369372 openIssues: row.open_issues,
370373 openPulls: row.open_pulls,
371374 openIssuesIncludingPulls: row.counts_include_pulls === 1,
375 sizeKb: row.size_kb,
372376 });
373377
374378 /**
@@ -379,8 +383,8 @@ const UPSERT_ORIGIN_REPO = `
379383 INSERT INTO origin_repo
380384 (origin_id, remote_id, full_name, description, private, fork,
381385 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)
384388 ON CONFLICT (origin_id, remote_id) DO UPDATE SET
385389 full_name = excluded.full_name,
386390 description = excluded.description,
@@ -395,6 +399,7 @@ const UPSERT_ORIGIN_REPO = `
395399 open_issues = excluded.open_issues,
396400 open_pulls = excluded.open_pulls,
397401 counts_include_pulls = excluded.counts_include_pulls,
402 size_kb = excluded.size_kb,
398403 -- A listing that does not report the parent must not forget one we
399404 -- already know: listRepos omits it on both forges, getRepo states it.
400405 fork_of = COALESCE(excluded.fork_of, origin_repo.fork_of)`;
@@ -421,6 +426,7 @@ const upsertArgs = (
421426 repo.openPulls,
422427 repo.openIssuesIncludesPulls ? 1 : 0,
423428 repo.parent,
429 Number.isSafeInteger(repo.sizeKb) && repo.sizeKb > 0 ? repo.sizeKb : 0,
424430 ];
425431
426432 /**
src/http/api.ts +3 −1
@@ -5003,7 +5003,8 @@ export function apiRoutes(router: Router<Handler>): void {
50035003 //
50045004 // A repository brought here for good: `source` is `{ originId, fullName }`
50055005 // (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
50075008 // clone itself is the job this answers with, watched at /migrate/:job.
50085009
50095010 router.add("POST", "/api/v1/migrate", async (ctx) => {
@@ -5039,6 +5040,7 @@ export function apiRoutes(router: Router<Handler>): void {
50395040 source,
50405041 ...(typeof input.owner === "string" ? { owner: input.owner } : {}),
50415042 ...(typeof input.name === "string" ? { name: input.name } : {}),
5043 ...(typeof input.branch === "string" ? { branch: input.branch } : {}),
50425044 ...(input.visibility === "public" || input.visibility === "private"
50435045 ? { visibility: input.visibility }
50445046 : {}),
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 {
790790 openIssues: asNumber(field(raw, "open_issues_count")),
791791 openPulls: asNumber(field(raw, "open_pr_counter")),
792792 openIssuesIncludesPulls: false,
793 sizeKb: asNumber(field(raw, "size")),
793794 };
794795 }
795796
src/origins/providers/github.ts +1
@@ -708,6 +708,7 @@ function toRepo(raw: unknown): RemoteRepo {
708708 openIssues: asNumber(field(raw, "open_issues_count")),
709709 openPulls: 0,
710710 openIssuesIncludesPulls: true,
711 sizeKb: asNumber(field(raw, "size")),
711712 };
712713 }
713714
src/origins/types.ts +2
@@ -78,6 +78,8 @@ export interface RemoteRepo {
7878 readonly openIssues: number;
7979 readonly openPulls: number;
8080 readonly openIssuesIncludesPulls: boolean;
81 /** On-disk size as the origin reports it, in KiB; 0 when it says nothing. */
82 readonly sizeKb: number;
8183 }
8284
8385 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 @@
44 * pasted URL, lands under an owner and a name you choose, and — when you
55 * put a crew on it — becomes an app the crew is already making run.
66 *
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 *
716 * Every mutation is `migrateRepo`; the page only puts its answer into words.
817 */
918
@@ -17,7 +26,13 @@ import {
1726 } from "../../actions/migrate.ts";
1827 import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts";
1928 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";
2136 import { scopeAllows } from "../../core/tokens.ts";
2237 import type { Ctx } from "../../http/context.ts";
2338 import type { MirrorJob, MirrorStage } from "../../origins/jobs.ts";
@@ -25,44 +40,309 @@ import { notFoundPage, pageHead, signInFirst } from "../chrome.ts";
2540 import { field, readForm } from "../form.ts";
2641 import { html, raw, type Html } from "../html.ts";
2742 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";
2952 import { prefill, originHost } from "./migrate-prefill.ts";
30 import { hostLabel } from "./origins.ts";
53 import { githubConnectable, hostLabel } from "./origins.ts";
3154
3255 interface MigrateValues {
3356 source: string;
57 /** The picker's filter: rows whose name or description holds it. */
58 q: string;
3459 url: string;
3560 owner: string;
3661 name: string;
3762 visibility: string;
3863 agent: string;
3964 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;
4077 }
4178
4279 /** An origin's repositories, for the picker; an origin that cannot answer is left out. */
4380 interface OriginChoices {
4481 readonly label: string;
4582 readonly id: string;
83 readonly kind: string;
4684 readonly host: string;
4785 readonly repos: readonly OriginRepoRow[];
4886 }
4987
88 /** Every origin you connected; one that cannot list has no rows. */
5089 async function originChoices(ctx: Ctx): Promise<OriginChoices[]> {
5190 const user = ctx.auth.user!;
5291 const out: OriginChoices[] = [];
5392 for (const origin of listUserOrigins(ctx.app, user)) {
5493 const listed = await listRemoteRepos(ctx.app, user, origin.id);
55 if (!listed.ok || listed.value.length === 0) continue;
5694 out.push({
5795 id: origin.id,
96 kind: origin.kind,
5897 host: originHost(origin.webUrl),
5998 label: `${origin.login} @ ${hostLabel(origin.webUrl)}`,
60 repos: listed.value,
99 repos: listed.ok ? listed.value : [],
61100 });
62101 }
63102 return out;
64103 }
65104
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 primary on the page, beside the form's Migrate button. One filled button per page: make this secondary or outline.

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

plain-list and pick-rows are not defined in any style module. Use the list row component row() from DESIGN.md §5 here, and for the account and branch lists too.

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 btn() with size: "sm", variant: "outline"; it can still carry name="pick" and value.

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
66346 /** `<origin id>/<owner>/<name>` — an id never holds a slash, so the first one splits. */
67347 const pickValue = (originId: string, fullName: string): string =>
68348 `${originId}/${fullName}`;
@@ -77,6 +357,7 @@ function migrateForm(
77357 ctx: Ctx,
78358 values: MigrateValues,
79359 origins: readonly OriginChoices[],
360 branches: BranchChoices | undefined,
80361 error?: string,
81362 unmatched: readonly string[] = [],
82363 ): Html {
@@ -88,63 +369,38 @@ function migrateForm(
88369 const agents = listAllAgents(ctx.app, user);
89370 const crew = agents.ok ? agents.value : [];
90371
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
126372 return html`<div class="narrow-wide">
127373 ${pageHead("Migrate a repository", {
128374 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.",
129375 })}
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}
130382 ${unmatched.length === 0
131383 ? null
132384 : html`<p class="muted small mb" id="prefill-unmatched">
133385 ${icon("globe")} Ignored, nothing here matches:
134386 ${unmatched.join(", ")}
135387 </p>`}
388 ${accounts(ctx, origins)}
389 <form method="get" action="/migrate" id="migrate-search"></form>
136390 ${card(
137391 html`<form method="post" action="/migrate">
138392 <h3 class="mb">Source</h3>
139 ${picker}
393 ${picker(origins, values, crew.length > 0, branches)}
140394 ${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",
142398 name: "url",
143399 value: values.url,
144400 placeholder: "https://github.com/owner/name",
145401 autocomplete: "off",
146402 mono: true,
147 autofocus: origins.length === 0,
403 autofocus: origins.every((o) => o.repos.length === 0),
148404 hint: "Any public repository over https, http or git. It is cloned anonymously.",
149405 })}
150406 <h3 class="mb mt">Destination</h3>
@@ -241,7 +497,13 @@ const migrateJs = /* js */ `
241497 (function () {
242498 var name = document.getElementById('f-name');
243499 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 });
245507 if (!name) return;
246508 var touched = name.value.trim() !== '';
247509 name.addEventListener('input', function () { touched = true; });
@@ -251,29 +513,30 @@ const migrateJs = /* js */ `
251513 }
252514 function follow() {
253515 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) : '';
258517 }
259518 if (url) url.addEventListener('input', follow);
260 if (source) source.addEventListener('change', follow);
261519 })();
262520 `;
263521
264 function show(
522 async function show(
265523 ctx: Ctx,
266524 values: MigrateValues,
267525 origins: readonly OriginChoices[],
268526 error?: string,
269527 status = 200,
270528 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;
272535 return page(
273536 {
274537 ctx,
275538 title: "Migrate",
276 body: migrateForm(ctx, values, origins, error, unmatched),
539 body: migrateForm(ctx, values, origins, branches, error, unmatched),
277540 scriptJs: migrateJs,
278541 },
279542 status,
@@ -286,10 +549,11 @@ export async function migratePage(ctx: Ctx): Promise<Response> {
286549 const q = ctx.url.searchParams;
287550 const origins = await originChoices(ctx);
288551 const agents = listAllAgents(ctx.app, ctx.auth.user);
552 const names = agents.ok ? agents.value.map((a) => a.user.username) : [];
289553 const linked = prefill(
290554 { from: q.get("from"), crew: q.get("crew") },
291555 origins,
292 agents.ok ? agents.value.map((a) => a.user.username) : [],
556 names,
293557 );
294558 return show(
295559 ctx,
@@ -297,12 +561,17 @@ export async function migratePage(ctx: Ctx): Promise<Response> {
297561 source: linked.repo
298562 ? pickValue(linked.repo.originId, linked.repo.fullName)
299563 : (q.get("source") ?? ""),
564 q: (q.get("q") ?? "").slice(0, 100),
300565 url: q.get("url") ?? "",
301566 owner: q.get("owner") ?? ctx.auth.user.username,
302567 name: q.get("name") ?? (linked.repo?.fullName.split("/").pop() ?? ""),
303568 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] ?? "",
305572 autonomous: false,
573 branch: (q.get("branch") ?? "").slice(0, 300),
574 showBranches: q.get("branches") === "1" || (q.get("branch") ?? "") !== "",
306575 },
307576 origins,
308577 undefined,
@@ -317,14 +586,24 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
317586 const form = await readForm(ctx, "rw");
318587 if (form instanceof Response) return form;
319588 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: "",
321592 url: field(form, "url").trim(),
322593 owner: field(form, "owner"),
323594 name: field(form, "name").trim(),
324595 visibility: field(form, "visibility") === "public" ? "public" : "private",
325596 agent: field(form, "agent"),
326597 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,
327605 };
606 values.showBranches = values.branch !== "";
328607 const again = async (message: string): Promise<Response> =>
329608 show(ctx, values, await originChoices(ctx), message, 422);
330609
@@ -348,6 +627,9 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
348627 owner: values.owner,
349628 name: values.name,
350629 visibility: values.visibility === "public" ? "public" : "private",
630 ...(picked === undefined || values.branch === ""
631 ? {}
632 : { branch: values.branch }),
351633 ...(values.agent === ""
352634 ? {}
353635 : { crew: { agent: values.agent, autonomous: values.autonomous } }),
@@ -365,14 +647,23 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
365647 const STAGES: { name: MirrorStage; label: string }[] = [
366648 { name: "creating", label: "Creating the repository" },
367649 { 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" },
369653 { name: "done", label: "Done" },
370654 ];
371655
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
372662 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);
374665 return html`<ol class="stages">
375 ${STAGES.map((stage, i) => {
666 ${stages.map((stage, i) => {
376667 const state =
377668 job.stage === "failed"
378669 ? "todo"
@@ -403,7 +694,7 @@ const migrateJobJs = /* js */ `
403694 var box = document.getElementById('migrate-job');
404695 var job = box && box.getAttribute('data-job');
405696 if (!job || typeof EventSource === 'undefined') return;
406 var order = ['creating', 'fetching', 'crew', 'done'];
697 var order = ['creating', 'fetching', 'issues', 'pulls', 'crew', 'done'];
407698 var source = new EventSource('/api/v1/migrate/' + encodeURIComponent(job) + '/stream');
408699 function patch(e) {
409700 var data;
src/web/pages/origins.ts +39 −14
@@ -374,6 +374,14 @@ const blankValues = (): ConnectValues => ({
374374 * the reader to find out on GitHub's site what to put in it, which is the
375375 * kind of dead end that sends someone back to pasting a token forever.
376376 */
377 /** Whether "Connect GitHub" has a client id to run the device flow with. */

githubConnectable was inserted between githubSection's doc comment and githubSection, so that comment now sits on top of the wrong function, next to this one's own comment. Move githubConnectable above that comment block.

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
377385 function githubSection(configured: boolean, callback: string): Html {
378386 if (!configured) {
379387 const back =
@@ -617,9 +625,7 @@ function originsView(ctx: Ctx, values: ConnectValues): Html {
617625 const origins = listUserOrigins(ctx.app, user);
618626 const mirrors = mirrorsByOrigin(ctx.app.db, user.id);
619627 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);
623629 const matched =
624630 values.host === ""
625631 ? undefined
@@ -830,6 +836,16 @@ function connectMessage(error: string): string {
830836 }
831837
832838 // 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
833849 export async function originOAuthStart(ctx: Ctx): Promise<Response> {
834850 const denied = accountOnly(ctx);
835851 if (denied) return denied;
@@ -837,6 +853,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
837853 if (form instanceof Response) return form;
838854 const kind = field(form, "kind");
839855 const host = field(form, "host").trim();
856 const then = thenOf(field(form, "then"));
840857
841858 const back = callbackUri(ctx);
842859 if (back === "" && kind !== "github") {
@@ -869,11 +886,12 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
869886 : connectMessage(started.error.error);
870887 return shell(ctx, originsView(ctx, values), 422);
871888 }
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}`);
877895 }
878896
879897 /**
@@ -959,14 +977,16 @@ export function originFlowPage(ctx: Ctx): Response {
959977 if (denied) return denied;
960978 const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? "");
961979 if (!flow) return shell(ctx, notFoundNote(), 404);
980 const then = thenOf(ctx.url.searchParams.get("then"));
962981
963 if (flow.status === "done") return redirect("/settings/origins?connected=1");
982 if (flow.status === "done")
983 return redirect(`${then ?? "/settings/origins"}?connected=1`);
964984 if (flow.status === "failed")
965985 return redirect(
966986 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`,
967987 );
968988
969 const pollAction = `/settings/origins/flows/${flow.id}/poll`;
989 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`;
970990 const authorize =
971991 flow.flow === "code"
972992 ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl)
@@ -1152,13 +1172,17 @@ export async function originFlowPoll(ctx: Ctx): Promise<Response> {
11521172 : redirect("/settings/origins?error=state");
11531173 }
11541174 const { status, detail } = polled.value;
1175 const then = thenOf(ctx.url.searchParams.get("then"));
11551176 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`);
11571179 if (status === "failed")
11581180 return redirect(
11591181 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`,
11601182 );
1161 return redirect(`/settings/origins/flows/${ctx.params.id ?? ""}`);
1183 return redirect(
1184 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`,
1185 );
11621186 }
11631187
11641188 // ---- one origin ----
@@ -1200,12 +1224,13 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> {
12001224 ctx.auth.user,
12011225 ctx.params.id ?? "",
12021226 );
1227 const back = thenOf(field(form, "then")) ?? "/settings/origins";
12031228 if (!gone.ok)
1204 return flashTo(ctx, "/settings/origins", {
1229 return flashTo(ctx, back, {
12051230 tone: "danger",
12061231 message: "No such origin.",
12071232 });
1208 return flashTo(ctx, "/settings/origins", {
1233 return flashTo(ctx, back, {
12091234 message: gone.value.revokedUpstream
12101235 ? "Disconnected, and the grant was revoked upstream."
12111236 : "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 {
829829 /** The row joins `j`/`k` keyboard traversal. */
830830 nav?: boolean;
831831 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;
832837 }
833838
834839 /** One list row. `<a>` when it navigates, `<div>` when it holds its own
@@ -851,7 +856,10 @@ export function row(o: RowOptions): Html {
851856 const attrs = html`class="row"
852857 ${o.state ? html`data-state="${o.state}"` : null}
853858 ${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}>
855863 ${o.href !== undefined
856864 ? html`<a href="${o.href}" ${attrs}>${inner}</a>`
857865 : html`<div ${attrs}>${inner}</div>`}
test/fake-origin.ts +6 −1
@@ -51,6 +51,8 @@ export interface FakeRepo {
5151 openIssues?: number;
5252 /** Forgejo only — GitHub has no separate pull counter. */
5353 openPulls?: number;
54 /** KiB, as both forges report `size`. */
55 sizeKb?: number;
5456 }
5557
5658 export interface FakeIssue {
@@ -64,6 +66,8 @@ export interface FakeIssue {
6466 createdAt?: string;
6567 updatedAt?: string;
6668 closedAt?: string | null;
69 /** What the listing says `comments` is; the thread itself is `comments`. */
70 commentCount?: number;
6771 /** Present for a pull request. */
6872 pull?: {
6973 base: string;
@@ -936,6 +940,7 @@ function repoJson(state: FakeState, repo: FakeRepo): Record<string, unknown> {
936940 ssh_url: "",
937941 html_url: repo.htmlUrl ?? `https://example.invalid/${repo.fullName}`,
938942 permissions: { push: repo.push ?? true, admin: repo.admin ?? true },
943 size: repo.sizeKb ?? 0,
939944 // Both forges report the parent here and nowhere else, which is why the
940945 // picker's cache must keep the one it has when a listing omits it.
941946 ...(repo.parent === undefined
@@ -975,7 +980,7 @@ function issueJson(
975980 updated_at: issue.updatedAt ?? issue.createdAt ?? new Date().toISOString(),
976981 closed_at: issue.closedAt ?? null,
977982 html_url: `${url.origin}/${issue.number}`,
978 comments: 0,
983 comments: issue.commentCount ?? 0,
979984 };
980985 if (issue.pull === undefined) return common;
981986 // 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 {
2323 migrateUrl,
2424 } from "../src/actions/migrate.ts";
2525 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";
2633 import {
2734 commitToBranch,
2835 createRepo,
@@ -39,7 +46,12 @@ import { createToken } from "../src/core/tokens.ts";
3946 import { createSession } from "../src/core/sessions.ts";
4047 import { advance, createJob, settled } from "../src/origins/jobs.ts";
4148 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";
4355 import { gitClientEnv } from "./helpers/git-client.ts";
4456 import { prefill } from "../src/web/pages/migrate-prefill.ts";
4557 import { quietLogs } from "./helpers/globals.ts";
@@ -98,6 +110,7 @@ describe("migrate", () => {
98110 let bobToken = "";
99111 let fixtureUrl = "";
100112 let originId = "";
113 let octo: User;
101114 // octo's credential at the "upstream": a token, since a password would
102115 // cost an argon2 verify on every git request.
103116 let upstreamSecret = "";
@@ -185,7 +198,7 @@ describe("migrate", () => {
185198 // request here rides a session or a token.
186199 alice = await person("alice", true);
187200 bob = await person("bob");
188 const octo = await person("octo");
201 octo = await person("octo");
189202 upstreamSecret = createToken(app.db, octo.id, "upstream", "rw").token;
190203 aliceCookie = cookieOf(alice);
191204 bobCookie = cookieOf(bob);
@@ -517,6 +530,479 @@ describe("migrate", () => {
517530 );
518531 });
519532
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)}&amp;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("&lt;b&gt;the app&lt;/b&gt;");
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
5201006 test("the page: the form, the plus menu, the job, and who may see it", async () => {
5211007 const anonymous = await browser("", "/migrate");
5221008 expect(anonymous.status).toBe(303);
@@ -525,7 +1011,8 @@ describe("migrate", () => {
5251011 const html = await (await browser(aliceCookie, "/migrate")).text();
5261012 expect(html).toContain('action="/migrate"');
5271013 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"/);
5291016 expect(html).toContain("octo/secret-widgets");
5301017 expect(html).toContain("Put a crew on it");
5311018 expect(html).toContain('name="agent"');
@@ -627,7 +1114,8 @@ describe("migrate", () => {
6271114 const hit = await get(
6281115 new URLSearchParams({ from: `${host}/octo/secret-widgets`, crew: "porter" }).toString(),
6291116 );
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"/);
6311119 expect(hit).toMatch(/<option[^>]*value="porter"[^>]*selected|selected[^>]*value="porter"/);
6321120 expect(hit).not.toContain("prefill-unmatched");
6331121
@@ -641,7 +1129,8 @@ describe("migrate", () => {
6411129 expect(miss).toContain("from=other.example/octo/secret-widgets");
6421130 expect(miss).not.toContain("<script>alert(1)");
6431131 expect(miss).toContain("&lt;script&gt;");
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"');
6451134
6461135 // A link starts nothing: no repository, no job.
6471136 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs