@zibby/workflow-templates 0.10.4 → 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.
@@ -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
+ };