@zibby/workflow-templates 0.10.2 → 0.10.10
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/index.js +98 -0
- package/package.json +1 -1
- package/sentry-triage/README.md +118 -0
- package/sentry-triage/graph.mjs +120 -48
- package/sentry-triage/lib/notify.js +67 -0
- package/sentry-triage/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/sentry-triage/nodes/fetch-issue-node.js +182 -0
- package/sentry-triage/nodes/fix-node.js +338 -0
- package/sentry-triage/nodes/notify-fix-node.js +79 -0
- package/sentry-triage/nodes/open-pr-node.js +180 -0
- package/sentry-triage/package.json +2 -1
- package/sentry-triage/state.js +116 -8
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch_issue — FIX-scenario entry. DETERMINISTIC. Pulls the FULL details
|
|
3
|
+
* for ONE Sentry issue (state.issueId) via the @zibby/skills client.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors fetch-issues-node (the TRIAGE scenario's bulk pull) exactly:
|
|
6
|
+
* - deterministic (no LLM round-trip, no hallucinated query),
|
|
7
|
+
* - still declares `skills: [SKILLS.SENTRY]` so the backend bundler marks
|
|
8
|
+
* Sentry a REQUIRED integration for THIS scenario too — without it a fix
|
|
9
|
+
* run would 401 on the first call. The skill's runtime tool injection is a
|
|
10
|
+
* no-op here; the integration-requirement signal is what matters.
|
|
11
|
+
*
|
|
12
|
+
* Where TRIAGE lists+classifies MANY issues, FIX targets ONE: a Sentry webhook
|
|
13
|
+
* (or a manual run) hands us the issue id, and we pull the full record —
|
|
14
|
+
* culprit, metadata.type/value/filename, the latest event's stacktrace if
|
|
15
|
+
* available, permalink, and suspect commits — so the `fix` node's coding agent
|
|
16
|
+
* can locate the defect.
|
|
17
|
+
*
|
|
18
|
+
* Auth: same path as fetch-issues — sentryGetIssue resolves the project-scoped
|
|
19
|
+
* Sentry token via PROJECT_API_TOKEN + PROGRESS_API_URL.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { SKILLS } from '@zibby/core';
|
|
24
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
25
|
+
import { sentryGetIssue } from '@zibby/skills/sentry';
|
|
26
|
+
|
|
27
|
+
const SuspectCommitShape = z.object({
|
|
28
|
+
id: z.string().optional(),
|
|
29
|
+
shortId: z.string().optional(),
|
|
30
|
+
message: z.string().optional(),
|
|
31
|
+
authorEmail: z.string().optional(),
|
|
32
|
+
authorName: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The single-issue shape FIX consumes. Superset of fetch-issues' IssueShape:
|
|
36
|
+
// adds `latestEventStacktrace` (a compact text rendering of the newest event's
|
|
37
|
+
// stack frames) which the bulk list endpoint never returns — only the single
|
|
38
|
+
// /issues/<id>/events/latest/ endpoint does, and the agent uses it to find the
|
|
39
|
+
// failing line.
|
|
40
|
+
const FetchIssueOutputSchema = z.object({
|
|
41
|
+
found: z.boolean(),
|
|
42
|
+
issue: z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
shortId: z.string().optional(),
|
|
45
|
+
title: z.string(),
|
|
46
|
+
culprit: z.string().optional(),
|
|
47
|
+
level: z.string().optional(),
|
|
48
|
+
status: z.string().optional(),
|
|
49
|
+
count: z.union([z.string(), z.number()]).optional(),
|
|
50
|
+
userCount: z.number().optional(),
|
|
51
|
+
firstSeen: z.string().optional(),
|
|
52
|
+
lastSeen: z.string().optional(),
|
|
53
|
+
permalink: z.string().optional(),
|
|
54
|
+
metadata: z.object({
|
|
55
|
+
type: z.string().optional(),
|
|
56
|
+
value: z.string().optional(),
|
|
57
|
+
filename: z.string().optional(),
|
|
58
|
+
}).optional(),
|
|
59
|
+
suspectCommits: z.array(SuspectCommitShape).optional(),
|
|
60
|
+
latestEventStacktrace: z.string().optional(),
|
|
61
|
+
}).optional(),
|
|
62
|
+
fetchedAt: z.string(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render the newest event's stack frames to a compact, agent-readable string.
|
|
67
|
+
* Sentry's issue detail does NOT include the event payload — its
|
|
68
|
+
* `/issues/<id>/events/latest/` endpoint does. We surface the in-app frames
|
|
69
|
+
* (filename:lineno · function) so the agent can jump to the failing code; this
|
|
70
|
+
* is best-effort and never throws (no stacktrace → undefined, no harm).
|
|
71
|
+
*/
|
|
72
|
+
function renderStacktrace(event) {
|
|
73
|
+
try {
|
|
74
|
+
const entries = event?.entries || [];
|
|
75
|
+
const exc = entries.find((e) => e?.type === 'exception');
|
|
76
|
+
const values = exc?.data?.values || [];
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const v of values) {
|
|
79
|
+
if (v?.type || v?.value) lines.push(`${v.type || 'Error'}: ${v.value || ''}`.trim());
|
|
80
|
+
const frames = v?.stacktrace?.frames || [];
|
|
81
|
+
// Sentry orders frames oldest→newest; show the last ~20, prefer in-app.
|
|
82
|
+
const inApp = frames.filter((f) => f?.inApp);
|
|
83
|
+
const chosen = (inApp.length ? inApp : frames).slice(-20);
|
|
84
|
+
for (const f of chosen) {
|
|
85
|
+
const where = [f.filename || f.module, f.lineNo ? `:${f.lineNo}` : '']
|
|
86
|
+
.filter(Boolean).join('');
|
|
87
|
+
const fn = f.function ? ` in ${f.function}` : '';
|
|
88
|
+
if (where || fn) lines.push(` at ${where}${fn}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const text = lines.join('\n').trim();
|
|
92
|
+
return text || undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const fetchIssueNode = {
|
|
99
|
+
name: 'fetch_issue',
|
|
100
|
+
skills: [SKILLS.SENTRY],
|
|
101
|
+
outputSchema: FetchIssueOutputSchema,
|
|
102
|
+
execute: async (context) => {
|
|
103
|
+
// Same state-access pattern as fetch-issues-node: the runner passes a
|
|
104
|
+
// context with .state.getAll(); tests pass the flat state directly.
|
|
105
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
106
|
+
? context.state.getAll()
|
|
107
|
+
: context;
|
|
108
|
+
|
|
109
|
+
const issueId = state?.issueId && String(state.issueId).trim();
|
|
110
|
+
if (!issueId) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
'fetch_issue: state.issueId is required for the fix scenario. ' +
|
|
113
|
+
'Pass it on the trigger payload (e.g. from a Sentry "issue alert" webhook ' +
|
|
114
|
+
'or a manual run), alongside trigger:"fix".',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`Fetching Sentry issue ${issueId} (fix scenario)`);
|
|
119
|
+
const detail = await sentryGetIssue(issueId);
|
|
120
|
+
|
|
121
|
+
const suspectCommits = (detail?.suspectCommits || []).map((c) => ({
|
|
122
|
+
id: c.id,
|
|
123
|
+
shortId: c.shortId || (c.id ? String(c.id).slice(0, 7) : undefined),
|
|
124
|
+
message: c.message,
|
|
125
|
+
authorEmail: c.author?.email,
|
|
126
|
+
authorName: c.author?.name,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
// Best-effort hydrate the latest event for its stacktrace. The issue detail
|
|
130
|
+
// endpoint does NOT carry the event payload — the global
|
|
131
|
+
// /issues/<id>/events/latest/ endpoint does. Same global (non-org-scoped)
|
|
132
|
+
// route + token resolution as sentryGetIssue. A failure here (no events,
|
|
133
|
+
// perms) must NOT block the fix — the agent still has culprit +
|
|
134
|
+
// metadata.filename to work from.
|
|
135
|
+
let latestEventStacktrace;
|
|
136
|
+
try {
|
|
137
|
+
const { token } = await resolveIntegrationToken('sentry');
|
|
138
|
+
const res = await fetch(
|
|
139
|
+
`https://sentry.io/api/0/issues/${issueId}/events/latest/`,
|
|
140
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
141
|
+
);
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
latestEventStacktrace = renderStacktrace(await res.json());
|
|
144
|
+
} else {
|
|
145
|
+
console.warn(` · latest-event fetch returned ${res.status} — proceeding without a stacktrace.`);
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.warn(` · latest-event fetch failed (${err.message}) — proceeding without a stacktrace.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const issue = {
|
|
152
|
+
id: String(detail?.id || issueId),
|
|
153
|
+
shortId: detail?.shortId,
|
|
154
|
+
title: detail?.title || detail?.metadata?.value || `Sentry issue ${issueId}`,
|
|
155
|
+
culprit: detail?.culprit,
|
|
156
|
+
level: detail?.level,
|
|
157
|
+
status: detail?.status,
|
|
158
|
+
count: detail?.count,
|
|
159
|
+
userCount: detail?.userCount,
|
|
160
|
+
firstSeen: detail?.firstSeen,
|
|
161
|
+
lastSeen: detail?.lastSeen,
|
|
162
|
+
permalink: detail?.permalink,
|
|
163
|
+
metadata: detail?.metadata
|
|
164
|
+
? {
|
|
165
|
+
type: detail.metadata.type,
|
|
166
|
+
value: detail.metadata.value,
|
|
167
|
+
filename: detail.metadata.filename,
|
|
168
|
+
}
|
|
169
|
+
: undefined,
|
|
170
|
+
suspectCommits,
|
|
171
|
+
latestEventStacktrace,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
console.log(
|
|
175
|
+
`Fetched ${issue.shortId || issue.id} [${issue.level || '?'}] ${issue.title} ` +
|
|
176
|
+
`· culprit=${issue.culprit || 'n/a'} · stacktrace=${latestEventStacktrace ? 'yes' : 'no'} ` +
|
|
177
|
+
`· suspectCommits=${suspectCommits.length}`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return { found: true, issue, fetchedAt: new Date().toISOString() };
|
|
181
|
+
},
|
|
182
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix node — FIX-scenario heart. Clone the repo, have a coding agent make the
|
|
3
|
+
* SMALLEST correct fix for one Sentry issue, gate it on the repo's tests with
|
|
4
|
+
* ONE bounded retry, then commit + push a feature branch.
|
|
5
|
+
*
|
|
6
|
+
* SELF-CONTAINED BY DESIGN. This template does NOT dispatch the `code-fix`
|
|
7
|
+
* sub-graph — a customer who deploys ONLY this agent would hit SUBGRAPH_NOT_FOUND
|
|
8
|
+
* (sub-graphs require the child to be separately deployed). So the clone/edit/
|
|
9
|
+
* test/push logic is brought IN here, adapted from
|
|
10
|
+
* code-fix/nodes/{clone-node,fix-code-node}.js. Deploy one agent, it works.
|
|
11
|
+
*
|
|
12
|
+
* Differences from code-fix's two-node clone+fix_code split:
|
|
13
|
+
* - Clone is DETERMINISTIC here (a direct `git clone` with the runtime token
|
|
14
|
+
* spliced into the URL), not an LLM `git_checkout` call — one fewer model
|
|
15
|
+
* round-trip, and the token-splice mechanics already live in code-fix's push
|
|
16
|
+
* path so we reuse them for clone too. We still declare `skills:[SKILLS.GIT]`
|
|
17
|
+
* so the backend marks the repo integration REQUIRED for this scenario
|
|
18
|
+
* (same "deterministic node, real integration-requirement signal" pattern
|
|
19
|
+
* as fetch_issues declaring SKILLS.SENTRY).
|
|
20
|
+
* - The "ticket" is built from the Sentry issue (id→key, title, body =
|
|
21
|
+
* culprit + type + message + filename + stacktrace + permalink) so the agent
|
|
22
|
+
* can locate the bug from the error, not a hand-written ticket.
|
|
23
|
+
*
|
|
24
|
+
* Test-gate (verbatim from code-fix): after the edit, run the repo's test
|
|
25
|
+
* command; a non-zero exit feeds the captured output back into the prompt for
|
|
26
|
+
* ONE re-edit, then stop. No test command detectable → tested:false, the PR
|
|
27
|
+
* still goes to a human (their call).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn } from 'child_process';
|
|
31
|
+
import { existsSync, readFileSync } from 'fs';
|
|
32
|
+
import { join } from 'path';
|
|
33
|
+
import { invokeAgent, SKILLS } from '@zibby/core';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
|
|
36
|
+
const MAX_TEST_RETRIES = 1; // one re-edit on test failure, then stop (design lock)
|
|
37
|
+
|
|
38
|
+
const FixOutputSchema = z.object({
|
|
39
|
+
success: z.boolean(),
|
|
40
|
+
branch: z.string().optional(),
|
|
41
|
+
repoPath: z.string().optional(),
|
|
42
|
+
cloneError: z.string().optional(),
|
|
43
|
+
tested: z.boolean(),
|
|
44
|
+
testsPassed: z.boolean().optional(),
|
|
45
|
+
attempts: z.number().optional(),
|
|
46
|
+
testCommand: z.string().optional(),
|
|
47
|
+
changedFiles: z.array(z.string()).optional(),
|
|
48
|
+
diffStat: z.string().optional(),
|
|
49
|
+
commitMessage: z.string().optional(),
|
|
50
|
+
agentOutput: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Safe spawn wrapper — explicit args array, never shell:true. Resolves
|
|
55
|
+
* `{ code, stdout, stderr }` (does NOT reject on non-zero) so the test-gate
|
|
56
|
+
* can inspect a failing exit code. Verbatim from code-fix/fix-code-node.js.
|
|
57
|
+
*/
|
|
58
|
+
function run(bin, args, cwd, env) {
|
|
59
|
+
return new Promise((res) => {
|
|
60
|
+
const proc = spawn(bin, args, {
|
|
61
|
+
cwd,
|
|
62
|
+
shell: false,
|
|
63
|
+
env: env || process.env,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
65
|
+
});
|
|
66
|
+
let stdout = '';
|
|
67
|
+
let stderr = '';
|
|
68
|
+
proc.stdout.on('data', (d) => { const s = d.toString(); stdout += s; console.log(s.trimEnd()); });
|
|
69
|
+
proc.stderr.on('data', (d) => { const s = d.toString(); stderr += s; console.log(s.trimEnd()); });
|
|
70
|
+
proc.on('close', (code) => res({ code, stdout, stderr }));
|
|
71
|
+
proc.on('error', (err) => res({ code: 1, stdout, stderr: stderr + `\n${err.message}` }));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Run a shell-style command string (e.g. "npm test") via sh -c. */
|
|
76
|
+
function runShell(cmd, cwd, env) {
|
|
77
|
+
return run('/bin/sh', ['-c', cmd], cwd, env);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Splice the runtime token into a clone/push URL, GitHub OR GitLab. Same
|
|
82
|
+
* mechanics as code-fix's push path (which did GitHub only); generalized here so
|
|
83
|
+
* the fix scenario clones + pushes private repos on either provider.
|
|
84
|
+
* GitHub: https://x-access-token:<tok>@github.com/...
|
|
85
|
+
* GitLab: https://oauth2:<tok>@gitlab.com/...
|
|
86
|
+
* Returns the original url unchanged when no token / unknown host.
|
|
87
|
+
*/
|
|
88
|
+
export function authedUrl(url, token) {
|
|
89
|
+
if (!url || !token) return url;
|
|
90
|
+
if (url.includes('github.com')) {
|
|
91
|
+
return url.replace(/^https:\/\/(?:[^@/]+@)?github\.com/, `https://x-access-token:${token}@github.com`);
|
|
92
|
+
}
|
|
93
|
+
if (url.includes('gitlab.com')) {
|
|
94
|
+
return url.replace(/^https:\/\/(?:[^@/]+@)?gitlab\.com/, `https://oauth2:${token}@gitlab.com`);
|
|
95
|
+
}
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the repo URL: explicit state.repoUrl (input field) wins, else the
|
|
101
|
+
* REPO_URL env (ENV-tab config). Matches code-fix's "repo from input/env"
|
|
102
|
+
* approach but for a single repo (the fix scenario fixes ONE repo).
|
|
103
|
+
*/
|
|
104
|
+
function resolveRepoUrl(state) {
|
|
105
|
+
return (state?.repoUrl && String(state.repoUrl).trim())
|
|
106
|
+
|| (process.env.REPO_URL && String(process.env.REPO_URL).trim())
|
|
107
|
+
|| '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function repoNameFromUrl(url) {
|
|
111
|
+
const m = (url || '').match(/[/:]([^/]+?)(?:\.git)?$/);
|
|
112
|
+
return m ? m[1] : 'repo';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Detect the repo's test command. TEST_COMMAND env wins; else sniff the repo.
|
|
117
|
+
* Verbatim from code-fix/fix-code-node.js.
|
|
118
|
+
*/
|
|
119
|
+
function detectTestCommand(repoPath) {
|
|
120
|
+
const override = (process.env.TEST_COMMAND || '').trim();
|
|
121
|
+
if (override) return override;
|
|
122
|
+
|
|
123
|
+
if (existsSync(join(repoPath, 'package.json'))) {
|
|
124
|
+
try {
|
|
125
|
+
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf-8'));
|
|
126
|
+
if (pkg.scripts && pkg.scripts.test && !/no test specified/i.test(pkg.scripts.test)) {
|
|
127
|
+
return 'npm test --silent';
|
|
128
|
+
}
|
|
129
|
+
} catch { /* fall through */ }
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
existsSync(join(repoPath, 'pytest.ini')) ||
|
|
133
|
+
existsSync(join(repoPath, 'pyproject.toml')) ||
|
|
134
|
+
existsSync(join(repoPath, 'setup.py')) ||
|
|
135
|
+
existsSync(join(repoPath, 'requirements.txt')) ||
|
|
136
|
+
existsSync(join(repoPath, 'tests'))
|
|
137
|
+
) {
|
|
138
|
+
return 'pytest -q';
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the fix "ticket" from a Sentry issue. The body packs everything the
|
|
145
|
+
* agent needs to locate the defect: error type + message, culprit, filename,
|
|
146
|
+
* the latest-event stacktrace (when fetch_issue hydrated one), suspect commits,
|
|
147
|
+
* and the permalink. Adapted from code-fix's buildFixPrompt ticket section.
|
|
148
|
+
*/
|
|
149
|
+
export function ticketFromIssue(issue) {
|
|
150
|
+
const md = issue?.metadata || {};
|
|
151
|
+
const lines = [];
|
|
152
|
+
if (md.type || md.value) lines.push(`Error: ${[md.type, md.value].filter(Boolean).join(': ')}`);
|
|
153
|
+
if (issue.culprit) lines.push(`Culprit: ${issue.culprit}`);
|
|
154
|
+
if (md.filename) lines.push(`File (Sentry's best guess): ${md.filename}`);
|
|
155
|
+
if (issue.level) lines.push(`Level: ${issue.level}`);
|
|
156
|
+
if (issue.count != null || issue.userCount != null) {
|
|
157
|
+
lines.push(`Impact: ${issue.count ?? '?'} event(s), ${issue.userCount ?? '?'} user(s) affected`);
|
|
158
|
+
}
|
|
159
|
+
const sc = (issue.suspectCommits || []).filter((c) => c.shortId || c.message);
|
|
160
|
+
if (sc.length) {
|
|
161
|
+
lines.push('Suspect commits:');
|
|
162
|
+
for (const c of sc.slice(0, 3)) {
|
|
163
|
+
lines.push(` - ${c.shortId || '?'} ${c.message ? `“${String(c.message).split('\n')[0]}”` : ''} ${c.authorEmail ? `(${c.authorEmail})` : ''}`.trim());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (issue.latestEventStacktrace) {
|
|
167
|
+
lines.push('\nLatest event stacktrace:\n```\n' + issue.latestEventStacktrace.slice(0, 6000) + '\n```');
|
|
168
|
+
}
|
|
169
|
+
if (issue.permalink) lines.push(`\nSentry: ${issue.permalink}`);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
key: issue.shortId || issue.id,
|
|
173
|
+
title: issue.title,
|
|
174
|
+
body: lines.join('\n'),
|
|
175
|
+
url: issue.permalink,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildFixPrompt(ticket, repoPath, extraTestFeedback) {
|
|
180
|
+
const body = ticket.body ? `\n\n## Error details (from Sentry)\n${ticket.body}` : '';
|
|
181
|
+
const base = `You are an autonomous bug-fixing agent. Fix exactly ONE production error in the repository checked out at:
|
|
182
|
+
|
|
183
|
+
${repoPath}
|
|
184
|
+
|
|
185
|
+
# Sentry issue ${ticket.key}: ${ticket.title}${body}
|
|
186
|
+
|
|
187
|
+
# Your job
|
|
188
|
+
1. Use the error type/message, culprit, filename, and stacktrace above to locate the defect. Read the relevant code and follow the repo's own conventions.
|
|
189
|
+
2. Make the SMALLEST correct change that fixes the error. Don't refactor unrelated code, don't reformat files you didn't need to touch.
|
|
190
|
+
3. If the repo has tests, add or update a test that would catch this bug — but only if it's natural to do so. Don't fabricate a test framework that isn't there.
|
|
191
|
+
4. Do NOT commit, branch, or push — the workflow does that for you after you finish editing. Just leave the working tree edited.
|
|
192
|
+
|
|
193
|
+
Keep the change tight and reviewable: a human will read this diff before merging.`;
|
|
194
|
+
|
|
195
|
+
if (extraTestFeedback) {
|
|
196
|
+
return `${base}
|
|
197
|
+
|
|
198
|
+
# IMPORTANT — your previous attempt failed the test suite
|
|
199
|
+
The test command was run after your last edit and FAILED. Here is the output. Read it carefully and fix the failures (your earlier change may be incomplete or wrong). This is your final attempt before the change goes to a human regardless.
|
|
200
|
+
|
|
201
|
+
\`\`\`
|
|
202
|
+
${extraTestFeedback.slice(-8000)}
|
|
203
|
+
\`\`\``;
|
|
204
|
+
}
|
|
205
|
+
return base;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const fixNode = {
|
|
209
|
+
name: 'fix',
|
|
210
|
+
// SKILLS.GIT → marks the repo integration REQUIRED for the fix scenario
|
|
211
|
+
// (renders "GitHub or GitLab" in the marketplace). Clone/push are done
|
|
212
|
+
// deterministically below with the runtime token; the skill declaration is
|
|
213
|
+
// the integration-requirement signal, same as fetch_issues + SKILLS.SENTRY.
|
|
214
|
+
skills: [SKILLS.GIT],
|
|
215
|
+
outputSchema: FixOutputSchema,
|
|
216
|
+
// Generous: clone + agent edit + up to two test runs on a real repo.
|
|
217
|
+
timeout: 20 * 60 * 1000,
|
|
218
|
+
execute: async (context) => {
|
|
219
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
220
|
+
? context.state.getAll()
|
|
221
|
+
: context;
|
|
222
|
+
|
|
223
|
+
const issue = state?.fetch_issue?.issue;
|
|
224
|
+
if (!issue) {
|
|
225
|
+
throw new Error('fix: no issue on state.fetch_issue.issue — fetch_issue must run first.');
|
|
226
|
+
}
|
|
227
|
+
const repoUrl = resolveRepoUrl(state);
|
|
228
|
+
if (!repoUrl) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'fix: no repo configured. Pass `repoUrl` on the trigger payload OR set the ' +
|
|
231
|
+
'REPO_URL ENV-tab var to the repository to fix (e.g. https://github.com/acme/web).',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const token = state?.githubToken || state?.gitlabToken || state?.gitToken;
|
|
236
|
+
const repoName = repoNameFromUrl(repoUrl);
|
|
237
|
+
const repoPath = join(state.workspace || process.cwd(), repoName);
|
|
238
|
+
const model = state?.model || 'auto';
|
|
239
|
+
const ticket = ticketFromIssue(issue);
|
|
240
|
+
|
|
241
|
+
console.log(`\n🔧 Fixing Sentry ${ticket.key}: ${ticket.title}`);
|
|
242
|
+
console.log(` repo=${repoUrl} → ${repoPath}`);
|
|
243
|
+
|
|
244
|
+
// ── 0. Clone (deterministic, token-spliced URL) ─────────────────
|
|
245
|
+
const cloneRes = await run('git', ['clone', '--depth', '1', authedUrl(repoUrl, token), repoPath]);
|
|
246
|
+
if (cloneRes.code !== 0) {
|
|
247
|
+
const cloneError = `git clone failed (exit ${cloneRes.code}): ${cloneRes.stderr.slice(-500)}`;
|
|
248
|
+
console.error(`❌ ${cloneError}`);
|
|
249
|
+
return { success: false, tested: false, cloneError, changedFiles: [] };
|
|
250
|
+
}
|
|
251
|
+
// Identity for the eventual commit (no global git config on a fresh task).
|
|
252
|
+
await run('git', ['config', 'user.email', 'zibby@agent.com'], repoPath);
|
|
253
|
+
await run('git', ['config', 'user.name', 'Zibby Agent'], repoPath);
|
|
254
|
+
|
|
255
|
+
// ── 1. First edit pass ──────────────────────────────────────────
|
|
256
|
+
let agentOutput = await invokeAgent(buildFixPrompt(ticket, repoPath), { state, model });
|
|
257
|
+
|
|
258
|
+
// ── 2 + 3. Inline test-gate with ONE bounded retry ──────────────
|
|
259
|
+
const testCommand = detectTestCommand(repoPath);
|
|
260
|
+
let tested = false;
|
|
261
|
+
let testsPassed;
|
|
262
|
+
let attempts = 1;
|
|
263
|
+
|
|
264
|
+
if (!testCommand) {
|
|
265
|
+
console.log('🧪 No test command detected. Skipping test-gate — PR still goes to a human.');
|
|
266
|
+
} else {
|
|
267
|
+
console.log(`🧪 Test-gate command: ${testCommand}`);
|
|
268
|
+
tested = true;
|
|
269
|
+
let result = await runShell(testCommand, repoPath);
|
|
270
|
+
testsPassed = result.code === 0;
|
|
271
|
+
console.log(testsPassed ? '✅ Tests passed on first attempt.' : `❌ Tests failed (exit ${result.code}).`);
|
|
272
|
+
|
|
273
|
+
while (!testsPassed && attempts <= MAX_TEST_RETRIES) {
|
|
274
|
+
attempts += 1;
|
|
275
|
+
console.log(`🔁 Re-editing with test feedback (attempt ${attempts}/${MAX_TEST_RETRIES + 1})…`);
|
|
276
|
+
const feedback = `$ ${testCommand}\n${result.stdout}\n${result.stderr}`;
|
|
277
|
+
agentOutput = await invokeAgent(buildFixPrompt(ticket, repoPath, feedback), { state, model });
|
|
278
|
+
result = await runShell(testCommand, repoPath);
|
|
279
|
+
testsPassed = result.code === 0;
|
|
280
|
+
console.log(testsPassed ? '✅ Tests passed after retry.' : `❌ Tests still failing (exit ${result.code}).`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── 4. Branch + commit + push ───────────────────────────────────
|
|
285
|
+
const status = await run('git', ['status', '--porcelain'], repoPath);
|
|
286
|
+
if (!status.stdout.trim()) {
|
|
287
|
+
console.warn('⚠️ Agent made no changes — nothing to commit. open_pr will skip.');
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
repoPath,
|
|
291
|
+
tested,
|
|
292
|
+
testsPassed,
|
|
293
|
+
attempts,
|
|
294
|
+
testCommand: testCommand || undefined,
|
|
295
|
+
changedFiles: [],
|
|
296
|
+
agentOutput,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const shortId = String(issue.shortId || issue.id).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
|
|
301
|
+
const branch = `zibby/sentry-fix-${shortId}-${Math.random().toString(36).slice(2, 6)}`;
|
|
302
|
+
const commitMessage = `fix: ${ticket.title}\n\nAutomated fix for Sentry ${ticket.key} by Zibby sentry-triage. `
|
|
303
|
+
+ `Tests: ${tested ? (testsPassed ? 'passing' : 'FAILING — needs human review') : 'not run'}.`;
|
|
304
|
+
|
|
305
|
+
await run('git', ['checkout', '-b', branch], repoPath);
|
|
306
|
+
await run('git', ['add', '-A'], repoPath);
|
|
307
|
+
await run('git', ['commit', '-m', commitMessage], repoPath);
|
|
308
|
+
|
|
309
|
+
// Authenticated remote for push (GitHub or GitLab).
|
|
310
|
+
if (token) {
|
|
311
|
+
await run('git', ['remote', 'set-url', 'origin', authedUrl(repoUrl, token)], repoPath);
|
|
312
|
+
}
|
|
313
|
+
const pushRes = await run('git', ['push', '-u', 'origin', branch], repoPath);
|
|
314
|
+
if (pushRes.code !== 0) {
|
|
315
|
+
throw new Error(`git push failed (exit ${pushRes.code}): ${pushRes.stderr.slice(-500)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const diffStatRes = await run('git', ['diff', '--stat', 'HEAD~1'], repoPath);
|
|
319
|
+
const changedRes = await run('git', ['diff', '--name-only', 'HEAD~1'], repoPath);
|
|
320
|
+
const changedFiles = changedRes.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
321
|
+
|
|
322
|
+
console.log(`✅ Pushed ${changedFiles.length} file(s) on ${branch}.`);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
success: true,
|
|
326
|
+
branch,
|
|
327
|
+
repoPath,
|
|
328
|
+
tested,
|
|
329
|
+
testsPassed,
|
|
330
|
+
attempts,
|
|
331
|
+
testCommand: testCommand || undefined,
|
|
332
|
+
changedFiles,
|
|
333
|
+
diffStat: diffStatRes.stdout,
|
|
334
|
+
commitMessage,
|
|
335
|
+
agentOutput,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify (fix scenario) — OPTIONAL chat ping after the fix attempt.
|
|
3
|
+
*
|
|
4
|
+
* Runs LAST on the fix branch (open_pr → notify → END, and also on the
|
|
5
|
+
* fix-failed short-circuit). Posts a one-liner:
|
|
6
|
+
* success → "🛠 Auto-fixed Sentry <shortId>: <title> → PR <url>"
|
|
7
|
+
* failure → "⚠️ Couldn't auto-fix Sentry <shortId> — needs a human" + Sentry link
|
|
8
|
+
*
|
|
9
|
+
* Declares NO `skills` (uses `optionalSkills:['chat_notify']` for marketplace
|
|
10
|
+
* display only) and calls the chat handler DIRECTLY via lib/notify.js — so this
|
|
11
|
+
* post is graceful: no chat target / unconnected integration → { sent:false },
|
|
12
|
+
* NEVER throws (the PR, if any, is already open). Mirrors github-code-review's
|
|
13
|
+
* notify-node. Reuses the SLACK_CHANNEL / LARK_RECEIVE_ID ENV-tab config the
|
|
14
|
+
* TRIAGE dispatcher already uses, so one config drives both scenarios.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { postChatNotification } from '../lib/notify.js';
|
|
19
|
+
|
|
20
|
+
const NotifyOutputSchema = z.object({
|
|
21
|
+
notify_fix: z.object({
|
|
22
|
+
sent: z.boolean(),
|
|
23
|
+
target: z.string().optional(),
|
|
24
|
+
skipped: z.string().optional(),
|
|
25
|
+
detail: z.string().optional(),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function buildMessage(state) {
|
|
30
|
+
const issue = state?.fetch_issue?.issue || {};
|
|
31
|
+
const fix = state?.fix || {};
|
|
32
|
+
const pr = state?.open_pr || {};
|
|
33
|
+
const id = issue.shortId || issue.id || '(unknown)';
|
|
34
|
+
const title = issue.title || '';
|
|
35
|
+
const sentryLink = issue.permalink ? `\n${issue.permalink}` : '';
|
|
36
|
+
|
|
37
|
+
if (fix.success && pr.success && pr.pr_url) {
|
|
38
|
+
const testNote = fix.tested
|
|
39
|
+
? (fix.testsPassed ? ' (tests passing)' : ' (⚠️ tests still failing — review carefully)')
|
|
40
|
+
: '';
|
|
41
|
+
return `🛠 Auto-fixed Sentry ${id}: ${title} → PR ${pr.pr_url}${testNote}${sentryLink}`;
|
|
42
|
+
}
|
|
43
|
+
if (fix.success && fix.branch && !pr.success) {
|
|
44
|
+
// Branch pushed but PR couldn't be opened — still worth a heads-up.
|
|
45
|
+
return `🛠 Pushed a fix branch for Sentry ${id}: ${title} (\`${fix.branch}\`), but couldn't open the PR${pr.skippedReason ? `: ${pr.skippedReason}` : ''}.${sentryLink}`;
|
|
46
|
+
}
|
|
47
|
+
return `⚠️ Couldn't auto-fix Sentry ${id} — needs a human${title ? `: ${title}` : ''}.${sentryLink}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const notifyFixNode = {
|
|
51
|
+
name: 'notify',
|
|
52
|
+
// Marketplace metadata only (Slack OR Lark, optional). The node calls the chat
|
|
53
|
+
// handler directly, declaring NO `skills`, so it never gates deploy.
|
|
54
|
+
optionalSkills: ['chat_notify'],
|
|
55
|
+
outputSchema: NotifyOutputSchema,
|
|
56
|
+
timeout: 30 * 1000,
|
|
57
|
+
execute: async (context) => {
|
|
58
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
59
|
+
? context.state.getAll()
|
|
60
|
+
: context;
|
|
61
|
+
|
|
62
|
+
const slackChannel = process.env.SLACK_CHANNEL && String(process.env.SLACK_CHANNEL).trim();
|
|
63
|
+
const larkReceiveId = process.env.LARK_RECEIVE_ID && String(process.env.LARK_RECEIVE_ID).trim();
|
|
64
|
+
|
|
65
|
+
if (!slackChannel && !larkReceiveId) {
|
|
66
|
+
return { notify_fix: { sent: false, skipped: 'no chat target configured (SLACK_CHANNEL / LARK_RECEIVE_ID)' } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const text = buildMessage(state);
|
|
70
|
+
const res = await postChatNotification({ slackChannel, larkReceiveId, text });
|
|
71
|
+
return {
|
|
72
|
+
notify_fix: {
|
|
73
|
+
sent: !!res.sent,
|
|
74
|
+
...(res.target ? { target: res.target } : {}),
|
|
75
|
+
...(res.detail ? { detail: res.detail } : {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|