Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b9aedf911 | |||
| a0dce6a4c2 | |||
| c783935936 | |||
| 71150900ff | |||
| d879b04e46 | |||
| a65bf353ab | |||
| bd1ddb9406 |
@@ -21,13 +21,11 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
|
||||
- **Published lint plugins** (2026-10-18, after they reached npm):
|
||||
`eslint-plugin-import-structure` (`modules` config) and
|
||||
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
|
||||
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
|
||||
config is left off: its `upper-snake-string-unions` and
|
||||
`no-upper-snake-object-keys` rules fire on wire tokens this repo does not
|
||||
name (profile types such as `"claude"`, failure reasons posted to Gitea,
|
||||
environment variable names), which cannot change without changing
|
||||
behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
|
||||
(no Lit).
|
||||
devDependencies, enabled in `vite.config.ts` through their `all` configs.
|
||||
`upper-snake-string-unions` was off while the user decided; since
|
||||
2026-10-19 it is on and profile types, failure reasons, and anchor sides
|
||||
are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust`
|
||||
does not apply (no Lit).
|
||||
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
|
||||
comment responses, the runtime manifest, and the outcome's profile type are
|
||||
validated; some type errors became `TypeError` with the same messages.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Enum-like string values are UPPER_SNAKE everywhere this repo owns the format, JSON included; external standards are converted at the boundary with explicit switch functions
|
||||
---
|
||||
|
||||
Decided 2026-10-19 (all repos). An enum-like string value, a member of a
|
||||
string-literal union, is `UPPER_SNAKE` wherever this repo owns the format:
|
||||
TypeScript unions, `profiles.json`, the runtime outcome file, and the
|
||||
`anchors.json` the agent writes per `prompt.md`. explicit-types
|
||||
`upper-snake-string-unions` enforces it and stays on.
|
||||
|
||||
A value an outside party defines (a CLI flag, an environment variable name,
|
||||
an HTTP API field, a human-readable message) is converted at the boundary by
|
||||
an explicit `switch` function, never by renaming our own values to match it:
|
||||
|
||||
```ts
|
||||
export interface ProfileIdentity {
|
||||
type: "CLAUDE" | "CODEX" | "OPENCODE";
|
||||
model: string;
|
||||
}
|
||||
|
||||
// The agent name the skills installer knows each profile type by.
|
||||
function skillsAgent(type: RuntimeProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-code";
|
||||
case "CODEX":
|
||||
return "codex";
|
||||
case "OPENCODE":
|
||||
return "opencode";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this repo:
|
||||
|
||||
- Profile `type`: `CLAUDE`, `CODEX`, `OPENCODE` (also in `profiles.json`).
|
||||
`skillsAgent()` (run phase) and `tokenVariable()` (profiles) map them to the
|
||||
installer's agent name and the credential environment variable;
|
||||
`defaultModel()` (credentials) picks a model.
|
||||
- `FailureReason`: `SIGN_IN_FAILED`, `USAGE_EXHAUSTED`, `FAILED`; `toMessage()`
|
||||
gives the words that logs and posted comments show.
|
||||
- Anchor `side`: `NEW` or `OLD` (`prompt.md` tells the agent so); the Gitea
|
||||
review call turns it into `new_position` / `old_position`.
|
||||
|
||||
A lookup object keyed by enum values would need `UPPER_SNAKE` keys, which
|
||||
explicit-types `no-upper-snake-object-keys` bans, so use a `switch`. TypeScript
|
||||
checks such a switch for exhaustiveness through the declared return type; no
|
||||
`default:` branch ([[lint-config]]).
|
||||
@@ -47,5 +47,37 @@ rules: {
|
||||
overrides: projectOverrides,
|
||||
```
|
||||
|
||||
## Rule decisions (2026-10-19, all repos)
|
||||
|
||||
- `typescript/promise-function-async` is on with `require-await` and
|
||||
`typescript/return-await`. A function that returns a promise is `async`
|
||||
and awaits it: `async f() { const x = await g(); return x; }`. Test
|
||||
doubles do the same (`const resolved = await Promise.resolve(v); return
|
||||
resolved;`), and `expect(promise).rejects` takes the promise itself.
|
||||
`src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must
|
||||
return the very promise it marks handled, or a failed spawn would surface
|
||||
as an unhandled rejection before callers await it.
|
||||
- `init-declarations` is on: an uninitialized `let` becomes
|
||||
`let x: T | null = null;` (`unknown` already includes `null`); a `try` that
|
||||
assigns and a `catch` that exits still narrow it.
|
||||
- `no-use-before-define` is on with only `{ functions: false }`.
|
||||
- `no-await-in-loop` is off (sequential awaits are intentional) and
|
||||
`unicorn/no-array-reduce` is on: sequential steps are plain `for` loops,
|
||||
never `reduce` chains or recursion.
|
||||
- `consistent-return`, `typescript/switch-exhaustiveness-check`, and
|
||||
`default-case` are off: TypeScript's checks cover them, and no `default:`
|
||||
branch is added to an exhaustive switch.
|
||||
- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*`
|
||||
rules are on. Untrusted data is `unknown` plus a type guard.
|
||||
- `unicorn/no-array-for-each` (use `for...of`),
|
||||
`unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing`
|
||||
(defaults) stay on.
|
||||
- explicit-types `upper-snake-string-unions` is on
|
||||
([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a
|
||||
custom error has a constructor that sets `this.name`.
|
||||
- `src/post/` and `src/profiles/` (code and tests) are exempt from
|
||||
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
|
||||
verdict marks and a middle dot.
|
||||
|
||||
A new category hit is fixed in the code; an off or exception is added only
|
||||
for a real collision or churn, with its reason. Rules: [[project-lint-plugin]].
|
||||
|
||||
@@ -11,12 +11,19 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
|
||||
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
|
||||
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
|
||||
- **Custom rules**, enabled as `project/<rule>`. Every repo has
|
||||
`no-disable-comments` and `no-asset-imports`; bot-agents has no others.
|
||||
`no-disable-comments` and `no-asset-imports`. Since 2026-10-19 every repo
|
||||
also has `prefer-ascii`, `no-nan` (a "no number" value is `null`, never
|
||||
`NaN`), and the hugoalh `recommended` ports from
|
||||
eslint-plugin-import-structure's own lint: `no-duplicate-typeofs`,
|
||||
`no-empty-yield`, `no-unsafe-number`, `no-useless-continue`,
|
||||
`no-duplicate-interfaces`, `no-duplicate-types`, `no-useless-type`, and
|
||||
`no-modifier-private`. `no-duplicate-awaits` and `no-duplicate-voids` are
|
||||
covered by `unicorn/no-unnecessary-await` and
|
||||
`typescript/no-meaningless-void-operator`, and `no-enum` by explicit-types.
|
||||
|
||||
`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs
|
||||
more files, they sit flat beside it and share the prefix
|
||||
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
|
||||
object and the rule list.
|
||||
`scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one
|
||||
`scripts/project-lint.ts` (no file splitting), next to the plugin object and
|
||||
the rule list.
|
||||
|
||||
```ts
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
|
||||
Generated
+4
-4
@@ -9,7 +9,7 @@
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@types/node": "^24.19.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"eslint-plugin-explicit-types": "^0.6.1",
|
||||
"eslint-plugin-explicit-types": "^0.7.0",
|
||||
"eslint-plugin-import-structure": "^0.10.2",
|
||||
"vite-plus": "^1.2.0",
|
||||
"yaml": "^2.9.1"
|
||||
@@ -1890,9 +1890,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-explicit-types": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz",
|
||||
"integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.0.tgz",
|
||||
"integrity": "sha512-TCX40NrxEKH+zXhIPvuY6e9dWjhHfAlEbkNlcyvhY7fUFG+sSIt3VMT0C/U2QGFbtVhDnV1xE7JkOlGGXzPcZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@types/node": "^24.19.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"eslint-plugin-explicit-types": "^0.6.1",
|
||||
"eslint-plugin-explicit-types": "^0.7.0",
|
||||
"eslint-plugin-import-structure": "^0.10.2",
|
||||
"vite-plus": "^1.2.0",
|
||||
"yaml": "^2.9.1"
|
||||
|
||||
+3
-3
@@ -2,16 +2,16 @@
|
||||
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
|
||||
"profiles": [
|
||||
{
|
||||
"type": "codex",
|
||||
"type": "CODEX",
|
||||
"model": "gpt-5.6-terra"
|
||||
},
|
||||
{
|
||||
"type": "claude",
|
||||
"type": "CLAUDE",
|
||||
"model": "claude-sonnet-5",
|
||||
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
|
||||
},
|
||||
{
|
||||
"type": "opencode",
|
||||
"type": "OPENCODE",
|
||||
"model": "openrouter/qwen/qwen3.8-27b",
|
||||
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ achieved.
|
||||
|
||||
Every finding that belongs to one line of the diff goes on that line instead of
|
||||
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
|
||||
`{"path": "<path from the repository root>", "line": <number>, "side": "new" |
|
||||
"old", "body": "<the finding>"}`.
|
||||
`side` is `new` for a line in the head file and `old` for one only in the base
|
||||
`{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
|
||||
"OLD", "body": "<the finding>"}`.
|
||||
`side` is `NEW` for a line in the head file and `OLD` for one only in the base
|
||||
file; `line` is that file's own line number, and it must be a line the diff
|
||||
touches, or Gitea refuses the anchor. Write the file only when there is
|
||||
something to anchor, and keep each body to the what, the why, and the how, with
|
||||
|
||||
+557
-21
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* `project` — this repository's changes to the Oxlint categories in
|
||||
* `vite.config.ts`. Its rules of its own are `no-disable-comments` and
|
||||
* `no-asset-imports`.
|
||||
* `project` - this repository's changes to the Oxlint categories in
|
||||
* `vite.config.ts`. Its rules of its own are `no-disable-comments`,
|
||||
* `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
|
||||
* rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
|
||||
*
|
||||
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
|
||||
import type {
|
||||
LanguageOptions,
|
||||
Position,
|
||||
RuleDefinition,
|
||||
RuleTextEdit,
|
||||
RuleVisitor,
|
||||
TextSourceCode,
|
||||
} from "@eslint/core";
|
||||
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
|
||||
import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
|
||||
|
||||
/** The source code both hosts hand a rule, with the comment list. */
|
||||
/** The source code both hosts hand a rule, with the comment list and index lookup. */
|
||||
interface ProjectSourceCode extends TextSourceCode {
|
||||
getAllComments: () => TSESTree.Comment[];
|
||||
getLocFromIndex: (index: number) => Position;
|
||||
}
|
||||
|
||||
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
||||
@@ -38,7 +47,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
|
||||
const RULE_NAME: string = "no-disable-comments";
|
||||
|
||||
/**
|
||||
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
|
||||
* `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
|
||||
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
|
||||
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
|
||||
* itself: when a rule fires, fix the source.
|
||||
@@ -68,6 +77,501 @@ const noDisableCommentsRule: ProjectRule = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The highest code point `prefer-ascii` lets through. */
|
||||
const MAX_ASCII_CODE_POINT: number = 127;
|
||||
|
||||
/**
|
||||
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
|
||||
* only through an entry in `projectOverrides`.
|
||||
*/
|
||||
const preferAsciiRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Require the source to be ASCII" },
|
||||
schema: [],
|
||||
messages: {
|
||||
nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Program(_node: TSESTree.Program): void {
|
||||
const { sourceCode } = context;
|
||||
let index: number = 0;
|
||||
for (const character of sourceCode.text) {
|
||||
const codePoint: number = character.codePointAt(0) ?? 0;
|
||||
if (codePoint > MAX_ASCII_CODE_POINT) {
|
||||
context.report({
|
||||
loc: {
|
||||
start: sourceCode.getLocFromIndex(index),
|
||||
end: sourceCode.getLocFromIndex(index + character.length),
|
||||
},
|
||||
messageId: "nonAscii",
|
||||
data: { character, codePoint: codePoint.toString(16).toUpperCase().padStart(4, "0") },
|
||||
});
|
||||
}
|
||||
index += character.length;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
|
||||
const noDuplicateTypeofsRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `typeof` applied twice in a row" },
|
||||
schema: [],
|
||||
messages: { doubled: 'Multiple `typeof`s always return `"string"`, possibly not intended.' },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
UnaryExpression(node: TSESTree.UnaryExpression): void {
|
||||
if (
|
||||
node.operator === "typeof" &&
|
||||
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
|
||||
node.argument.operator === "typeof"
|
||||
) {
|
||||
context.report({ node, messageId: "doubled" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
|
||||
const noEmptyYieldRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `yield` without an expression" },
|
||||
schema: [],
|
||||
messages: { empty: "Empty `yield` is not allowed, possibly missing the expression." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
YieldExpression(node: TSESTree.YieldExpression): void {
|
||||
if (node.argument === null) {
|
||||
context.report({ node, messageId: "empty" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
|
||||
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>(["globalThis", "self", "window"]);
|
||||
|
||||
/** The name a member expression's property spells, computed or not. */
|
||||
function propertyName(node: TSESTree.MemberExpression): string | null {
|
||||
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
|
||||
return node.property.name;
|
||||
}
|
||||
if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") {
|
||||
return node.property.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
|
||||
function isGlobalChain(node: TSESTree.Expression): boolean {
|
||||
if (node.type === AST_NODE_TYPES.Identifier) {
|
||||
return GLOBAL_NAMES.has(node.name);
|
||||
}
|
||||
return (
|
||||
node.type === AST_NODE_TYPES.MemberExpression &&
|
||||
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
|
||||
isGlobalChain(node.object)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a member expression reads the global `NaN`: `Number.NaN`,
|
||||
* `globalThis.NaN`, or `globalThis.Number.NaN`.
|
||||
*/
|
||||
function isNanMember(node: TSESTree.MemberExpression): boolean {
|
||||
if (propertyName(node) !== "NaN") {
|
||||
return false;
|
||||
}
|
||||
const { object } = node;
|
||||
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isGlobalChain(object) ||
|
||||
(object.type === AST_NODE_TYPES.MemberExpression &&
|
||||
propertyName(object) === "Number" &&
|
||||
isGlobalChain(object.object))
|
||||
);
|
||||
}
|
||||
|
||||
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
|
||||
const noNanRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `NaN` as a value" },
|
||||
schema: [],
|
||||
messages: { nan: "Using `NaN` as a value is usually an error and not intended." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Identifier(node: TSESTree.Identifier): void {
|
||||
if (node.name === "NaN" && node.parent.type !== AST_NODE_TYPES.MemberExpression) {
|
||||
context.report({ node, messageId: "nan" });
|
||||
}
|
||||
},
|
||||
MemberExpression(node: TSESTree.MemberExpression): void {
|
||||
if (isNanMember(node)) {
|
||||
context.report({ node, messageId: "nan" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-unsafe-number` - a number literal outside the safe integer range. */
|
||||
const noUnsafeNumberRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow number literals outside the safe integer range" },
|
||||
schema: [],
|
||||
messages: {
|
||||
unsafe:
|
||||
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Literal(node: TSESTree.Literal): void {
|
||||
if (
|
||||
typeof node.value === "number" &&
|
||||
(node.value < Number.MIN_SAFE_INTEGER || node.value > Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
context.report({ node, messageId: "unsafe" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
|
||||
type LoopStatement =
|
||||
| TSESTree.DoWhileStatement
|
||||
| TSESTree.ForInStatement
|
||||
| TSESTree.ForOfStatement
|
||||
| TSESTree.ForStatement
|
||||
| TSESTree.WhileStatement;
|
||||
|
||||
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
|
||||
interface TrailingContinue {
|
||||
statement: TSESTree.ContinueStatement;
|
||||
removable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unlabeled `continue` a loop ends with, and whether removing it is a
|
||||
* safe fix: it is in a block, rather than the loop's whole body.
|
||||
*/
|
||||
function trailingContinue(node: LoopStatement): TrailingContinue | null {
|
||||
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
|
||||
const last = node.body.body.at(-1);
|
||||
return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null
|
||||
? { statement: last, removable: true }
|
||||
: null;
|
||||
}
|
||||
if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) {
|
||||
return { statement: node.body, removable: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
|
||||
* trailing `continue;` in a block is removed by the fix; a loop whose whole
|
||||
* body is `continue;` is only reported.
|
||||
*/
|
||||
const noUselessContinueRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow an unlabeled `continue` at the end of a loop" },
|
||||
fixable: "code",
|
||||
schema: [],
|
||||
messages: {
|
||||
useless: "The statement `continue` at the end of the loop and without a label is useless.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(node: LoopStatement): void {
|
||||
const found: TrailingContinue | null = trailingContinue(node);
|
||||
if (found === null) {
|
||||
return;
|
||||
}
|
||||
if (found.removable) {
|
||||
context.report({
|
||||
node: found.statement,
|
||||
messageId: "useless",
|
||||
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
|
||||
});
|
||||
} else {
|
||||
context.report({ node: found.statement, messageId: "useless" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
DoWhileStatement: check,
|
||||
ForInStatement: check,
|
||||
ForOfStatement: check,
|
||||
ForStatement: check,
|
||||
WhileStatement: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
|
||||
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"parent",
|
||||
"range",
|
||||
"loc",
|
||||
"start",
|
||||
"end",
|
||||
]);
|
||||
|
||||
/** Node types whose member lists mean the same in any order. */
|
||||
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
"TSInterfaceBody",
|
||||
"TSTypeLiteral",
|
||||
"TSUnionType",
|
||||
"TSIntersectionType",
|
||||
]);
|
||||
|
||||
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
|
||||
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
"TSAnyKeyword",
|
||||
"TSBigIntKeyword",
|
||||
"TSBooleanKeyword",
|
||||
"TSIntrinsicKeyword",
|
||||
"TSNeverKeyword",
|
||||
"TSNullKeyword",
|
||||
"TSNumberKeyword",
|
||||
"TSObjectKeyword",
|
||||
"TSStringKeyword",
|
||||
"TSSymbolKeyword",
|
||||
"TSUndefinedKeyword",
|
||||
"TSUnknownKeyword",
|
||||
"TSVoidKeyword",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A node's shape as text, without positions, so two declarations compare
|
||||
* alike wherever they sit. Members of a type literal, interface body,
|
||||
* union, or intersection are sorted, since their order means nothing.
|
||||
*/
|
||||
function serialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
const unordered: boolean = "type" in value && UNORDERED_TYPES.has(String(value.type));
|
||||
const parts: string[] = Object.entries(value)
|
||||
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
|
||||
.map(([key, child]: [string, unknown]) => {
|
||||
const text: string =
|
||||
unordered && Array.isArray(child)
|
||||
? `[${child
|
||||
.map((item: unknown) => serialize(item))
|
||||
.toSorted()
|
||||
.join(",")}]`
|
||||
: serialize(child);
|
||||
return `${key}:${text}`;
|
||||
});
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
|
||||
/** The top-level declarations in a block, exported or not, that `matches`. */
|
||||
function declarationsOf<T extends TSESTree.Node>(
|
||||
statements: TSESTree.Node[],
|
||||
matches: (node: TSESTree.Node) => node is T,
|
||||
): T[] {
|
||||
const found: T[] = [];
|
||||
for (const statement of statements) {
|
||||
const declaration: TSESTree.Node | null =
|
||||
statement.type === AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement;
|
||||
if (declaration !== null && matches(declaration)) {
|
||||
found.push(declaration);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Whether a node is an interface declaration. */
|
||||
function isInterface(node: TSESTree.Node): node is TSESTree.TSInterfaceDeclaration {
|
||||
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
|
||||
}
|
||||
|
||||
/** Whether a node is a type alias declaration. */
|
||||
function isTypeAlias(node: TSESTree.Node): node is TSESTree.TSTypeAliasDeclaration {
|
||||
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
|
||||
}
|
||||
|
||||
/** Groups nodes by a key and returns the groups with more than one node. */
|
||||
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
|
||||
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
|
||||
return [...groups.values()].filter((group: T[]) => group.length > 1);
|
||||
}
|
||||
|
||||
/** What an interface declares, its `extends` list included. */
|
||||
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
|
||||
return `${serialize(node.extends)}&${serialize(node.body)}`;
|
||||
}
|
||||
|
||||
/** An interface's name. */
|
||||
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
|
||||
return node.id.name;
|
||||
}
|
||||
|
||||
/** What a type alias stands for. */
|
||||
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
|
||||
return serialize(node.typeAnnotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-duplicate-interfaces` - two interfaces in one block with the same
|
||||
* name, which merge silently, or with the same shape, which could be one.
|
||||
*/
|
||||
const noDuplicateInterfacesRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow interfaces with the same name or shape in one block" },
|
||||
schema: [],
|
||||
messages: {
|
||||
sameShape: "Found multiple interfaces with the same shape, possibly mergeable.",
|
||||
sameName: "Found multiple interfaces named '{{name}}', possibly not intended.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(statements: TSESTree.Node[]): void {
|
||||
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface);
|
||||
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
|
||||
context.report({ node, messageId: "sameShape" });
|
||||
}
|
||||
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
|
||||
context.report({ node, messageId: "sameName", data: { name: node.id.name } });
|
||||
}
|
||||
}
|
||||
return {
|
||||
BlockStatement(node: TSESTree.BlockStatement): void {
|
||||
check(node.body);
|
||||
},
|
||||
Program(node: TSESTree.Program): void {
|
||||
check(node.body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-duplicate-types` - two type aliases in one block with the same type. */
|
||||
const noDuplicateTypesRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow type aliases with the same type in one block" },
|
||||
schema: [],
|
||||
messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." },
|
||||
},
|
||||
create(context) {
|
||||
function check(statements: TSESTree.Node[]): void {
|
||||
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias);
|
||||
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
|
||||
context.report({ node, messageId: "sameShape" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
BlockStatement(node: TSESTree.BlockStatement): void {
|
||||
check(node.body);
|
||||
},
|
||||
Program(node: TSESTree.Program): void {
|
||||
check(node.body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A class member that can carry the `private` keyword. */
|
||||
type AccessibilityCarrier =
|
||||
| TSESTree.AccessorProperty
|
||||
| TSESTree.MethodDefinition
|
||||
| TSESTree.PropertyDefinition
|
||||
| TSESTree.TSAbstractAccessorProperty
|
||||
| TSESTree.TSAbstractMethodDefinition
|
||||
| TSESTree.TSAbstractPropertyDefinition
|
||||
| TSESTree.TSParameterProperty;
|
||||
|
||||
/**
|
||||
* `no-modifier-private` - class members are made private with `#`, never the
|
||||
* `private` keyword, which only TypeScript enforces. A `private constructor`
|
||||
* has no `#` form and stays allowed.
|
||||
*/
|
||||
const noModifierPrivateRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow the private modifier" },
|
||||
schema: [],
|
||||
messages: { private: "Use a # private name instead of the private modifier." },
|
||||
},
|
||||
create(context) {
|
||||
function check(node: AccessibilityCarrier): void {
|
||||
const isConstructor: boolean =
|
||||
node.type === AST_NODE_TYPES.MethodDefinition && node.kind === "constructor";
|
||||
if (node.accessibility === "private" && !isConstructor) {
|
||||
context.report({ node, messageId: "private" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
AccessorProperty: check,
|
||||
MethodDefinition: check,
|
||||
PropertyDefinition: check,
|
||||
TSAbstractAccessorProperty: check,
|
||||
TSAbstractMethodDefinition: check,
|
||||
TSAbstractPropertyDefinition: check,
|
||||
TSParameterProperty: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
|
||||
function isSimpleType(node: TSESTree.TypeNode): boolean {
|
||||
if (SIMPLE_TYPES.has(node.type)) {
|
||||
return true;
|
||||
}
|
||||
if (node.type === AST_NODE_TYPES.TSLiteralType) {
|
||||
return node.literal.type === AST_NODE_TYPES.Literal;
|
||||
}
|
||||
return (
|
||||
node.type === AST_NODE_TYPES.TSTypeReference &&
|
||||
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
node.typeArguments === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
|
||||
const noUselessTypeRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow type aliases too simple to need a name" },
|
||||
schema: [],
|
||||
messages: { simple: "The type is too simple to need an alias." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
|
||||
if (isSimpleType(node.typeAnnotation)) {
|
||||
context.report({ node, messageId: "simple" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
|
||||
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
||||
|
||||
@@ -104,7 +608,7 @@ function isAssetSource(source: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-asset-imports` — rejects importing anything but a script module: a
|
||||
* `no-asset-imports` - rejects importing anything but a script module: a
|
||||
* source path, static or a dynamic `import()` with a string literal, ending in
|
||||
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
|
||||
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
|
||||
@@ -195,7 +699,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 }],
|
||||
@@ -203,6 +707,17 @@ const rules: DummyRuleMap = {
|
||||
// No lint suppression and only `.ts` imports, as in the plugin template.
|
||||
"project/no-disable-comments": "error",
|
||||
"project/no-asset-imports": "error",
|
||||
// Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
|
||||
"project/prefer-ascii": "error",
|
||||
"project/no-duplicate-typeofs": "error",
|
||||
"project/no-empty-yield": "error",
|
||||
"project/no-nan": "error",
|
||||
"project/no-unsafe-number": "error",
|
||||
"project/no-useless-continue": "error",
|
||||
"project/no-duplicate-interfaces": "error",
|
||||
"project/no-duplicate-types": "error",
|
||||
"project/no-modifier-private": "error",
|
||||
"project/no-useless-type": "error",
|
||||
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
|
||||
// Collides with `#private`: members are public unless marked so.
|
||||
"typescript/explicit-member-accessibility": "off",
|
||||
@@ -242,13 +757,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 `no-await-in-loop`: sequential steps chain through `reduce`.
|
||||
"unicorn/no-array-reduce": "off",
|
||||
// Collides with literal Gitea Actions `${{ }}` expressions in workflow strings.
|
||||
"no-template-curly-in-string": "off",
|
||||
// Node code, everywhere: this action runs on Node.
|
||||
@@ -258,12 +766,14 @@ 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",
|
||||
// A `default` on a switch over a union would hide a new member from TS.
|
||||
"default-case": "off",
|
||||
// Off, too much churn:
|
||||
// Literal numbers read better than a constant per use.
|
||||
"no-magic-numbers": "off",
|
||||
@@ -313,6 +823,22 @@ 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" },
|
||||
},
|
||||
// Posted Gitea text shows emoji verdict marks and a middle dot.
|
||||
{
|
||||
files: [
|
||||
"src/post/post.ts",
|
||||
"src/post/post.test.ts",
|
||||
"src/profiles/profiles.ts",
|
||||
"src/profiles/profiles.test.ts",
|
||||
],
|
||||
rules: { "project/prefer-ascii": "off" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -324,6 +850,16 @@ const plugin = {
|
||||
rules: {
|
||||
"no-disable-comments": noDisableCommentsRule,
|
||||
"no-asset-imports": noAssetImportsRule,
|
||||
"prefer-ascii": preferAsciiRule,
|
||||
"no-duplicate-typeofs": noDuplicateTypeofsRule,
|
||||
"no-empty-yield": noEmptyYieldRule,
|
||||
"no-nan": noNanRule,
|
||||
"no-unsafe-number": noUnsafeNumberRule,
|
||||
"no-useless-continue": noUselessContinueRule,
|
||||
"no-duplicate-interfaces": noDuplicateInterfacesRule,
|
||||
"no-duplicate-types": noDuplicateTypesRule,
|
||||
"no-modifier-private": noModifierPrivateRule,
|
||||
"no-useless-type": noUselessTypeRule,
|
||||
},
|
||||
configs: { project: { rules } },
|
||||
};
|
||||
|
||||
+21
-18
@@ -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 });
|
||||
}
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ async function readCodexAuth(path: string): Promise<string> {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+51
-43
@@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
||||
profiles: [
|
||||
{
|
||||
index: 0,
|
||||
type: "claude",
|
||||
type: "CLAUDE",
|
||||
model: "test",
|
||||
uid: currentUid(),
|
||||
gid: currentGid(),
|
||||
@@ -89,7 +89,7 @@ async function fixture(
|
||||
const outcome: RuntimeOutcome = source.ok
|
||||
? {
|
||||
...source,
|
||||
profile: { type: "codex", model: "gpt-test" },
|
||||
profile: { type: "CODEX", model: "gpt-test" },
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
anchorsPath: `${profileHome}/anchors.json`,
|
||||
}
|
||||
@@ -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,16 +137,17 @@ 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);
|
||||
const [request] = requests;
|
||||
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
||||
expect(requestBody(request?.init)).toStrictEqual({
|
||||
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||
body: "done\n\n> Profile: `CODEX` · `gpt-test`",
|
||||
});
|
||||
expect(request?.init?.headers).toStrictEqual({
|
||||
Authorization: "token reviewer-secret",
|
||||
@@ -156,7 +158,7 @@ test("posts a successful issue result and removes runtime credentials", async ()
|
||||
|
||||
test("retries a rejected inline review without anchors", async () => {
|
||||
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
||||
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
||||
{ path: "run.ts", line: 1, side: "NEW", body: "Finding" },
|
||||
]);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
@@ -166,18 +168,21 @@ 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;
|
||||
},
|
||||
);
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
|
||||
comments: [{ body: "Finding\n\n> Profile: `CODEX` · `gpt-test`" }],
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({
|
||||
comments: [],
|
||||
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
||||
body: "✅ Approved\nReady\n\n> Profile: `CODEX` · `gpt-test`",
|
||||
});
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
@@ -186,23 +191,23 @@ test("reports each tried profile and keeps the action failed", async () => {
|
||||
const result = await fixture({
|
||||
ok: false,
|
||||
failures: [
|
||||
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
|
||||
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
|
||||
{ type: "opencode", model: "openrouter/test", reason: "failed" },
|
||||
{ type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
|
||||
{ type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
|
||||
{ type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
|
||||
],
|
||||
});
|
||||
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> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
||||
"`claude` · `claude-test` (usage exhausted), " +
|
||||
"`opencode` · `openrouter/test` (failed)",
|
||||
"AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (sign-in failed), " +
|
||||
"`CLAUDE` · `claude-test` (usage exhausted), " +
|
||||
"`OPENCODE` · `openrouter/test` (failed)",
|
||||
},
|
||||
]);
|
||||
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||
@@ -216,21 +221,21 @@ test("drops failure entries that are not known labels", async () => {
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
failures: [
|
||||
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
|
||||
{ type: "shell", model: "x", reason: "failed" },
|
||||
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
|
||||
{ type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
|
||||
{ type: "shell", model: "x", reason: "FAILED" },
|
||||
{ type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
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> Tried: `claude` · `claude-test` (sign-in failed)",
|
||||
body: "AI bot failed.\n\n> Tried: `CLAUDE` · `claude-test` (sign-in failed)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
{
|
||||
@@ -307,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn
|
||||
|
||||
expect(bodies).toStrictEqual([
|
||||
{
|
||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
||||
body: "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (failed)",
|
||||
},
|
||||
]);
|
||||
assert(await missing(result.runtimeDir));
|
||||
|
||||
+24
-17
@@ -16,7 +16,7 @@ export interface PostContext {
|
||||
interface Anchor {
|
||||
path: string;
|
||||
line: number;
|
||||
side: "new" | "old";
|
||||
side: "NEW" | "OLD";
|
||||
body: string;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ const VERDICTS = new Map<string, [event: string, mark: string]>([
|
||||
["Approved", ["APPROVED", "✅"]],
|
||||
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
|
||||
]);
|
||||
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
|
||||
const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]);
|
||||
const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
|
||||
const FAILURE_REASONS = new Set<unknown>(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]);
|
||||
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
|
||||
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
|
||||
const failureComment = (failures: ProfileFailure[]): string =>
|
||||
@@ -36,7 +36,12 @@ const failureComment = (failures: ProfileFailure[]): string =>
|
||||
// The `fetch` subset the reviewer posts through, which tests replace.
|
||||
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
class GiteaRequestError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "GiteaRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
@@ -51,7 +56,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const path = field(entry, "path");
|
||||
const line = field(entry, "line");
|
||||
const givenSide = field(entry, "side");
|
||||
const side = givenSide === undefined ? "new" : givenSide;
|
||||
const side = givenSide === undefined ? "NEW" : givenSide;
|
||||
const body = field(entry, "body");
|
||||
if (typeof path !== "string" || path === "") {
|
||||
throw new TypeError(`${at}.path: expected a repository path`);
|
||||
@@ -59,8 +64,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
||||
throw new TypeError(`${at}.line: expected a line number`);
|
||||
}
|
||||
if (side !== "new" && side !== "old") {
|
||||
throw new Error(`${at}.side: expected "new" or "old"`);
|
||||
if (side !== "NEW" && side !== "OLD") {
|
||||
throw new Error(`${at}.side: expected "NEW" or "OLD"`);
|
||||
}
|
||||
if (typeof body !== "string" || body.trim() === "") {
|
||||
throw new TypeError(`${at}.body: expected the finding`);
|
||||
@@ -121,7 +126,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 +157,7 @@ export async function postRuntime(
|
||||
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
|
||||
const reviewerToken = reviewerText.trim();
|
||||
const request = async (path: string, body: unknown): Promise<void> => {
|
||||
let response: Response;
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await fetcher(`${context.apiUrl}/${path}`, {
|
||||
method: "POST",
|
||||
@@ -170,12 +175,13 @@ export async function postRuntime(
|
||||
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
const postComment = (body: string): Promise<void> =>
|
||||
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||
const postComment = async (body: string): Promise<void> => {
|
||||
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 +207,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<void> =>
|
||||
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||
const postReview = async (entries: Anchor[]): Promise<void> => {
|
||||
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||
body: stripAnsi(
|
||||
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
||||
),
|
||||
@@ -210,10 +216,11 @@ export async function postRuntime(
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
new_position: side === "NEW" ? line : 0,
|
||||
old_position: side === "OLD" ? line : 0,
|
||||
})),
|
||||
});
|
||||
};
|
||||
await postReview(anchors).catch(async (error: unknown) => {
|
||||
if (anchors.length === 0) {
|
||||
throw error;
|
||||
@@ -227,7 +234,7 @@ export async function postRuntime(
|
||||
if (error instanceof GiteaRequestError) {
|
||||
throw error;
|
||||
}
|
||||
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||
await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }]));
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "claude",
|
||||
type: "CLAUDE",
|
||||
model: "test",
|
||||
uid,
|
||||
gid: uid,
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,10 @@ async function profileProcessIds(uid: number): Promise<number[]> {
|
||||
.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) => {
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "./profiles.ts";
|
||||
|
||||
test("formats profile attribution as a Markdown quote", () => {
|
||||
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
|
||||
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
||||
expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
|
||||
"> Profile: `OPENCODE` · `openrouter/qwen/qwen3.8-27b`",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,33 +20,35 @@ test("falls through only after usage exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ 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") {
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(visited).toStrictEqual(["claude", "codex"]);
|
||||
expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
|
||||
});
|
||||
|
||||
test("recognizes Claude's weekly limit message", async () => {
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") {
|
||||
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" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") {
|
||||
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"]);
|
||||
@@ -78,40 +82,41 @@ test("falls through after a sign-in failure and records each try", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "codex") {
|
||||
async (profile) => {
|
||||
if (profile.type === "CODEX") {
|
||||
throw new AgentAuthError("codex sign-in failed");
|
||||
}
|
||||
if (profile.type === "claude") {
|
||||
if (profile.type === "CLAUDE") {
|
||||
throw new Error("API Error: 429");
|
||||
}
|
||||
return Promise.resolve("fallback");
|
||||
const resolved = await Promise.resolve("fallback");
|
||||
return resolved;
|
||||
},
|
||||
failures,
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
expect(failures).toStrictEqual([
|
||||
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
|
||||
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
|
||||
]);
|
||||
expect(failureFooter(failures)).toStrictEqual(
|
||||
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
|
||||
"`claude` · `claude-sonnet-5` (usage exhausted)",
|
||||
"> Tried: `CODEX` · `gpt-5.6-terra` (sign-in failed), " +
|
||||
"`CLAUDE` · `claude-sonnet-5` (usage exhausted)",
|
||||
);
|
||||
});
|
||||
|
||||
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" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
],
|
||||
(profile) => {
|
||||
throw new AgentAuthError(`${profile.type} sign-in failed`);
|
||||
@@ -121,19 +126,19 @@ test("throws the last sign-in failure when every profile fails", async () => {
|
||||
).rejects.toThrow(
|
||||
expect.toSatisfy(
|
||||
(error: unknown) =>
|
||||
error instanceof AgentAuthError && error.message.includes("claude sign-in failed"),
|
||||
error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"),
|
||||
),
|
||||
);
|
||||
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
|
||||
expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]);
|
||||
});
|
||||
|
||||
test("preserves an ordinary agent failure", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(() =>
|
||||
await expect(
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
@@ -141,16 +146,16 @@ test("preserves an ordinary agent failure", async () => {
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("tests failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
expect(visited).toStrictEqual(["CLAUDE"]);
|
||||
});
|
||||
|
||||
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" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
@@ -158,7 +163,7 @@ test("does not mistake incidental quota text for exhaustion", async () => {
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("quota parser test failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
expect(visited).toStrictEqual(["CLAUDE"]);
|
||||
});
|
||||
|
||||
test("exposes only the selected profile credential to the agent", () => {
|
||||
@@ -178,7 +183,7 @@ test("exposes only the selected profile credential to the agent", () => {
|
||||
]);
|
||||
const profile = {
|
||||
index: 0,
|
||||
type: "claude" as const,
|
||||
type: "CLAUDE" as const,
|
||||
model: "claude-sonnet-5",
|
||||
uid: 20_000,
|
||||
gid: 20_000,
|
||||
@@ -204,7 +209,7 @@ test("exposes only the selected profile credential to the agent", () => {
|
||||
test("uses a private Codex home without inventing an API credential", () => {
|
||||
const profile = {
|
||||
index: 1,
|
||||
type: "codex" as const,
|
||||
type: "CODEX" as const,
|
||||
model: "gpt-5.6-terra",
|
||||
uid: 20_001,
|
||||
gid: 20_001,
|
||||
@@ -224,7 +229,7 @@ test("uses a private Codex home without inventing an API credential", () => {
|
||||
test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
||||
const profile = {
|
||||
index: 2,
|
||||
type: "opencode" as const,
|
||||
type: "OPENCODE" as const,
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
uid: 20_002,
|
||||
gid: 20_002,
|
||||
|
||||
+53
-39
@@ -1,6 +1,6 @@
|
||||
// The agent and model a profile runs, which every report names.
|
||||
export interface ProfileIdentity {
|
||||
type: "claude" | "codex" | "opencode";
|
||||
type: "CLAUDE" | "CODEX" | "OPENCODE";
|
||||
model: string;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,19 @@ export function withProfile(body: string, profile: ProfileIdentity): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
||||
export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
|
||||
|
||||
// The words a failure reason reads as in logs and posted comments.
|
||||
function toMessage(reason: FailureReason): string {
|
||||
switch (reason) {
|
||||
case "SIGN_IN_FAILED":
|
||||
return "sign-in failed";
|
||||
case "USAGE_EXHAUSTED":
|
||||
return "usage exhausted";
|
||||
case "FAILED":
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProfileFailure extends ProfileIdentity {
|
||||
reason: FailureReason;
|
||||
@@ -29,7 +41,12 @@ interface ProfileHome {
|
||||
}
|
||||
|
||||
// Raised once an agent's credentials are rejected and any re-login is spent.
|
||||
export class AgentAuthError extends Error {}
|
||||
export class AgentAuthError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AgentAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||
@@ -39,9 +56,9 @@ function isUsageExhausted(error: unknown): boolean {
|
||||
|
||||
function failureReason(error: unknown): FailureReason {
|
||||
if (error instanceof AgentAuthError) {
|
||||
return "sign-in failed";
|
||||
return "SIGN_IN_FAILED";
|
||||
}
|
||||
return isUsageExhausted(error) ? "usage exhausted" : "failed";
|
||||
return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
|
||||
}
|
||||
|
||||
export function failureFooter(failures: ProfileFailure[]): string {
|
||||
@@ -49,41 +66,30 @@ export function failureFooter(failures: ProfileFailure[]): string {
|
||||
return "> Profile: none";
|
||||
}
|
||||
return `> Tried: ${failures
|
||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${toMessage(reason)})`)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||
// or exhausted usage, and records every profile it tried.
|
||||
async function attemptFrom<T, P extends ProfileIdentity>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[],
|
||||
index: number,
|
||||
): Promise<T> {
|
||||
const profile = profiles[index];
|
||||
if (profile === undefined) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "failed" || index === profiles.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runProfiles<T, P extends ProfileIdentity>(
|
||||
// Tries the profiles in order, moving on only after a sign-in failure or
|
||||
// exhausted usage, and records every profile it tried.
|
||||
export async function runProfiles<T, P extends ProfileIdentity>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
): Promise<T> {
|
||||
return attemptFrom(profiles, attempt, failures, 0);
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "FAILED" || index === profiles.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
console.error(`${profile.type} ${toMessage(reason)}; trying next profile`);
|
||||
}
|
||||
}
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
|
||||
// Environments are maps from variable name to value, converted to the object
|
||||
@@ -126,6 +132,18 @@ export function sanitizedEnvironment(
|
||||
return result;
|
||||
}
|
||||
|
||||
// The environment variable each agent CLI reads its credential from.
|
||||
function tokenVariable(type: ProfileIdentity["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
case "CODEX":
|
||||
return "OPENAI_API_KEY";
|
||||
case "OPENCODE":
|
||||
return "OPENROUTER_API_KEY";
|
||||
}
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: ReadonlyMap<string, string>,
|
||||
profile: ProfileHome,
|
||||
@@ -133,15 +151,11 @@ export function agentEnvironment(
|
||||
): Map<string, string> {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result.set("HOME", profile.home);
|
||||
if (profile.type === "codex") {
|
||||
if (profile.type === "CODEX") {
|
||||
result.set("CODEX_HOME", `${profile.home}/.codex`);
|
||||
}
|
||||
if (token !== undefined && token !== "") {
|
||||
const tokenName = {
|
||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
codex: "OPENAI_API_KEY",
|
||||
opencode: "OPENROUTER_API_KEY",
|
||||
}[profile.type];
|
||||
const tokenName = tokenVariable(profile.type);
|
||||
result.set(tokenName, token);
|
||||
}
|
||||
return result;
|
||||
|
||||
+31
-23
@@ -150,6 +150,18 @@ async function runAgentCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// The agent name the skills installer knows each profile type by.
|
||||
function skillsAgent(type: RuntimeProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-code";
|
||||
case "CODEX":
|
||||
return "codex";
|
||||
case "OPENCODE":
|
||||
return "opencode";
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
await runAgentCommand(profile, "git", [
|
||||
"config",
|
||||
@@ -163,7 +175,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
...SKILLS_INSTALL.split(" "),
|
||||
"--global",
|
||||
"--agent",
|
||||
profile.type === "claude" ? "claude-code" : profile.type,
|
||||
skillsAgent(profile.type),
|
||||
"--yes",
|
||||
]);
|
||||
}
|
||||
@@ -184,10 +196,12 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
||||
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<string, string>([
|
||||
["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],
|
||||
@@ -283,21 +297,6 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
|
||||
return result.result;
|
||||
}
|
||||
|
||||
// Polls every 100 ms until the login prints something, failing if it exits first.
|
||||
async function pollForInstructions(
|
||||
shownText: () => string,
|
||||
status: Promise<number>,
|
||||
): Promise<void> {
|
||||
if (shownText().trim() !== "") {
|
||||
return;
|
||||
}
|
||||
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
|
||||
if (exited) {
|
||||
throw new AgentAuthError("AI bot login produced no instructions");
|
||||
}
|
||||
await pollForInstructions(shownText, status);
|
||||
}
|
||||
|
||||
async function codexDeviceLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: ReadonlyMap<string, string>,
|
||||
@@ -319,7 +318,12 @@ async function codexDeviceLogin(
|
||||
};
|
||||
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
||||
await sleep(3000);
|
||||
await pollForInstructions(() => shown, status);
|
||||
while (shown.trim() === "") {
|
||||
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
|
||||
if (exited) {
|
||||
throw new AgentAuthError("AI bot login produced no instructions");
|
||||
}
|
||||
}
|
||||
await postAuthorComment(withProfile(shown, profile));
|
||||
const [code] = await Promise.all([status, drained]);
|
||||
if (code !== 0) {
|
||||
@@ -442,8 +446,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();
|
||||
@@ -455,7 +463,7 @@ async function runOpenCode(
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const token = await readProfileToken(profile);
|
||||
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
|
||||
const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
|
||||
let stagedCodexAuth: string | null = null;
|
||||
let body: string | null = null;
|
||||
let resultPath: string | null = null;
|
||||
@@ -469,9 +477,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const rendered = await renderPrompt(profile);
|
||||
reviewPath = rendered.reviewPath;
|
||||
anchorsPath = rendered.anchorsPath;
|
||||
if (profile.type === "claude") {
|
||||
if (profile.type === "CLAUDE") {
|
||||
body = await runClaude(profile, token ?? "", rendered.prompt);
|
||||
} else if (profile.type === "codex") {
|
||||
} else if (profile.type === "CODEX") {
|
||||
resultPath = await runCodex(
|
||||
profile,
|
||||
rendered.prompt,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -31,9 +31,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{ type: "claude", token: ENCRYPTED },
|
||||
{ type: "opencode", token: ENCRYPTED },
|
||||
{ type: "CODEX", model: "gpt-custom" },
|
||||
{ type: "CLAUDE", token: ENCRYPTED },
|
||||
{ type: "OPENCODE", token: ENCRYPTED },
|
||||
],
|
||||
}),
|
||||
MASTER_KEY,
|
||||
@@ -41,14 +41,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
).toStrictEqual({
|
||||
reviewerToken: "secret-token",
|
||||
profiles: [
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{ type: "CODEX", model: "gpt-custom" },
|
||||
{
|
||||
type: "claude",
|
||||
type: "CLAUDE",
|
||||
model: "claude-sonnet-5",
|
||||
token: "secret-token",
|
||||
},
|
||||
{
|
||||
type: "opencode",
|
||||
type: "OPENCODE",
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
token: "secret-token",
|
||||
},
|
||||
@@ -57,12 +57,10 @@ 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" }] }),
|
||||
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }),
|
||||
MASTER_KEY,
|
||||
),
|
||||
).rejects.toThrow("profiles[0].token");
|
||||
|
||||
+24
-17
@@ -3,18 +3,18 @@ import { webcrypto } from "node:crypto";
|
||||
import { field, isMapping } from "#common";
|
||||
import type { BotProfile } from "#profiles";
|
||||
|
||||
interface DefaultModels {
|
||||
claude: string;
|
||||
codex: string;
|
||||
opencode: string;
|
||||
// The model a profile runs when profiles.json names none.
|
||||
function defaultModel(type: BotProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-sonnet-5";
|
||||
case "CODEX":
|
||||
return "gpt-5.6-terra";
|
||||
case "OPENCODE":
|
||||
return "openrouter/qwen/qwen3.8-27b";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MODELS: DefaultModels = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
opencode: "openrouter/qwen/qwen3.8-27b",
|
||||
};
|
||||
|
||||
export interface BotCredentials {
|
||||
reviewerToken: string;
|
||||
profiles: BotProfile[];
|
||||
@@ -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<webcrypto.CryptoKey> {
|
||||
async function importMasterKey(
|
||||
encoded: string,
|
||||
usage: webcrypto.KeyUsage,
|
||||
): Promise<webcrypto.CryptoKey> {
|
||||
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<string> {
|
||||
@@ -87,13 +91,13 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
|
||||
const type = field(entry, "type");
|
||||
const model = field(entry, "model");
|
||||
const token = field(entry, "token");
|
||||
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
||||
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
|
||||
if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
|
||||
throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
|
||||
}
|
||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||
throw new Error(`${at}.model: expected a model name`);
|
||||
}
|
||||
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
||||
if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
if (token !== undefined && typeof token !== "string") {
|
||||
@@ -101,7 +105,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
|
||||
}
|
||||
return {
|
||||
type,
|
||||
model: model ?? DEFAULT_MODELS[type],
|
||||
model: model ?? defaultModel(type),
|
||||
...(typeof token === "string" && {
|
||||
token: await decryptCredential(token, masterKey),
|
||||
}),
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
|
||||
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "claude", token: ENCRYPTED }],
|
||||
profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
|
||||
+22
-26
@@ -61,32 +61,28 @@ export async function prepareRuntime(
|
||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||
|
||||
// One profile at a time, in order, as each claims the next UID.
|
||||
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
|
||||
async (previous, profile, index) => {
|
||||
const prepared = await previous;
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = join(runtimeDir, `profile-${index}`);
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chown(home, uid, uid);
|
||||
const tokenFile =
|
||||
profile.token === undefined || profile.token === "" ? null : join(home, "token");
|
||||
if (tokenFile !== null && profile.token !== undefined) {
|
||||
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||
}
|
||||
prepared.push({
|
||||
index,
|
||||
type: profile.type,
|
||||
model: profile.model,
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile,
|
||||
});
|
||||
return prepared;
|
||||
},
|
||||
Promise.resolve([]),
|
||||
);
|
||||
const profiles: RuntimeProfile[] = [];
|
||||
for (const [index, profile] of credentials.profiles.entries()) {
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = join(runtimeDir, `profile-${index}`);
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chown(home, uid, uid);
|
||||
const tokenFile =
|
||||
profile.token === undefined || profile.token === "" ? null : join(home, "token");
|
||||
if (tokenFile !== null && profile.token !== undefined) {
|
||||
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||
}
|
||||
profiles.push({
|
||||
index,
|
||||
type: profile.type,
|
||||
model: profile.model,
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile,
|
||||
});
|
||||
}
|
||||
|
||||
const manifestPath = join(runtimeDir, "manifest.json");
|
||||
const manifest: RuntimeManifest = {
|
||||
|
||||
@@ -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<string> {
|
||||
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
|
||||
export async function makeTempDir(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), "bot-agents-test-"));
|
||||
return path;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
|
||||
+20
-6
@@ -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\("(?<asset>[^"]+)", 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);
|
||||
}
|
||||
@@ -180,9 +194,9 @@ test("central profiles fall back from Codex to Claude to OpenCode", async () =>
|
||||
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
|
||||
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
|
||||
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
|
||||
"codex",
|
||||
"claude",
|
||||
"opencode",
|
||||
"CODEX",
|
||||
"CLAUDE",
|
||||
"OPENCODE",
|
||||
]);
|
||||
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
|
||||
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
|
||||
|
||||
Reference in New Issue
Block a user