Skip to content

Commit 1e1d6d6

Browse files
committed
feature-296-bugbot-autofix: Refactor GitHub Action execution logic to prevent auto-running during tests by checking for JEST_WORKER_ID. Update branch_repository.d.ts for clarity in status type definitions. Ensure proper CLI argument parsing only when not in test environment.
1 parent 4df08da commit 1e1d6d6

10 files changed

Lines changed: 329 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Unit tests for mainRun (common_action).
3+
* Mocks use cases and queue; covers dispatch branches and error handling.
4+
*/
5+
export {};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* Unit tests for bugbot_fix_intent_payload: getBugbotFixIntentPayload, canRunBugbotAutofix, canRunDoUserRequest.
3+
*/
4+
export {};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Unit tests for mainRun (common_action).
3+
* Mocks use cases and queue; covers dispatch branches and error handling.
4+
*/
5+
export {};

build/github_action/src/data/repository/branch_repository.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export declare class BranchRepository {
3333
totalCommits: number;
3434
files: {
3535
filename: string;
36-
status: "modified" | "added" | "removed" | "renamed" | "copied" | "changed" | "unchanged";
36+
status: "added" | "removed" | "modified" | "renamed" | "copied" | "changed" | "unchanged";
3737
additions: number;
3838
deletions: number;
3939
changes: number;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* Unit tests for bugbot_fix_intent_payload: getBugbotFixIntentPayload, canRunBugbotAutofix, canRunDoUserRequest.
3+
*/
4+
export {};

src/__tests__/cli.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Unit tests for CLI commands.
3+
* Mocks execSync (getGitInfo), runLocalAction, IssueRepository, AiRepository.
4+
*/
5+
6+
import { execSync } from 'child_process';
7+
import { program } from '../cli';
8+
import { runLocalAction } from '../actions/local_action';
9+
import { ACTIONS, INPUT_KEYS } from '../utils/constants';
10+
11+
jest.mock('child_process', () => ({
12+
execSync: jest.fn(),
13+
}));
14+
15+
jest.mock('../actions/local_action', () => ({
16+
runLocalAction: jest.fn().mockResolvedValue(undefined),
17+
}));
18+
19+
jest.mock('../utils/logger', () => ({
20+
logError: jest.fn(),
21+
logInfo: jest.fn(),
22+
}));
23+
24+
const mockIsIssue = jest.fn();
25+
jest.mock('../data/repository/issue_repository', () => ({
26+
IssueRepository: jest.fn().mockImplementation(() => ({
27+
isIssue: mockIsIssue,
28+
})),
29+
}));
30+
31+
jest.mock('../data/repository/ai_repository', () => ({
32+
AiRepository: jest.fn().mockImplementation(() => ({
33+
copilotMessage: jest.fn().mockResolvedValue({ text: 'OK', sessionId: 's1' }),
34+
})),
35+
}));
36+
37+
describe('CLI', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
(execSync as jest.Mock).mockReturnValue(Buffer.from('https://github.com/test-owner/test-repo.git'));
41+
(runLocalAction as jest.Mock).mockResolvedValue(undefined);
42+
mockIsIssue.mockResolvedValue(true);
43+
});
44+
45+
describe('think', () => {
46+
it('calls runLocalAction with think action and question from -q', async () => {
47+
await program.parseAsync(['node', 'cli', 'think', '-q', 'how does X work?']);
48+
49+
expect(runLocalAction).toHaveBeenCalledTimes(1);
50+
const params = (runLocalAction as jest.Mock).mock.calls[0][0];
51+
expect(params[INPUT_KEYS.SINGLE_ACTION]).toBe(ACTIONS.THINK);
52+
expect(params[INPUT_KEYS.WELCOME_TITLE]).toContain('AI Reasoning');
53+
expect(params.repo).toEqual({ owner: 'test-owner', repo: 'test-repo' });
54+
expect(params.comment?.body || params.eventName).toBeDefined();
55+
});
56+
57+
it('exits with error when getGitInfo fails', async () => {
58+
(execSync as jest.Mock).mockImplementation(() => {
59+
throw new Error('git not found');
60+
});
61+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {}) as () => never);
62+
const { logError } = require('../utils/logger');
63+
64+
await program.parseAsync(['node', 'cli', 'think', '-q', 'hello']);
65+
66+
expect(logError).toHaveBeenCalled();
67+
expect(exitSpy).toHaveBeenCalledWith(1);
68+
exitSpy.mockRestore();
69+
});
70+
71+
});
72+
73+
describe('do', () => {
74+
it('calls AiRepository and logs response', async () => {
75+
const { AiRepository } = require('../data/repository/ai_repository');
76+
const logSpy = jest.spyOn(console, 'log').mockImplementation();
77+
78+
await program.parseAsync(['node', 'cli', 'do', '-p', 'refactor this']);
79+
80+
expect(AiRepository).toHaveBeenCalled();
81+
const instance = AiRepository.mock.results[AiRepository.mock.results.length - 1].value;
82+
expect(instance.copilotMessage).toHaveBeenCalled();
83+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('RESPONSE'));
84+
logSpy.mockRestore();
85+
});
86+
87+
});
88+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Unit tests for runGitHubAction.
3+
* Mocks @actions/core, ProjectRepository, mainRun, and finish flow.
4+
*/
5+
6+
import * as core from '@actions/core';
7+
import { runGitHubAction } from '../github_action';
8+
import { INPUT_KEYS } from '../../utils/constants';
9+
10+
jest.mock('@actions/core', () => ({
11+
getInput: jest.fn(),
12+
setFailed: jest.fn(),
13+
}));
14+
15+
jest.mock('../../utils/logger', () => ({
16+
logInfo: jest.fn(),
17+
logError: jest.fn(),
18+
}));
19+
20+
jest.mock('../../utils/opencode_server', () => ({
21+
startOpencodeServer: jest.fn(),
22+
}));
23+
24+
const mockMainRun = jest.fn();
25+
jest.mock('../common_action', () => ({
26+
mainRun: (...args: unknown[]) => mockMainRun(...args),
27+
}));
28+
29+
const mockPublishInvoke = jest.fn();
30+
const mockStoreInvoke = jest.fn();
31+
jest.mock('../../usecase/steps/common/publish_resume_use_case', () => ({
32+
PublishResultUseCase: jest.fn().mockImplementation(() => ({ invoke: mockPublishInvoke })),
33+
}));
34+
jest.mock('../../usecase/steps/common/store_configuration_use_case', () => ({
35+
StoreConfigurationUseCase: jest.fn().mockImplementation(() => ({ invoke: mockStoreInvoke })),
36+
}));
37+
38+
const mockGetProjectDetail = jest.fn();
39+
jest.mock('../../data/repository/project_repository', () => ({
40+
ProjectRepository: jest.fn().mockImplementation(() => ({
41+
getProjectDetail: mockGetProjectDetail,
42+
})),
43+
}));
44+
45+
describe('runGitHubAction', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
(core.getInput as jest.Mock).mockImplementation((key: string, opts?: { required?: boolean }) => {
49+
if (opts?.required && key === INPUT_KEYS.TOKEN) return 'fake-token';
50+
return '';
51+
});
52+
mockGetProjectDetail.mockResolvedValue({ id: 'p1', title: 'Board', url: 'https://example.com' });
53+
mockMainRun.mockResolvedValue([]);
54+
mockPublishInvoke.mockResolvedValue([]);
55+
mockStoreInvoke.mockResolvedValue([]);
56+
});
57+
58+
it('builds Execution and calls mainRun', async () => {
59+
await runGitHubAction();
60+
61+
expect(core.getInput).toHaveBeenCalledWith(INPUT_KEYS.TOKEN, { required: true });
62+
expect(mockMainRun).toHaveBeenCalledTimes(1);
63+
const execution = mockMainRun.mock.calls[0][0];
64+
expect(execution).toBeDefined();
65+
expect(execution.tokens).toBeDefined();
66+
expect(execution.ai).toBeDefined();
67+
expect(execution.singleAction).toBeDefined();
68+
});
69+
70+
it('does not start OpenCode server when opencode-start-server is not true', async () => {
71+
const { startOpencodeServer } = require('../../utils/opencode_server');
72+
await runGitHubAction();
73+
expect(startOpencodeServer).not.toHaveBeenCalled();
74+
});
75+
76+
it('calls finishWithResults (PublishResult and StoreConfiguration) after mainRun', async () => {
77+
await runGitHubAction();
78+
79+
expect(mockPublishInvoke).toHaveBeenCalledTimes(1);
80+
expect(mockStoreInvoke).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it('uses INPUT_VARS_JSON when set for getInput', async () => {
84+
const inputVarsJson = JSON.stringify({
85+
INPUT_TOKEN: 'from-env-token',
86+
INPUT_DEBUG: 'true',
87+
});
88+
const orig = process.env.INPUT_VARS_JSON;
89+
process.env.INPUT_VARS_JSON = inputVarsJson;
90+
(core.getInput as jest.Mock).mockImplementation(() => '');
91+
92+
await runGitHubAction();
93+
94+
const execution = mockMainRun.mock.calls[0][0];
95+
expect(execution).toBeDefined();
96+
process.env.INPUT_VARS_JSON = orig;
97+
});
98+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Unit tests for runLocalAction.
3+
* Mocks getActionInputsWithDefaults, ProjectRepository, mainRun, chalk, boxen.
4+
*/
5+
6+
jest.mock('chalk', () => ({
7+
cyan: (s: string) => s,
8+
gray: (s: string) => s,
9+
red: (s: string) => s,
10+
default: { cyan: (s: string) => s, gray: (s: string) => s, red: (s: string) => s },
11+
}));
12+
jest.mock('boxen', () => jest.fn((text: string) => text));
13+
14+
jest.mock('../../utils/logger', () => ({
15+
logInfo: jest.fn(),
16+
}));
17+
18+
const mockGetActionInputsWithDefaults = jest.fn();
19+
jest.mock('../../utils/yml_utils', () => ({
20+
getActionInputsWithDefaults: () => mockGetActionInputsWithDefaults(),
21+
}));
22+
23+
const mockMainRun = jest.fn();
24+
jest.mock('../common_action', () => ({
25+
mainRun: (...args: unknown[]) => mockMainRun(...args),
26+
}));
27+
28+
const mockGetProjectDetail = jest.fn();
29+
jest.mock('../../data/repository/project_repository', () => ({
30+
ProjectRepository: jest.fn().mockImplementation(() => ({
31+
getProjectDetail: mockGetProjectDetail,
32+
})),
33+
}));
34+
35+
import { runLocalAction } from '../local_action';
36+
import { INPUT_KEYS } from '../../utils/constants';
37+
38+
/** Minimal defaults so local_action can run (avoids .split on undefined). */
39+
function minimalActionInputs(): Record<string, string> {
40+
const keys = Object.values(INPUT_KEYS) as string[];
41+
return Object.fromEntries(keys.map((k) => [k, '']));
42+
}
43+
44+
describe('runLocalAction', () => {
45+
beforeEach(() => {
46+
jest.clearAllMocks();
47+
mockGetActionInputsWithDefaults.mockReturnValue(minimalActionInputs());
48+
mockGetProjectDetail.mockResolvedValue({ id: 'p1', title: 'Board', url: 'https://example.com' });
49+
mockMainRun.mockResolvedValue([]);
50+
});
51+
52+
it('builds Execution from additionalParams and actionInputs and calls mainRun', async () => {
53+
const params: Record<string, unknown> = {
54+
[INPUT_KEYS.TOKEN]: 'local-token',
55+
repo: { owner: 'o', repo: 'r' },
56+
eventName: 'push',
57+
commits: { ref: 'refs/heads/main' },
58+
};
59+
60+
await runLocalAction(params);
61+
62+
expect(mockMainRun).toHaveBeenCalledTimes(1);
63+
const execution = mockMainRun.mock.calls[0][0];
64+
expect(execution).toBeDefined();
65+
expect(execution.tokens).toBeDefined();
66+
expect(execution.ai).toBeDefined();
67+
expect(execution.welcome).toBeDefined();
68+
});
69+
70+
it('uses additionalParams over actionInputs defaults', async () => {
71+
mockGetActionInputsWithDefaults.mockReturnValue({
72+
...minimalActionInputs(),
73+
[INPUT_KEYS.DEBUG]: 'false',
74+
[INPUT_KEYS.TOKEN]: 'default-token',
75+
});
76+
const params: Record<string, unknown> = {
77+
[INPUT_KEYS.TOKEN]: 'override-token',
78+
[INPUT_KEYS.DEBUG]: 'true',
79+
repo: { owner: 'x', repo: 'y' },
80+
eventName: 'push',
81+
commits: { ref: 'refs/heads/develop' },
82+
};
83+
84+
await runLocalAction(params);
85+
86+
const execution = mockMainRun.mock.calls[0][0];
87+
expect(execution.tokens.token).toBe('override-token');
88+
expect(execution.debug).toBe(true);
89+
});
90+
91+
it('logs steps and reminders via boxen after mainRun', async () => {
92+
const boxen = require('boxen');
93+
mockMainRun.mockResolvedValue([
94+
{ executed: true, steps: ['Step 1'], errors: [], reminders: [] },
95+
{ executed: true, steps: [], errors: [], reminders: ['Reminder 1'] },
96+
]);
97+
const params: Record<string, unknown> = {
98+
[INPUT_KEYS.TOKEN]: 't',
99+
repo: { owner: 'o', repo: 'r' },
100+
eventName: 'push',
101+
commits: { ref: 'refs/heads/main' },
102+
};
103+
104+
await runLocalAction(params);
105+
106+
expect(boxen).toHaveBeenCalled();
107+
expect(boxen.mock.calls[0][0]).toContain('Step 1');
108+
expect(boxen.mock.calls[0][0]).toContain('Reminder 1');
109+
});
110+
});

src/actions/github_action.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -695,10 +695,13 @@ function setFirstErrorIfExists(results: Result[]): void {
695695
}
696696
}
697697

698-
runGitHubAction()
699-
.then(() => process.exit(0))
700-
.catch((err: unknown) => {
701-
logError(err);
702-
core.setFailed(err instanceof Error ? err.message : String(err));
703-
process.exit(1);
704-
});
698+
// Only auto-run when executed as the action entry (not when imported by tests)
699+
if (typeof process.env.JEST_WORKER_ID === 'undefined') {
700+
runGitHubAction()
701+
.then(() => process.exit(0))
702+
.catch((err: unknown) => {
703+
logError(err);
704+
core.setFailed(err instanceof Error ? err.message : String(err));
705+
process.exit(1);
706+
});
707+
}

src/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,4 +464,7 @@ program
464464
await runLocalAction(params);
465465
});
466466

467-
program.parse(process.argv);
467+
if (typeof process.env.JEST_WORKER_ID === 'undefined') {
468+
program.parse(process.argv);
469+
}
470+
export { program };

0 commit comments

Comments
 (0)