@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,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": "Hourly Sentry issue triage bot — LLM-classifies new issues by severity and pings Slack OR Lark for anything threshold.",
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": {
@@ -1,17 +1,32 @@
1
1
  /**
2
- * sentry-triage — input + context schemas.
2
+ * sentry-triage — input + context schemas. MULTI-SCENARIO.
3
3
  *
4
- * Trigger payload (inputSchema) is intentionally tiny: just sinceMinutes,
5
- * the one per-run dial. Everything else is deploy-time ENV-tab config:
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
- * Required (set ONE at least one chat target):
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('Lookback minutes (5–1440)'),
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; triage doesn\'t need it but graph.run requires it.'),
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({