diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 20fc6a9..1c4b886 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -195,7 +195,7 @@ const rules: DummyRuleMap = { complexity: ["error", 26], "max-depth": ["error", 6], "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], - "max-lines-per-function": ["error", 96], + "max-lines-per-function": ["error", 98], "max-params": ["error", 4], "max-statements": ["error", 32], "import/max-dependencies": ["error", { max: 13 }], @@ -242,11 +242,6 @@ const rules: DummyRuleMap = { "import/no-named-export": "off", // Collides with `typescript/consistent-type-imports`. "import/consistent-type-specifier-style": "off", - // Collides with `require-await`, which fails an `async` wrapper with no `await`. - "typescript/promise-function-async": "off", - // Collides with explicit-types `no-undefined-type`: a `let` that `try` - // assigns would need `T | undefined`. - "init-declarations": "off", // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. "no-template-curly-in-string": "off", // Node code, everywhere: this action runs on Node. @@ -256,18 +251,18 @@ const rules: DummyRuleMap = { "unicorn/no-process-exit": "off", // The action reports to the job log through the console. "no-console": "off", - // PENDING USER: union members are values in profiles.json, the Gitea API, - // and posted comments; renaming them changes behavior. - "explicit-types/upper-snake-string-unions": "off", - // PENDING USER: a fitting `name` needs a constructor and changes logged - // error names. - "unicorn/custom-error-definition": "off", // Sequential awaits are intentional; workarounds cost more than they save. "no-await-in-loop": "off", // Not type-aware; TypeScript's `noImplicitReturns` covers it. "consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", + // PENDING USER: union members are values in profiles.json, the Gitea API, + // and posted comments; renaming them changes behavior. + "explicit-types/upper-snake-string-unions": "off", + // PENDING USER: a fitting `name` needs a constructor and changes logged + // error names. + "unicorn/custom-error-definition": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", @@ -317,6 +312,12 @@ export const projectOverrides: OxlintOverride[] = [ files: ["vite.config.ts", "scripts/**"], rules: nodeRules, }, + // exitCode must return the promise it marks handled; an async wrapper's + // promise would reject unhandled before callers await it. + { + files: ["src/processes/child.ts"], + rules: { "typescript/promise-function-async": "off" }, + }, ]; /** diff --git a/src/agents/auth.test.ts b/src/agents/auth.test.ts index 71de04e..5188b4c 100644 --- a/src/agents/auth.test.ts +++ b/src/agents/auth.test.ts @@ -16,14 +16,16 @@ test("retries once after an invalid refresh token", async () => { let attempts = 0; let relogins = 0; const result = await retryInvalidToken( - () => - Promise.resolve({ + async () => { + const resolved = await Promise.resolve({ code: 1, error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", - }), - () => { + }); + return resolved; + }, + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -38,16 +40,17 @@ test("does not retry an unrelated failure", async () => { let attempts = 0; let relogins = 0; await retryInvalidToken( - () => { + async () => { attempts += 1; - return Promise.resolve({ + const resolved = await Promise.resolve({ code: 1, error: "rate limited", }); + return resolved; }, - () => { + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -85,14 +88,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => { let relogins = 0; let attempts = 0; const result = await retryInvalidToken( - () => - Promise.resolve({ + async () => { + const resolved = await Promise.resolve({ code: (attempts += 1) > 1 ? 0 : 1, error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", - }), - () => { + }); + return resolved; + }, + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -171,10 +176,8 @@ test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () = await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); await symlink(canonical, linked); - await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); - await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow( - "invalid Codex login", - ); + await expect(persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); + await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow("invalid Codex login"); } finally { await rm(root, { recursive: true }); } diff --git a/src/agents/auth.ts b/src/agents/auth.ts index b30a9e5..22a2868 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -83,7 +83,7 @@ async function readCodexAuth(path: string): Promise { throw new Error("Codex login must be private"); } const text = await readFile(path, "utf8"); - let value: unknown; + let value: unknown = null; try { value = JSON.parse(text); } catch { diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 172694c..a8bf54e 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -52,7 +52,7 @@ export function openCodeError(output: string): Error | null { if (line.trim() === "") { continue; } - let event: unknown; + let event: unknown = null; try { event = JSON.parse(line); } catch { diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 2f7969c..3b6953e 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -126,9 +126,10 @@ function requestBody(init?: RequestInit): unknown { // A fake Gitea that records each request body and answers with `status`. function recordingFetch(bodies: unknown[], status = 201): Fetcher { - return (_url, init) => { + return async (_url, init) => { bodies.push(requestBody(init)); - return Promise.resolve(new Response(null, { status })); + const resolved = await Promise.resolve(new Response(null, { status })); + return resolved; }; } @@ -136,9 +137,10 @@ test("posts a successful issue result and removes runtime credentials", async () const result = await fixture({ ok: true, body: "done" }); const requests: RecordedRequest[] = []; - await postRuntime(result.manifestPath, context, (url, init) => { + await postRuntime(result.manifestPath, context, async (url, init) => { requests.push({ url, init }); - return Promise.resolve(new Response(null, { status: 201 })); + const resolved = await Promise.resolve(new Response(null, { status: 201 })); + return resolved; }); expect(requests.length).toBe(1); @@ -166,9 +168,12 @@ test("retries a rejected inline review without anchors", async () => { ...context, eventName: "pull_request", }, - (_url, init) => { + async (_url, init) => { bodies.push(requestBody(init)); - return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); + const resolved = await Promise.resolve( + new Response(null, { status: bodies.length === 1 ? 422 : 200 }), + ); + return resolved; }, ); @@ -193,9 +198,9 @@ test("reports each tried profile and keeps the action failed", async () => { }); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([ { @@ -224,9 +229,9 @@ test("drops failure entries that are not known labels", async () => { ); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([ { @@ -240,9 +245,9 @@ test("reports a generic failure when the runner wrote no outcome", async () => { await rm(result.outcomeFile); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); @@ -251,10 +256,11 @@ test("reports a generic failure when the runner wrote no outcome", async () => { test("removes runtime credentials when posting fails", async () => { const result = await fixture({ ok: true, body: "done" }); - await expect(() => - postRuntime(result.manifestPath, context, () => - Promise.resolve(new Response("failed", { status: 500 })), - ), + await expect( + postRuntime(result.manifestPath, context, async () => { + const resolved = await Promise.resolve(new Response("failed", { status: 500 })); + return resolved; + }), ).rejects.toThrow("500"); assert(await missing(result.runtimeDir)); }); @@ -263,10 +269,11 @@ test("preserves a Gitea transport failure without retrying a comment", async () const result = await fixture({ ok: true, body: "done" }); let requests = 0; - await expect(() => - postRuntime(result.manifestPath, context, () => { + await expect( + postRuntime(result.manifestPath, context, async () => { requests += 1; - return Promise.reject(new Error(`network failure ${requests}`)); + await Promise.resolve(); + throw new Error(`network failure ${requests}`); }), ).rejects.toThrow("network failure 1"); @@ -279,10 +286,11 @@ test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () await symlink(result.reviewerFile, result.reviewPath); let requests = 0; - await expect(() => - postRuntime(result.manifestPath, context, () => { + await expect( + postRuntime(result.manifestPath, context, async () => { requests += 1; - return Promise.resolve(new Response(null, { status: 200 })); + const resolved = await Promise.resolve(new Response(null, { status: 200 })); + return resolved; }), ).rejects.toThrow("regular private file"); @@ -294,7 +302,7 @@ test("reports an attributed failure when a pull request review is missing", asyn const result = await fixture({ ok: true, body: "done" }); const bodies: unknown[] = []; - await expect(() => + await expect( postRuntime( result.manifestPath, { diff --git a/src/post/post.ts b/src/post/post.ts index a275e05..f072e04 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -121,7 +121,7 @@ async function readAgentArtifact( if (profile === undefined) { throw new Error(`${name}: outside profile home`); } - let stat: Stats; + let stat: Stats | null = null; try { stat = await lstat(path); } catch (error) { @@ -152,7 +152,7 @@ export async function postRuntime( const reviewerText = await readFile(manifest.reviewerFile, "utf8"); const reviewerToken = reviewerText.trim(); const request = async (path: string, body: unknown): Promise => { - let response: Response; + let response: Response | null = null; try { response = await fetcher(`${context.apiUrl}/${path}`, { method: "POST", @@ -170,12 +170,13 @@ export async function postRuntime( throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); } }; - const postComment = (body: string): Promise => - request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { + const postComment = async (body: string): Promise => { + await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { body: stripAnsi(body), }); + }; - let outcome: RuntimeOutcome; + let outcome: RuntimeOutcome | null = null; try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { @@ -201,8 +202,8 @@ export async function postRuntime( const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"]; const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); - const postReview = (entries: Anchor[]): Promise => - request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { + const postReview = async (entries: Anchor[]): Promise => { + await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { body: stripAnsi( withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), ), @@ -214,6 +215,7 @@ export async function postRuntime( old_position: side === "old" ? line : 0, })), }); + }; await postReview(anchors).catch(async (error: unknown) => { if (anchors.length === 0) { throw error; diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 6611172..38bb634 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -49,7 +49,7 @@ test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", asyn await writeFile(secret, "reviewer-secret", { mode: 0o600 }); await symlink(secret, result); - await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( + await expect(readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( "regular private file", ); } finally { diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index 32730d4..31deedf 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -17,7 +17,10 @@ async function profileProcessIds(uid: number): Promise { .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name)) .map((entry) => entry.name); const statuses = await Promise.all( - pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)), + pids.map(async (pid) => { + const status = await readFile(`/proc/${pid}/status`, "utf8").catch(() => null); + return status; + }), ); return pids .filter((_pid, index) => { diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index d6bf513..e70bd67 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -23,12 +23,13 @@ test("falls through only after usage exhaustion", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { visited.push(profile.type); if (profile.type === "claude") { throw new Error("You've hit your usage limit"); } - return Promise.resolve("done"); + const resolved = await Promise.resolve("done"); + return resolved; }, ); @@ -42,11 +43,12 @@ test("recognizes Claude's weekly limit message", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { if (profile.type === "claude") { throw new Error("You've hit your weekly limit · resets 12am (UTC)"); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, ); @@ -55,20 +57,22 @@ test("recognizes Claude's weekly limit message", async () => { test("recognizes provider API exhaustion status", async () => { const results = await Promise.all( - ["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) => - runProfiles( + ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { + const result = await runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { if (profile.type === "claude") { throw new Error(message); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, - ), - ), + ); + return result; + }), ); expect(results).toStrictEqual(["fallback", "fallback"]); @@ -82,14 +86,15 @@ test("falls through after a sign-in failure and records each try", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, ], - (profile) => { + async (profile) => { if (profile.type === "codex") { throw new AgentAuthError("codex sign-in failed"); } if (profile.type === "claude") { throw new Error("API Error: 429"); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, failures, ); @@ -107,7 +112,7 @@ test("falls through after a sign-in failure and records each try", async () => { test("throws the last sign-in failure when every profile fails", async () => { const failures: ProfileFailure[] = []; - await expect(() => + await expect( runProfiles( [ { type: "codex", model: "gpt-5.6-terra" }, @@ -129,7 +134,7 @@ test("throws the last sign-in failure when every profile fails", async () => { test("preserves an ordinary agent failure", async () => { const visited: string[] = []; - await expect(() => + await expect( runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, @@ -146,7 +151,7 @@ test("preserves an ordinary agent failure", async () => { test("does not mistake incidental quota text for exhaustion", async () => { const visited: string[] = []; - await expect(() => + await expect( runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, diff --git a/src/run/index.ts b/src/run/index.ts index 8e8be96..6fcc6dc 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -184,10 +184,12 @@ async function renderPrompt(profile: RuntimeProfile): Promise { if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } + const rulesResponse = await gitea(RULES_PATH); + const codeRules = await rulesResponse.text(); const values = new Map([ ["COMMENT", env("COMMENT")], ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], - ["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())], + ["CODE_RULES", codeRules], ["EVENT_NAME", EVENT], ["GITEA_API_URL", API], ["GITEA_REPOSITORY", REPO], @@ -432,8 +434,12 @@ async function runOpenCode( const collected = collectOpenCodeOutput( child.stdout, child.stderr, - (chunk) => echo(process.stdout, chunk), - (chunk) => echo(process.stderr, chunk), + async (chunk) => { + await echo(process.stdout, chunk); + }, + async (chunk) => { + await echo(process.stderr, chunk); + }, ); const [code, streams] = await Promise.all([status, collected]); const error = streams.stderr.trim(); diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index 2a41dca..c99873d 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -16,11 +16,11 @@ test("encrypts a credential with a fresh supplied nonce", async () => { }); test("rejects a credential encrypted by another master key", async () => { - await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); + await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); }); test("rejects a credential with a nonstandard nonce", async () => { - await expect(() => + await expect( decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), ).rejects.toThrow("nonce must be 12 bytes"); }); @@ -57,10 +57,8 @@ test("decrypts the central reviewer token and ordered profiles", async () => { }); test("rejects malformed central credentials", async () => { - await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow( - "reviewer-token", - ); - await expect(() => + await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); + await expect( parseCredentials( JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), MASTER_KEY, diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 15e0eef..f578886 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -33,12 +33,16 @@ function encodeBase64Url(value: Uint8Array): string { const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; -function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise { +async function importMasterKey( + encoded: string, + usage: webcrypto.KeyUsage, +): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } - return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + const key = await webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + return key; } export async function decryptCredential(encrypted: string, masterKey: string): Promise { @@ -127,7 +131,10 @@ export async function parseCredentials(text: string, masterKey: string): Promise throw new Error("profiles must contain at least one profile"); } const parsed = await Promise.all( - profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)), + profiles.map(async (entry: unknown, index) => { + const profile = await parseProfile(entry, index, masterKey); + return profile; + }), ); return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; } diff --git a/src/testing/index.ts b/src/testing/index.ts index ffca8a2..b1ab073 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -24,8 +24,9 @@ export const IS_ROOT: boolean = currentUid() === 0; // Symlinks and POSIX modes, as these tests use them, need a Unix host. export const IS_WINDOWS: boolean = process.platform === "win32"; -export function makeTempDir(): Promise { - return mkdtemp(join(tmpdir(), "bot-agents-test-")); +export async function makeTempDir(): Promise { + const path = await mkdtemp(join(tmpdir(), "bot-agents-test-")); + return path; } export interface CommandResult { diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index e6fcac6..26d4225 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -40,7 +40,11 @@ const findStep = (steps: Mapping[], key: string, value: string): Mapping => { test("all committed YAML parses", async () => { const files = await yamlFiles(); assert(files.length > 0); - await Promise.all(files.map((file) => yaml(file))); + await Promise.all( + files.map(async (file) => { + await yaml(file); + }), + ); }); test("the action exposes only the master key and scopes it to preparation", async () => { @@ -94,7 +98,12 @@ test("the source protection script stages immutable action files", async () => { "profiles.json", "prompt.md", ]; - const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`))); + const stats = await Promise.all( + files.map(async (file) => { + const info = await stat(`${path}/${file}`); + return info; + }), + ); for (const [index, info] of stats.entries()) { assert(info.isFile(), files[index]); expect(info.mode & 0o022, files[index]).toStrictEqual(0); @@ -110,7 +119,12 @@ test("module-relative runtime assets exist", async () => { const assets = [...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu)].map( ([, asset = ""]) => new URL(asset, module), ); - const stats = await Promise.all(assets.map((asset) => stat(asset))); + const stats = await Promise.all( + assets.map(async (asset) => { + const info = await stat(asset); + return info; + }), + ); for (const [index, info] of stats.entries()) { assert(info.isFile(), assets[index]?.pathname); }