Public: anyone can read this; the crew can change it.
A contribute token pushes only to its own contrib branch, and the push prints the pull it opened #12047
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12013-a-contribute-token-pushes-only-to-its-ow → main 5 commits fork
10 files
changed +543
−58
docs/features/contribute-token.md +3 −1
| @@ -15,5 +15,7 @@ or the MCP `start` tool with `action: "contribute"` (same arguments). The answer | ||
| 15 | 15 | - **Scope:** read public repositories; push `contrib/<agent>/<segment>/*` on that one repository; open pulls from those branches (`POST /api/v1/repos/<owner>/<name>/pulls`, `head: "contrib/<agent>/<segment>/<topic>"`); read and update its own pulls, checks included. Everything else answers 403 (`src/http/contribute_gate.ts`, `allows` in `src/core/contribute_token.ts`). It never reaches the default branch and a person merges. |
| 16 | 16 | - **Lifetime:** 24 hours. `POST /api/v1/contribute` with the token presented renews it once, by another day, never past a week from minting. |
| 17 | 17 | - **Own branches:** the agent name is claimed, so two tokens may share one. The `<segment>` is derived from the token's own id and is in the `branches` and `git.push` the mint answers; the pre-receive hook refuses any ref outside it, so one token cannot push over or delete another's branches. |
| 18 | - **Limits:** 5 tokens an hour and 20 a day per address (hashed), 120 requests a minute per token; at most 5 pulls open per token, 20 opened a day per token and 30 a day from all of a repository's outside tokens (counted from the pull rows; over it answers 429). | |
| 18 | - **Limits:** 5 tokens an hour and 20 a day per address (hashed), 120 requests a minute per token; at most 5 pulls open per token, 20 opened a day per token, 25 a day per claimed agent name (`PULLS_PER_AGENT_DAY`, so minting more tokens buys nothing) and 30 a day from all of a repository's outside tokens (counted from the pull rows; over it answers 429). | |
| 19 | 19 | - **Claimed, not verified:** the agent name and origin are what the agent said. The forge makes a throwaway guest account per token (no password, no crew, no credits) so a pull has an author; the account stays after the token ends. |
| 20 | ||
| 21 | **The push opens the pull (#12013).** A push of a new `contrib/<agent>/<segment>/<topic>` branch opens a pull on the default branch (the tip commit's first line is the title, its whole message the body); a later push to the branch updates that pull. The push output prints, as `remote:` lines, the pull's URL and `checks (plain text)`, the URL of `GET /api/v1/repos/<owner>/<name>/pulls/<n>/checks?format=text` (`state:`, `head:`, `required:`). Refused with one line: a push to the default branch or another token's branch (the line names the branches the token may push), a token past its expiry (401), a push over 5 MiB (`CONTRIBUTE_PUSH_MAX_BYTES`; git says "pack exceeds maximum allowed size (5.00 MiB)"), and a new branch once the token has 5 pulls open has opened 20 in a day, the agent name has opened 25, or the repository has taken 30 in a day (the line carries the number). A push that creates more new branches than the token has room to open pulls is refused whole with both numbers. Opens are serialized per token (`inTurn`), shared by the API and the push, with the caps read inside the turn; a branch that loses a race to a cap is still pushed and the output says no pull was opened. Code: `src/actions/contribute_push.ts`; test: `test/contribute-push.test.ts`. |
src/actions/contribute_push.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * What an outside agent's push does beyond landing a branch (#12013). Before it | |
| 3 | * lands, a new branch is refused with the number when the token's pull caps are | |
| 4 | * spent. After it lands, a new `contrib/…` branch opens a pull (the tip commit's | |
| 5 | * message is its title and body) and a later push finds the same pull; the lines | |
| 6 | * returned are what the push prints back to the agent: the pull's URL and the | |
| 7 | * plain-text URL of its checks. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { capWords, inTurn, pullCapReached, pullRoom } from "../core/contribute_token.ts"; | |
| 12 | import { MAX_BODY, MAX_TITLE } from "../core/issues.ts"; | |
| 13 | import { pullForBranch } from "../core/pulls.ts"; | |
| 14 | import type { Repo } from "../core/repos.ts"; | |
| 15 | import type { User } from "../core/users.ts"; | |
| 16 | import { runGit } from "../git/env.ts"; | |
| 17 | import { ZERO_OID, type RefUpdate } from "../git/hook-protocol.ts"; | |
| 18 | import { repoDir } from "../git/repos.ts"; | |
| 19 | import { openPull } from "./pulls.ts"; | |
| 20 | ||
| 21 | const HEADS = "refs/heads/"; | |
| 22 | ||
| 23 | const errorText = (error: unknown): string => (typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error)); | |
| 24 | ||
| 25 | /** Branches this push creates: the ones that would open a pull. */ | |
| 26 | const created = (updates: readonly RefUpdate[]): RefUpdate[] => | |
| 27 | updates.filter((u) => u.ref.startsWith(HEADS) && ZERO_OID.test(u.oldOid) && !ZERO_OID.test(u.newOid)); | |
| 28 | ||
| 29 | /** | |
| 30 | * The one plain line refusing this push because its pull caps are spent, or because | |
| 31 | * it creates more branches than there is room for pulls; undefined otherwise. | |
| 32 | */ | |
| 33 | export function contribPushRefusal(app: App, repo: Repo, userId: string, updates: readonly RefUpdate[], now = new Date()): string | undefined { | |
| 34 | const fresh = created(updates).length; | |
| 35 | if (fresh === 0) return undefined; | |
| 36 | const { room, capped } = pullRoom(app.db, repo.id, userId, now); | |
| 37 | if (capped !== undefined) return `not pushed: ${capWords(capped)}`; | |
| 38 | if (fresh > room) | |
| 39 | return `not pushed: this push creates ${fresh} new branches and this token may open ${room} more pull${room === 1 ? "" : "s"} now; push fewer branches at a time`; | |
| 40 | return undefined; | |
| 41 | } | |
| 42 | ||
| 43 | /** The commit's message split into a title (its first line) and the whole message as the body. */ | |
| 44 | export function pullWords(message: string, branch: string): { title: string; body: string } { | |
| 45 | const text = message.replace(/\r\n?/g, "\n").trim(); | |
| 46 | const title = (text.split("\n", 1)[0] ?? "").trim().slice(0, MAX_TITLE) || branch; | |
| 47 | return { title, body: text.slice(0, MAX_BODY) }; | |
| 48 | } | |
| 49 | ||
| 50 | /** Open or find the pull of every branch the push moved; the lines the push prints. */ | |
| 51 | export async function contribPushPulls( | |
| 52 | app: App, | |
| 53 | repo: Repo, | |
| 54 | actor: User | undefined, | |
| 55 | updates: readonly RefUpdate[], | |
| 56 | origin: string, | |
| 57 | ): Promise<string[]> { | |
| 58 | const lines: string[] = []; | |
| 59 | if (actor === undefined) return lines; | |
| 60 | const at = `${origin}/${repo.ownerName}/${repo.name}`; | |
| 61 | const show = (number: number, verb: string): void => { | |
| 62 | lines.push(`${verb} pull #${number}: ${at}/pulls/${number}`); | |
| 63 | lines.push(`checks (plain text): ${origin}/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${number}/checks?format=text`); | |
| 64 | }; | |
| 65 | for (const update of updates) { | |
| 66 | if (!update.ref.startsWith(HEADS) || ZERO_OID.test(update.newOid)) continue; | |
| 67 | const branch = update.ref.slice(HEADS.length); | |
| 68 | const known = pullForBranch(app.db, repo.id, branch); | |
| 69 | if (known !== undefined && known.state === "open" && known.mergedAt === null) { | |
| 70 | show(known.number, "updated"); | |
| 71 | continue; | |
| 72 | } | |
| 73 | const log = await runGit(["log", "-1", "--format=%B", update.newOid], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) }); | |
| 74 | const words = pullWords(log.ok ? log.value : "", branch); | |
| 75 | // One open at a time per token, with the caps read inside the turn (as the API's does). | |
| 76 | const opened = await inTurn(actor.id, async () => { | |
| 77 | const capped = pullCapReached(app.db, repo.id, actor.id, new Date()); | |
| 78 | if (capped !== undefined) return capped; | |
| 79 | return openPull(app, actor, repo, { | |
| 80 | title: words.title, | |
| 81 | body: words.body, | |
| 82 | baseBranch: repo.defaultBranch, | |
| 83 | headBranch: branch, | |
| 84 | outsideAgent: true, | |
| 85 | }); | |
| 86 | }); | |
| 87 | if (typeof opened === "string") lines.push(`${branch} is pushed but no pull was opened: ${capWords(opened)}`); | |
| 88 | else if (opened.ok) show(opened.value.number, "opened"); | |
| 89 | else lines.push(`${branch} is pushed but no pull was opened (${errorText(opened.error)}): open one with POST /api/v1/repos/${repo.ownerName}/${repo.name}/pulls`); | |
| 90 | } | |
| 91 | return lines; | |
| 92 | } |
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberChecks.ts +2 −1
| @@ -3,8 +3,9 @@ import { REPO, type Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | path: `${REPO}/pulls/{number}/checks`, |
| 6 | summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists", | |
| 6 | summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists; `?format=text` answers three plain-text lines (`state:`, `head:`, `required:`) instead", | |
| 7 | 7 | auth: false, |
| 8 | 8 | role: "anonymous", |
| 9 | query: { format: "string" }, | |
| 9 | 10 | reply: { head: "string", state: "string", required: "boolean", run: "object", runs: "array" }, |
| 10 | 11 | }; |
src/core/contribute_token.ts +67 −19
| @@ -42,7 +42,11 @@ export const MINTS_PER_DAY = 20; | ||
| 42 | 42 | */ |
| 43 | 43 | export const OPEN_PULLS_PER_TOKEN = 5; |
| 44 | 44 | export const PULLS_PER_TOKEN_DAY = 20; |
| 45 | /** Pulls all the tokens that claim one agent name may open on a repository in a day. */ | |
| 46 | export const PULLS_PER_AGENT_DAY = 25; | |
| 45 | 47 | export const PULLS_PER_REPO_DAY = 30; |
| 48 | /** The most one push of an outside agent's may carry (git's `receive.maxInputSize`): 5 MiB. */ | |
| 49 | export const CONTRIBUTE_PUSH_MAX_BYTES = 5 * 1024 * 1024; | |
| 46 | 50 | export const AGENT_MAX = 64; |
| 47 | 51 | export const ORIGIN_MAX = 200; |
| 48 | 52 | |
| @@ -68,31 +72,75 @@ export function inContribNamespace(ref: string, agent: string): boolean { | ||
| 68 | 72 | export const headInContribNamespace = (head: string, agent: string): boolean => |
| 69 | 73 | inContribNamespace(`refs/heads/${head}`, agent); |
| 70 | 74 | |
| 75 | export type PullCap = "token_open" | "token_day" | "agent_day" | "repo_day"; | |
| 76 | ||
| 71 | 77 | /** |
| 72 | * Why this token's user may not open another pull on `repoId` now, or undefined. | |
| 73 | * Counted from the pull rows themselves: open ones by this author, and the day's | |
| 74 | * by this author and by every outside token's author on the repository. | |
| 78 | * How many more pulls this token's user may open on `repoId` now, and the cap that | |
| 79 | * binds (undefined while room is left). Counted from the pull rows themselves: open | |
| 80 | * ones by this author, and the day's by this author, by every token of the same | |
| 81 | * claimed agent name, and by every outside token's author on the repository. | |
| 75 | 82 | */ |
| 76 | export function pullCapReached( | |
| 77 | db: Database, | |
| 78 | repoId: string, | |
| 79 | userId: string, | |
| 80 | now: Date, | |
| 81 | ): "token_open" | "token_day" | "repo_day" | undefined { | |
| 83 | export function pullRoom(db: Database, repoId: string, userId: string, now: Date): { room: number; capped?: PullCap } { | |
| 82 | 84 | const count = (sql: string, ...args: string[]): number => |
| 83 | 85 | db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0; |
| 84 | 86 | const since = new Date(now.getTime() - 86_400_000).toISOString(); |
| 85 | 87 | const mine = "SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND author_id = ?2"; |
| 86 | if (count(`${mine} AND state = 'open'`, repoId, userId) >= OPEN_PULLS_PER_TOKEN) return "token_open"; | |
| 87 | if (count(`${mine} AND created_at >= ?3`, repoId, userId, since) >= PULLS_PER_TOKEN_DAY) return "token_day"; | |
| 88 | const all = count( | |
| 89 | `SELECT COUNT(*) AS n FROM issue | |
| 90 | WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?2 | |
| 91 | AND author_id IN (SELECT t.user_id FROM token t JOIN contribute_token c ON c.token_id = t.id WHERE c.repo_id = ?1)`, | |
| 92 | repoId, | |
| 93 | since, | |
| 94 | ); | |
| 95 | return all >= PULLS_PER_REPO_DAY ? "repo_day" : undefined; | |
| 88 | const outsiders = "SELECT t.user_id FROM token t JOIN contribute_token c ON c.token_id = t.id WHERE c.repo_id = ?1"; | |
| 89 | const left: [number, PullCap][] = [ | |
| 90 | [OPEN_PULLS_PER_TOKEN - count(`${mine} AND state = 'open'`, repoId, userId), "token_open"], | |
| 91 | [PULLS_PER_TOKEN_DAY - count(`${mine} AND created_at >= ?3`, repoId, userId, since), "token_day"], | |
| 92 | [ | |
| 93 | PULLS_PER_AGENT_DAY - | |
| 94 | count( | |
| 95 | `SELECT COUNT(*) AS n FROM issue | |
| 96 | WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?3 | |
| 97 | AND author_id IN (${outsiders} AND c.agent = (SELECT c2.agent FROM token t2 JOIN contribute_token c2 ON c2.token_id = t2.id WHERE t2.user_id = ?2 AND c2.repo_id = ?1 LIMIT 1))`, | |
| 98 | repoId, | |
| 99 | userId, | |
| 100 | since, | |
| 101 | ), | |
| 102 | "agent_day", | |
| 103 | ], | |
| 104 | [ | |
| 105 | PULLS_PER_REPO_DAY - | |
| 106 | count( | |
| 107 | `SELECT COUNT(*) AS n FROM issue | |
| 108 | WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?2 AND author_id IN (${outsiders})`, | |
| 109 | repoId, | |
| 110 | since, | |
| 111 | ), | |
| 112 | "repo_day", | |
| 113 | ], | |
| 114 | ]; | |
| 115 | let binding = left[0]!; | |
| 116 | for (const l of left) if (l[0] < binding[0]) binding = l; | |
| 117 | return binding[0] > 0 ? { room: binding[0] } : { room: 0, capped: binding[1] }; | |
| 118 | } | |
| 119 | ||
| 120 | /** Why this token's user may not open another pull on `repoId` now, or undefined. */ | |
| 121 | export const pullCapReached = (db: Database, repoId: string, userId: string, now: Date): PullCap | undefined => | |
| 122 | pullRoom(db, repoId, userId, now).capped; | |
| 123 | ||
| 124 | /** The plain refusal for a pull cap, with the number it holds to. */ | |
| 125 | export function capWords(capped: PullCap): string { | |
| 126 | if (capped === "repo_day") | |
| 127 | return `this repository takes at most ${PULLS_PER_REPO_DAY} pulls a day from outside agents and has taken them; try tomorrow`; | |
| 128 | if (capped === "agent_day") | |
| 129 | return `this agent name has opened ${PULLS_PER_AGENT_DAY} pulls in a day here (the most it may); try again tomorrow`; | |
| 130 | if (capped === "token_open") return `this token has ${OPEN_PULLS_PER_TOKEN} pulls open (the most it may); wait for one to close`; | |
| 131 | return `this token has opened ${PULLS_PER_TOKEN_DAY} pulls in a day (the most it may); try again tomorrow`; | |
| 132 | } | |
| 133 | ||
| 134 | const turns = new Map<string, Promise<unknown>>(); | |
| 135 | /** Run `fn` after every earlier call with the same key has finished: one at a time per token. */ | |
| 136 | export function inTurn<T>(key: string, fn: () => Promise<T>): Promise<T> { | |
| 137 | const run = (turns.get(key) ?? Promise.resolve()).then(fn, fn); | |
| 138 | const tail = run.catch(() => undefined); | |
| 139 | turns.set(key, tail); | |
| 140 | void tail.then(() => { | |
| 141 | if (turns.get(key) === tail) turns.delete(key); | |
| 142 | }); | |
| 143 | return run; | |
| 96 | 144 | } |
| 97 | 145 | |
| 98 | 146 | /** What a request is, as the scope check reads it. */ |
src/git/hook-protocol.ts +2
| @@ -19,6 +19,8 @@ export interface HookRequest { | ||
| 19 | 19 | readonly proposalChild?: string; |
| 20 | 20 | /** An outside agent's name: every ref must be inside `contrib/<agent>/`. */ |
| 21 | 21 | readonly contribAgent?: string; |
| 22 | /** The forge's address as the pusher reached it, for the URLs a push prints. */ | |
| 23 | readonly pushOrigin?: string; | |
| 22 | 24 | readonly updates: readonly RefUpdate[]; |
| 23 | 25 | /** |
| 24 | 26 | * Quarantine environment forwarded from the hook process. Any git command |
src/git/hooks.ts +4
| @@ -64,6 +64,8 @@ export function hookEnv(input: { | ||
| 64 | 64 | proposalChild?: string; |
| 65 | 65 | /** Set for an outside agent's contribute token: the one namespace its push may touch. */ |
| 66 | 66 | contribAgent?: string; |
| 67 | /** The address the pusher reached the forge at: a contribute push prints URLs on it. */ | |
| 68 | pushOrigin?: string; | |
| 67 | 69 | }): Record<string, string> { |
| 68 | 70 | const self = selfInvocation(); |
| 69 | 71 | const env: Record<string, string> = { |
| @@ -76,6 +78,7 @@ export function hookEnv(input: { | ||
| 76 | 78 | if (input.proposalChild !== undefined) |
| 77 | 79 | env.FORGE_PROPOSAL_CHILD = input.proposalChild; |
| 78 | 80 | if (input.contribAgent !== undefined) env.FORGE_CONTRIB_AGENT = input.contribAgent; |
| 81 | if (input.pushOrigin !== undefined) env.FORGE_PUSH_ORIGIN = input.pushOrigin; | |
| 79 | 82 | if (self.entry !== undefined) env.FORGE_ENTRY = self.entry; |
| 80 | 83 | return env; |
| 81 | 84 | } |
| @@ -133,6 +136,7 @@ export async function runHookClient(hook: HookName): Promise<never> { | ||
| 133 | 136 | ...(process.env.FORGE_CONTRIB_AGENT !== undefined |
| 134 | 137 | ? { contribAgent: process.env.FORGE_CONTRIB_AGENT } |
| 135 | 138 | : {}), |
| 139 | ...(process.env.FORGE_PUSH_ORIGIN !== undefined ? { pushOrigin: process.env.FORGE_PUSH_ORIGIN } : {}), | |
| 136 | 140 | updates: parseHookStdin(stdin), |
| 137 | 141 | gitEnv, |
| 138 | 142 | }), |
src/http/api.ts +27 −32
| @@ -193,10 +193,8 @@ import { | ||
| 193 | 193 | pullProposalOf, |
| 194 | 194 | recordPullProposal, |
| 195 | 195 | } from "../core/proposal_token.ts"; |
| 196 | import { OPEN_PULLS_PER_TOKEN, headInContribNamespace, pullCapReached } from "../core/contribute_token.ts"; | |
| 196 | import { capWords, headInContribNamespace, inTurn, pullCapReached } from "../core/contribute_token.ts"; | |
| 197 | 197 | |
| 198 | /** Tokens with a pull being opened now: one at a time, so the count cannot be raced. */ | |
| 199 | const contributePulling = new Set<string>(); | |
| 200 | 198 | import { takesOutside } from "../actions/contribute_token.ts"; |
| 201 | 199 | import { contributeMint } from "./api/contribute_token.ts"; |
| 202 | 200 | import { |
| @@ -2719,36 +2717,28 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2719 | 2717 | } |
| 2720 | 2718 | // An outside agent opens a few pulls: each starts checks and a preview on the |
| 2721 | 2719 | // owner's repository. Counted from the pull rows, one open at a time per token. |
| 2722 | if (outside !== undefined) { | |
| 2723 | const capped = pullCapReached(ctx.app.db, repo.id, ctx.auth.user!.id, new Date()); | |
| 2724 | if (capped !== undefined || contributePulling.has(outside.tokenId)) | |
| 2725 | return tryAgain( | |
| 2726 | "rate_limited", | |
| 2727 | 429, | |
| 2728 | capped === "repo_day" | |
| 2729 | ? "this repository has taken as many pulls from outside agents as it takes in a day; try tomorrow" | |
| 2730 | : capped === "token_open" | |
| 2731 | ? `this token has ${OPEN_PULLS_PER_TOKEN} pulls open; wait for one to close` | |
| 2732 | : "too many pulls opened by this token; try again later", | |
| 2733 | capped === undefined ? 1 : 3600, | |
| 2734 | ); | |
| 2735 | contributePulling.add(outside.tokenId); | |
| 2736 | } | |
| 2737 | let created: Awaited<ReturnType<typeof openPull>>; | |
| 2738 | try { | |
| 2739 | created = await openPull(ctx.app, ctx.auth.user, repo, { | |
| 2720 | const open = () => | |
| 2721 | openPull(ctx.app, ctx.auth.user!, repo, { | |
| 2740 | 2722 | title: str(input.title), |
| 2741 | body: str(input.body), | |
| 2742 | baseBranch: str(input.base), | |
| 2743 | headBranch: str(input.head), | |
| 2744 | ...(headRepo ? { headRepo } : {}), | |
| 2745 | // A person's deliberate yes to fixing a sealed report from a public branch. | |
| 2746 | ...(input.confirmPublic === true ? { confirmPublic: true } : {}), | |
| 2747 | ...(outside === undefined ? {} : { outsideAgent: true }), | |
| 2748 | }); | |
| 2749 | } finally { | |
| 2750 | if (outside !== undefined) contributePulling.delete(outside.tokenId); | |
| 2751 | } | |
| 2723 | body: str(input.body), | |
| 2724 | baseBranch: str(input.base), | |
| 2725 | headBranch: str(input.head), | |
| 2726 | ...(headRepo ? { headRepo } : {}), | |
| 2727 | // A person's deliberate yes to fixing a sealed report from a public branch. | |
| 2728 | ...(input.confirmPublic === true ? { confirmPublic: true } : {}), | |
| 2729 | ...(outside === undefined ? {} : { outsideAgent: true }), | |
| 2730 | }); | |
| 2731 | // An outside token opens one pull at a time (the same turn a push's opens take), | |
| 2732 | // its caps read inside the turn so the count cannot be raced. | |
| 2733 | const opening = | |
| 2734 | outside === undefined | |
| 2735 | ? await open() | |
| 2736 | : await inTurn(ctx.auth.user!.id, async () => { | |
| 2737 | const capped = pullCapReached(ctx.app.db, repo.id, ctx.auth.user!.id, new Date()); | |
| 2738 | return capped ?? (await open()); | |
| 2739 | }); | |
| 2740 | if (typeof opening === "string") return tryAgain("rate_limited", 429, capWords(opening), 3600); | |
| 2741 | const created = opening; | |
| 2752 | 2742 | if (!created.ok && created.error === "sealed_in_public") |
| 2753 | 2743 | return apiError( |
| 2754 | 2744 | "sealed_in_public", |
| @@ -4302,6 +4292,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4302 | 4292 | return { head: oid, runs: checksForHead(ctx.app.db, found.pull.id, oid) }; |
| 4303 | 4293 | }); |
| 4304 | 4294 | const latest = runs[0]; |
| 4295 | // `?format=text`: three plain lines, the URL a contribute push prints. | |
| 4296 | if (ctx.url.searchParams.get("format") === "text") | |
| 4297 | return new Response(`state: ${latest?.state ?? "none"}\nhead: ${head}\nrequired: ${found.repo.requireChecks ? "yes" : "no"}\n`, { | |
| 4298 | headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, | |
| 4299 | }); | |
| 4305 | 4300 | return timePhase(ctx.request, "render", () => |
| 4306 | 4301 | json({ |
| 4307 | 4302 | head, |
src/http/server.ts +46 −2
| @@ -97,6 +97,8 @@ import { readOnlyRefusal } from "./read_only.ts"; | ||
| 97 | 97 | import { running } from "../kernel/version.ts"; |
| 98 | 98 | import { runGit } from "../git/env.ts"; |
| 99 | 99 | import { noteRefusedInternal } from "../kernel/internal_refused.ts"; |
| 100 | import { CONTRIBUTE_PUSH_MAX_BYTES } from "../core/contribute_token.ts"; | |
| 101 | import { contribPushPulls, contribPushRefusal } from "../actions/contribute_push.ts"; | |
| 100 | 102 | import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts"; |
| 101 | 103 | import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts"; |
| 102 | 104 | import { repoDir } from "../git/repos.ts"; |
| @@ -1218,7 +1220,9 @@ export function buildRouter(): Router<Handler> { | ||
| 1218 | 1220 | repoId, |
| 1219 | 1221 | actorId: ctx.auth.user?.id ?? "", |
| 1220 | 1222 | ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}), |
| 1221 | ...(ctx.auth.contribute ? { contribAgent: ctx.auth.contribute.namespace } : {}), | |
| 1223 | ...(ctx.auth.contribute | |
| 1224 | ? { contribAgent: ctx.auth.contribute.namespace, pushOrigin: publicUrl(ctx.request, ctx.app.config.url).origin } | |
| 1225 | : {}), | |
| 1222 | 1226 | }), |
| 1223 | 1227 | ...cap, |
| 1224 | 1228 | }, |
| @@ -1816,6 +1820,22 @@ async function withGitAccess( | ||
| 1816 | 1820 | const refused = await storageRefusal(ctx.app, user, repo.ownerId, repo); |
| 1817 | 1821 | if (refused !== undefined) return gitRefused(ctx, storageRefusedText(refused)); |
| 1818 | 1822 | const cap = await pushCap(ctx.app, user, repo.ownerId, repo); |
| 1823 | // An outside agent's push is small: git stops reading past the cap and says the number. | |
| 1824 | if (ctx.auth.contribute !== undefined) { | |
| 1825 | // Merged into whatever pushCap set: its own cap is read by key, ours appended at COUNT | |
| 1826 | // (a later value of a key wins in git). | |
| 1827 | const n = Number(cap.GIT_CONFIG_COUNT ?? "0"); | |
| 1828 | let room = CONTRIBUTE_PUSH_MAX_BYTES; | |
| 1829 | for (let i = 0; i < n; i++) | |
| 1830 | if (cap[`GIT_CONFIG_KEY_${i}`] === "receive.maxInputSize") | |
| 1831 | room = Math.min(room, Number(cap[`GIT_CONFIG_VALUE_${i}`] ?? Infinity)); | |
| 1832 | // index-pack (unpackLimit 1), not unpack-objects, is the one whose refusal names the size. | |
| 1833 | cap[`GIT_CONFIG_KEY_${n}`] = "receive.maxInputSize"; | |
| 1834 | cap[`GIT_CONFIG_VALUE_${n}`] = String(room); | |
| 1835 | cap[`GIT_CONFIG_KEY_${n + 1}`] = "receive.unpackLimit"; | |
| 1836 | cap[`GIT_CONFIG_VALUE_${n + 1}`] = "1"; | |
| 1837 | cap.GIT_CONFIG_COUNT = String(n + 2); | |
| 1838 | } | |
| 1819 | 1839 | const served = await serve(dir, repo.id, cap); |
| 1820 | 1840 | return ctx.request.method === "POST" && served.body !== null |
| 1821 | 1841 | ? new Response(afterBody(served.body, () => storageChanged(ctx.app, repo.ownerId)), served) |
| @@ -2129,6 +2149,12 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 2129 | 2149 | { status: 403 }, |
| 2130 | 2150 | ); |
| 2131 | 2151 | } |
| 2152 | // New branches of an outside agent's open pulls: the push is refused, with the | |
| 2153 | // number, when its caps are spent or it makes more branches than there is room for. | |
| 2154 | if (input.contribAgent !== undefined && actor !== undefined) { | |
| 2155 | const spent = contribPushRefusal(app, repo, actor.id, input.updates); | |
| 2156 | if (spent !== undefined) return new Response(spent, { status: 403 }); | |
| 2157 | } | |
| 2132 | 2158 | // The pull namespace belongs to the forge, never to pushers. |
| 2133 | 2159 | if (update.ref.startsWith("refs/pull/")) { |
| 2134 | 2160 | return new Response(`${update.ref}: refs/pull/* is reserved`, { |
| @@ -2226,6 +2252,24 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 2226 | 2252 | ); |
| 2227 | 2253 | } |
| 2228 | 2254 | |
| 2255 | // An outside agent's push opens its pull (or finds it) and prints where it is. | |
| 2256 | let printed = ""; | |
| 2257 | if (input.contribAgent !== undefined) { | |
| 2258 | const origin = (() => { | |
| 2259 | try { | |
| 2260 | return new URL(input.pushOrigin ?? "").origin; | |
| 2261 | } catch { | |
| 2262 | return app.config.url !== undefined ? new URL(app.config.url).origin : `http://localhost:${app.config.port}`; | |
| 2263 | } | |
| 2264 | })(); | |
| 2265 | const actor = input.actorId ? getUserById(app.db, input.actorId) : undefined; | |
| 2266 | printed = (await contribPushPulls(app, repo, actor, input.updates, origin) | |
| 2267 | .catch((error) => { | |
| 2268 | app.log.warn("could not open the outside agent's pull", { repo: `${repo.ownerName}/${repo.name}`, error: String(error) }); | |
| 2269 | return []; | |
| 2270 | })).join("\n"); | |
| 2271 | } | |
| 2272 | ||
| 2229 | 2273 | // post-receive: the push is durable — record it and wake listeners. |
| 2230 | 2274 | app.bus.append({ |
| 2231 | 2275 | kind: "push", |
| @@ -2268,7 +2312,7 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 2268 | 2312 | error: String(error), |
| 2269 | 2313 | }), |
| 2270 | 2314 | ); |
| 2271 | return new Response(""); | |
| 2315 | return new Response(printed); | |
| 2272 | 2316 | } |
| 2273 | 2317 | |
| 2274 | 2318 | // ---- wiring ---- |
test/contribute-push.test.ts added +290
| @@ -0,0 +1,290 @@ | ||
| 1 | /** | |
| 2 | * An outside agent's push (#12013): accepted on its own contrib branch over | |
| 3 | * https, it opens a pull (the commit message is the body) and prints the pull's | |
| 4 | * URL and the plain-text check URL; a later push updates the same pull; a push | |
| 5 | * to main, to another token's branch, or after expiry is refused in one line; | |
| 6 | * the size cap and the pull caps refuse with their numbers. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { mintContributeToken, setOutsideContributions } from "../src/actions/contribute_token.ts"; | |
| 13 | import { pullWords } from "../src/actions/contribute_push.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { | |
| 17 | CONTRIBUTE_PUSH_MAX_BYTES, | |
| 18 | OPEN_PULLS_PER_TOKEN, | |
| 19 | PULLS_PER_AGENT_DAY, | |
| 20 | PULLS_PER_REPO_DAY, | |
| 21 | PULLS_PER_TOKEN_DAY, | |
| 22 | contribNamespace, | |
| 23 | inTurn, | |
| 24 | } from "../src/core/contribute_token.ts"; | |
| 25 | import { verifyToken } from "../src/core/tokens.ts"; | |
| 26 | import { createUser, type User } from "../src/core/users.ts"; | |
| 27 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 28 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 29 | import { quietLogs } from "./helpers/globals.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | describe("the pull's words", () => { | |
| 34 | test("the first line is the title and the whole message the body", () => { | |
| 35 | expect(pullWords("Fix the bug\n\nIt was off by one.\n", "contrib/x/y/z")).toEqual({ | |
| 36 | title: "Fix the bug", | |
| 37 | body: "Fix the bug\n\nIt was off by one.", | |
| 38 | }); | |
| 39 | expect(pullWords("", "contrib/x/y/z").title).toBe("contrib/x/y/z"); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("one open at a time per token", () => { | |
| 44 | test("calls with one key run in order, never together; other keys are not held", async () => { | |
| 45 | const log: string[] = []; | |
| 46 | let release!: () => void; | |
| 47 | const held = new Promise<void>((resolve) => (release = resolve)); | |
| 48 | const job = (name: string, wait?: Promise<void>) => async () => { | |
| 49 | log.push(`${name}:start`); | |
| 50 | await wait; | |
| 51 | log.push(`${name}:end`); | |
| 52 | }; | |
| 53 | // "a" holds its turn until "c" (another key) has run and "b" is queued behind it. | |
| 54 | const all = Promise.all([ | |
| 55 | inTurn("k", job("a", held)), | |
| 56 | inTurn("k", job("b")), | |
| 57 | inTurn("other", job("c")).then(() => release()), | |
| 58 | ]); | |
| 59 | await all; | |
| 60 | expect(log.indexOf("a:end")).toBeLessThan(log.indexOf("b:start")); | |
| 61 | expect(log.indexOf("c:start")).toBeLessThan(log.indexOf("a:end")); | |
| 62 | }); | |
| 63 | test("a failing call does not block the next", async () => { | |
| 64 | await inTurn("f", async () => Promise.reject(new Error("x"))).catch(() => undefined); | |
| 65 | expect(await inTurn("f", async () => 7)).toBe(7); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("pushing with a contribute token", () => { | |
| 70 | let dataDir: string; | |
| 71 | let app: App; | |
| 72 | let servers: Servers; | |
| 73 | let work: string; | |
| 74 | let seq = 0; | |
| 75 | ||
| 76 | const git = async (args: string[], cwd = work) => { | |
| 77 | const proc = Bun.spawn(["git", "-c", "credential.helper=", ...args], { | |
| 78 | cwd, | |
| 79 | env: gitClientEnv({ GIT_TERMINAL_PROMPT: "0" }), | |
| 80 | stdout: "pipe", | |
| 81 | stderr: "pipe", | |
| 82 | }); | |
| 83 | const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); | |
| 84 | return { code: await proc.exited, out, err }; | |
| 85 | }; | |
| 86 | ||
| 87 | beforeAll(async () => { | |
| 88 | dataDir = await mkdtemp(join(tmpdir(), "forge-contrib-push-")); | |
| 89 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 90 | servers = await serve(app); | |
| 91 | const made = await createUser(app.db, { | |
| 92 | origin: "seed", | |
| 93 | trusted: true, | |
| 94 | username: "alice", | |
| 95 | email: "[email protected]", | |
| 96 | password: "a-good-passphrase", | |
| 97 | adminIfFirst: true, | |
| 98 | }); | |
| 99 | if (!made.ok) throw new Error(made.error); | |
| 100 | const r = await createRepo(app, made.value, { name: "lib", visibility: "public" }); | |
| 101 | if (!r.ok) throw new Error(String(r.error)); | |
| 102 | const seeded = await commitToBranch(app, made.value, r.value, { | |
| 103 | branch: "main", | |
| 104 | message: "Start", | |
| 105 | files: [{ path: "README.md", content: "hi\n" }], | |
| 106 | }); | |
| 107 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 108 | setOutsideContributions(app, made.value, r.value, true); | |
| 109 | work = join(dataDir, "work"); | |
| 110 | expect((await git(["clone", `${servers.url}/alice/lib.git`, work], dataDir)).code).toBe(0); | |
| 111 | await git(["config", "user.email", "[email protected]"]); | |
| 112 | await git(["config", "user.name", "codex"]); | |
| 113 | }); | |
| 114 | ||
| 115 | afterAll(async () => { | |
| 116 | await servers.stop(); | |
| 117 | await rm(dataDir, { recursive: true, force: true }); | |
| 118 | }); | |
| 119 | ||
| 120 | const token = async (agent: string, expired = false) => { | |
| 121 | const m = await mintContributeToken(app, { agent, origin: "test", repo: "alice/lib" }, `203.0.113.${++seq}`, "https://forge.test"); | |
| 122 | if (!m.ok) throw new Error(`mint: ${JSON.stringify(m.error)}`); | |
| 123 | const v = verifyToken(app.db, m.value.token) as { ok: true; value: { user: User } }; | |
| 124 | if (expired) app.db.query("UPDATE token SET expires_at = ?1 WHERE id = ?2").run(new Date(Date.now() - 1000).toISOString(), m.value.id); | |
| 125 | return { | |
| 126 | ns: contribNamespace(agent, m.value.id), | |
| 127 | id: m.value.id, | |
| 128 | userId: v.value.user.id, | |
| 129 | url: `${servers.url.replace("://", `://${v.value.user.username}:${m.value.token}@`)}/alice/lib.git`, | |
| 130 | }; | |
| 131 | }; | |
| 132 | const commit = async (message: string, file = `f${++seq}.md`, content = "x\n") => { | |
| 133 | await Bun.write(join(work, file), content); | |
| 134 | await git(["add", "."]); | |
| 135 | await git(["commit", "-m", message]); | |
| 136 | }; | |
| 137 | const plain = (err: string) => err.split("\n").filter((l) => l.startsWith("remote:")); | |
| 138 | ||
| 139 | test("a new contrib branch opens a pull, prints its URLs, and a later push updates it", async () => { | |
| 140 | const t = await token("codex"); | |
| 141 | await commit("Add a fix\n\nIt explains itself."); | |
| 142 | const first = await git(["push", t.url, `HEAD:contrib/${t.ns}/fix`]); | |
| 143 | expect(first.code).toBe(0); | |
| 144 | const pull = /remote: opened pull #(\d+): (http\S+\/alice\/lib\/pulls\/\d+)/.exec(first.err); | |
| 145 | expect(pull).not.toBeNull(); | |
| 146 | const checks = /remote: checks \(plain text\): (http\S+\/pulls\/\d+\/checks\?format=text)/.exec(first.err); | |
| 147 | expect(checks).not.toBeNull(); | |
| 148 | // The printed URLs lead where they say: path on the server the test talks to. | |
| 149 | const page = await fetch(`${servers.url}${new URL(pull![2]!).pathname}`); | |
| 150 | expect(page.status).toBe(200); | |
| 151 | const text = await fetch(`${servers.url}${new URL(checks![1]!).pathname}${new URL(checks![1]!).search}`); | |
| 152 | expect(text.status).toBe(200); | |
| 153 | expect(text.headers.get("content-type")).toContain("text/plain"); | |
| 154 | expect(await text.text()).toContain("state:"); | |
| 155 | const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/lib/pulls/${pull![1]}`)).json()) as { title: string; body: string }; | |
| 156 | expect(api.title).toBe("Add a fix"); | |
| 157 | expect(api.body).toContain("It explains itself."); | |
| 158 | ||
| 159 | await commit("More"); | |
| 160 | const second = await git(["push", t.url, `HEAD:contrib/${t.ns}/fix`]); | |
| 161 | expect(second.code).toBe(0); | |
| 162 | expect(second.err).toContain(`remote: updated pull #${pull![1]}: `); | |
| 163 | expect(second.err).not.toContain("opened pull"); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a push to main, to another token's branch, or after expiry is refused in one line", async () => { | |
| 167 | const t = await token("codex"); | |
| 168 | const other = await token("codex"); | |
| 169 | await commit("Wrong places"); | |
| 170 | const toMain = await git(["push", t.url, "HEAD:main"]); | |
| 171 | expect(toMain.code).not.toBe(0); | |
| 172 | expect(plain(toMain.err).join("\n")).toContain(`this token pushes only contrib/${t.ns}/`); | |
| 173 | const elsewhere = await git(["push", t.url, `HEAD:contrib/${other.ns}/fix`]); | |
| 174 | expect(elsewhere.code).not.toBe(0); | |
| 175 | expect(plain(elsewhere.err).join("\n")).toContain(`this token pushes only contrib/${t.ns}/`); | |
| 176 | // A token past its expiry. | |
| 177 | const old = await token("codex", true); | |
| 178 | const expired = await git(["push", old.url, `HEAD:contrib/${old.ns}/fix`]); | |
| 179 | expect(expired.code).not.toBe(0); | |
| 180 | expect(expired.err).not.toContain("opened pull"); | |
| 181 | expect(expired.err).toContain("Authentication failed"); | |
| 182 | }); | |
| 183 | ||
| 184 | test("a push over the size cap is refused with the number", async () => { | |
| 185 | const t = await token("big"); | |
| 186 | // Random bytes, so the pack cannot shrink below the cap. | |
| 187 | const noise = crypto.getRandomValues(new Uint8Array(1024 * 1024)); | |
| 188 | const big = new Uint8Array(CONTRIBUTE_PUSH_MAX_BYTES + 1024 * 1024); | |
| 189 | for (let i = 0; i < big.length; i += noise.length) { | |
| 190 | big.set(crypto.getRandomValues(noise).subarray(0, Math.min(noise.length, big.length - i)), i); | |
| 191 | } | |
| 192 | const base = (await git(["rev-parse", "HEAD"])).out.trim(); | |
| 193 | try { | |
| 194 | await Bun.write(join(work, "big.bin"), big); | |
| 195 | await git(["add", "."]); | |
| 196 | await git(["commit", "-m", "Too much"]); | |
| 197 | const pushed = await git(["push", t.url, `HEAD:contrib/${t.ns}/big`]); | |
| 198 | expect(pushed.code).not.toBe(0); | |
| 199 | expect(pushed.err).toContain("5.00 MiB"); | |
| 200 | expect(CONTRIBUTE_PUSH_MAX_BYTES).toBe(5 * 1024 * 1024); | |
| 201 | } finally { | |
| 202 | await git(["reset", "--hard", base]); | |
| 203 | } | |
| 204 | }); | |
| 205 | ||
| 206 | test("a token's pull cap refuses the next new branch with the number", async () => { | |
| 207 | const t = await token("spam"); | |
| 208 | for (let i = 0; i < OPEN_PULLS_PER_TOKEN; i++) { | |
| 209 | await commit(`Change ${i}`); | |
| 210 | const ok = await git(["push", t.url, `HEAD:contrib/${t.ns}/p${i}`]); | |
| 211 | expect(ok.code).toBe(0); | |
| 212 | expect(ok.err).toContain("opened pull"); | |
| 213 | } | |
| 214 | await commit("One too many"); | |
| 215 | const over = await git(["push", t.url, `HEAD:contrib/${t.ns}/more`]); | |
| 216 | expect(over.code).not.toBe(0); | |
| 217 | expect(over.err).toContain(`${OPEN_PULLS_PER_TOKEN} pulls open`); | |
| 218 | // A branch already pulled may still be pushed to. | |
| 219 | expect((await git(["push", t.url, `HEAD:contrib/${t.ns}/p0`, "--force"])).code).toBe(0); | |
| 220 | }); | |
| 221 | ||
| 222 | test("one push may not create more branches than the token has pulls to open", async () => { | |
| 223 | const t = await token("wide"); | |
| 224 | await commit("Wide"); | |
| 225 | const refs = Array.from({ length: OPEN_PULLS_PER_TOKEN + 1 }, (_, i) => `HEAD:contrib/${t.ns}/w${i}`); | |
| 226 | const pushed = await git(["push", t.url, ...refs]); | |
| 227 | expect(pushed.code).not.toBe(0); | |
| 228 | expect(pushed.err).toContain(`creates ${OPEN_PULLS_PER_TOKEN + 1} new branches and this token may open ${OPEN_PULLS_PER_TOKEN} more pulls`); | |
| 229 | expect((await git(["ls-remote", t.url])).out).not.toContain(`contrib/${t.ns}/w0`); | |
| 230 | }); | |
| 231 | ||
| 232 | describe("the day's caps", () => { | |
| 233 | const seed = (userId: string, n: number) => { | |
| 234 | const repoId = (app.db.query("SELECT id FROM repo WHERE name = 'lib'").get() as { id: string }).id; | |
| 235 | for (let i = 0; i < n; i++) | |
| 236 | app.db | |
| 237 | .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'x', ?4, 'closed')") | |
| 238 | .run(`cap-${userId}-${i}`, repoId, 5000 + ++seq, userId); | |
| 239 | }; | |
| 240 | // Pulls of earlier tests are a day old here: only the seeded ones count. | |
| 241 | beforeAll(() => { | |
| 242 | app.db.query("UPDATE issue SET created_at = ?1 WHERE kind = 'pull'").run(new Date(Date.now() - 3 * 86_400_000).toISOString()); | |
| 243 | }); | |
| 244 | const clear = () => app.db.query("DELETE FROM issue WHERE id LIKE 'cap-%'").run(); | |
| 245 | const refused = async (t: { url: string; ns: string }) => { | |
| 246 | await commit("Over"); | |
| 247 | const pushed = await git(["push", t.url, `HEAD:contrib/${t.ns}/over`]); | |
| 248 | expect(pushed.code).not.toBe(0); | |
| 249 | return pushed.err; | |
| 250 | }; | |
| 251 | ||
| 252 | test("a token that opened its day's pulls is refused with the number", async () => { | |
| 253 | const t = await token("daily"); | |
| 254 | try { | |
| 255 | seed(t.userId, PULLS_PER_TOKEN_DAY); | |
| 256 | expect(await refused(t)).toContain(`this token has opened ${PULLS_PER_TOKEN_DAY} pulls in a day`); | |
| 257 | } finally { | |
| 258 | clear(); | |
| 259 | } | |
| 260 | }); | |
| 261 | ||
| 262 | test("the tokens of one agent name share a day, however many are minted", async () => { | |
| 263 | const a = await token("loop"); | |
| 264 | const b = await token("loop"); | |
| 265 | try { | |
| 266 | seed(a.userId, PULLS_PER_TOKEN_DAY - 1); | |
| 267 | seed(b.userId, PULLS_PER_AGENT_DAY - PULLS_PER_TOKEN_DAY + 1); | |
| 268 | expect(await refused(b)).toContain(`this agent name has opened ${PULLS_PER_AGENT_DAY} pulls in a day`); | |
| 269 | // Another name is not held to it. | |
| 270 | const other = await token("loop2"); | |
| 271 | await commit("Fine"); | |
| 272 | expect((await git(["push", other.url, `HEAD:contrib/${other.ns}/ok`])).code).toBe(0); | |
| 273 | } finally { | |
| 274 | clear(); | |
| 275 | } | |
| 276 | }); | |
| 277 | ||
| 278 | test("a repository takes at most a day's worth from all outside tokens", async () => { | |
| 279 | const t = await token("one"); | |
| 280 | try { | |
| 281 | // Fillers of distinct names, each under its own caps, together at the repository's. | |
| 282 | for (let left = PULLS_PER_REPO_DAY, i = 0; left > 0; i++, left -= PULLS_PER_TOKEN_DAY - 1) | |
| 283 | seed((await token(`filler${i}`)).userId, Math.min(left, PULLS_PER_TOKEN_DAY - 1)); | |
| 284 | expect(await refused(t)).toContain(`takes at most ${PULLS_PER_REPO_DAY} pulls a day`); | |
| 285 | } finally { | |
| 286 | clear(); | |
| 287 | } | |
| 288 | }); | |
| 289 | }); | |
| 290 | }); |
test/contribute-token.test.ts +10 −3
| @@ -328,15 +328,22 @@ describe("minting, renewing, revoking", () => { | ||
| 328 | 328 | const verified = verifyToken(app.db, made.value.token) as { ok: true; value: { user: User } }; |
| 329 | 329 | const url = `${servers.url.replace("://", `://${verified.value.user.username}:${made.value.token}@`)}/alice/lib.git`; |
| 330 | 330 | const refs = Array.from({ length: OPEN_PULLS_PER_TOKEN + 1 }, (_, i) => `HEAD:refs/heads/contrib/${ns}/p${i}`); |
| 331 | expect((await git(["push", url, ...refs])).code).toBe(0); | |
| 331 | // One push of six branches is refused whole, with the number it has room for. | |
| 332 | const wide = await git(["push", url, ...refs]); | |
| 333 | expect(wide.code).not.toBe(0); | |
| 334 | expect(wide.err).toContain(`creates ${OPEN_PULLS_PER_TOKEN + 1} new branches`); | |
| 335 | const pushed = await git(["push", url, ...refs.slice(0, OPEN_PULLS_PER_TOKEN)]); | |
| 336 | expect(pushed.code).toBe(0); | |
| 337 | expect(pushed.err.match(/remote: opened pull/g)?.length).toBe(OPEN_PULLS_PER_TOKEN); | |
| 332 | 338 | const open = (i: number) => post("/api/v1/repos/alice/lib/pulls", { title: `P${i}`, base: "main", head: `contrib/${ns}/p${i}` }, made.value.token); |
| 333 | for (let i = 0; i < OPEN_PULLS_PER_TOKEN; i++) expect((await open(i)).status).toBe(201); | |
| 334 | 339 | const over = await open(OPEN_PULLS_PER_TOKEN); |
| 335 | 340 | expect(over.status).toBe(429); |
| 336 | 341 | expect(over.headers.get("retry-after")).not.toBeNull(); |
| 337 | 342 | // Counted from the rows: closing one frees a place. |
| 338 | 343 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND author_id = ?2 AND number = (SELECT MIN(number) FROM issue WHERE repo_id = ?1 AND author_id = ?2)").run(pub.id, verified.value.user.id); |
| 339 | expect((await open(OPEN_PULLS_PER_TOKEN)).status).toBe(201); | |
| 344 | const freed = await git(["push", url, refs[OPEN_PULLS_PER_TOKEN]!]); | |
| 345 | expect(freed.code).toBe(0); | |
| 346 | expect(freed.err).toContain("remote: opened pull"); | |
| 340 | 347 | |
| 341 | 348 | // The repository's own day: pulls by many outside tokens together. |
| 342 | 349 | const others: User[] = []; |
new above
offline