Public: anyone can read this; the crew can change it.
`discover` and `provider forge` work against a 0.5.x forge, proven by a fixture of its answers #9496
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9456-discover-and-provider-forge-work-against → main 6 commits fork 2 threads · 2 unresolved
5 files
changed +122
−2
src/origins/providers/forge.ts +3 −1
| @@ -23,7 +23,7 @@ import type { | ||
| 23 | 23 | RepoGroup, |
| 24 | 24 | } from "../types.ts"; |
| 25 | 25 | import { ForgejoProvider, nextPage, toRepo } from "./forgejo.ts"; |
| 26 | import { NO_ACCOUNT } from "../words.ts"; | |
| 26 | import { FORGE_PULL_CHECK_NEEDS, NO_ACCOUNT } from "../words.ts"; | |
| 27 | 27 | import { forgeCursor, toForgeComment, toForgeWork } from "./forge-work.ts"; |
| 28 | 28 | import { asArray, asId, asString, asTime, dig, fail, field, seg, splitFullName, succeed } from "./http.ts"; |
| 29 | 29 | |
| @@ -304,6 +304,8 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 304 | 304 | if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`); |
| 305 | 305 | const result = await this.api.call({ path: `${base}/pulls/${number}`, query: { diff: 0 } }); |
| 306 | 306 | if (!result.ok) return result; |
| 307 | // Before 0.7.355 a pull carries no `check`: say so, not "no checks". | |
| 308 | if (field(result.value.data, "check") === undefined) return fail("invalid_response", FORGE_PULL_CHECK_NEEDS); | |
| 307 | 309 | const word = asString(field(result.value.data, "check")); |
| 308 | 310 | return succeed({ |
| 309 | 311 | word: word === "green" || word === "red" || word === "running" ? word : "none", |
src/origins/words.ts +4
| @@ -33,3 +33,7 @@ export const FORGE_NOT_CARRIED = | ||
| 33 | 33 | */ |
| 34 | 34 | export const FORGE_SIGN_IN_ON_PAGE = |
| 35 | 35 | "Another forge signs in with one click on the web page; here, with a token."; |
| 36 | ||
| 37 | /** A forge older than 0.7.355 reports no check on a pull; said so, with the release that does. */ | |
| 38 | export const FORGE_PULL_CHECK_NEEDS = | |
| 39 | "this forge is older than 0.7.355 and does not report a pull's check; it needs 0.7.355 or later"; |
test/fixtures/forge-0.5.x.json added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | { | |
| 2 | "about": "Reconstructed by hand from the 0.5.x API shape (health 0.5.29), not captured from a live 0.5.x forge; answers by path. Absent paths answer 404, as that release does: no /api/v1/version, no /.well-known/nodeinfo, no /api/v1/user/repos, no `check` on a pull.", | |
| 3 | "token": "friend-token", | |
| 4 | "open": { | |
| 5 | "/api/v1/health": { "ok": true, "version": "0.5.29", "commit": "2d910c08", "draining": false }, | |
| 6 | "/api/v1/here": { "here": { "kind": "forge", "name": "/" }, "you": null, "actions": [], "mcp": {}, "docs": [] } | |
| 7 | }, | |
| 8 | "authed": { | |
| 9 | "/api/v1/user": { "username": "ben", "displayName": "Ben" }, | |
| 10 | "/api/v1/repos/ben/notes/pulls/1": { "number": 1, "title": "Tidy", "state": "open", "headOid": "abc" } | |
| 11 | }, | |
| 12 | "repos": [ | |
| 13 | { "id": "1", "ownerName": "zed", "name": "other", "description": "other app", "visibility": "public", "forkParentId": null, "defaultBranch": "main" }, | |
| 14 | { "id": "2", "ownerName": "ben", "name": "notes", "description": "notes app", "visibility": "public", "forkParentId": null, "defaultBranch": "main" }, | |
| 15 | { "id": "3", "ownerName": "ben", "name": "secret", "description": "secret app", "visibility": "private", "forkParentId": null, "defaultBranch": "main" }, | |
| 16 | { "id": "4", "ownerName": "zed", "name": "more", "description": "more app", "visibility": "public", "forkParentId": null, "defaultBranch": "main" } | |
| 17 | ] | |
| 18 | } |
test/forge-origin-fixture-0-5.test.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { discover } from "../src/origins/discover.ts"; | |
| 3 | import { providerFor } from "../src/origins/providers/index.ts"; | |
| 4 | import { staticCredential } from "../src/origins/types.ts"; | |
| 5 | import { FORGE_PULL_CHECK_NEEDS } from "../src/origins/words.ts"; | |
| 6 | import fixture from "./fixtures/forge-0.5.x.json"; | |
| 7 | ||
| 8 | /** | |
| 9 | * A 0.5.x forge (#9456), replayed from a fixture reconstructed by hand from that release's API shape; every | |
| 10 | * path it does not name answers 404. The two `pin:` tests are regression pins: discover and listRepos already | |
| 11 | * worked against 0.5.x on main and pass there unchanged. The pull-check tests cover the one change: an answer | |
| 12 | * with no `check` names the release it needs, and a current forge's answer, which always carries `check` | |
| 13 | * (`none` when nothing ran), still reads as its word. | |
| 14 | */ | |
| 15 | const current: Record<string, unknown> = { | |
| 16 | "/api/v1/repos/ben/notes/pulls/2": { number: 2, title: "Now", state: "open", headOid: "def", check: "none" }, | |
| 17 | "/api/v1/repos/ben/notes/pulls/3": { number: 3, title: "Green", state: "open", headOid: "fed", check: "green" }, | |
| 18 | }; | |
| 19 | let server: ReturnType<typeof Bun.serve>; | |
| 20 | let base = ""; | |
| 21 | const seen: string[] = []; | |
| 22 | const open = fixture.open as Record<string, unknown>; | |
| 23 | const authed = fixture.authed as Record<string, unknown>; | |
| 24 | ||
| 25 | beforeAll(() => { | |
| 26 | process.env.FORGE_DEV = "1"; | |
| 27 | server = Bun.serve({ | |
| 28 | port: 0, | |
| 29 | hostname: "127.0.0.1", | |
| 30 | fetch(req) { | |
| 31 | const url = new URL(req.url); | |
| 32 | seen.push(url.pathname); | |
| 33 | if (url.pathname in open) return Response.json(open[url.pathname]); | |
| 34 | const ok = req.headers.get("authorization") === `Bearer ${fixture.token}`; | |
| 35 | if (url.pathname in current) return ok ? Response.json(current[url.pathname]) : new Response("{}", { status: 401 }); | |
| 36 | if (url.pathname in authed) | |
| 37 | return ok ? Response.json(authed[url.pathname]) : new Response("{}", { status: 401 }); | |
| 38 | if (url.pathname === "/api/v1/repos") { | |
| 39 | if (!ok) return new Response("{}", { status: 401 }); | |
| 40 | const after = Number(url.searchParams.get("cursor") ?? 0); | |
| 41 | const limit = Number(url.searchParams.get("limit") ?? 30); | |
| 42 | const rows = fixture.repos.filter((r) => Number(r.id) > after).slice(0, limit); | |
| 43 | const last = rows[rows.length - 1]; | |
| 44 | return Response.json({ items: rows, next_cursor: rows.length >= limit && last ? last.id : null }); | |
| 45 | } | |
| 46 | return new Response("{}", { status: 404 }); | |
| 47 | }, | |
| 48 | }); | |
| 49 | base = `http://127.0.0.1:${server.port}`; | |
| 50 | }); | |
outdated
This test and the listing test below it pass without any source change, so they do not meet "fails without the change". State plainly that they are regression pins, or pair each with a change that makes it fail on main. |
||
| 51 | afterAll(() => server.stop(true)); | |
| 52 | ||
| 53 | const provider = () => | |
| 54 | providerFor( | |
| 55 | staticCredential({ | |
| 56 | kind: "gitea", | |
| 57 | software: "forge", | |
| 58 | apiUrl: `${base}/api/v1`, | |
| 59 | webUrl: base, | |
| 60 | login: "ben", | |
| 61 | token: fixture.token, | |
| 62 | }), | |
| 63 | ); | |
| 64 | ||
| 65 | test("pin: discover names the 0.5.x forge from its fixture, with no version endpoint", async () => { | |
| 66 | const found = await discover(base); | |
| 67 | expect(found.ok).toBe(true); | |
| 68 | if (!found.ok) return; | |
| 69 | expect(found.value.software).toBe("forge 0.5.29"); | |
| 70 | expect(found.value.via).toBe("health"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("pin: provider forge lists the account's repositories from the fixture, never /user/repos", async () => { | |
| 74 | const page = await provider().listRepos(); | |
| 75 | expect(page.ok).toBe(true); | |
| 76 | if (!page.ok) return; | |
| 77 | expect(page.value.items.map((r) => r.fullName)).toEqual(["ben/notes", "ben/secret"]); | |
| 78 | expect(seen).toContain("/api/v1/repos"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a pull's check, which that release lacks, fails naming the version it needs", async () => { | |
| 82 | const check = await provider().getPullCheck?.("ben/notes", 1); | |
| 83 | if (check === undefined) throw new Error("no getPullCheck"); | |
| 84 | expect(check.ok).toBe(false); | |
| 85 | if (check.ok) return; | |
| 86 | expect(check.error.message).toBe(FORGE_PULL_CHECK_NEEDS); | |
| 87 | expect(check.error.message).toContain("0.7.355"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a current forge's pull, whose check is the word none, still reads as none, not as an old forge", async () => { | |
| 91 | const p = provider(); | |
| 92 | for (const [n, word, head] of [[2, "none", "def"], [3, "green", "fed"]] as const) { | |
| 93 | const check = await p.getPullCheck?.("ben/notes", n); | |
| 94 | expect(check).toEqual({ ok: true, value: { word, headOid: head } }); | |
| 95 | } | |
| 96 | }); |
test/origin-words.test.ts +1 −1
| @@ -15,7 +15,7 @@ const PROVIDERS = [ | ||
| 15 | 15 | const sentences = Object.entries(words) as [string, string][]; |
| 16 | 16 | |
| 17 | 17 | test("each sentence is non-empty", () => { |
| 18 | expect(sentences.length).toBe(7); | |
| 18 | expect(sentences.length).toBe(8); | |
| 19 | 19 | for (const [, s] of sentences) expect(s.trim().length).toBeGreaterThan(0); |
| 20 | 20 | }); |
| 21 | 21 |
Absent
checkis treated as an old forge. Confirm that the current forge always sendscheck, with the word none when there is no run. If it can omit the field, a current forge will now get a false "older than 0.7.355" error.