Skip to content

Commit 287d447

Browse files
committed
ci: add GitHub Actions workflow + tests for new code
Adds: * `.github/workflows/ci.yml` — adapted from openai/codex-plugin-cc's pull-request-ci.yml. Runs on every PR and on pushes to main, on Node 22, syntax-checks all `.mjs` companion scripts via `node --check`, and runs the full test suite (`npm test`). Pinned action SHAs match codex's workflow for parity. * `package-lock.json` — generated so CI can use `npm ci` for reproducible installs (was missing in upstream tasict/opencode-plugin-cc). * `tests/git.test.mjs` — 11 new cases for `detectPrReference`, covering positive matches (`PR #N`, `pr #N`, `PR N`, embedded inside longer focus text), negatives (bare `#N` issue refs, plain text, empty/null), and the strip-from-focus workflow used by handleAdversarialReview. * `tests/process.test.mjs` — 7 new cases for `findOpencodeAuthFile` and `getConfiguredProviders`, covering valid auth.json with one or many providers, empty object, malformed JSON, JSON array, JSON null, and the XDG_DATA_HOME-first lookup order. Tests override XDG_DATA_HOME so they read fixtures from a tmp dir instead of the developer's real ~/.local/share/opencode/auth.json. Test count: 39 -> 57. All passing on a clean `npm ci` install. The "missing auth.json" case is intentionally not asserted because findOpencodeAuthFile falls through to platform-default paths (~/.local/share/opencode/auth.json on Linux) which may legitimately exist on a developer machine.
1 parent d29cc1e commit 287d447

4 files changed

Lines changed: 237 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI
2+
3+
# Adapted from openai/codex-plugin-cc's pull-request-ci.yml.
4+
# Modified by JohnnyVicious (2026): drops the Codex CLI install step (this
5+
# fork wraps OpenCode, not Codex) and runs only on a single Node version
6+
# matching the package.json `engines.node` floor. (Apache License 2.0
7+
# §4(b) modification notice — see NOTICE.)
8+
9+
on:
10+
pull_request:
11+
push:
12+
branches:
13+
- main
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
test:
20+
name: Test
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 5
23+
24+
steps:
25+
- name: Check out repository
26+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
27+
28+
- name: Set up Node.js
29+
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
30+
with:
31+
node-version: 22
32+
cache: npm
33+
34+
- name: Install dependencies
35+
run: npm ci
36+
37+
- name: Syntax-check companion scripts
38+
run: |
39+
node --check plugins/opencode/scripts/opencode-companion.mjs
40+
node --check plugins/opencode/scripts/lib/git.mjs
41+
node --check plugins/opencode/scripts/lib/prompts.mjs
42+
node --check plugins/opencode/scripts/lib/process.mjs
43+
node --check plugins/opencode/scripts/lib/opencode-server.mjs
44+
45+
- name: Run tests
46+
run: npm test

package-lock.json

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/git.test.mjs

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import fs from "node:fs";
44
import path from "node:path";
55
import { createTmpDir, cleanupTmpDir } from "./helpers.mjs";
66
import { runCommand } from "../plugins/opencode/scripts/lib/process.mjs";
7-
import { getGitRoot, getCurrentBranch, getStatus } from "../plugins/opencode/scripts/lib/git.mjs";
7+
import {
8+
getGitRoot,
9+
getCurrentBranch,
10+
getStatus,
11+
detectPrReference,
12+
} from "../plugins/opencode/scripts/lib/git.mjs";
813

914
let tmpDir;
1015

@@ -47,3 +52,64 @@ describe("git", () => {
4752
assert.ok(status.includes("new-file.txt"));
4853
});
4954
});
55+
56+
describe("detectPrReference", () => {
57+
it("matches 'PR #N' inside text", () => {
58+
const r = detectPrReference("on PR #390");
59+
assert.deepEqual(r, { prNumber: 390, matched: "PR #390" });
60+
});
61+
62+
it("matches a bare 'PR #N'", () => {
63+
const r = detectPrReference("PR #42");
64+
assert.deepEqual(r, { prNumber: 42, matched: "PR #42" });
65+
});
66+
67+
it("matches 'pr #N' lowercase", () => {
68+
const r = detectPrReference("pr #7");
69+
assert.deepEqual(r, { prNumber: 7, matched: "pr #7" });
70+
});
71+
72+
it("matches 'PR N' without the hash", () => {
73+
const r = detectPrReference("PR 123");
74+
assert.deepEqual(r, { prNumber: 123, matched: "PR 123" });
75+
});
76+
77+
it("matches 'pr N' lowercase without the hash", () => {
78+
const r = detectPrReference("pr 1");
79+
assert.deepEqual(r, { prNumber: 1, matched: "pr 1" });
80+
});
81+
82+
it("matches the first PR reference inside longer focus text", () => {
83+
const r = detectPrReference("review PR #42 for security issues");
84+
assert.deepEqual(r, { prNumber: 42, matched: "PR #42" });
85+
});
86+
87+
it("returns null when no PR reference is present", () => {
88+
assert.equal(detectPrReference("review the auth changes"), null);
89+
});
90+
91+
it("does NOT match a bare '#N' issue reference", () => {
92+
// Issue/comment references like "fix #123" must not be misread as PRs.
93+
assert.equal(detectPrReference("fix #123 in the code"), null);
94+
});
95+
96+
it("returns null for empty string", () => {
97+
assert.equal(detectPrReference(""), null);
98+
});
99+
100+
it("returns null for null/undefined input", () => {
101+
assert.equal(detectPrReference(null), null);
102+
assert.equal(detectPrReference(undefined), null);
103+
});
104+
105+
it("matched substring can be stripped to clean focus text", () => {
106+
const focus = "review PR #42 for security issues";
107+
const detected = detectPrReference(focus);
108+
assert.ok(detected);
109+
const stripped = focus
110+
.replace(detected.matched, "")
111+
.replace(/\s+/g, " ")
112+
.trim();
113+
assert.equal(stripped, "review for security issues");
114+
});
115+
});

