Public: anyone can read this; the crew can change it.
A mirrored repository's credit (upstream, licence, original authors) is in the REST and MCP answers, not only on its page #10970
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10904-a-mirrored-repository-s-credit-upstream → main 4 commits fork
6 files
changed +55
−6
src/actions/credit_facts.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | /** | |
| 2 | * A repository's credit as the REST and MCP answers carry it (#10904): the | |
| 3 | * same reading the page and the terminal print, as plain data. Null for a | |
| 4 | * repository that is not a mirror. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { creditOf, type Credit } from "../core/credit.ts"; | |
| 9 | import type { Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { localSource } from "../view/local.ts"; | |
| 12 | ||
| 13 | export async function creditFacts(app: App, repo: Repo, viewer: User | undefined): Promise<Credit | null> { | |
| 14 | const credit = await creditOf(app.db, app.reposRoot, repo, viewer, localSource(app, repo, viewer), repo.defaultBranch); | |
| 15 | return credit ?? null; | |
| 16 | } |
src/contract/openapi/routes/getApiV1ReposOwnerName.ts +1 −1
| @@ -4,7 +4,7 @@ export const route: Route = { | ||
| 4 | 4 | method: "get", |
| 5 | 5 | path: REPO, |
| 6 | 6 | summary: |
| 7 | "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else", | |
| 7 | "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else; `credit` is a mirror's { upstream: { fullName, htmlUrl, viaFork }, licence, authors }, null for a repository that is not a mirror", | |
| 8 | 8 | auth: false, |
| 9 | 9 | role: "anonymous", |
| 10 | 10 | refusals: { not_found: "404: no such repository, or it is not readable by the caller" }, |
src/http/api.ts +6 −2
| @@ -132,6 +132,7 @@ import { | ||
| 132 | 132 | import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts"; |
| 133 | 133 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor, validEventsKind } from "../core/events_page.ts"; |
| 134 | 134 | import { audienceFor } from "../core/audience.ts"; |
| 135 | import { creditFacts } from "../actions/credit_facts.ts"; | |
| 135 | 136 | import { |
| 136 | 137 | forkParentFor, |
| 137 | 138 | getReposByIds, |
| @@ -1605,15 +1606,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1605 | 1606 | return json(created.value, 201); |
| 1606 | 1607 | }); |
| 1607 | 1608 | |
| 1608 | router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => { | |
| 1609 | router.add("GET", "/api/v1/repos/:owner/:name", async (ctx) => { | |
| 1609 | 1610 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1610 | 1611 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); |
| 1611 | 1612 | if (!repo) return notFound(); |
| 1612 | 1613 | const facts = timePhase(ctx.request, "counts", () => repoJson(ctx, repo)); |
| 1613 | 1614 | const audience = timePhase(ctx.request, "audience", () => audienceFor(ctx.app.db, repo, ctx.auth.user)); |
| 1615 | const credited = performance.now(); | |
| 1616 | const credit = await creditFacts(ctx.app, repo, ctx.auth.user); | |
| 1617 | notePhase(ctx.request, "credit", performance.now() - credited); | |
| 1614 | 1618 | const rendering = performance.now(); |
| 1615 | 1619 | const upstream = upstreamFacts(ctx.app.db, repo, ctx.auth.user); |
| 1616 | const res = json({ ...facts, audience, upstream }); | |
| 1620 | const res = json({ ...facts, audience, upstream, credit }); | |
| 1617 | 1621 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 1618 | 1622 | return res; |
| 1619 | 1623 | }); |
src/http/mcp/tools/repo.ts +4 −2
| @@ -2,11 +2,12 @@ import { provenanceFacts } from "../../../core/provenance.ts"; | ||
| 2 | 2 | import { forkParentFor } from "../../../core/repos.ts"; |
| 3 | 3 | import { audienceFor } from "../../../core/audience.ts"; |
| 4 | 4 | import { upstreamFacts } from "../../../actions/upstream_follow.ts"; |
| 5 | import { creditFacts } from "../../../actions/credit_facts.ts"; | |
| 5 | 6 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | name: "repo", |
| 9 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name`", | |
| 10 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), `credit` (a mirror's upstream, licence, authors; `null` otherwise), the same as `GET /api/v1/repos/:owner/:name`", | |
| 10 | 11 | description: |
| 11 | 12 | "One repository you can read, as GET /api/v1/repos/:owner/:name.", |
| 12 | 13 | inputSchema: { |
| @@ -15,7 +16,7 @@ export const tool: Tool = { | ||
| 15 | 16 | required: ["repo"], |
| 16 | 17 | }, |
| 17 | 18 | writes: false, |
| 18 | run(ctx, user, args) { | |
| 19 | async run(ctx, user, args) { | |
| 19 | 20 | const repo = repoArg(ctx, user, args); |
| 20 | 21 | if (isAnswer(repo)) return repo; |
| 21 | 22 | return good({ |
| @@ -30,6 +31,7 @@ export const tool: Tool = { | ||
| 30 | 31 | forkParentFor(ctx.app.db, repo, user), |
| 31 | 32 | ), |
| 32 | 33 | upstream: upstreamFacts(ctx.app.db, repo, user), |
| 34 | credit: await creditFacts(ctx.app, repo, user), | |
| 33 | 35 | }); |
| 34 | 36 | }, |
| 35 | 37 | }; |
test/mirror-credit.test.ts +27
| @@ -12,6 +12,7 @@ import { AUTHOR_WINDOW, clearCreditCache, creditOf, creditText, licenceName, top | ||
| 12 | 12 | import { runGit } from "../src/git/env.ts"; |
| 13 | 13 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; |
| 14 | 14 | import { createRepo, forkRepo, getRepoById, type Repo } from "../src/core/repos.ts"; |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | 16 | import { createUser } from "../src/core/users.ts"; |
| 16 | 17 | import { repoDir } from "../src/git/repos.ts"; |
| 17 | 18 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -32,6 +33,7 @@ describe("the credit line of a mirror", () => { | ||
| 32 | 33 | let plain: Repo; |
| 33 | 34 | let frozen: Repo; |
| 34 | 35 | let orphan: Repo; |
| 36 | let token = ""; | |
| 35 | 37 | |
| 36 | 38 | const put = async (repo: Repo, who: string, files: { path: string; content: string }[]) => { |
| 37 | 39 | const r = await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), { |
| @@ -52,6 +54,7 @@ describe("the credit line of a mirror", () => { | ||
| 52 | 54 | const made0 = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", adminIfFirst: true }); |
| 53 | 55 | if (!made0.ok) throw new Error(String(made0.error)); |
| 54 | 56 | const u = made0.value; |
| 57 | token = createToken(app.db, u.id, "credit", "rw").token; | |
| 55 | 58 | const mk = async (name: string) => { |
| 56 | 59 | const r = await createRepo(app.db, app.reposRoot, { ownerId: u.id, name, visibility: "public", description: "", defaultBranch: "main" }); |
| 57 | 60 | if (!r.ok) throw new Error(r.error); |
| @@ -163,4 +166,28 @@ describe("the credit line of a mirror", () => { | ||
| 163 | 166 | expect(await topAuthors(join(dataDir, "nowhere"), "main")).toEqual([]); |
| 164 | 167 | expect((await topAuthors(dir, "main"))[0]).toBe("Zed & Zeta"); |
| 165 | 168 | }); |
| 169 | ||
| 170 | const rest = async (repo: Repo) => | |
| 171 | (await (await fetch(`${servers.url}/api/v1/repos/${repo.ownerName}/${repo.name}`)).json()) as { credit: unknown }; | |
| 172 | const mcp = async (repo: Repo) => { | |
| 173 | const res = await fetch(`${servers.url}/mcp`, { | |
| 174 | method: "POST", | |
| 175 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 176 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "repo", arguments: { repo: `${repo.ownerName}/${repo.name}` } } }), | |
| 177 | }); | |
| 178 | const body = (await res.json()) as any; | |
| 179 | return JSON.parse(body.result.content[0].text) as { credit: unknown }; | |
| 180 | }; | |
| 181 | ||
| 182 | test("REST and MCP carry the credit a page prints, and null for a native repository", async () => { | |
| 183 | const want = { upstream: { fullName: "up/lib", htmlUrl: "https://up.example/up/lib", viaFork: false }, licence: "MIT", authors: ["Zed & Zeta", "Quinn"] }; | |
| 184 | expect((await rest(mirror)).credit).toEqual(want); | |
| 185 | expect((await mcp(mirror)).credit).toEqual(want); | |
| 186 | const viaFork = { ...want, upstream: { ...want.upstream, viaFork: true } }; | |
| 187 | expect((await rest(fork)).credit).toEqual(viaFork); | |
| 188 | expect((await mcp(fork)).credit).toEqual(viaFork); | |
| 189 | expect((await rest(plain)).credit).toBeNull(); | |
| 190 | expect((await mcp(plain)).credit).toBeNull(); | |
| 191 | expect(((await rest(bare)).credit as { licence: string }).licence).toBe("no licence file"); | |
| 192 | }); | |
| 166 | 193 | }); |
test/security-floor-doors.test.ts +1 −1
| @@ -92,7 +92,7 @@ test("each return of the public door and of `answered` is floored", () => { | ||
| 92 | 92 | // `response` is only ever what `answered` gave back. |
| 93 | 93 | for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/); |
| 94 | 94 | // `answered` floors what the work returns, and its failure path floors too. |
| 95 | expect(region).toContain("const done = await compress(request, response).then(securityFloor)"); | |
| 95 | expect(region).toMatch(/const done = await compress\(request, response\)\.then\(\(r\) => securityFloor\(/); | |
| 96 | 96 | expect(server).toMatch(/function failure\([^]*?\n}\n/); |
| 97 | 97 | const failure = server.match(/function failure\([^]*?\n}\n/)![0]; |
| 98 | 98 | for (const r of failure.matchAll(/\breturn\s+([^\n]+)/g)) expect(r[1]!.trim()).toMatch(/^securityFloor\(/); |