Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"name": "github-workflow",
"source": "./plugins/github-workflow",
"description": "Git/GitHub ワークフロー支援 — Stop 時にブランチ状態とコンフリクトを通知",
"version": "0.0.9"
"version": "0.0.10"
},
{
"name": "mutils",
Expand Down
7 changes: 7 additions & 0 deletions apps/ops-harbor/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export default mergeConfig(
test: {
include: ["src/**/*.test.{ts,tsx}"],
environment: "happy-dom",
coverage: {
include: ["src/client/**/model/**/*.{ts,tsx}", "src/client/**/lib/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}", "src/client/pages/settings/**"],
thresholds: {
functions: 75,
},
},
},
}),
);
8 changes: 7 additions & 1 deletion packages/ops-harbor-core/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ export default mergeConfig(
test: {
coverage: {
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/types.ts"],
exclude: [
"src/**/*.test.ts",
"src/types.ts",
"src/index.ts",
"src/github.ts",
"src/prompt.ts",
],
},
},
}),
Expand Down
1 change: 1 addition & 0 deletions plugins/github-workflow/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ bun run typecheck # type check
| ----- | ----------------------- | -------------------------------------------------------------------------------------------------------------- |
| agent | ci-watcher | Monitor CI for pushed branches in the background. Check PR checks if PR exists, otherwise watch workflow runs. |
| hook | auto-ci-watch | PostToolUse (Bash) |
| hook | auto-create-pr | PostToolUse (Bash) |
| hook | check-branch-status | Stop |
| hook | check-push-pr-conflicts | PostToolUse (Bash) |
| hook | log-git-status | Stop |
Expand Down
285 changes: 285 additions & 0 deletions plugins/github-workflow/hooks/auto-create-pr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { describe, expect, test, vi } from "vitest";

const { capturedHookDefs, mockExecFileSync, mockSpawnSync, mockSpawn } = vi.hoisted(() => ({
capturedHookDefs: [] as { run: (ctx: unknown) => unknown }[],
mockExecFileSync: vi.fn<(...args: unknown[]) => string>(),
mockSpawnSync: vi.fn(),
mockSpawn: vi.fn(),
}));

vi.mock("@r_masseater/cc-plugin-lib", () => ({
HookLogger: {
fromFile: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
[Symbol.dispose]: vi.fn(),
}),
},
wrapRun: vi.fn((_logger: unknown, fn: unknown) => fn),
}));

vi.mock("cc-hooks-ts", () => ({
defineHook: vi.fn((def: { run: (ctx: unknown) => unknown }) => {
capturedHookDefs.push(def);
return def;
}),
runHook: vi.fn(),
}));

vi.mock("node:child_process", () => ({
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
}));

// Mock Bun global
const originalBun = globalThis.Bun;

function mockBunGlobal(overrides: { spawnSync?: typeof mockSpawnSync; spawn?: typeof mockSpawn }) {
Object.assign(globalThis, {
Bun: {
...originalBun,
spawnSync: overrides.spawnSync ?? mockSpawnSync,
spawn: overrides.spawn ?? mockSpawn,
},
});
}
Comment on lines +35 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore globalThis.Bun and mock state after each test.

At Line 38-Line 44, the suite mutates a global and never restores it. This can pollute later tests and cause order-dependent failures.

Suggested fix
-import { describe, expect, test, vi } from "vitest";
+import { afterEach, describe, expect, test, vi } from "vitest";
@@
 const originalBun = globalThis.Bun;
