self-healing-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/cli.js +273 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# self-healing-cli
|
|
2
|
+
|
|
3
|
+
A CI auto-fix CLI tool powered by [GitHub Copilot CLI](https://github.com/github/copilot). When your CI fails, it captures the error logs, asks Copilot to fix them, verifies the fix, and creates a PR — or leaves a comment if the fix doesn't work.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
CI command fails
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
+---------+ +-----------+ +----------+
|
|
12
|
+
| collect |---->| heal |---->| Copilot |
|
|
13
|
+
| (logs) | | (analyze) | | (fix) |
|
|
14
|
+
+---------+ +-----------+ +----------+
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
Has changes?
|
|
18
|
+
/ \
|
|
19
|
+
yes no
|
|
20
|
+
/ \
|
|
21
|
+
Verify Comment:
|
|
22
|
+
command "no changes"
|
|
23
|
+
/ \
|
|
24
|
+
pass fail
|
|
25
|
+
/ \
|
|
26
|
+
Create PR Comment:
|
|
27
|
+
"needs review"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm install
|
|
34
|
+
pnpm build
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### `collect` — Capture CI logs
|
|
40
|
+
|
|
41
|
+
Run a command and save its output to a file. Exits with the command's exit code.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
self-healing collect --command <cmd> --output <file>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Option | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `--command` | Command to run |
|
|
50
|
+
| `--output` | Output log file path |
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
self-healing collect --command "pnpm run check" --output check.log
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### `heal` — Auto-fix with Copilot
|
|
59
|
+
|
|
60
|
+
Read a log file, send it to Copilot CLI for analysis, verify the fix, and create a PR or comment.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
self-healing heal --log <file> --verify <cmd> [--model <model>] [--tail <lines>]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Option | Description | Default |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `--log` | Input log file path | (required) |
|
|
69
|
+
| `--verify` | Command to verify the fix | (required) |
|
|
70
|
+
| `--model` | Copilot model to use | Copilot default |
|
|
71
|
+
| `--tail` | Number of log lines to send | `100` |
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
self-healing heal --log check.log --verify "pnpm run check" --model gpt-5.2
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## GitHub Actions
|
|
80
|
+
|
|
81
|
+
### Auto-heal on lint/test failure
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
# .github/workflows/lint.yml
|
|
85
|
+
- name: Lint
|
|
86
|
+
id: lint
|
|
87
|
+
continue-on-error: true
|
|
88
|
+
run: npx self-healing collect --command "pnpm run check" --output lint.log
|
|
89
|
+
|
|
90
|
+
- name: Heal lint
|
|
91
|
+
if: steps.lint.outcome == 'failure'
|
|
92
|
+
env:
|
|
93
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
|
94
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
95
|
+
run: |
|
|
96
|
+
npm install -g @github/copilot
|
|
97
|
+
npx self-healing heal --log lint.log --verify "pnpm run check"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Manual dispatch
|
|
101
|
+
|
|
102
|
+
The `copilot.yml` workflow supports `workflow_dispatch` with custom `command` and `verify` inputs, so you can trigger a heal run from the GitHub Actions UI for any command.
|
|
103
|
+
|
|
104
|
+
## Prerequisites
|
|
105
|
+
|
|
106
|
+
- Node.js >= 22
|
|
107
|
+
- [GitHub Copilot CLI](https://github.com/github/copilot) (`npm install -g @github/copilot`)
|
|
108
|
+
- [GitHub CLI](https://cli.github.com/) (`gh`) — for creating PRs and comments
|
|
109
|
+
- `COPILOT_GITHUB_TOKEN` secret in your repo
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pnpm install # install dependencies
|
|
115
|
+
pnpm dev # watch mode
|
|
116
|
+
pnpm build # build
|
|
117
|
+
pnpm test # run tests
|
|
118
|
+
pnpm run check # lint + format (biome)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Tech Stack
|
|
122
|
+
|
|
123
|
+
- TypeScript (ESM)
|
|
124
|
+
- [Rslib](https://rslib.rs/) — build
|
|
125
|
+
- [Rstest](https://rstest.rs/) — test
|
|
126
|
+
- [Biome](https://biomejs.dev/) — lint & format
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import node_fs from "node:fs";
|
|
3
|
+
import node_path from "node:path";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
const exec = (command, options)=>{
|
|
6
|
+
try {
|
|
7
|
+
const stdout = execSync(command, {
|
|
8
|
+
cwd: options?.cwd,
|
|
9
|
+
env: options?.env ? {
|
|
10
|
+
...process.env,
|
|
11
|
+
...options.env
|
|
12
|
+
} : void 0,
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
stdio: [
|
|
15
|
+
'pipe',
|
|
16
|
+
'pipe',
|
|
17
|
+
'pipe'
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
stdout,
|
|
23
|
+
stderr: '',
|
|
24
|
+
exitCode: 0
|
|
25
|
+
};
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const err = error;
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
stdout: err.stdout ?? '',
|
|
31
|
+
stderr: err.stderr ?? '',
|
|
32
|
+
exitCode: err.status ?? 1
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const prefix = '[self-healing]';
|
|
37
|
+
const logger = {
|
|
38
|
+
info: (...args)=>console.log(prefix, ...args),
|
|
39
|
+
error: (...args)=>console.error(prefix, 'ERROR:', ...args),
|
|
40
|
+
success: (...args)=>console.log(prefix, '✓', ...args)
|
|
41
|
+
};
|
|
42
|
+
const collect = (options)=>{
|
|
43
|
+
const { command, outputFile } = options;
|
|
44
|
+
logger.info(`Running: ${command}`);
|
|
45
|
+
const result = exec(`${command} 2>&1`);
|
|
46
|
+
const output = result.stdout + result.stderr;
|
|
47
|
+
const dir = node_path.dirname(outputFile);
|
|
48
|
+
if (!node_fs.existsSync(dir)) node_fs.mkdirSync(dir, {
|
|
49
|
+
recursive: true
|
|
50
|
+
});
|
|
51
|
+
node_fs.writeFileSync(outputFile, output, 'utf-8');
|
|
52
|
+
logger.info(`Log saved to ${outputFile} (exit code: ${result.exitCode})`);
|
|
53
|
+
return {
|
|
54
|
+
exitCode: result.exitCode,
|
|
55
|
+
logFile: outputFile
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const getCurrentBranch = (cwd)=>{
|
|
59
|
+
const result = exec('git branch --show-current', {
|
|
60
|
+
cwd
|
|
61
|
+
});
|
|
62
|
+
return result.stdout.trim();
|
|
63
|
+
};
|
|
64
|
+
const hasChanges = (cwd)=>{
|
|
65
|
+
const result = exec('git status --porcelain', {
|
|
66
|
+
cwd
|
|
67
|
+
});
|
|
68
|
+
return result.stdout.trim().length > 0;
|
|
69
|
+
};
|
|
70
|
+
const createBranch = (name, cwd)=>{
|
|
71
|
+
exec(`git checkout -b ${name}`, {
|
|
72
|
+
cwd
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const commitAll = (message, cwd)=>{
|
|
76
|
+
exec('git add -A', {
|
|
77
|
+
cwd
|
|
78
|
+
});
|
|
79
|
+
exec(`git commit -m "${message}"`, {
|
|
80
|
+
cwd
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const push = (branch, cwd)=>{
|
|
84
|
+
exec(`git push origin ${branch}`, {
|
|
85
|
+
cwd
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const configBot = (cwd)=>{
|
|
89
|
+
exec('git config user.name "github-actions[bot]"', {
|
|
90
|
+
cwd
|
|
91
|
+
});
|
|
92
|
+
exec('git config user.email "github-actions[bot]@users.noreply.github.com"', {
|
|
93
|
+
cwd
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const buildPrBody = (options)=>`## Auto-generated Fix
|
|
97
|
+
|
|
98
|
+
This PR was automatically created to fix CI failures on **${options.baseBranch}**.
|
|
99
|
+
|
|
100
|
+
### Error Log
|
|
101
|
+
\`\`\`
|
|
102
|
+
${options.logSnippet}
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
*Generated by self-healing-cli*`;
|
|
107
|
+
const createPullRequest = (options)=>{
|
|
108
|
+
const result = exec(`gh pr create --title "${options.title}" --body "${options.body.replace(/"/g, '\\"')}" --base "${options.base}" --head "${options.head}"`, {
|
|
109
|
+
cwd: options.cwd
|
|
110
|
+
});
|
|
111
|
+
if (!result.ok) logger.error('Failed to create PR:', result.stderr);
|
|
112
|
+
return result.stdout.trim();
|
|
113
|
+
};
|
|
114
|
+
const commentOnCommit = (options)=>{
|
|
115
|
+
const result = exec(`gh api repos/{owner}/{repo}/commits/${options.sha}/comments -f body="${options.body.replace(/"/g, '\\"')}"`, {
|
|
116
|
+
cwd: options.cwd
|
|
117
|
+
});
|
|
118
|
+
if (!result.ok) logger.error('Failed to comment on commit:', result.stderr);
|
|
119
|
+
};
|
|
120
|
+
const readLogTail = (logFile, lines)=>{
|
|
121
|
+
const content = node_fs.readFileSync(logFile, 'utf-8');
|
|
122
|
+
const allLines = content.split('\n');
|
|
123
|
+
return allLines.slice(-lines).join('\n');
|
|
124
|
+
};
|
|
125
|
+
const buildCopilotPrompt = (options)=>[
|
|
126
|
+
'Analyze this CI failure and fix it.',
|
|
127
|
+
'After applying fixes, verify by running:',
|
|
128
|
+
` ${options.verifyCommand}`,
|
|
129
|
+
'',
|
|
130
|
+
'Error log:',
|
|
131
|
+
options.errorLog
|
|
132
|
+
].join('\n');
|
|
133
|
+
const heal = (options)=>{
|
|
134
|
+
const { logFile, verifyCommand, tailLines = 100, model } = options;
|
|
135
|
+
const errorLog = readLogTail(logFile, tailLines);
|
|
136
|
+
logger.info(`Read ${tailLines} lines from ${logFile}`);
|
|
137
|
+
const prompt = buildCopilotPrompt({
|
|
138
|
+
errorLog,
|
|
139
|
+
verifyCommand
|
|
140
|
+
});
|
|
141
|
+
const baseBranch = getCurrentBranch();
|
|
142
|
+
const shaResult = exec('git rev-parse HEAD');
|
|
143
|
+
const commitSha = shaResult.stdout.trim();
|
|
144
|
+
const fixBranch = `auto-fix/self-healing-${Date.now()}`;
|
|
145
|
+
configBot();
|
|
146
|
+
createBranch(fixBranch);
|
|
147
|
+
logger.info(`Created branch: ${fixBranch}`);
|
|
148
|
+
logger.info('Asking Copilot to analyze and fix...');
|
|
149
|
+
const modelFlag = model ? ` --model ${model}` : '';
|
|
150
|
+
const escapedPrompt = prompt.replace(/"/g, '\\"');
|
|
151
|
+
const copilotResult = exec(`copilot --prompt "${escapedPrompt}" --yolo${modelFlag}`);
|
|
152
|
+
if (!copilotResult.ok) {
|
|
153
|
+
logger.error('Copilot CLI failed:', copilotResult.stderr);
|
|
154
|
+
const body = `## Self-Healing CLI\n\nCopilot CLI failed to run.\n\n\`\`\`\n${copilotResult.stderr}\n\`\`\``;
|
|
155
|
+
commentOnCommit({
|
|
156
|
+
sha: commitSha,
|
|
157
|
+
body
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
fixed: false,
|
|
161
|
+
comment: body
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!hasChanges()) {
|
|
165
|
+
logger.info('Copilot made no file changes');
|
|
166
|
+
const body = '## Self-Healing CLI\n\nCopilot analyzed the failure but did not produce any file changes.';
|
|
167
|
+
commentOnCommit({
|
|
168
|
+
sha: commitSha,
|
|
169
|
+
body
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
fixed: false,
|
|
173
|
+
comment: body
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
logger.info(`Verifying fix with: ${verifyCommand}`);
|
|
177
|
+
const verifyResult = exec(verifyCommand);
|
|
178
|
+
if (verifyResult.ok) {
|
|
179
|
+
logger.success('Fix verified!');
|
|
180
|
+
commitAll('fix: auto-fix CI failure\n\nApplied by self-healing-cli via Copilot.');
|
|
181
|
+
push(fixBranch);
|
|
182
|
+
const prBody = buildPrBody({
|
|
183
|
+
baseBranch,
|
|
184
|
+
logSnippet: errorLog.slice(-2000)
|
|
185
|
+
});
|
|
186
|
+
const prUrl = createPullRequest({
|
|
187
|
+
title: `fix: auto-heal CI failure on ${baseBranch}`,
|
|
188
|
+
body: prBody,
|
|
189
|
+
base: baseBranch,
|
|
190
|
+
head: fixBranch
|
|
191
|
+
});
|
|
192
|
+
logger.success(`PR created: ${prUrl}`);
|
|
193
|
+
return {
|
|
194
|
+
fixed: true,
|
|
195
|
+
prUrl
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
logger.error('Fix did not pass verification');
|
|
199
|
+
const body = `## Self-Healing CLI\n\nCopilot suggested a fix but it did not pass verification (\`${verifyCommand}\`).\n\nPlease review manually.\n\n### Verification output\n\`\`\`\n${verifyResult.stdout + verifyResult.stderr}\n\`\`\``;
|
|
200
|
+
commentOnCommit({
|
|
201
|
+
sha: commitSha,
|
|
202
|
+
body
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
fixed: false,
|
|
206
|
+
comment: body
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
const cli_args = process.argv.slice(2);
|
|
210
|
+
const cli_command = cli_args[0];
|
|
211
|
+
const getFlag = (name)=>{
|
|
212
|
+
const index = cli_args.indexOf(`--${name}`);
|
|
213
|
+
if (-1 === index || index + 1 >= cli_args.length) return;
|
|
214
|
+
return cli_args[index + 1];
|
|
215
|
+
};
|
|
216
|
+
const printHelp = ()=>{
|
|
217
|
+
console.log(`
|
|
218
|
+
self-healing-cli
|
|
219
|
+
|
|
220
|
+
Usage:
|
|
221
|
+
self-healing collect --command <cmd> --output <file>
|
|
222
|
+
self-healing heal --log <file> --verify <cmd> [--model <model>] [--tail <lines>]
|
|
223
|
+
|
|
224
|
+
Commands:
|
|
225
|
+
collect Run a command and save output to a log file
|
|
226
|
+
heal Feed log to Copilot CLI, verify fix, create PR or comment
|
|
227
|
+
|
|
228
|
+
Options:
|
|
229
|
+
--command Command to run (collect)
|
|
230
|
+
--output Output log file path (collect)
|
|
231
|
+
--log Input log file path (heal)
|
|
232
|
+
--verify Verification command (heal)
|
|
233
|
+
--model Copilot model to use (heal, optional)
|
|
234
|
+
--tail Number of log lines to send (heal, default: 100)
|
|
235
|
+
--help Show this help message
|
|
236
|
+
`);
|
|
237
|
+
};
|
|
238
|
+
if (!cli_command || '--help' === cli_command || '-h' === cli_command) {
|
|
239
|
+
printHelp();
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
if ('collect' === cli_command) {
|
|
243
|
+
const cmd = getFlag('command');
|
|
244
|
+
const output = getFlag('output');
|
|
245
|
+
if (!cmd || !output) {
|
|
246
|
+
logger.error('collect requires --command and --output');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
const result = collect({
|
|
250
|
+
command: cmd,
|
|
251
|
+
outputFile: output
|
|
252
|
+
});
|
|
253
|
+
process.exit(result.exitCode);
|
|
254
|
+
}
|
|
255
|
+
if ('heal' === cli_command) {
|
|
256
|
+
const logFile = getFlag('log');
|
|
257
|
+
const verify = getFlag('verify');
|
|
258
|
+
if (!logFile || !verify) {
|
|
259
|
+
logger.error('heal requires --log and --verify');
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
const tailStr = getFlag('tail');
|
|
263
|
+
const result = heal({
|
|
264
|
+
logFile,
|
|
265
|
+
verifyCommand: verify,
|
|
266
|
+
model: getFlag('model'),
|
|
267
|
+
tailLines: tailStr ? Number(tailStr) : void 0
|
|
268
|
+
});
|
|
269
|
+
process.exit(result.fixed ? 0 : 1);
|
|
270
|
+
}
|
|
271
|
+
logger.error(`Unknown command: ${cli_command}`);
|
|
272
|
+
printHelp();
|
|
273
|
+
process.exit(1);
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "self-healing-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"self-healing": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@biomejs/biome": "2.3.8",
|
|
13
|
+
"@rslib/core": "^0.19.5",
|
|
14
|
+
"@rstest/adapter-rslib": "^0.2.0",
|
|
15
|
+
"@rstest/core": "^0.8.1",
|
|
16
|
+
"@types/node": "^24.10.9",
|
|
17
|
+
"typescript": "^5.9.3"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "rslib build",
|
|
25
|
+
"check": "biome check --write",
|
|
26
|
+
"dev": "rslib build --watch",
|
|
27
|
+
"format": "biome format --write",
|
|
28
|
+
"test": "rstest",
|
|
29
|
+
"test:watch": "rstest --watch"
|
|
30
|
+
}
|
|
31
|
+
}
|