Public: anyone can read this; the crew can change it.
Migrate: say when a GitHub token sees public repos only (private ones don't list), let the token be replaced in place, and give the filter a button #7521
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7516-migrate-say-when-a-github-token-sees-pub → main 2 commits fork 5 threads · 5 unresolved
docs/ORIGINS.md +15
| @@ -577,6 +577,21 @@ chosen gets the same rule in the job, over the clone's own tips | ||
| 577 | 577 | That branch becomes the default branch here, the job says which and why, |
| 578 | 578 | and the crew's brief names it: start from it, open the pull against it. |
| 579 | 579 | |
| 580 | **A token that sees public repositories only says so.** A classic GitHub | |
| 581 | token without `repo` (its `X-OAuth-Scopes` on `/user`), or a fine-grained one | |
| 582 | whose `/user/repos?visibility=private&per_page=1` answers no row, lists no | |
| 583 | private repository, and the account's card on /migrate says it in one line: | |
| 584 | "This token sees public repositories only, so private ones won't appear", | |
| 585 | with **Make a token with `repo` on GitHub** linking GitHub's new-token page (`scopes=repo` | |
| 586 | prefilled) and a field for the new one (`POST /settings/origins/:id/token`). | |
| 587 | The new token must answer for the same login; the origin keeps its id, its | |
| 588 | mirrors and pins, and its repositories are listed again at once. The answer | |
| 589 | is remembered ten minutes per origin and forgotten when the token changes | |
| 590 | (`origins/token-reach.ts`). The terminal's migrate screen draws the same | |
| 591 | line and the new-token page under its rows, and `t` takes the new token into | |
| 592 | the same action. The picker's filter narrows the rows as you | |
| 593 | type, Enter or the **Filter** button asks the server. | |
| 594 | ||
| 580 | 595 | **The conversations come too** — from a connected origin, not a pasted URL |
| 581 | 596 | (`origins/carry.ts`). Labels; every issue, open and closed, with its comments |
| 582 | 597 | and labels, under its original number; and the open pull requests as pulls |
src/actions/origins.ts +69
| @@ -85,6 +85,7 @@ import { | ||
| 85 | 85 | type FlowRow, |
| 86 | 86 | } from "../origins/oauth.ts"; |
| 87 | 87 | import { forgetProvider, providerFor } from "../origins/providers/index.ts"; |
| 88 | import { forgetTokenReach } from "../origins/token-reach.ts"; | |
| 88 | 89 | import { |
| 89 | 90 | requestSyncNow, |
| 90 | 91 | syncOrigin, |
| @@ -203,6 +204,72 @@ export async function connectOrigin( | ||
| 203 | 204 | }); |
| 204 | 205 | } |
| 205 | 206 | |
| 207 | export type ReplaceTokenError = | |
| 208 | | "unauthorized" | |
| 209 | | "not_found" | |
| 210 | | "rate_limited" | |
| 211 | | "bad_token" | |
| 212 | | "unreachable" | |
| 213 | | "other_account"; | |
| 214 | ||
| 215 | /** | |
| 216 | * Put a new pasted token on an origin that is already connected — one that | |
| 217 | * sees more, or one that still works. The row keeps its id, so its mirrors | |
| 218 | * and pins stay attached; the token must answer for the same account, or | |
| 219 | * it is a different origin and Connect is the way to add it. The picker's | |
| 220 | * rows are listed again at once with what the new token sees. | |
| 221 | */ | |
| 222 | export async function replaceOriginToken( | |
| 223 | app: App, | |
| 224 | actor: User | undefined, | |
| 225 | originId: string, | |
| 226 | token: string, | |
| 227 | ): Promise<Result<Origin, ReplaceTokenError>> { | |
| 228 | const user = requireUser(actor); | |
| 229 | if (!user.ok) return user; | |
| 230 | const origin = getOriginFor(app.db, user.value.id, originId); | |
| 231 | if (!origin) return err("not_found"); | |
| 232 | if (tooManyAttempts(app, user.value.id)) return err("rate_limited"); | |
| 233 | const secret = token.trim(); | |
| 234 | if (secret === "") return err("bad_token"); | |
| 235 | ||
| 236 | const identity = await providerFor( | |
| 237 | staticCredential({ | |
| 238 | kind: origin.kind, | |
| 239 | apiUrl: origin.apiUrl, | |
| 240 | webUrl: origin.webUrl, | |
| 241 | token: secret, | |
| 242 | }), | |
| 243 | ).whoami(); | |
| 244 | if (!identity.ok) | |
| 245 | return err( | |
| 246 | identity.error.error === "unreachable" ? "unreachable" : "bad_token", | |
| 247 | ); | |
| 248 | if (identity.value.login.toLowerCase() !== origin.login.toLowerCase()) | |
| 249 | return err("other_account"); | |
| 250 | ||
| 251 | renewOrigin(app.db, app.config.dataDir, origin.id, { | |
| 252 | token: secret, | |
| 253 | authKind: "pat", | |
| 254 | }); | |
| 255 | forgetProvider({ dataDir: app.config.dataDir, originId: origin.id }); | |
| 256 | forgetTokenReach(app.config.dataDir, origin.id); | |
| 257 | app.bus.append({ | |
| 258 | kind: "origin.connected", | |
| 259 | actorId: user.value.id, | |
| 260 | payload: { | |
| 261 | originId: origin.id, | |
| 262 | host: origin.webUrl, | |
| 263 | login: origin.login, | |
| 264 | authKind: "pat", | |
| 265 | renewed: true, | |
| 266 | }, | |
| 267 | }); | |
| 268 | const renewed = getOriginById(app.db, origin.id)!; | |
| 269 | await listRemoteRepos(app, user.value, origin.id, true); | |
| 270 | return ok(renewed); | |
| 271 | } | |
| 272 | ||
| 206 | 273 | /** A connected origin, and the frozen mirrors connecting it brought back. */ |
| 207 | 274 | export type Connected = Origin & { |
| 208 | 275 | readonly reattached: readonly Reattachment[]; |
| @@ -237,6 +304,7 @@ function storeOrigin( | ||
| 237 | 304 | ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), |
| 238 | 305 | }); |
| 239 | 306 | forgetProvider({ dataDir: app.config.dataDir, originId: existing.id }); |
| 307 | forgetTokenReach(app.config.dataDir, existing.id); | |
| 240 | 308 | app.bus.append({ |
| 241 | 309 | kind: "origin.connected", |
| 242 | 310 | actorId: user.id, |
| @@ -316,6 +384,7 @@ export async function disconnectOrigin( | ||
| 316 | 384 | |
| 317 | 385 | deleteOrigin(app.db, user.value.id, originId); |
| 318 | 386 | forgetProvider({ dataDir: app.config.dataDir, originId }); |
| 387 | forgetTokenReach(app.config.dataDir, originId); | |
| 319 | 388 | app.bus.append({ |
| 320 | 389 | kind: "origin.disconnected", |
| 321 | 390 | actorId: user.value.id, |
src/http/server.ts +2
| @@ -463,6 +463,7 @@ import { | ||
| 463 | 463 | originOAuthStart, |
| 464 | 464 | originSyncSubmit, |
| 465 | 465 | originsPage, |
| 466 | originReplaceToken, | |
| 466 | 467 | originTrustedHosts, |
| 467 | 468 | } from "../web/pages/origins.ts"; |
| 468 | 469 | import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts"; |
| @@ -1021,6 +1022,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1021 | 1022 | router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage); |
| 1022 | 1023 | router.add("POST", "/settings/origins/:id/sync", originSyncSubmit); |
| 1023 | 1024 | router.add("POST", "/settings/origins/:id/delete", originDisconnect); |
| 1025 | router.add("POST", "/settings/origins/:id/token", originReplaceToken); | |
| 1024 | 1026 | |
| 1025 | 1027 | // Browsing a repository on an origin, live and read-only. Registered here, |
| 1026 | 1028 | // above every parameterized repo route, so `/origins` can never be captured |
src/origins/providers/github.ts +22
| @@ -30,6 +30,7 @@ import type { | ||
| 30 | 30 | RemoteMilestone, |
| 31 | 31 | RemoteReviewState, |
| 32 | 32 | RemoteTreeEntry, |
| 33 | TokenReach, | |
| 33 | 34 | } from "../types.ts"; |
| 34 | 35 | import { |
| 35 | 36 | NO_ACCOUNT, |
| @@ -109,6 +110,27 @@ export class GitHubProvider implements Provider { | ||
| 109 | 110 | }); |
| 110 | 111 | } |
| 111 | 112 | |
| 113 | /** | |
| 114 | * A classic token (and an OAuth grant) names its scopes on every answer | |
| 115 | * in `X-OAuth-Scopes`: without `repo` it lists public repositories only. | |
| 116 | * A fine-grained token names none, so one private row is asked for. | |
| 117 | */ | |
| 118 | async tokenReach(): Promise<ProviderResult<TokenReach>> { | |
| 119 | const user = await this.api.call({ path: "/user" }); | |
| 120 | if (!user.ok) return user; | |
| 121 | const scopes = user.value.headers.get("x-oauth-scopes"); | |
| 122 | if (scopes !== null) | |
| 123 | return succeed( | |
| 124 | scopes.split(",").some((s) => s.trim() === "repo") ? "private" : "public", | |
| 125 | ); | |
| 126 | const probe = await this.api.call({ | |
| 127 | path: "/user/repos", | |
| 128 | query: { visibility: "private", per_page: 1 }, | |
| 129 | }); | |
| 130 | if (!probe.ok) return probe; | |
| 131 | return succeed(asArray(probe.value.data).length > 0 ? "private" : "public"); | |
| 132 | } | |
| 133 | ||
| 112 | 134 | async listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>> { |
| 113 | 135 | const page = pageOf(cursor); |
| 114 | 136 | const result = await this.api.call({ |
src/origins/token-reach.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * What an origin's token can see, remembered. A GitHub token without the | |
| 3 | * `repo` scope — or a fine-grained one limited to public repositories — | |
| 4 | * lists public repositories only, and a picker with no private rows looks | |
| 5 | * broken unless it says why (#7516). | |
| 6 | * | |
| 7 | * The answer costs a request or two at the origin, so it is kept per | |
| 8 | * origin for a while and forgotten the moment the token is replaced. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import type { Origin } from "../core/origins.ts"; | |
| 13 | import { providerForOrigin } from "./credential.ts"; | |
| 14 | import type { TokenReach } from "./types.ts"; | |
| 15 | ||
| 16 | const KEEP_MS = 10 * 60_000; | |
| 17 | const MAX_KEPT = 256; | |
| 18 | const kept = new Map<string, { reach: TokenReach; at: number }>(); | |
| 19 | ||
| 20 | const keyOf = (dataDir: string, originId: string): string => | |
| 21 | `${dataDir} ${originId}`; | |
| 22 | ||
| 23 | /** | |
| 24 | * The origin's reach, or undefined when its provider names none or the | |
| 25 | * origin did not answer — the picker then says nothing rather than guess. | |
| 26 | */ | |
| 27 | export async function tokenReach( | |
| 28 | app: App, | |
| 29 | origin: Origin, | |
| 30 | ): Promise<TokenReach | undefined> { | |
| 31 | const key = keyOf(app.config.dataDir, origin.id); | |
| 32 | const hit = kept.get(key); | |
| 33 | if (hit !== undefined && Date.now() - hit.at < KEEP_MS) return hit.reach; | |
| 34 | const provider = providerForOrigin(app, origin); | |
| 35 | if (!provider.ok || provider.value.tokenReach === undefined) return undefined; | |
| 36 | const read = await provider.value.tokenReach(); | |
| 37 | if (!read.ok) return undefined; | |
| 38 | if (kept.size >= MAX_KEPT) { | |
| 39 | const oldest = kept.keys().next(); | |
| 40 | if (!oldest.done) kept.delete(oldest.value); | |
| 41 | } | |
| 42 | kept.set(key, { reach: read.value, at: Date.now() }); | |
| 43 | return read.value; | |
| 44 | } | |
| 45 | ||
| 46 | /** Drop what was learned about an origin's token: it was replaced or removed. */ | |
| 47 | export function forgetTokenReach(dataDir: string, originId: string): void { | |
| 48 | kept.delete(keyOf(dataDir, originId)); | |
| 49 | } | |
| 50 | ||
| 51 | export const PUBLIC_ONLY = | |
| 52 | "This token sees public repositories only, so private ones won't appear"; | |
| 53 | ||
| 54 | export const FINE_GRAINED_NOTE = | |
| 55 | "A fine-grained token: Repository access: All repositories; Contents: Read"; | |
| 56 | ||
| 57 | /** A refused replacement, in words — the page and the terminal say the same. */ | |
| 58 | export function replaceTokenMessage(error: string): string { | |
| 59 | const words: Record<string, string> = { | |
| 60 | not_found: "No such origin.", | |
| 61 | other_account: | |
| 62 | "That token belongs to another account. Connect it as its own origin instead.", | |
| 63 | bad_token: "That token was refused by the origin.", | |
| 64 | unreachable: "That instance could not be reached.", | |
| 65 | rate_limited: "Too many connect attempts. Wait a few minutes.", | |
| 66 | unauthorized: "Sign in first.", | |
| 67 | }; | |
| 68 | return words[error] ?? "That token could not be put on this origin."; | |
| 69 | } | |
| 70 | ||
| 71 | /** GitHub's new-token page with the scope the picker needs filled in. */ | |
| 72 | export const newTokenUrl = (webUrl: string): string => | |
| 73 | `${webUrl.replace(/\/+$/, "")}/settings/tokens/new?scopes=repo&description=forge-migrate`; |
src/origins/types.ts +8
| @@ -261,6 +261,9 @@ export interface ListIssuesQuery { | ||
| 261 | 261 | readonly cursor?: string; |
| 262 | 262 | } |
| 263 | 263 | |
| 264 | /** What a token can list: private repositories too, or public ones only. */ | |
| 265 | export type TokenReach = "private" | "public"; | |
| 266 | ||
| 264 | 267 | /** |
| 265 | 268 | * What every provider implements. Results are `Result<T, ProviderFailure>` |
| 266 | 269 | * (see kernel/result.ts) — network and remote refusals are expected outcomes. |
| @@ -276,6 +279,11 @@ export interface Provider { | ||
| 276 | 279 | readonly webUrl: string; |
| 277 | 280 | |
| 278 | 281 | whoami(): Promise<ProviderResult<RemoteIdentity>>; |
| 282 | /** | |
| 283 | * Whether the credential can see private repositories at all. Left out | |
| 284 | * by an origin whose tokens carry no such limit worth naming. | |
| 285 | */ | |
| 286 | tokenReach?(): Promise<ProviderResult<TokenReach>>; | |
| 279 | 287 | listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>>; |
| 280 | 288 | getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>; |
| 281 | 289 |
src/tui/screens/migrate.ts +102 −3
| @@ -6,6 +6,10 @@ | ||
| 6 | 6 | * the job. Nothing here is a second path: the last step is `migrateRepo` |
| 7 | 7 | * with `branch` and `crew`, and this file only puts its questions and its |
| 8 | 8 | * answer on a terminal. |
| 9 | * | |
| 10 | * An origin whose token sees public repositories only says so under the | |
| 11 | * rows, with GitHub's new-token page to visit; `t` takes the new token | |
| 12 | * into `replaceOriginToken`, the action the web page posts to. | |
| 9 | 13 | */ |
| 10 | 14 | |
| 11 | 15 | import { listAllAgents } from "../../actions/crew.ts"; |
| @@ -15,7 +19,11 @@ import { | ||
| 15 | 19 | migrateRepo, |
| 16 | 20 | type MigrateSource, |
| 17 | 21 | } from "../../actions/migrate.ts"; |
| 18 | import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts"; | |
| 22 | import { | |
| 23 | listRemoteRepos, | |
| 24 | listUserOrigins, | |
| 25 | replaceOriginToken, | |
| 26 | } from "../../actions/origins.ts"; | |
| 19 | 27 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 20 | 28 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 21 | 29 | import type { OriginRepoRow } from "../../core/origins.ts"; |
| @@ -24,6 +32,12 @@ import { subscribe, type MirrorJob, type MirrorStage } from "../../origins/jobs. | ||
| 24 | 32 | import { providerForOrigin } from "../../origins/credential.ts"; |
| 25 | 33 | import { cachedRemoteTips, pickWorkBranch } from "../../origins/work-branch.ts"; |
| 26 | 34 | import { getOriginFor } from "../../core/origins.ts"; |
| 35 | import { | |
| 36 | PUBLIC_ONLY, | |
| 37 | newTokenUrl, | |
| 38 | replaceTokenMessage, | |
| 39 | tokenReach, | |
| 40 | } from "../../origins/token-reach.ts"; | |
| 27 | 41 | import type { Ctx, Screen } from "../app/app.ts"; |
| 28 | 42 | import { binding } from "../app/keys.ts"; |
| 29 | 43 | import { G } from "../term/screen.ts"; |
| @@ -51,6 +65,13 @@ interface Row { | ||
| 51 | 65 | readonly repo: OriginRepoRow; |
| 52 | 66 | } |
| 53 | 67 | |
| 68 | /** An origin whose token lists public repositories only. */ | |
| 69 | interface PublicOnly { | |
| 70 | readonly originId: string; | |
| 71 | readonly login: string; | |
| 72 | readonly webUrl: string; | |
| 73 | } | |
| 74 | ||
| 54 | 75 | /** Where the repository comes from, and — for an origin — the branch chosen. */ |
| 55 | 76 | interface Picked { |
| 56 | 77 | readonly source: MigrateSource; |
| @@ -74,6 +95,7 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 74 | 95 | let loading = true; |
| 75 | 96 | // Origins that would not answer: said aloud, never read as "no repositories". |
| 76 | 97 | let unreachable: string[] = []; |
| 98 | let publicOnly: PublicOnly[] = []; | |
| 77 | 99 | let self: Screen; |
| 78 | 100 | |
| 79 | 101 | const shown = (): Row[] => |
| @@ -160,6 +182,46 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 160 | 182 | ); |
| 161 | 183 | }; |
| 162 | 184 | |
| 185 | const replaceToken = (origin: PublicOnly): void => { | |
| 186 | if (user === undefined) return; | |
| 187 | ctx.push( | |
| 188 | promptScreen(ctx, self, { | |
| 189 | title: `Replace the token for ${origin.login}`, | |
| 190 | label: "New token (with repo)", | |
| 191 | hint: newTokenUrl(origin.webUrl), | |
| 192 | mask: true, | |
| 193 | onSubmit: (token) => { | |
| 194 | if (token.trim() === "") return "Paste the new token first."; | |
| 195 | return replaceOriginToken(f.app, user, origin.originId, token).then( | |
| 196 | async (replaced) => { | |
| 197 | if (!replaced.ok) { | |
| 198 | ctx.toast(replaceTokenMessage(replaced.error), "danger"); | |
| 199 | return; | |
| 200 | } | |
| 201 | ctx.toast(`Token replaced for ${origin.login}; listed again.`); | |
| 202 | await self.load?.(new AbortController().signal); | |
| 203 | ctx.invalidate(); | |
| 204 | }, | |
| 205 | ); | |
| 206 | }, | |
| 207 | }), | |
| 208 | ); | |
| 209 | }; | |
| 210 | ||
| 211 | const pickReplace = (): void => { | |
| 212 | if (publicOnly.length === 1) return replaceToken(publicOnly[0]!); | |
| 213 | ctx.push( | |
| 214 | pickerScreen(ctx, self, { | |
| 215 | title: "Replace which token", | |
| 216 | items: publicOnly.map((o) => ({ id: o.originId, label: o.login })), | |
| 217 | onPick: ([id]) => { | |
| 218 | const origin = publicOnly.find((o) => o.originId === id); | |
| 219 | if (origin !== undefined) replaceToken(origin); | |
| 220 | }, | |
| 221 | }), | |
| 222 | ); | |
| 223 | }; | |
| 224 | ||
| 163 | 225 | self = { |
| 164 | 226 | title: "Migrate a repository", |
| 165 | 227 | get keys() { |
| @@ -168,6 +230,7 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 168 | 230 | binding(["enter"], "pick branch"), |
| 169 | 231 | binding(["/"], "filter"), |
| 170 | 232 | binding(["u"], "paste a URL"), |
| 233 | ...(publicOnly.length > 0 ? [binding(["t"], "replace token")] : []), | |
| 171 | 234 | binding(["esc"], "back"), |
| 172 | 235 | ]; |
| 173 | 236 | }, |
| @@ -178,8 +241,18 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 178 | 241 | } |
| 179 | 242 | const out: Row[] = []; |
| 180 | 243 | const failed: string[] = []; |
| 244 | const limited: PublicOnly[] = []; | |
| 181 | 245 | for (const origin of listUserOrigins(f.app, user)) { |
| 182 | const listed = await listRemoteRepos(f.app, user, origin.id); | |
| 246 | const [listed, reach] = await Promise.all([ | |
| 247 | listRemoteRepos(f.app, user, origin.id), | |
| 248 | tokenReach(f.app, origin), | |
| 249 | ]); | |
| 250 | if (reach === "public") | |
| 251 | limited.push({ | |
| 252 | originId: origin.id, | |
| 253 | login: origin.login, | |
| 254 | webUrl: origin.webUrl, | |
| 255 | }); | |
| 183 | 256 | if (!listed.ok) { |
| 184 | 257 | failed.push(origin.login); |
| 185 | 258 | continue; |
| @@ -189,12 +262,34 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 189 | 262 | } |
| 190 | 263 | rows = out; |
| 191 | 264 | unreachable = failed; |
| 265 | publicOnly = limited; | |
| 192 | 266 | loading = false; |
| 193 | 267 | ctx.invalidate(); |
| 194 | 268 | }, |
| 195 | 269 | render(area, s) { |
| 196 | 270 | const c = ctx.theme.c; |
| 197 | const [top, list] = split(area, "v", [len(2), fill(1)]); | |
| 271 | const [top, list, foot] = split(area, "v", [ | |
| 272 | len(2), | |
| 273 | fill(1), | |
| 274 | len(publicOnly.length === 0 ? 0 : 2 * publicOnly.length), | |
| 275 | ]); | |
| 276 | publicOnly.forEach((o, i) => { | |
| 277 | const at = foot!.y + 2 * i; | |
| 278 | s.text( | |
| 279 | foot!.x, | |
| 280 | at, | |
| 281 | truncate(`${PUBLIC_ONLY} — ${o.login}.`, foot!.w), | |
| 282 | style(c.attn, c.bg), | |
| 283 | foot!.x + foot!.w, | |
| 284 | ); | |
| 285 | s.text( | |
| 286 | foot!.x, | |
| 287 | at + 1, | |
| 288 | truncate(`Make one with repo at ${newTokenUrl(o.webUrl)} · t to paste it`, foot!.w), | |
| 289 | style(c.mutedFg, c.bg), | |
| 290 | foot!.x + foot!.w, | |
| 291 | ); | |
| 292 | }); | |
| 198 | 293 | ctx.setCursor(filter.render(s, { ...top!, h: 1 }, c, "filter repositories")); |
| 199 | 294 | const items = shown(); |
| 200 | 295 | const down = `Could not reach ${unreachable.join(", ")}.`; |
| @@ -261,6 +356,10 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 261 | 356 | pasteUrl(); |
| 262 | 357 | return true; |
| 263 | 358 | } |
| 359 | if (e.key === "t" && publicOnly.length > 0) { | |
| 360 | pickReplace(); | |
| 361 | return true; | |
| 362 | } | |
| 264 | 363 | if (state.key(e, shown().length)) return true; |
| 265 | 364 | if (e.key === "enter") { |
| 266 | 365 | const row = shown()[state.index]; |
src/web/pages/migrate.ts +66 −12
| @@ -13,6 +13,10 @@ | ||
| 13 | 13 | * commits and selects the one the app most likely lives on — the person's |
| 14 | 14 | * newest (`origins/work-branch.ts`); the Migrate button carries the choice. |
| 15 | 15 | * |
| 16 | * An account whose token sees public repositories only says so on its card | |
| 17 | * (`origins/token-reach.ts`), with GitHub's new-token page one click away | |
| 18 | * and a field that replaces the token in place. | |
| 19 | * | |
| 16 | 20 | * Every mutation is `migrateRepo`; the page only puts its answer into words. |
| 17 | 21 | */ |
| 18 | 22 | |
| @@ -57,6 +61,13 @@ import { | ||
| 57 | 61 | selectField, |
| 58 | 62 | field as formField, |
| 59 | 63 | } from "../ui.ts"; |
| 64 | import { | |
| 65 | FINE_GRAINED_NOTE, | |
| 66 | PUBLIC_ONLY, | |
| 67 | newTokenUrl, | |
| 68 | tokenReach, | |
| 69 | } from "../../origins/token-reach.ts"; | |
| 70 | import type { TokenReach } from "../../origins/types.ts"; | |
| 60 | 71 | import { STAGE_BADGE_JS, stageRow, stageStateFor } from "../stage-row.ts"; |
| 61 | 72 | import { prefill, originHost } from "./migrate-prefill.ts"; |
| 62 | 73 | import { githubConnectable, hostLabel } from "./origins.ts"; |
| @@ -92,7 +103,10 @@ interface OriginChoices { | ||
| 92 | 103 | readonly id: string; |
| 93 | 104 | readonly kind: string; |
| 94 | 105 | readonly host: string; |
| 106 | readonly webUrl: string; | |
| 95 | 107 | readonly repos: readonly OriginRepoRow[]; |
| 108 | /** What its token sees; undefined when the origin names no limit. */ | |
| 109 | readonly reach: TokenReach | undefined; | |
| 96 | 110 | } |
| 97 | 111 | |
| 98 | 112 | /** Every origin you connected; one that cannot list has no rows. */ |
| @@ -100,13 +114,18 @@ async function originChoices(ctx: Ctx): Promise<OriginChoices[]> { | ||
| 100 | 114 | const user = ctx.auth.user!; |
| 101 | 115 | const out: OriginChoices[] = []; |
| 102 | 116 | for (const origin of listUserOrigins(ctx.app, user)) { |
| 103 | const listed = await listRemoteRepos(ctx.app, user, origin.id); | |
| 117 | const [listed, reach] = await Promise.all([ | |
| 118 | listRemoteRepos(ctx.app, user, origin.id), | |
| 119 | tokenReach(ctx.app, origin), | |
| 120 | ]); | |
| 104 | 121 | out.push({ |
| 105 | 122 | id: origin.id, |
| 106 | 123 | kind: origin.kind, |
| 107 | 124 | host: originHost(origin.webUrl), |
| 125 | webUrl: origin.webUrl, | |
| 108 | 126 | label: `${origin.login} @ ${hostLabel(origin.webUrl)}`, |
| 109 | 127 | repos: listed.ok ? listed.value : [], |
| 128 | reach, | |
| 110 | 129 | }); |
| 111 | 130 | } |
| 112 | 131 | return out; |
| @@ -221,6 +240,36 @@ export function matchesFilter( | ||
| 221 | 240 | ); |
| 222 | 241 | } |
| 223 | 242 | |
| 243 | /** | |
| 244 | * Under an account whose token lists public repositories only: why the | |
| 245 | * private ones are missing, where to make a token that sees them, and the | |
| 246 | * field that puts it on this origin without disconnecting it. | |
| 247 | */ | |
| 248 | function publicOnly(origin: OriginChoices): Html { | |
| 249 | return html`<div class="mt" id="migrate-reach-${origin.id}" data-reach="public"> | |
| 250 | <p class="small">${icon("lock")} ${PUBLIC_ONLY} — ${origin.label}.</p> | |
| 251 | <p class="small"> | |
| 252 | <a href="${newTokenUrl(origin.webUrl)}" target="_blank" rel="noopener noreferrer" | |
| 253 | >Make a token with <span class="mono">repo</span> on GitHub</a | |
outdated
This link opens GitHub's new-token page. It doesn't replace anything, and the button below is also named Replace. Name the link for what it does, e.g. "Make a token with repo on GitHub". |
||
| 254 | > | |
| 255 | </p> | |
| 256 | <form method="post" action="/settings/origins/${origin.id}/token"> | |
| 257 | <input type="hidden" name="then" value="migrate" /> | |
| 258 | ${formField({ | |
| 259 | label: `New token for ${origin.label}`, | |
| 260 | name: "token", | |
outdated
Hand-rolled |
||
| 261 | type: "password", | |
| 262 | hint: `${FINE_GRAINED_NOTE}.`, | |
| 263 | mono: true, | |
| 264 | required: true, | |
| 265 | autocomplete: "off", | |
| 266 | id: `f-token-${origin.id}`, | |
| 267 | })} | |
| 268 | ${btn("Replace", { size: "sm", variant: "outline", type: "submit" })} | |
| 269 | </form> | |
| 270 | </div>`; | |
| 271 | } | |
| 272 | ||
| 224 | 273 | /** |
| 225 | 274 | * The accounts half, outside the migrate form (forms do not nest): each |
| 226 | 275 | * connected origin with a Disconnect, and Connect GitHub when there is no |
| @@ -265,6 +314,7 @@ function accounts(ctx: Ctx, origins: readonly OriginChoices[]): Html { | ||
| 265 | 314 | }), |
| 266 | 315 | )} |
| 267 | 316 | </ul>`} |
| 317 | ${origins.filter((o) => o.reach === "public").map(publicOnly)} | |
The web card names the limit, but the terminal's migrate screen ( |
||
| 268 | 318 | ${connect}`, |
| 269 | 319 | { |
| 270 | 320 | head: html`${icon("globe")} |
| @@ -294,16 +344,20 @@ function picker( | ||
| 294 | 344 | </p>`; |
| 295 | 345 | return html`<div class="field" id="migrate-picker"> |
| 296 | 346 | <label for="f-q">Your repositories</label> |
| 297 | <input | |
| 298 | class="input" | |
| 299 | type="search" | |
| 300 | id="f-q" | |
| 301 | name="q" | |
| 302 | form="migrate-search" | |
| 303 | value="${values.q}" | |
| 304 | placeholder="Filter by name or description" | |
| 305 | autocomplete="off" | |
| 306 | /> | |
| 347 | <div class="bar"> | |
| 348 | <input | |
| 349 | class="input" | |
| 350 | type="search" | |
| 351 | id="f-q" | |
| 352 | name="q" | |
| 353 | form="migrate-search" | |
| 354 | value="${values.q}" | |
| 355 | placeholder="Filter by name or description" | |
| 356 | autocomplete="off" | |
| 357 | enterkeyhint="search" | |
| 358 | /> | |
| 359 | ${btn("Filter", { variant: "outline", type: "submit", form: "migrate-search", icon: "search" })} | |
| 360 | </div> | |
| 307 | 361 | ${values.source === "" |
| 308 | 362 | ? null |
| 309 | 363 | : html`<input type="hidden" name="source" value="${values.source}" />`} |
| @@ -526,7 +580,7 @@ function migrateForm( | ||
| 526 | 580 | * The name field follows the picked repository or the pasted URL until |
| 527 | 581 | * somebody types in it — a preview; the server names it the same way. |
| 528 | 582 | */ |
| 529 | const migrateJs = /* js */ ` | |
| 583 | export const migrateJs = /* js */ ` | |
| 530 | 584 | (function () { |
| 531 | 585 | var name = document.getElementById('f-name'); |
| 532 | 586 | var url = document.getElementById('f-url'); |
src/web/pages/origins.ts +29
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | listOriginApps, |
| 25 | 25 | listUserOrigins, |
| 26 | 26 | registerOriginApp, |
| 27 | replaceOriginToken, | |
| 27 | 28 | startOAuthConnect, |
| 28 | 29 | syncOriginNow, |
| 29 | 30 | type Reattachment, |
| @@ -37,6 +38,7 @@ import { | ||
| 37 | 38 | updateTrustedHosts, |
| 38 | 39 | } from "../../origins/trusted-hosts.ts"; |
| 39 | 40 | import { reattachMessage } from "./frozen.ts"; |
| 41 | import { replaceTokenMessage } from "../../origins/token-reach.ts"; | |
| 40 | 42 | import { |
| 41 | 43 | listDetachedMirrors, |
| 42 | 44 | mirrorsByOrigin, |
| @@ -1102,3 +1104,30 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> { | ||
| 1102 | 1104 | : "Disconnected. Revoke the token on the origin too if you want it dead there.", |
| 1103 | 1105 | }); |
| 1104 | 1106 | } |
| 1107 | ||
| 1108 | // POST /settings/origins/:id/token | |
| 1109 | /** | |
| 1110 | * A new token on an origin already connected: the origin, its mirrors and | |
| 1111 | * its pins stay; its repositories are listed again with what it sees. | |
| 1112 | */ | |
| 1113 | export async function originReplaceToken(ctx: Ctx): Promise<Response> { | |
| 1114 | const denied = accountOnly(ctx); | |
| 1115 | if (denied) return denied; | |
| 1116 | const form = await readForm(ctx, "admin"); | |
| 1117 | if (form instanceof Response) return form; | |
| 1118 | const back = thenOf(field(form, "then")) ?? "/settings/origins"; | |
| 1119 | const replaced = await replaceOriginToken( | |
| 1120 | ctx.app, | |
| 1121 | ctx.auth.user, | |
| 1122 | ctx.params.id ?? "", | |
| 1123 | field(form, "token"), | |
| 1124 | ); | |
| 1125 | if (!replaced.ok) | |
| 1126 | return flashTo(ctx, back, { | |
| 1127 | tone: "danger", | |
| 1128 | message: replaceTokenMessage(replaced.error), | |
| 1129 | }); | |
| 1130 | return flashTo(ctx, back, { | |
| 1131 | message: `Token replaced for ${replaced.value.login} at ${hostLabel(replaced.value.webUrl)}; its repositories are listed again.`, | |
| 1132 | }); | |
| 1133 | } |
test/fake-origin.ts +25 −8
| @@ -217,6 +217,11 @@ export interface FakeState { | ||
| 217 | 217 | nextId: number; |
| 218 | 218 | /** The OAuth endpoints, when a test wants the connect flows. */ |
| 219 | 219 | oauth?: FakeOAuth; |
| 220 | /** | |
| 221 | * GitHub's `X-OAuth-Scopes` on every answer, as a classic token sends it; | |
| 222 | * unset, the fake is a fine-grained token, which names none. | |
| 223 | */ | |
| 224 | scopes?: string; | |
| 220 | 225 | } |
| 221 | 226 | |
| 222 | 227 | export interface FakeForge { |
| @@ -392,16 +397,28 @@ function route( | ||
| 392 | 397 | return jsonResponse({ max_response_items: state.maxResponseItems }); |
| 393 | 398 | |
| 394 | 399 | if (rest === "/user") |
| 395 | return jsonResponse({ | |
| 396 | id: 7, | |
| 397 | login: state.login, | |
| 398 | full_name: state.login, | |
| 399 | name: state.login, | |
| 400 | html_url: `${url.origin}/${state.login}`, | |
| 401 | }); | |
| 400 | return jsonResponse( | |
| 401 | { | |
| 402 | id: 7, | |
| 403 | login: state.login, | |
| 404 | full_name: state.login, | |
| 405 | name: state.login, | |
| 406 | html_url: `${url.origin}/${state.login}`, | |
| 407 | }, | |
| 408 | 200, | |
| 409 | state.scopes === undefined ? {} : { "x-oauth-scopes": state.scopes }, | |
| 410 | ); | |
| 402 | 411 | |
| 403 | 412 | if (rest === "/user/repos" || rest === "/repos/search") { |
| 404 | const rows = state.repos.map((r) => repoJson(state, r)); | |
| 413 | const visibility = q.get("visibility"); | |
| 414 | const rows = state.repos | |
| 415 | .filter( | |
| 416 | (r) => | |
| 417 | visibility === null || | |
| 418 | visibility === "all" || | |
| 419 | (visibility === "private") === (r.private ?? false), | |
| 420 | ) | |
| 421 | .map((r) => repoJson(state, r)); | |
| 405 | 422 | const payload = rest === "/repos/search" ? { ok: true, data: rows } : rows; |
| 406 | 423 | return jsonResponse(payload, 200, { "x-total-count": String(rows.length) }); |
| 407 | 424 | } |
test/migrate-token-reach.test.ts added +329
| @@ -0,0 +1,329 @@ | ||
| 1 | /** | |
| 2 | * Migrate says when a GitHub token sees public repositories only (#7516). | |
| 3 | * The owner connected a classic token without `repo`, saw 62 public rows | |
| 4 | * and no private one, and thought the forge was broken. Here a fake GitHub | |
| 5 | * answers with the scopes a classic token sends (or none, as a fine-grained | |
| 6 | * one does): the card names the limit, links GitHub's new-token page, and | |
| 7 | * takes the new token in place — same origin, rows listed again. The | |
| 8 | * picker's filter narrows as you type, and has a button. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { connectOrigin, listRemoteRepos } from "../src/actions/origins.ts"; | |
| 17 | import { listOrigins } from "../src/core/origins.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { GitHubProvider } from "../src/origins/providers/github.ts"; | |
| 22 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 23 | import { PUBLIC_ONLY, newTokenUrl } from "../src/origins/token-reach.ts"; | |
| 24 | import { html } from "../src/web/html.ts"; | |
| 25 | import { staticCredential } from "../src/origins/types.ts"; | |
| 26 | import { migrateJs } from "../src/web/pages/migrate.ts"; | |
| 27 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 28 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 29 | import { settleApp } from "./helpers/tui.ts"; | |
| 30 | import { newFakeState, serveFakeForge } from "./fake-origin.ts"; | |
| 31 | import { quietLogs } from "./helpers/globals.ts"; | |
| 32 | import { promote } from "./helpers/members.ts"; | |
| 33 | ||
| 34 | quietLogs(); | |
| 35 | ||
| 36 | let dataDir: string; | |
| 37 | let app: App; | |
| 38 | let servers: Servers; | |
| 39 | let base: string; | |
| 40 | let alice: User; | |
| 41 | let bob: User; | |
| 42 | let carol: User; | |
| 43 | let cookie: string; | |
| 44 | let bobCookie: string; | |
| 45 | /** The line as the page draws it: escaped like every other text. */ | |
| 46 | const publicOnly = html`${PUBLIC_ONLY}`.text; | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-reach-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | base = `http://127.0.0.1:${servers.port}`; | |
| 53 | const person = async (username: string, adminIfFirst = false) => { | |
| 54 | const made = await createUser(app.db, { | |
| 55 | origin: "seed", | |
| 56 | trusted: true, | |
| 57 | username, | |
| 58 | email: `${username}@forge.test`, | |
| 59 | adminIfFirst, | |
| 60 | }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | promote(app.db, made.value); | |
| 63 | return made.value; | |
| 64 | }; | |
| 65 | alice = await person("alice", true); | |
| 66 | bob = await person("bob"); | |
| 67 | carol = await person("carol"); | |
| 68 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 69 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 70 | }); | |
| 71 | ||
| 72 | afterAll(async () => { | |
| 73 | servers.stop(); | |
| 74 | forgetProviders(dataDir); | |
| 75 | await rm(dataDir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | const get = async (path: string): Promise<string> => | |
| 79 | (await fetch(`${base}${path}`, { headers: { cookie } })).text(); | |
| 80 | ||
| 81 | const post = ( | |
| 82 | path: string, | |
| 83 | form: Record<string, string>, | |
| 84 | as = cookie, | |
| 85 | ): Promise<Response> => | |
| 86 | fetch(`${base}${path}`, { | |
| 87 | method: "POST", | |
| 88 | headers: { cookie: as, origin: new URL(base).origin }, | |
| 89 | body: new URLSearchParams(form), | |
| 90 | redirect: "manual", | |
| 91 | }); | |
| 92 | ||
| 93 | /** The page a form's 303 lands on, with the flash cookie it set. */ | |
| 94 | async function landing(answer: Response, as = cookie): Promise<string> { | |
| 95 | expect(answer.status).toBe(303); | |
| 96 | const flash = answer.headers | |
| 97 | .getSetCookie() | |
| 98 | .map((c) => c.split(";")[0]) | |
| 99 | .join("; "); | |
| 100 | const to = answer.headers.get("location")!; | |
| 101 | return ( | |
| 102 | await fetch(`${base}${to}`, { headers: { cookie: `${as}; ${flash}` } }) | |
| 103 | ).text(); | |
| 104 | } | |
| 105 | ||
| 106 | const repo = (id: number, fullName: string, isPrivate = false) => ({ | |
| 107 | id, | |
| 108 | fullName, | |
| 109 | description: `about ${fullName}`, | |
| 110 | cloneUrl: `https://example.invalid/${fullName}.git`, | |
| 111 | private: isPrivate, | |
| 112 | }); | |
| 113 | ||
| 114 | test("a classic token without `repo` says public only and links the new-token page; one with `repo` does not", async () => { | |
| 115 | const state = newFakeState("github", "trevato", "public-only-token"); | |
| 116 | state.scopes = "public_repo, read:org"; | |
outdated
This proves a redirect, not a refusal. Assert the danger flash, and that the public-only line is still on /migrate. Also add a case for |
||
| 117 | state.repos.push(repo(1, "trevato/ESPO"), repo(2, "trevato/garden")); | |
| 118 | const github = serveFakeForge(state); | |
| 119 | try { | |
| 120 | const connected = await connectOrigin(app, alice, { | |
| 121 | host: github.url, | |
| 122 | kind: "github", | |
| 123 | token: "public-only-token", | |
| 124 | }); | |
| 125 | if (!connected.ok) throw new Error(connected.error); | |
| 126 | const originId = connected.value.id; | |
| 127 | ||
| 128 | const page = await get("/migrate"); | |
| 129 | expect(page).toContain(publicOnly); | |
| 130 | expect(page).toContain('data-reach="public"'); | |
| 131 | expect(page).toContain( | |
| 132 | `href="${newTokenUrl(github.url).replaceAll("&", "&")}"`, | |
| 133 | ); | |
| 134 | expect(page).toContain("/settings/tokens/new?scopes=repo&description=forge-migrate"); | |
| 135 | expect(page).toContain("Repository access: All repositories; Contents: Read"); | |
| 136 | expect(page).toContain(`action="/settings/origins/${originId}/token"`); | |
| 137 | expect(page).not.toContain("trevato/secret"); | |
| 138 | ||
| 139 | // A wrong token is refused in words, and the origin stays as it was. | |
| 140 | const refused = await post(`/settings/origins/${originId}/token`, { | |
| 141 | token: "not-a-token", | |
| 142 | then: "migrate", | |
| 143 | }); | |
| 144 | expect(refused.headers.get("location")).toStartWith("/migrate"); | |
| 145 | const refusedPage = await landing(refused); | |
| 146 | expect(refusedPage).toContain("That token was refused by the origin."); | |
| 147 | expect(refusedPage).toContain(publicOnly); | |
| 148 | ||
| 149 | // A token for another account is refused; the stored one still lists. | |
| 150 | state.login = "someone-else"; | |
| 151 | state.token = "other-account-token"; | |
| 152 | const other = await landing( | |
| 153 | await post(`/settings/origins/${originId}/token`, { | |
| 154 | token: "other-account-token", | |
| 155 | then: "migrate", | |
| 156 | }), | |
| 157 | ); | |
| 158 | expect(other).toContain("That token belongs to another account."); | |
| 159 | state.login = "trevato"; | |
| 160 | state.token = "public-only-token"; | |
| 161 | const still = await listRemoteRepos(app, alice, originId, true); | |
| 162 | expect(still.ok && still.value.map((r) => r.fullName).sort()).toEqual([ | |
| 163 | "trevato/ESPO", | |
| 164 | "trevato/garden", | |
| 165 | ]); | |
| 166 | ||
| 167 | // Somebody else cannot name alice's origin at all. | |
| 168 | const stranger = await landing( | |
| 169 | await post( | |
| 170 | `/settings/origins/${originId}/token`, | |
| 171 | { token: "public-only-token", then: "migrate" }, | |
| 172 | bobCookie, | |
| 173 | ), | |
| 174 | bobCookie, | |
| 175 | ); | |
| 176 | expect(stranger).toContain("No such origin."); | |
| 177 | expect((await listRemoteRepos(app, alice, originId, true)).ok).toBe(true); | |
| 178 | expect(listOrigins(app.db, bob.id)).toEqual([]); | |
| 179 | ||
| 180 | // The new token sees private repositories: the same origin, re-listed. | |
| 181 | state.token = "repo-token"; | |
| 182 | state.scopes = "repo, read:org"; | |
| 183 | state.repos.push(repo(3, "trevato/secret", true)); | |
| 184 | const replaced = await post(`/settings/origins/${originId}/token`, { | |
| 185 | token: "repo-token", | |
| 186 | then: "migrate", | |
| 187 | }); | |
| 188 | expect(replaced.status).toBe(303); | |
| 189 | expect(replaced.headers.get("location")).toStartWith("/migrate"); | |
| 190 | const origins = listOrigins(app.db, alice.id); | |
| 191 | expect(origins.map((o) => o.id)).toEqual([originId]); | |
| 192 | ||
| 193 | const after = await get("/migrate"); | |
| 194 | expect(after).not.toContain(publicOnly); | |
| 195 | expect(after).not.toContain("data-reach"); | |
| 196 | expect(after).toContain("trevato/secret"); | |
| 197 | expect(after).toContain("3 repositories"); | |
| 198 | } finally { | |
| 199 | github.stop(); | |
| 200 | } | |
| 201 | }); | |
| 202 | ||
| 203 | test("a fine-grained token names no scopes: one private row is the answer", async () => { | |
| 204 | const state = newFakeState("github", "mona", "fine-grained"); | |
| 205 | state.repos.push(repo(1, "mona/open")); | |
| 206 | const github = serveFakeForge(state); | |
| 207 | try { | |
| 208 | const provider = new GitHubProvider( | |
| 209 | staticCredential({ | |
| 210 | kind: "github", | |
| 211 | apiUrl: github.url, | |
| 212 | webUrl: github.url, | |
| 213 | token: "fine-grained", | |
| 214 | }), | |
| 215 | ); | |
| 216 | expect(await provider.tokenReach()).toEqual({ ok: true, value: "public" }); | |
| 217 | state.repos.push(repo(2, "mona/closed", true)); | |
| 218 | expect(await provider.tokenReach()).toEqual({ ok: true, value: "private" }); | |
| 219 | } finally { | |
| 220 | github.stop(); | |
| 221 | } | |
| 222 | }); | |
| 223 | ||
| 224 | test("the filter: a visible button submits the search form; Enter's answer is the server's filter", async () => { | |
| 225 | const page = await get("/migrate"); | |
| 226 | expect(page).toMatch( | |
| 227 | /<button[^>]*type="submit"[^>]*form="migrate-search"[^>]*>[\s\S]*?Filter\s*<\/button>|<button[^>]*form="migrate-search"[^>]*type="submit"[^>]*>[\s\S]*?Filter\s*<\/button>/, | |
| 228 | ); | |
| 229 | expect(page).toContain('<form method="get" action="/migrate" id="migrate-search">'); | |
| 230 | const filtered = await get("/migrate?q=ESPO"); | |
| 231 | expect(filtered).toContain("trevato/ESPO"); | |
| 232 | expect(filtered).not.toContain('<span class="mono">trevato/garden</span>'); | |
| 233 | }); | |
| 234 | ||
| 235 | /** Just enough DOM for `migrateJs`: the filter field and the picker's rows. */ | |
| 236 | function fakeDom(names: readonly string[]) { | |
| 237 | const listeners: Record<string, (() => void)[]> = {}; | |
| 238 | const q = { | |
| 239 | value: "", | |
| 240 | addEventListener: (kind: string, fn: () => void) => { | |
| 241 | (listeners[kind] ??= []).push(fn); | |
| 242 | }, | |
| 243 | }; | |
| 244 | const rows = names.map((name) => ({ | |
| 245 | name, | |
| 246 | hidden: false, | |
| 247 | getAttribute: (attr: string) => | |
| 248 | attr === "data-q" ? `${name} about ${name}`.toLowerCase() : null, | |
| 249 | })); | |
| 250 | const document = { | |
| 251 | getElementById: (id: string) => (id === "f-q" ? q : null), | |
| 252 | querySelectorAll: (selector: string) => | |
| 253 | selector === "[data-pick-rows] [data-q]" ? rows : [], | |
| 254 | }; | |
| 255 | const type = (text: string) => { | |
| 256 | q.value = text; | |
| 257 | for (const fn of listeners.input ?? []) fn(); | |
| 258 | }; | |
| 259 | return { document, rows, type }; | |
| 260 | } | |
| 261 | ||
| 262 | test("typing ESPO hides the other rows, and clearing it shows them again", () => { | |
| 263 | const dom = fakeDom(["trevato/ESPO", "trevato/garden", "trevato/espo-docs"]); | |
| 264 | new Function("document", migrateJs)(dom.document); | |
| 265 | dom.type("ESPO"); | |
| 266 | expect(dom.rows.filter((r) => !r.hidden).map((r) => r.name)).toEqual([ | |
| 267 | "trevato/ESPO", | |
| 268 | "trevato/espo-docs", | |
| 269 | ]); | |
| 270 | dom.type(""); | |
| 271 | expect(dom.rows.every((r) => !r.hidden)).toBe(true); | |
| 272 | }); | |
| 273 | ||
| 274 | test("the terminal's migrate screen says public only, names the new-token page, and t replaces the token in place", async () => { | |
| 275 | const state = newFakeState("github", "carol-gh", "carol-public"); | |
| 276 | state.scopes = "public_repo"; | |
| 277 | state.repos.push(repo(1, "carol-gh/open")); | |
| 278 | const github = serveFakeForge(state); | |
| 279 | const terminal = headlessTerminal(140, 40); | |
| 280 | const { app: tui } = createForgeApp( | |
| 281 | app, | |
| 282 | { | |
| 283 | sshUsername: carol.username, | |
| 284 | remoteAddress: "127.0.0.1", | |
| 285 | fingerprint: "SHA256:test", | |
| 286 | user: carol, | |
| 287 | }, | |
| 288 | { terminal, exitHooks: false }, | |
| 289 | ); | |
| 290 | try { | |
| 291 | const connected = await connectOrigin(app, carol, { | |
| 292 | host: github.url, | |
| 293 | kind: "github", | |
| 294 | token: "carol-public", | |
| 295 | }); | |
| 296 | if (!connected.ok) throw new Error(connected.error); | |
| 297 | tui.start(); | |
| 298 | await settleApp(tui); | |
| 299 | terminal.feed("M"); | |
| 300 | await settleApp(tui); | |
| 301 | const screen = tui.screen.toText(); | |
| 302 | expect(screen).toContain("carol-gh/open"); | |
| 303 | expect(screen).toContain(PUBLIC_ONLY); | |
| 304 | expect(screen).toContain("/settings/tokens/new?scopes=repo&description=forge-migrate"); | |
| 305 | ||
| 306 | state.token = "carol-repo"; | |
| 307 | state.scopes = "repo"; | |
| 308 | state.repos.push(repo(2, "carol-gh/closed", true)); | |
| 309 | terminal.feed("t"); | |
| 310 | await settleApp(tui); | |
| 311 | expect(tui.screen.toText()).toContain("Replace the token for carol-gh"); | |
| 312 | terminal.feed("carol-repo"); | |
| 313 | terminal.feed("\r"); | |
| 314 | const end = Date.now() + 10_000; | |
| 315 | while (!tui.screen.toText().includes("carol-gh/closed") && Date.now() < end) { | |
| 316 | await settleApp(tui); | |
| 317 | await Bun.sleep(25); | |
| 318 | } | |
| 319 | const after = tui.screen.toText(); | |
| 320 | expect(after).toContain("carol-gh/closed"); | |
| 321 | expect(after).not.toContain(PUBLIC_ONLY); | |
| 322 | expect(listOrigins(app.db, carol.id).map((o) => o.id)).toEqual([ | |
| 323 | connected.value.id, | |
| 324 | ]); | |
| 325 | } finally { | |
| 326 | tui.stop?.(); | |
| 327 | github.stop(); | |
| 328 | } | |
| 329 | }); |
.fieldis the form-field component (label, control, hint, error). Here it's a box for paragraphs. Use a plain wrapper for the explanation, andfield({...type:"password", hint: FINE_GRAINED_NOTE})for the input. See the review body.