+
+afterEach(() => {
+  mockExecFileSync.mockReset();
+  mockSpawnSync.mockReset();
+  mockSpawn.mockReset();
+  Object.assign(globalThis, { Bun: originalBun });
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/github-workflow/hooks/auto-create-pr.test.ts` around lines 35 - 45,
The test suite mutates globalThis.Bun via mockBunGlobal (which captures
originalBun) but never restores it; add an afterEach hook that resets
globalThis.Bun back to originalBun and clears or restores any mocked functions
(e.g., restore mockSpawnSync/mockSpawn) so tests do not leak state—use the
captured originalBun from the top-level variable and ensure mockBunGlobal
continues to assign overridden methods only during a test.


import "./entry/auto-create-pr.ts";

describe("auto-create-pr hook", () => {
function createMockContext(command: string) {
const successResult = { type: "success" };
let deferFn: (() => Promise<unknown>) | null = null;
return {
input: { tool_input: { command } },
success: vi.fn(() => successResult),
defer: vi.fn((fn: () => Promise<unknown>) => {
deferFn = fn;
return { type: "defer" };
}),
getDeferFn: () => deferFn,
};
}

function getHookRun() {
const hookDef = capturedHookDefs[0];
if (!hookDef) throw new Error("Hook not captured");
return hookDef.run;
}

test("returns success for non-push commands", () => {
const ctx = createMockContext("git status");
const run = getHookRun();
const result = run(ctx);
expect(ctx.success).toHaveBeenCalledWith({});
expect(result).toStrictEqual({ type: "success" });
});

test("returns success when branch is unavailable", () => {
mockExecFileSync.mockImplementation(() => {
throw new Error("not a git repo");
});

const ctx = createMockContext("git push origin main");
const run = getHookRun();
run(ctx);

expect(ctx.success).toHaveBeenCalledWith({});
});

test("returns success when on default branch", () => {
mockExecFileSync.mockImplementation((command, args) => {
const commandName = command as string;
const commandArgs = args as string[];
if (commandName === "git" && commandArgs[0] === "symbolic-ref") return "main";
return "";
});
mockSpawnSync.mockReturnValue({
stdout: { toString: () => "main" },
stderr: { toString: () => "" },
});

mockBunGlobal({ spawnSync: mockSpawnSync });

const ctx = createMockContext("git push");
const run = getHookRun();
run(ctx);

expect(ctx.success).toHaveBeenCalledWith({});
});

test("returns success when PR already exists", () => {
mockExecFileSync.mockImplementation((command, args) => {
const commandName = command as string;
const commandArgs = args as string[];
if (commandName === "git" && commandArgs[0] === "symbolic-ref") return "feature/test";
return "";
});
mockSpawnSync
.mockReturnValueOnce({
// getDefaultBranch
stdout: { toString: () => "main" },
stderr: { toString: () => "" },
})
.mockReturnValueOnce({
// prExists
exitCode: 0,
stdout: { toString: () => '{"number":42}' },
stderr: { toString: () => "" },
});

mockBunGlobal({ spawnSync: mockSpawnSync });

const ctx = createMockContext("git push origin feature/test");
const run = getHookRun();
run(ctx);

expect(ctx.success).toHaveBeenCalledWith({});
});

test("defers PR creation when no PR exists", () => {
mockExecFileSync.mockImplementation((command, args) => {
const commandName = command as string;
const commandArgs = args as string[];
if (commandName === "git" && commandArgs[0] === "symbolic-ref") return "feature/new";
return "";
});
mockSpawnSync
.mockReturnValueOnce({
// getDefaultBranch
stdout: { toString: () => "main" },
stderr: { toString: () => "" },
})
.mockReturnValueOnce({
// prExists - no PR
exitCode: 1,
stdout: { toString: () => "" },
stderr: { toString: () => "no pull requests found" },
});

mockBunGlobal({ spawnSync: mockSpawnSync });

const ctx = createMockContext("git push origin feature/new");
const run = getHookRun();
const result = run(ctx);

expect(result).toStrictEqual({ type: "defer" });
expect(ctx.defer).toHaveBeenCalled();
});

test("deferred function reports successful PR creation", async () => {
mockExecFileSync.mockImplementation((command, args) => {
const commandName = command as string;
const commandArgs = args as string[];
if (commandName === "git" && commandArgs[0] === "symbolic-ref") return "feature/new";
return "";
});
mockSpawnSync
.mockReturnValueOnce({
stdout: { toString: () => "main" },
stderr: { toString: () => "" },
})
.mockReturnValueOnce({
exitCode: 1,
stdout: { toString: () => "" },
stderr: { toString: () => "no pull requests found" },
});

const createReadableStream = (text: string) =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});

mockSpawn
.mockReturnValueOnce({
// gh pr create
exited: Promise.resolve(0),
exitCode: 0,
stdout: createReadableStream("https://github.com/owner/repo/pull/1"),
stderr: createReadableStream(""),
})
.mockReturnValueOnce({
// gh pr view
exited: Promise.resolve(0),
exitCode: 0,
stdout: createReadableStream("Add new feature"),
stderr: createReadableStream(""),
});

mockBunGlobal({ spawnSync: mockSpawnSync, spawn: mockSpawn });

const ctx = createMockContext("git push origin feature/new");
const run = getHookRun();
run(ctx);

const deferFn = ctx.getDeferFn();
expect(deferFn).not.toBeNull();

const result = (await deferFn!()) as {
output: { hookSpecificOutput: { additionalContext: string } };
};

expect(result.output.hookSpecificOutput.additionalContext).toContain(
"[PR Auto-Create] Created PR",
);
expect(result.output.hookSpecificOutput.additionalContext).toContain(
"https://github.com/owner/repo/pull/1",
);
expect(result.output.hookSpecificOutput.additionalContext).toContain("Add new feature");
});

test("deferred function reports failed PR creation", async () => {
mockExecFileSync.mockImplementation((command, args) => {
const commandName = command as string;
const commandArgs = args as string[];
if (commandName === "git" && commandArgs[0] === "symbolic-ref") return "feature/fail";
return "";
});
mockSpawnSync
.mockReturnValueOnce({
stdout: { toString: () => "main" },
stderr: { toString: () => "" },
})
.mockReturnValueOnce({
exitCode: 1,
stdout: { toString: () => "" },
stderr: { toString: () => "no pull requests found" },
});

const createReadableStream = (text: string) =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});

mockSpawn.mockReturnValueOnce({
exited: Promise.resolve(1),
exitCode: 1,
stdout: createReadableStream(""),
stderr: createReadableStream("pull request create failed"),
});

mockBunGlobal({ spawnSync: mockSpawnSync, spawn: mockSpawn });

const ctx = createMockContext("git push origin feature/fail");
const run = getHookRun();
run(ctx);

const deferFn = ctx.getDeferFn();
const result = (await deferFn!()) as {
output: { hookSpecificOutput: { additionalContext: string } };
};

expect(result.output.hookSpecificOutput.additionalContext).toContain(
"[PR Auto-Create] Failed to create PR",
);
expect(result.output.hookSpecificOutput.additionalContext).toContain(
"pull request create failed",
);
});
});
Loading
Loading