tests/process.test.mjs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { describe, it } from "node:test";
1+
import { describe, it, beforeEach, afterEach } from "node:test";
22
import assert from "node:assert/strict";
3-
import { runCommand } from "../plugins/opencode/scripts/lib/process.mjs";
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import { createTmpDir, cleanupTmpDir } from "./helpers.mjs";
6+
import {
7+
runCommand,
8+
findOpencodeAuthFile,
9+
getConfiguredProviders,
10+
} from "../plugins/opencode/scripts/lib/process.mjs";
411

512
describe("process", () => {
613
it("runCommand captures stdout", async () => {
@@ -19,3 +26,82 @@ describe("process", () => {
1926
assert.ok(stderr.includes("err"));
2027
});
2128
});
29+
30+
// Tests for OpenCode auth.json discovery + provider detection.
31+
//
32+
// We override XDG_DATA_HOME to point at an isolated tmp dir so the test
33+
// reads our fixture instead of the developer's real ~/.local/share auth
34+
// file. The "missing file" case is intentionally not asserted here because
35+
// `findOpencodeAuthFile` falls through to the platform-default path
36+
// (~/.local/share/opencode/auth.json on Linux), which may legitimately
37+
// exist on a developer machine and would make the assertion non-portable.
38+
39+
describe("OpenCode provider discovery", () => {
40+
let tmpDir;
41+
let savedXdg;
42+
43+
beforeEach(() => {
44+
tmpDir = createTmpDir("opencode-auth");
45+
savedXdg = process.env.XDG_DATA_HOME;
46+
process.env.XDG_DATA_HOME = tmpDir;
47+
});
48+
49+
afterEach(() => {
50+
cleanupTmpDir(tmpDir);
51+
if (savedXdg === undefined) {
52+
delete process.env.XDG_DATA_HOME;
53+
} else {
54+
process.env.XDG_DATA_HOME = savedXdg;
55+
}
56+
});
57+
58+
function writeAuthJson(content) {
59+
const dir = path.join(tmpDir, "opencode");
60+
fs.mkdirSync(dir, { recursive: true });
61+
fs.writeFileSync(path.join(dir, "auth.json"), content);
62+
return path.join(dir, "auth.json");
63+
}
64+
65+
it("findOpencodeAuthFile picks up XDG_DATA_HOME first", () => {
66+
const expected = writeAuthJson("{}");
67+
const found = findOpencodeAuthFile();
68+
assert.equal(found, expected);
69+
});
70+
71+
it("getConfiguredProviders returns top-level keys for valid auth.json", () => {
72+
writeAuthJson(JSON.stringify({ openrouter: { type: "api", key: "x" } }));
73+
assert.deepEqual(getConfiguredProviders(), ["openrouter"]);
74+
});
75+
76+
it("getConfiguredProviders returns multiple providers", () => {
77+
writeAuthJson(
78+
JSON.stringify({
79+
openrouter: { type: "api", key: "x" },
80+
openai: { type: "oauth", token: "y" },
81+
anthropic: { type: "api", key: "z" },
82+
})
83+
);
84+
const providers = getConfiguredProviders().sort();
85+
assert.deepEqual(providers, ["anthropic", "openai", "openrouter"]);
86+
});
87+
88+
it("getConfiguredProviders returns [] for an empty auth.json object", () => {
89+
writeAuthJson("{}");
90+
assert.deepEqual(getConfiguredProviders(), []);
91+
});
92+
93+
it("getConfiguredProviders returns [] for malformed JSON", () => {
94+
writeAuthJson("not valid json {{");
95+
assert.deepEqual(getConfiguredProviders(), []);
96+
});
97+
98+
it("getConfiguredProviders returns [] when auth.json is a JSON array", () => {
99+
writeAuthJson(JSON.stringify(["not", "an", "object"]));
100+
assert.deepEqual(getConfiguredProviders(), []);
101+
});
102+
103+
it("getConfiguredProviders returns [] when auth.json is a JSON null", () => {
104+
writeAuthJson("null");
105+
assert.deepEqual(getConfiguredProviders(), []);
106+
});
107+
});

0 commit comments

Comments
 (0)