Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

# Adapted from openai/codex-plugin-cc's pull-request-ci.yml.
# Modified by JohnnyVicious (2026): drops the Codex CLI install step (this
# fork wraps OpenCode, not Codex) and runs only on a single Node version
# matching the package.json `engines.node` floor. (Apache License 2.0
# §4(b) modification notice — see NOTICE.)

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Syntax-check companion scripts
run: |
node --check plugins/opencode/scripts/opencode-companion.mjs
node --check plugins/opencode/scripts/lib/git.mjs
node --check plugins/opencode/scripts/lib/prompts.mjs
node --check plugins/opencode/scripts/lib/process.mjs
node --check plugins/opencode/scripts/lib/opencode-server.mjs

- name: Run tests
run: npm test
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 67 additions & 1 deletion tests/git.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import fs from "node:fs";
import path from "node:path";
import { createTmpDir, cleanupTmpDir } from "./helpers.mjs";
import { runCommand } from "../plugins/opencode/scripts/lib/process.mjs";
import { getGitRoot, getCurrentBranch, getStatus } from "../plugins/opencode/scripts/lib/git.mjs";
import {
getGitRoot,
getCurrentBranch,
getStatus,
detectPrReference,
} from "../plugins/opencode/scripts/lib/git.mjs";

let tmpDir;

Expand Down Expand Up @@ -47,3 +52,64 @@ describe("git", () => {
assert.ok(status.includes("new-file.txt"));
});
});

describe("detectPrReference", () => {
it("matches 'PR #N' inside text", () => {
const r = detectPrReference("on PR #390");
assert.deepEqual(r, { prNumber: 390, matched: "PR #390" });
});

it("matches a bare 'PR #N'", () => {
const r = detectPrReference("PR #42");
assert.deepEqual(r, { prNumber: 42, matched: "PR #42" });
});

it("matches 'pr #N' lowercase", () => {
const r = detectPrReference("pr #7");
assert.deepEqual(r, { prNumber: 7, matched: "pr #7" });
});

it("matches 'PR N' without the hash", () => {
const r = detectPrReference("PR 123");
assert.deepEqual(r, { prNumber: 123, matched: "PR 123" });
});

it("matches 'pr N' lowercase without the hash", () => {
const r = detectPrReference("pr 1");
assert.deepEqual(r, { prNumber: 1, matched: "pr 1" });
});

it("matches the first PR reference inside longer focus text", () => {
const r = detectPrReference("review PR #42 for security issues");
assert.deepEqual(r, { prNumber: 42, matched: "PR #42" });
});

it("returns null when no PR reference is present", () => {
assert.equal(detectPrReference("review the auth changes"), null);
});

it("does NOT match a bare '#N' issue reference", () => {
// Issue/comment references like "fix #123" must not be misread as PRs.
assert.equal(detectPrReference("fix #123 in the code"), null);
});

it("returns null for empty string", () => {
assert.equal(detectPrReference(""), null);
});

it("returns null for null/undefined input", () => {
assert.equal(detectPrReference(null), null);
assert.equal(detectPrReference(undefined), null);
});

it("matched substring can be stripped to clean focus text", () => {
const focus = "review PR #42 for security issues";
const detected = detectPrReference(focus);
assert.ok(detected);
const stripped = focus
.replace(detected.matched, "")
.replace(/\s+/g, " ")
.trim();
assert.equal(stripped, "review for security issues");
});
});
90 changes: 88 additions & 2 deletions tests/process.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, it } from "node:test";
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { runCommand } from "../plugins/opencode/scripts/lib/process.mjs";
import fs from "node:fs";
import path from "node:path";
import { createTmpDir, cleanupTmpDir } from "./helpers.mjs";
import {
runCommand,
findOpencodeAuthFile,
getConfiguredProviders,
} from "../plugins/opencode/scripts/lib/process.mjs";

describe("process", () => {
it("runCommand captures stdout", async () => {
Expand All @@ -19,3 +26,82 @@ describe("process", () => {
assert.ok(stderr.includes("err"));
});
});

// Tests for OpenCode auth.json discovery + provider detection.
//
// We override XDG_DATA_HOME to point at an isolated tmp dir so the test
// reads our fixture instead of the developer's real ~/.local/share auth
// file. The "missing file" case is intentionally not asserted here because
// `findOpencodeAuthFile` falls through to the platform-default path
// (~/.local/share/opencode/auth.json on Linux), which may legitimately
// exist on a developer machine and would make the assertion non-portable.

describe("OpenCode provider discovery", () => {
let tmpDir;
let savedXdg;

beforeEach(() => {
tmpDir = createTmpDir("opencode-auth");
savedXdg = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = tmpDir;
});

afterEach(() => {
cleanupTmpDir(tmpDir);
if (savedXdg === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = savedXdg;
}
});

function writeAuthJson(content) {
const dir = path.join(tmpDir, "opencode");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "auth.json"), content);
return path.join(dir, "auth.json");
}

it("findOpencodeAuthFile picks up XDG_DATA_HOME first", () => {
const expected = writeAuthJson("{}");
const found = findOpencodeAuthFile();
assert.equal(found, expected);
});

it("getConfiguredProviders returns top-level keys for valid auth.json", () => {
writeAuthJson(JSON.stringify({ openrouter: { type: "api", key: "x" } }));
assert.deepEqual(getConfiguredProviders(), ["openrouter"]);
});

it("getConfiguredProviders returns multiple providers", () => {
writeAuthJson(
JSON.stringify({
openrouter: { type: "api", key: "x" },
openai: { type: "oauth", token: "y" },
anthropic: { type: "api", key: "z" },
})
);
const providers = getConfiguredProviders().sort();
assert.deepEqual(providers, ["anthropic", "openai", "openrouter"]);
});

it("getConfiguredProviders returns [] for an empty auth.json object", () => {
writeAuthJson("{}");
assert.deepEqual(getConfiguredProviders(), []);
});

it("getConfiguredProviders returns [] for malformed JSON", () => {
writeAuthJson("not valid json {{");
assert.deepEqual(getConfiguredProviders(), []);
});

it("getConfiguredProviders returns [] when auth.json is a JSON array", () => {
writeAuthJson(JSON.stringify(["not", "an", "object"]));
assert.deepEqual(getConfiguredProviders(), []);
});

it("getConfiguredProviders returns [] when auth.json is a JSON null", () => {
writeAuthJson("null");
assert.deepEqual(getConfiguredProviders(), []);
});
});