@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* open_pr node — open a PR (GitHub) / MR (GitLab) off the branch `fix` pushed.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from code-fix/nodes/create-pr-node.js (which was GitHub-only) and
|
|
5
|
+
* extended to GitLab — the fix scenario clones+pushes either provider, so PR
|
|
6
|
+
* creation must cover both. Output contract: { pr_url, branch }.
|
|
7
|
+
*
|
|
8
|
+
* Conditional: if `fix` produced no branch (agent made no changes, or clone/fix
|
|
9
|
+
* failed) we SKIP — the graph routes here only when fix.success && fix.branch,
|
|
10
|
+
* but we keep a defensive guard for direct invocation / replay.
|
|
11
|
+
*
|
|
12
|
+
* Stops here by design: the engine has no approval/pause primitive, so the open
|
|
13
|
+
* PR/MR IS the approval — a human reviews + merges. We never auto-merge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import axios from 'axios';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
const OpenPROutputSchema = z.object({
|
|
20
|
+
success: z.boolean(),
|
|
21
|
+
pr_url: z.string().optional(),
|
|
22
|
+
branch: z.string().optional(),
|
|
23
|
+
number: z.number().optional(),
|
|
24
|
+
repo: z.string().optional(),
|
|
25
|
+
provider: z.enum(['github', 'gitlab']).optional(),
|
|
26
|
+
skippedReason: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect provider + owner/repo from the clone URL. Returns null for an
|
|
31
|
+
* unrecognized host (open_pr then skips — the branch is still pushed).
|
|
32
|
+
*/
|
|
33
|
+
export function parseRepo(url) {
|
|
34
|
+
let m = (url || '').match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
35
|
+
if (m) return { provider: 'github', owner: m[1], repo: m[2] };
|
|
36
|
+
m = (url || '').match(/gitlab\.com[/:](.+?)\/([^/]+?)(?:\.git)?$/);
|
|
37
|
+
if (m) return { provider: 'gitlab', owner: m[1], repo: m[2] };
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveRepoUrl(state) {
|
|
42
|
+
return (state?.repoUrl && String(state.repoUrl).trim())
|
|
43
|
+
|| (process.env.REPO_URL && String(process.env.REPO_URL).trim())
|
|
44
|
+
|| '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildPRBody(state) {
|
|
48
|
+
const issue = state?.fetch_issue?.issue || {};
|
|
49
|
+
const fix = state?.fix || {};
|
|
50
|
+
const testLine = fix.tested
|
|
51
|
+
? (fix.testsPassed
|
|
52
|
+
? `✅ Test-gate: \`${fix.testCommand}\` passing (${fix.attempts || 1} attempt${(fix.attempts || 1) > 1 ? 's' : ''}).`
|
|
53
|
+
: `⚠️ Test-gate: \`${fix.testCommand}\` still FAILING after ${fix.attempts || 1} attempt(s) — needs human review.`)
|
|
54
|
+
: 'ℹ️ No test command detected — change not gated by tests.';
|
|
55
|
+
|
|
56
|
+
const md = issue.metadata || {};
|
|
57
|
+
const errLine = (md.type || md.value) ? `\`${[md.type, md.value].filter(Boolean).join(': ')}\`` : '';
|
|
58
|
+
|
|
59
|
+
return `## 🐛 Sentry ${issue.shortId || issue.id || ''}: ${issue.title || ''}
|
|
60
|
+
|
|
61
|
+
${errLine ? `**Error:** ${errLine}\n` : ''}${issue.culprit ? `**Culprit:** \`${issue.culprit}\`\n` : ''}${issue.permalink ? `**Sentry:** ${issue.permalink}\n` : ''}
|
|
62
|
+
|
|
63
|
+
## 🔧 Change
|
|
64
|
+
|
|
65
|
+
${testLine}
|
|
66
|
+
|
|
67
|
+
${fix.diffStat ? '```\n' + fix.diffStat.trim() + '\n```' : ''}
|
|
68
|
+
|
|
69
|
+
${(fix.changedFiles || []).length ? '### Files\n' + fix.changedFiles.map((f) => `- \`${f}\``).join('\n') : ''}
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
🤖 Automated fix by [Zibby sentry-triage](https://zibby.dev). A human should review before merging.
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const openPRNode = {
|
|
77
|
+
name: 'open_pr',
|
|
78
|
+
outputSchema: OpenPROutputSchema,
|
|
79
|
+
execute: async (context) => {
|
|
80
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
81
|
+
? context.state.getAll()
|
|
82
|
+
: context;
|
|
83
|
+
|
|
84
|
+
console.log('\n🔀 Opening pull/merge request…');
|
|
85
|
+
|
|
86
|
+
const fix = state?.fix || {};
|
|
87
|
+
const branch = fix.branch;
|
|
88
|
+
const repoUrl = resolveRepoUrl(state);
|
|
89
|
+
const token = state?.githubToken || state?.gitlabToken || state?.gitToken;
|
|
90
|
+
const issue = state?.fetch_issue?.issue || {};
|
|
91
|
+
|
|
92
|
+
// Defensive guard (the conditional edge already gates on this).
|
|
93
|
+
if (!fix.success || !branch) {
|
|
94
|
+
const reason = 'fix produced no branch (no changes committed).';
|
|
95
|
+
console.log(`⏭️ Skipping PR: ${reason}`);
|
|
96
|
+
return { success: false, skippedReason: reason };
|
|
97
|
+
}
|
|
98
|
+
if (!token) {
|
|
99
|
+
const reason = 'No git token — cannot open PR/MR. Connect the GitHub or GitLab integration.';
|
|
100
|
+
console.log(`⏭️ Skipping PR: ${reason}`);
|
|
101
|
+
return { success: false, branch, skippedReason: reason };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parsed = parseRepo(repoUrl);
|
|
105
|
+
if (!parsed) {
|
|
106
|
+
const reason = `Unrecognized repo host: ${repoUrl}. open_pr supports github.com and gitlab.com.`;
|
|
107
|
+
console.log(`⏭️ Skipping PR: ${reason}`);
|
|
108
|
+
return { success: false, branch, skippedReason: reason };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const title = `fix: ${issue.title || branch}`;
|
|
112
|
+
const base = process.env.PR_BASE || 'main';
|
|
113
|
+
|
|
114
|
+
// ── GitHub ───────────────────────────────────────────────────────
|
|
115
|
+
if (parsed.provider === 'github') {
|
|
116
|
+
try {
|
|
117
|
+
const resp = await axios.post(
|
|
118
|
+
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/pulls`,
|
|
119
|
+
{ title, head: branch, base, body: buildPRBody(state), draft: false },
|
|
120
|
+
{
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `token ${token}`,
|
|
123
|
+
Accept: 'application/vnd.github.v3+json',
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
const pr = resp.data;
|
|
129
|
+
console.log(`✅ PR opened: ${pr.html_url} (#${pr.number})`);
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
pr_url: pr.html_url,
|
|
133
|
+
branch,
|
|
134
|
+
number: pr.number,
|
|
135
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
136
|
+
provider: 'github',
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err.response?.data?.message || err.message;
|
|
140
|
+
throw new Error(`Failed to open GitHub PR for ${parsed.owner}/${parsed.repo}: ${msg}`, { cause: err });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── GitLab ───────────────────────────────────────────────────────
|
|
145
|
+
// The project id is the URL-encoded "owner/repo" path; GitLab accepts it
|
|
146
|
+
// in place of the numeric id on every project endpoint.
|
|
147
|
+
const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`);
|
|
148
|
+
try {
|
|
149
|
+
const resp = await axios.post(
|
|
150
|
+
`https://gitlab.com/api/v4/projects/${projectId}/merge_requests`,
|
|
151
|
+
{
|
|
152
|
+
source_branch: branch,
|
|
153
|
+
target_branch: base,
|
|
154
|
+
title,
|
|
155
|
+
description: buildPRBody(state),
|
|
156
|
+
remove_source_branch: true,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
headers: {
|
|
160
|
+
'PRIVATE-TOKEN': token,
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
const mr = resp.data;
|
|
166
|
+
console.log(`✅ MR opened: ${mr.web_url} (!${mr.iid})`);
|
|
167
|
+
return {
|
|
168
|
+
success: true,
|
|
169
|
+
pr_url: mr.web_url,
|
|
170
|
+
branch,
|
|
171
|
+
number: mr.iid,
|
|
172
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
173
|
+
provider: 'gitlab',
|
|
174
|
+
};
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const msg = err.response?.data?.message || err.response?.data?.error || err.message;
|
|
177
|
+
throw new Error(`Failed to open GitLab MR for ${parsed.owner}/${parsed.repo}: ${msg}`, { cause: err });
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "Sentry issue triage + auto-fix bot — scheduled triage digests (Slack/Lark), plus a 'fix' scenario that clones the repo, lets a coding agent fix one issue with a test-gate, and opens a PR/MR.",
|
|
7
7
|
"main": "graph.mjs",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"test": "vitest run"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@zibby/core": "^0.5.1",
|
|
13
13
|
"@zibby/skills": "^0.1.26",
|
|
14
|
+
"axios": "^1.6.0",
|
|
14
15
|
"zod": "^3.23.0"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
package/sentry-triage/state.js
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sentry-triage — input + context schemas.
|
|
2
|
+
* sentry-triage — input + context schemas. MULTI-SCENARIO.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* One deployed agent handles BOTH scenarios, selected by an entry decision on
|
|
5
|
+
* `state.trigger` (mirrors github-code-review's entry route):
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* trigger:'fix' → FIX scenario (fetch ONE issue → clone+fix+test+push →
|
|
8
|
+
* open PR/MR → notify). Needs `issueId` +
|
|
9
|
+
* a repo (repoUrl input OR REPO_URL env).
|
|
10
|
+
* else (default) → TRIAGE scenario (the unchanged scheduled hourly triage:
|
|
11
|
+
* list issues → classify → dispatch digest).
|
|
12
|
+
*
|
|
13
|
+
* Trigger payload (inputSchema) is intentionally small. Everything else is
|
|
14
|
+
* deploy-time ENV-tab config:
|
|
15
|
+
*
|
|
16
|
+
* TRIAGE — chat target (set ONE; required for the digest):
|
|
8
17
|
* SLACK_CHANNEL channel id "C012345" or "#name"
|
|
9
18
|
* LARK_RECEIVE_ID oc_… chat id, ou_… open id, or email
|
|
10
|
-
*
|
|
11
|
-
* Optional:
|
|
19
|
+
* TRIAGE — optional:
|
|
12
20
|
* SEVERITY_THRESHOLD NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
|
|
13
21
|
* SLACK_MENTIONS JSON array — appended to CRITICAL Slack alerts only
|
|
14
22
|
* LARK_MENTIONS JSON array — appended to CRITICAL Lark alerts only
|
|
23
|
+
*
|
|
24
|
+
* FIX — repo + PR config:
|
|
25
|
+
* REPO_URL repo to fix (used when `repoUrl` input is absent)
|
|
26
|
+
* TEST_COMMAND override the auto-detected test command
|
|
27
|
+
* PR_BASE base branch for the PR/MR (default "main")
|
|
28
|
+
* FIX — chat (optional, reuses the TRIAGE target):
|
|
29
|
+
* SLACK_CHANNEL / LARK_RECEIVE_ID — where the "auto-fixed / couldn't fix" ping goes
|
|
15
30
|
*/
|
|
16
31
|
|
|
17
32
|
import { z } from 'zod';
|
|
@@ -27,13 +42,106 @@ export function meetsSeverityThreshold(severity, threshold) {
|
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export const sentryTriageInputSchema = z.object({
|
|
45
|
+
// ── Scenario selector (entry route) ──────────────────────────────
|
|
46
|
+
// 'fix' → FIX scenario; anything else (incl. absent → default 'triage') →
|
|
47
|
+
// the scheduled TRIAGE scenario. A Sentry "issue alert" webhook (or a manual
|
|
48
|
+
// run) sets trigger:'fix' + issueId to ask for an auto-fix attempt.
|
|
49
|
+
trigger: z.enum(['triage', 'fix']).default('triage')
|
|
50
|
+
.describe("Scenario selector: 'fix' = clone+fix+PR for one issue; else the scheduled triage digest."),
|
|
51
|
+
|
|
52
|
+
// ── TRIAGE scenario ──────────────────────────────────────────────
|
|
30
53
|
sinceMinutes: z.number().int().min(5).max(1440).default(60)
|
|
31
|
-
.describe('
|
|
54
|
+
.describe('TRIAGE: lookback minutes (5–1440).'),
|
|
55
|
+
|
|
56
|
+
// ── FIX scenario ─────────────────────────────────────────────────
|
|
57
|
+
// issueId is required-WHEN-fix (enforced in fetch_issue, not the schema, so a
|
|
58
|
+
// triage run never has to carry it). repoUrl is the repo to fix; when absent
|
|
59
|
+
// the fix node falls back to the REPO_URL ENV-tab var.
|
|
60
|
+
issueId: z.string().min(1).optional()
|
|
61
|
+
.describe('FIX: the Sentry issue id (numeric or shortId) to auto-fix. Required when trigger="fix".'),
|
|
62
|
+
repoUrl: z.string().url().optional()
|
|
63
|
+
.describe('FIX: repository to fix (github.com/… or gitlab.com/…). Falls back to the REPO_URL env var.'),
|
|
64
|
+
model: z.string().default('auto')
|
|
65
|
+
.describe('FIX: LLM model override for the coding agent. Default auto-selects.'),
|
|
32
66
|
});
|
|
33
67
|
|
|
34
68
|
export const sentryTriageContextSchema = z.object({
|
|
35
69
|
workspace: z.string().optional()
|
|
36
|
-
.describe('Workspace path — runner-injected;
|
|
70
|
+
.describe('Workspace path — runner-injected. TRIAGE ignores it; the FIX scenario clones the repo here.'),
|
|
71
|
+
|
|
72
|
+
// Runner-injected git tokens (FIX scenario) — from the project's connected
|
|
73
|
+
// GitHub/GitLab integration. Spliced into the clone/push URL + used to open
|
|
74
|
+
// the PR/MR. Absent on the triage path. (githubToken matches code-fix's name;
|
|
75
|
+
// gitlabToken/gitToken are accepted as fallbacks.)
|
|
76
|
+
githubToken: z.string().optional()
|
|
77
|
+
.describe('FIX: GitHub token — runner-injected. Clones private repos + opens the PR.'),
|
|
78
|
+
gitlabToken: z.string().optional()
|
|
79
|
+
.describe('FIX: GitLab token — runner-injected. Clones private repos + opens the MR.'),
|
|
80
|
+
gitToken: z.string().optional()
|
|
81
|
+
.describe('FIX: provider-agnostic git token fallback — runner-injected.'),
|
|
82
|
+
|
|
83
|
+
// ── FIX scenario node outputs ────────────────────────────────────
|
|
84
|
+
fetch_issue: z.object({
|
|
85
|
+
found: z.boolean(),
|
|
86
|
+
issue: z.object({
|
|
87
|
+
id: z.string(),
|
|
88
|
+
shortId: z.string().optional(),
|
|
89
|
+
title: z.string(),
|
|
90
|
+
culprit: z.string().optional(),
|
|
91
|
+
level: z.string().optional(),
|
|
92
|
+
status: z.string().optional(),
|
|
93
|
+
count: z.union([z.string(), z.number()]).optional(),
|
|
94
|
+
userCount: z.number().optional(),
|
|
95
|
+
firstSeen: z.string().optional(),
|
|
96
|
+
lastSeen: z.string().optional(),
|
|
97
|
+
permalink: z.string().optional(),
|
|
98
|
+
metadata: z.object({
|
|
99
|
+
type: z.string().optional(),
|
|
100
|
+
value: z.string().optional(),
|
|
101
|
+
filename: z.string().optional(),
|
|
102
|
+
}).optional(),
|
|
103
|
+
suspectCommits: z.array(z.object({
|
|
104
|
+
id: z.string().optional(),
|
|
105
|
+
shortId: z.string().optional(),
|
|
106
|
+
message: z.string().optional(),
|
|
107
|
+
authorEmail: z.string().optional(),
|
|
108
|
+
authorName: z.string().optional(),
|
|
109
|
+
})).optional(),
|
|
110
|
+
latestEventStacktrace: z.string().optional(),
|
|
111
|
+
}).optional(),
|
|
112
|
+
fetchedAt: z.string().optional(),
|
|
113
|
+
}).optional().describe('FIX: output of fetch_issue — the one full Sentry issue.'),
|
|
114
|
+
|
|
115
|
+
fix: z.object({
|
|
116
|
+
success: z.boolean(),
|
|
117
|
+
branch: z.string().optional(),
|
|
118
|
+
repoPath: z.string().optional(),
|
|
119
|
+
cloneError: z.string().optional(),
|
|
120
|
+
tested: z.boolean(),
|
|
121
|
+
testsPassed: z.boolean().optional(),
|
|
122
|
+
attempts: z.number().optional(),
|
|
123
|
+
testCommand: z.string().optional(),
|
|
124
|
+
changedFiles: z.array(z.string()).optional(),
|
|
125
|
+
diffStat: z.string().optional(),
|
|
126
|
+
commitMessage: z.string().optional(),
|
|
127
|
+
}).optional().describe('FIX: output of the fix node — branch + inline test-gate result.'),
|
|
128
|
+
|
|
129
|
+
open_pr: z.object({
|
|
130
|
+
success: z.boolean(),
|
|
131
|
+
pr_url: z.string().optional(),
|
|
132
|
+
branch: z.string().optional(),
|
|
133
|
+
number: z.number().optional(),
|
|
134
|
+
repo: z.string().optional(),
|
|
135
|
+
provider: z.enum(['github', 'gitlab']).optional(),
|
|
136
|
+
skippedReason: z.string().optional(),
|
|
137
|
+
}).optional().describe('FIX: output of open_pr — { pr_url, branch }.'),
|
|
138
|
+
|
|
139
|
+
notify_fix: z.object({
|
|
140
|
+
sent: z.boolean(),
|
|
141
|
+
target: z.string().optional(),
|
|
142
|
+
skipped: z.string().optional(),
|
|
143
|
+
detail: z.string().optional(),
|
|
144
|
+
}).optional().describe('FIX: output of the fix-scenario notify node.'),
|
|
37
145
|
|
|
38
146
|
fetch_issues: z.object({
|
|
39
147
|
issues: z.array(z.object({
|