@jiraacp/cli 2026.405.4
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 +283 -0
- package/dist/abort-GQE4OI5S.js +103 -0
- package/dist/abort-GQE4OI5S.js.map +1 -0
- package/dist/abort-VMRQOADY.js +96 -0
- package/dist/abort-VMRQOADY.js.map +1 -0
- package/dist/bot-WOTETAJY.js +13 -0
- package/dist/bot-WOTETAJY.js.map +1 -0
- package/dist/cancel-clarification-4G5S2HJZ.js +64 -0
- package/dist/cancel-clarification-4G5S2HJZ.js.map +1 -0
- package/dist/chunk-3U373M37.js +67 -0
- package/dist/chunk-3U373M37.js.map +1 -0
- package/dist/chunk-3YHD4SIN.js +97 -0
- package/dist/chunk-3YHD4SIN.js.map +1 -0
- package/dist/chunk-6IY6CRUJ.js +690 -0
- package/dist/chunk-6IY6CRUJ.js.map +1 -0
- package/dist/chunk-B6OA3XJK.js +1167 -0
- package/dist/chunk-B6OA3XJK.js.map +1 -0
- package/dist/chunk-BM4R6NST.js +191 -0
- package/dist/chunk-BM4R6NST.js.map +1 -0
- package/dist/chunk-FLPIU2QO.js +77 -0
- package/dist/chunk-FLPIU2QO.js.map +1 -0
- package/dist/chunk-H7YXX4UA.js +86 -0
- package/dist/chunk-H7YXX4UA.js.map +1 -0
- package/dist/chunk-IT74N3UH.js +19 -0
- package/dist/chunk-IT74N3UH.js.map +1 -0
- package/dist/chunk-JOT4UVSO.js +186 -0
- package/dist/chunk-JOT4UVSO.js.map +1 -0
- package/dist/chunk-KSJKCLEJ.js +222 -0
- package/dist/chunk-KSJKCLEJ.js.map +1 -0
- package/dist/chunk-LIEW4ULF.js +139 -0
- package/dist/chunk-LIEW4ULF.js.map +1 -0
- package/dist/chunk-M4V3YOCY.js +82 -0
- package/dist/chunk-M4V3YOCY.js.map +1 -0
- package/dist/chunk-MMWQHH25.js +207 -0
- package/dist/chunk-MMWQHH25.js.map +1 -0
- package/dist/chunk-OJ4CNF73.js +78 -0
- package/dist/chunk-OJ4CNF73.js.map +1 -0
- package/dist/chunk-PFJAC3RO.js +137 -0
- package/dist/chunk-PFJAC3RO.js.map +1 -0
- package/dist/chunk-PVKVCUNR.js +159 -0
- package/dist/chunk-PVKVCUNR.js.map +1 -0
- package/dist/chunk-RXT4WSIY.js +35 -0
- package/dist/chunk-RXT4WSIY.js.map +1 -0
- package/dist/chunk-RZK74PDF.js +34 -0
- package/dist/chunk-RZK74PDF.js.map +1 -0
- package/dist/chunk-UDTWVKRX.js +68 -0
- package/dist/chunk-UDTWVKRX.js.map +1 -0
- package/dist/chunk-VCEONSWJ.js +307 -0
- package/dist/chunk-VCEONSWJ.js.map +1 -0
- package/dist/chunk-VWBCDZWQ.js +119 -0
- package/dist/chunk-VWBCDZWQ.js.map +1 -0
- package/dist/chunk-WEJCTFQB.js +228 -0
- package/dist/chunk-WEJCTFQB.js.map +1 -0
- package/dist/chunk-YJK7IRPI.js +223 -0
- package/dist/chunk-YJK7IRPI.js.map +1 -0
- package/dist/claude-md-HQ6L4CRP.js +8 -0
- package/dist/claude-md-HQ6L4CRP.js.map +1 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands-RG45VBTZ.js +407 -0
- package/dist/commands-RG45VBTZ.js.map +1 -0
- package/dist/commands-WYVRVE5Z.js +400 -0
- package/dist/commands-WYVRVE5Z.js.map +1 -0
- package/dist/config-edit-G7O56HXO.js +50 -0
- package/dist/config-edit-G7O56HXO.js.map +1 -0
- package/dist/config-set-QN3JRNZL.js +63 -0
- package/dist/config-set-QN3JRNZL.js.map +1 -0
- package/dist/daemon-CGBV55JK.js +104 -0
- package/dist/daemon-CGBV55JK.js.map +1 -0
- package/dist/dashboard-YVFJ5DXR.js +143 -0
- package/dist/dashboard-YVFJ5DXR.js.map +1 -0
- package/dist/doctor-BPTLVLTD.js +98 -0
- package/dist/doctor-BPTLVLTD.js.map +1 -0
- package/dist/human-loop-RBTA2TYK.js +16 -0
- package/dist/human-loop-RBTA2TYK.js.map +1 -0
- package/dist/human-loop-XGWXUNCS.js +18 -0
- package/dist/human-loop-XGWXUNCS.js.map +1 -0
- package/dist/index.d.ts +583 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-DGW7HCJ5.js +21 -0
- package/dist/loader-DGW7HCJ5.js.map +1 -0
- package/dist/logs-JUVQWN6C.js +93 -0
- package/dist/logs-JUVQWN6C.js.map +1 -0
- package/dist/mcp.js +132 -0
- package/dist/mcp.js.map +1 -0
- package/dist/orchestrator-3MGXX3QW.js +22 -0
- package/dist/orchestrator-3MGXX3QW.js.map +1 -0
- package/dist/orchestrator-BVUKN5N3.js +13 -0
- package/dist/orchestrator-BVUKN5N3.js.map +1 -0
- package/dist/pause-FLDZ3OD6.js +62 -0
- package/dist/pause-FLDZ3OD6.js.map +1 -0
- package/dist/projects-QMIGNW7U.js +129 -0
- package/dist/projects-QMIGNW7U.js.map +1 -0
- package/dist/replay-M4JEG4Z4.js +151 -0
- package/dist/replay-M4JEG4Z4.js.map +1 -0
- package/dist/schedule-CDHD77VZ.js +17 -0
- package/dist/schedule-CDHD77VZ.js.map +1 -0
- package/dist/serve-XI7JTIPZ.js +231 -0
- package/dist/serve-XI7JTIPZ.js.map +1 -0
- package/dist/sprint-KZZWVNK6.js +200 -0
- package/dist/sprint-KZZWVNK6.js.map +1 -0
- package/dist/status-I6GU2LWE.js +48 -0
- package/dist/status-I6GU2LWE.js.map +1 -0
- package/dist/topic-manager-4AMEPMFI.js +12 -0
- package/dist/topic-manager-4AMEPMFI.js.map +1 -0
- package/dist/triage-WNHGPVZQ.js +251 -0
- package/dist/triage-WNHGPVZQ.js.map +1 -0
- package/dist/usage-AWWBI37F.js +155 -0
- package/dist/usage-AWWBI37F.js.map +1 -0
- package/dist/wizard-CYEJJLNF.js +190 -0
- package/dist/wizard-CYEJJLNF.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requestApproval,
|
|
3
|
+
requestClarification
|
|
4
|
+
} from "./chunk-KSJKCLEJ.js";
|
|
5
|
+
import {
|
|
6
|
+
acquireLock,
|
|
7
|
+
createTelegramNotifier
|
|
8
|
+
} from "./chunk-WEJCTFQB.js";
|
|
9
|
+
import {
|
|
10
|
+
StateManager,
|
|
11
|
+
getEvents,
|
|
12
|
+
getLockPath,
|
|
13
|
+
getMemoryDir,
|
|
14
|
+
getRunDir
|
|
15
|
+
} from "./chunk-BM4R6NST.js";
|
|
16
|
+
import {
|
|
17
|
+
createLogger,
|
|
18
|
+
initBot
|
|
19
|
+
} from "./chunk-M4V3YOCY.js";
|
|
20
|
+
|
|
21
|
+
// src/integrations/jira/client.ts
|
|
22
|
+
import axios from "axios";
|
|
23
|
+
function loadInstances() {
|
|
24
|
+
const instances = {};
|
|
25
|
+
for (const key of Object.keys(process.env)) {
|
|
26
|
+
const match = key.match(/^JIRA_([A-Z0-9]+)_URL$/);
|
|
27
|
+
if (!match) continue;
|
|
28
|
+
const name = match[1].toLowerCase();
|
|
29
|
+
const upper = match[1];
|
|
30
|
+
const url = process.env[`JIRA_${upper}_URL`];
|
|
31
|
+
const token = process.env[`JIRA_${upper}_TOKEN`];
|
|
32
|
+
const email = process.env[`JIRA_${upper}_EMAIL`];
|
|
33
|
+
if (url && token && email) {
|
|
34
|
+
instances[name] = { url, token, email };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return instances;
|
|
38
|
+
}
|
|
39
|
+
var INSTANCES = loadInstances();
|
|
40
|
+
function getClient(instance) {
|
|
41
|
+
const config = INSTANCES[instance];
|
|
42
|
+
if (!config) {
|
|
43
|
+
const available = Object.keys(INSTANCES).join(", ") || "none";
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Unknown Jira instance: "${instance}". Available: ${available}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return axios.create({
|
|
49
|
+
baseURL: `${config.url}/rest/api/3`,
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Basic ${Buffer.from(`${config.email}:${config.token}`).toString("base64")}`,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
Accept: "application/json"
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/integrations/github/client.ts
|
|
59
|
+
import { Octokit } from "@octokit/rest";
|
|
60
|
+
function createGitHubClient(token, owner, repo) {
|
|
61
|
+
const octokit = new Octokit({ auth: token });
|
|
62
|
+
return {
|
|
63
|
+
async createBranch(base, branch) {
|
|
64
|
+
const { data: ref } = await octokit.git.getRef({
|
|
65
|
+
owner,
|
|
66
|
+
repo,
|
|
67
|
+
ref: `heads/${base}`
|
|
68
|
+
});
|
|
69
|
+
await octokit.git.createRef({
|
|
70
|
+
owner,
|
|
71
|
+
repo,
|
|
72
|
+
ref: `refs/heads/${branch}`,
|
|
73
|
+
sha: ref.object.sha
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async createPR({ title, body, head, base, draft = false }) {
|
|
77
|
+
const { data } = await octokit.pulls.create({
|
|
78
|
+
owner,
|
|
79
|
+
repo,
|
|
80
|
+
title,
|
|
81
|
+
body,
|
|
82
|
+
head,
|
|
83
|
+
base,
|
|
84
|
+
draft
|
|
85
|
+
});
|
|
86
|
+
return data.number;
|
|
87
|
+
},
|
|
88
|
+
async mergePR(prNumber, strategy) {
|
|
89
|
+
const mergeMethod = strategy === "squash" ? "squash" : strategy === "rebase" ? "rebase" : "merge";
|
|
90
|
+
await octokit.pulls.merge({
|
|
91
|
+
owner,
|
|
92
|
+
repo,
|
|
93
|
+
pull_number: prNumber,
|
|
94
|
+
merge_method: mergeMethod
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
async addReviewers(prNumber, reviewers) {
|
|
98
|
+
if (reviewers.length === 0) return;
|
|
99
|
+
await octokit.pulls.requestReviewers({
|
|
100
|
+
owner,
|
|
101
|
+
repo,
|
|
102
|
+
pull_number: prNumber,
|
|
103
|
+
reviewers
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
async getPR(prNumber) {
|
|
107
|
+
const { data } = await octokit.pulls.get({
|
|
108
|
+
owner,
|
|
109
|
+
repo,
|
|
110
|
+
pull_number: prNumber
|
|
111
|
+
});
|
|
112
|
+
return { state: data.state, merged: data.merged };
|
|
113
|
+
},
|
|
114
|
+
async getRunStatus(branch) {
|
|
115
|
+
try {
|
|
116
|
+
const { data } = await octokit.repos.getCombinedStatusForRef({
|
|
117
|
+
owner,
|
|
118
|
+
repo,
|
|
119
|
+
ref: branch
|
|
120
|
+
});
|
|
121
|
+
if (data.state === "success") return "success";
|
|
122
|
+
if (data.state === "failure") return "failure";
|
|
123
|
+
return "pending";
|
|
124
|
+
} catch {
|
|
125
|
+
return "unknown";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/utils/pricing.ts
|
|
132
|
+
var MODEL_PRICING = {
|
|
133
|
+
"claude-haiku-4-5": { inputPer1M: 0.8, outputPer1M: 4 },
|
|
134
|
+
"claude-sonnet-4-5": { inputPer1M: 3, outputPer1M: 15 },
|
|
135
|
+
"claude-opus-4-5": { inputPer1M: 15, outputPer1M: 75 },
|
|
136
|
+
"claude-haiku-4": { inputPer1M: 0.8, outputPer1M: 4 },
|
|
137
|
+
"claude-sonnet-4": { inputPer1M: 3, outputPer1M: 15 },
|
|
138
|
+
"claude-opus-4": { inputPer1M: 15, outputPer1M: 75 }
|
|
139
|
+
};
|
|
140
|
+
var DEFAULT_PRICING = { inputPer1M: 3, outputPer1M: 15 };
|
|
141
|
+
function extractTokenUsage(output) {
|
|
142
|
+
if (typeof output !== "object" || output === null) return null;
|
|
143
|
+
const raw = output["tokenUsage"];
|
|
144
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
145
|
+
const tu = raw;
|
|
146
|
+
if (typeof tu["inputTokens"] !== "number" || typeof tu["outputTokens"] !== "number")
|
|
147
|
+
return null;
|
|
148
|
+
return {
|
|
149
|
+
inputTokens: tu["inputTokens"],
|
|
150
|
+
outputTokens: tu["outputTokens"],
|
|
151
|
+
model: typeof tu["model"] === "string" ? tu["model"] : "claude-sonnet-4"
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function estimateCostUsd(inputTokens, outputTokens, model) {
|
|
155
|
+
const pricing = MODEL_PRICING[model] ?? DEFAULT_PRICING;
|
|
156
|
+
return inputTokens / 1e6 * pricing.inputPer1M + outputTokens / 1e6 * pricing.outputPer1M;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/pipeline/cost-guard.ts
|
|
160
|
+
async function checkCostLimit(opts) {
|
|
161
|
+
const events = getEvents(opts.runDir);
|
|
162
|
+
let totalCost = 0;
|
|
163
|
+
for (const event of events) {
|
|
164
|
+
if (event.type !== "STAGE_COMPLETED") continue;
|
|
165
|
+
const tu = extractTokenUsage(event.output);
|
|
166
|
+
if (!tu) continue;
|
|
167
|
+
totalCost += estimateCostUsd(tu.inputTokens, tu.outputTokens, tu.model);
|
|
168
|
+
}
|
|
169
|
+
if (totalCost >= opts.maxCostUsd) {
|
|
170
|
+
await opts.telegram.send(
|
|
171
|
+
`\u26D4 <b>${opts.ticketKey}</b> \u2014 Cost limit reached ($${totalCost.toFixed(4)} / $${opts.maxCostUsd}). Aborting pipeline.`
|
|
172
|
+
);
|
|
173
|
+
return "abort";
|
|
174
|
+
}
|
|
175
|
+
if (totalCost >= 0.8 * opts.maxCostUsd) {
|
|
176
|
+
await opts.telegram.send(
|
|
177
|
+
`\u26A0\uFE0F <b>${opts.ticketKey}</b> \u2014 Cost at 80% of limit ($${totalCost.toFixed(4)} / $${opts.maxCostUsd}).`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return "continue";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/integrations/jira/tools.ts
|
|
184
|
+
import { z } from "zod";
|
|
185
|
+
var instanceSchema = z.string().describe('Instance name (e.g. "hi", "geo")');
|
|
186
|
+
var GetTasksSchema = z.object({
|
|
187
|
+
instance: instanceSchema,
|
|
188
|
+
assignees: z.array(z.string()).describe("List of Jira usernames or accountIds"),
|
|
189
|
+
project_key: z.string().optional().describe('Filter by project key (e.g. "HI")'),
|
|
190
|
+
status: z.string().optional().describe('Filter by status (e.g. "In Progress")'),
|
|
191
|
+
max_results: z.number().default(20)
|
|
192
|
+
});
|
|
193
|
+
var GetTicketSchema = z.object({
|
|
194
|
+
instance: instanceSchema,
|
|
195
|
+
ticket_key: z.string().describe('Jira ticket key (e.g. "HI-123")')
|
|
196
|
+
});
|
|
197
|
+
var GetTransitionsSchema = z.object({
|
|
198
|
+
instance: instanceSchema,
|
|
199
|
+
ticket_key: z.string()
|
|
200
|
+
});
|
|
201
|
+
var TransitionTicketSchema = z.object({
|
|
202
|
+
instance: instanceSchema,
|
|
203
|
+
ticket_key: z.string(),
|
|
204
|
+
transition_name: z.string().describe('Transition name (e.g. "In Review", "Done")')
|
|
205
|
+
});
|
|
206
|
+
var AddCommentSchema = z.object({
|
|
207
|
+
instance: instanceSchema,
|
|
208
|
+
ticket_key: z.string(),
|
|
209
|
+
comment: z.string().describe("Comment text (plain text or Jira markdown)")
|
|
210
|
+
});
|
|
211
|
+
var ReassignSchema = z.object({
|
|
212
|
+
instance: instanceSchema,
|
|
213
|
+
ticket_key: z.string(),
|
|
214
|
+
account_id: z.string().describe("Jira accountId of the new assignee")
|
|
215
|
+
});
|
|
216
|
+
async function getTicket(args) {
|
|
217
|
+
const client = getClient(args.instance);
|
|
218
|
+
const { data } = await client.get(`/issue/${args.ticket_key}`);
|
|
219
|
+
return JSON.stringify(
|
|
220
|
+
{
|
|
221
|
+
key: data.key,
|
|
222
|
+
summary: data.fields.summary,
|
|
223
|
+
status: data.fields.status.name,
|
|
224
|
+
assignee: data.fields.assignee?.displayName ?? "Unassigned",
|
|
225
|
+
priority: data.fields.priority?.name,
|
|
226
|
+
description: extractText(data.fields.description),
|
|
227
|
+
acceptance_criteria: extractText(data.fields.customfield_10016),
|
|
228
|
+
created: data.fields.created,
|
|
229
|
+
updated: data.fields.updated
|
|
230
|
+
},
|
|
231
|
+
null,
|
|
232
|
+
2
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
async function transitionTicket(args) {
|
|
236
|
+
const client = getClient(args.instance);
|
|
237
|
+
const { data } = await client.get(`/issue/${args.ticket_key}/transitions`);
|
|
238
|
+
const transition = data.transitions.find(
|
|
239
|
+
(t) => t.name.toLowerCase() === args.transition_name.toLowerCase()
|
|
240
|
+
);
|
|
241
|
+
if (!transition) {
|
|
242
|
+
const available = data.transitions.map((t) => t.name).join(", ");
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Transition "${args.transition_name}" not found. Available: ${available}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
await client.post(`/issue/${args.ticket_key}/transitions`, {
|
|
248
|
+
transition: { id: transition.id }
|
|
249
|
+
});
|
|
250
|
+
return `Transitioned ${args.ticket_key} to "${transition.name}"`;
|
|
251
|
+
}
|
|
252
|
+
async function addComment(args) {
|
|
253
|
+
const client = getClient(args.instance);
|
|
254
|
+
await client.post(`/issue/${args.ticket_key}/comment`, {
|
|
255
|
+
body: {
|
|
256
|
+
type: "doc",
|
|
257
|
+
version: 1,
|
|
258
|
+
content: [
|
|
259
|
+
{ type: "paragraph", content: [{ type: "text", text: args.comment }] }
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return `Comment added to ${args.ticket_key}`;
|
|
264
|
+
}
|
|
265
|
+
async function reassign(args) {
|
|
266
|
+
const client = getClient(args.instance);
|
|
267
|
+
await client.put(`/issue/${args.ticket_key}/assignee`, {
|
|
268
|
+
accountId: args.account_id
|
|
269
|
+
});
|
|
270
|
+
return `${args.ticket_key} reassigned to accountId: ${args.account_id}`;
|
|
271
|
+
}
|
|
272
|
+
function extractText(adfNode) {
|
|
273
|
+
if (!adfNode) return "";
|
|
274
|
+
if (typeof adfNode === "string") return adfNode;
|
|
275
|
+
if (adfNode.type === "text") return adfNode.text ?? "";
|
|
276
|
+
if (adfNode.content) return adfNode.content.map(extractText).join(" ");
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/memory/context-builder.ts
|
|
281
|
+
import fs from "fs";
|
|
282
|
+
import path from "path";
|
|
283
|
+
function writeTicketContext(memoryDir, ticket) {
|
|
284
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
285
|
+
const content = `# Ticket: ${ticket.key}
|
|
286
|
+
|
|
287
|
+
## Summary
|
|
288
|
+
${ticket.summary}
|
|
289
|
+
|
|
290
|
+
## Description
|
|
291
|
+
${ticket.description || "(none)"}
|
|
292
|
+
|
|
293
|
+
## Acceptance Criteria
|
|
294
|
+
${ticket.acceptanceCriteria || "(none)"}
|
|
295
|
+
|
|
296
|
+
## Priority
|
|
297
|
+
${ticket.priority}
|
|
298
|
+
${ticket.clarifications ? `
|
|
299
|
+
## Clarifications from Team
|
|
300
|
+
${ticket.clarifications}` : ""}
|
|
301
|
+
`;
|
|
302
|
+
fs.writeFileSync(path.join(memoryDir, "ticket-context.md"), content);
|
|
303
|
+
}
|
|
304
|
+
function readTicketContext(memoryDir) {
|
|
305
|
+
const p = path.join(memoryDir, "ticket-context.md");
|
|
306
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
|
|
307
|
+
}
|
|
308
|
+
function appendClarifications(memoryDir, answers) {
|
|
309
|
+
const p = path.join(memoryDir, "ticket-context.md");
|
|
310
|
+
if (fs.existsSync(p)) {
|
|
311
|
+
fs.appendFileSync(p, `
|
|
312
|
+
## Clarifications from Team
|
|
313
|
+
${answers}
|
|
314
|
+
`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function writeReviewFeedback(memoryDir, feedback) {
|
|
318
|
+
const lines = [
|
|
319
|
+
`# Review Results`,
|
|
320
|
+
`PR: #${feedback.prNumber}`,
|
|
321
|
+
`Major issues: ${feedback.issues.filter((i) => i.severity === "major").length}`,
|
|
322
|
+
`Minor issues: ${feedback.issues.filter((i) => i.severity === "minor").length}`,
|
|
323
|
+
`Auto-resolved: ${feedback.autoResolved}`,
|
|
324
|
+
"",
|
|
325
|
+
"## Issues",
|
|
326
|
+
...feedback.issues.map(
|
|
327
|
+
(i) => `- [${i.severity.toUpperCase()}] ${i.message}`
|
|
328
|
+
)
|
|
329
|
+
];
|
|
330
|
+
fs.writeFileSync(
|
|
331
|
+
path.join(memoryDir, "review-feedback.md"),
|
|
332
|
+
lines.join("\n")
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
function getContextFilesForStage(projectDir, memoryDir, stage) {
|
|
336
|
+
const claudeMd = path.join(projectDir, ".claude", "CLAUDE.md");
|
|
337
|
+
const ticketCtx = path.join(memoryDir, "ticket-context.md");
|
|
338
|
+
const reviewFeedback = path.join(memoryDir, "review-feedback.md");
|
|
339
|
+
const files = [];
|
|
340
|
+
if (fs.existsSync(claudeMd)) files.push(claudeMd);
|
|
341
|
+
if (["code", "git", "review", "deploy", "test", "notify"].includes(stage)) {
|
|
342
|
+
if (fs.existsSync(ticketCtx)) files.push(ticketCtx);
|
|
343
|
+
}
|
|
344
|
+
if (stage === "test" && fs.existsSync(reviewFeedback)) {
|
|
345
|
+
files.push(reviewFeedback);
|
|
346
|
+
}
|
|
347
|
+
return files;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/pipeline/stages/1-fetch.ts
|
|
351
|
+
var fetchStage = {
|
|
352
|
+
id: "fetch",
|
|
353
|
+
name: "Fetch Ticket",
|
|
354
|
+
model: "haiku",
|
|
355
|
+
async run(ctx) {
|
|
356
|
+
const { config, ticketKey, memoryDir } = ctx;
|
|
357
|
+
ctx.logger.info({ ticketKey }, "Fetching ticket from Jira");
|
|
358
|
+
const raw = await getTicket({
|
|
359
|
+
instance: config.jira.instance,
|
|
360
|
+
ticket_key: ticketKey
|
|
361
|
+
});
|
|
362
|
+
const ticket = JSON.parse(raw);
|
|
363
|
+
writeTicketContext(memoryDir, {
|
|
364
|
+
key: ticket.key,
|
|
365
|
+
summary: ticket.summary,
|
|
366
|
+
description: ticket.description ?? "",
|
|
367
|
+
acceptanceCriteria: ticket.acceptance_criteria ?? "",
|
|
368
|
+
priority: ticket.priority ?? "Medium"
|
|
369
|
+
});
|
|
370
|
+
ctx.logger.info({ ticketKey, summary: ticket.summary }, "Ticket fetched");
|
|
371
|
+
return {
|
|
372
|
+
summary: ticket.summary,
|
|
373
|
+
description: ticket.description,
|
|
374
|
+
acceptanceCriteria: ticket.acceptance_criteria,
|
|
375
|
+
status: ticket.status
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// src/utils/process.ts
|
|
381
|
+
import { spawn } from "child_process";
|
|
382
|
+
async function spawnSafe(bin, args, opts = {}) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
const proc = spawn(bin, args, {
|
|
385
|
+
cwd: opts.cwd,
|
|
386
|
+
env: opts.env ?? buildMinimalEnv(),
|
|
387
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
388
|
+
});
|
|
389
|
+
let stdout = "";
|
|
390
|
+
let stderr = "";
|
|
391
|
+
let stallTimer;
|
|
392
|
+
let globalTimer;
|
|
393
|
+
const resetStall = () => {
|
|
394
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
395
|
+
if (opts.stallTimeoutMs) {
|
|
396
|
+
stallTimer = setTimeout(() => {
|
|
397
|
+
proc.kill("SIGKILL");
|
|
398
|
+
reject(
|
|
399
|
+
new Error(
|
|
400
|
+
`Process stalled (no output for ${opts.stallTimeoutMs}ms)`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
}, opts.stallTimeoutMs);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
if (opts.timeoutMs) {
|
|
407
|
+
globalTimer = setTimeout(() => {
|
|
408
|
+
proc.kill("SIGKILL");
|
|
409
|
+
reject(new Error(`Process timed out after ${opts.timeoutMs}ms`));
|
|
410
|
+
}, opts.timeoutMs);
|
|
411
|
+
}
|
|
412
|
+
resetStall();
|
|
413
|
+
proc.stdout.on("data", (chunk) => {
|
|
414
|
+
stdout += chunk.toString();
|
|
415
|
+
resetStall();
|
|
416
|
+
});
|
|
417
|
+
proc.stderr.on("data", (chunk) => {
|
|
418
|
+
stderr += chunk.toString();
|
|
419
|
+
});
|
|
420
|
+
proc.on("close", (code) => {
|
|
421
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
422
|
+
if (globalTimer) clearTimeout(globalTimer);
|
|
423
|
+
resolve({ stdout, stderr, exitCode: code ?? 1 });
|
|
424
|
+
});
|
|
425
|
+
proc.on("error", (err) => {
|
|
426
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
427
|
+
if (globalTimer) clearTimeout(globalTimer);
|
|
428
|
+
reject(err);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function buildMinimalEnv(extra = {}) {
|
|
433
|
+
return {
|
|
434
|
+
PATH: process.env["PATH"] ?? "",
|
|
435
|
+
HOME: process.env["HOME"] ?? "",
|
|
436
|
+
ANTHROPIC_API_KEY: process.env["ANTHROPIC_API_KEY"] ?? "",
|
|
437
|
+
...extra
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/pipeline/runner.ts
|
|
442
|
+
var MODEL_IDS = {
|
|
443
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
444
|
+
sonnet: "claude-sonnet-4-6",
|
|
445
|
+
opus: "claude-opus-4-6"
|
|
446
|
+
};
|
|
447
|
+
async function runAgent(opts) {
|
|
448
|
+
const args = [
|
|
449
|
+
"--model",
|
|
450
|
+
MODEL_IDS[opts.model],
|
|
451
|
+
"--print",
|
|
452
|
+
"--output-format",
|
|
453
|
+
"text"
|
|
454
|
+
];
|
|
455
|
+
for (const f of opts.contextFiles ?? []) {
|
|
456
|
+
args.push("--context", f);
|
|
457
|
+
}
|
|
458
|
+
args.push(opts.prompt);
|
|
459
|
+
const result = await spawnSafe("claude", args, {
|
|
460
|
+
cwd: opts.workdir,
|
|
461
|
+
env: buildMinimalEnv(opts.extraEnv),
|
|
462
|
+
timeoutMs: opts.timeoutMs ?? 18e5,
|
|
463
|
+
stallTimeoutMs: opts.stallTimeoutMs ?? 3e5
|
|
464
|
+
});
|
|
465
|
+
if (result.exitCode !== 0) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Agent exited with code ${result.exitCode}:
|
|
468
|
+
${result.stderr}`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return result.stdout.trim();
|
|
472
|
+
}
|
|
473
|
+
async function runAgentsParallel(a, b) {
|
|
474
|
+
return Promise.all([runAgent(a), runAgent(b)]);
|
|
475
|
+
}
|
|
476
|
+
function detectComplexity(description) {
|
|
477
|
+
const complexKeywords = [
|
|
478
|
+
"auth",
|
|
479
|
+
"payment",
|
|
480
|
+
"stripe",
|
|
481
|
+
"oauth",
|
|
482
|
+
"jwt",
|
|
483
|
+
"migration",
|
|
484
|
+
"schema",
|
|
485
|
+
"database",
|
|
486
|
+
"refactor",
|
|
487
|
+
"cross-module",
|
|
488
|
+
"multi-service",
|
|
489
|
+
"security",
|
|
490
|
+
"encryption",
|
|
491
|
+
"permission"
|
|
492
|
+
];
|
|
493
|
+
const lower = description.toLowerCase();
|
|
494
|
+
const matches = complexKeywords.filter((k) => lower.includes(k));
|
|
495
|
+
return matches.length >= 2 ? "opus" : "sonnet";
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/pipeline/stages/2-analyze.ts
|
|
499
|
+
var CLARITY_PROMPT = (ticketCtx, requiredFields) => `
|
|
500
|
+
Analyze this Jira ticket for clarity. Score from 0.0 to 1.0.
|
|
501
|
+
|
|
502
|
+
Required fields: ${requiredFields.join(", ")}
|
|
503
|
+
|
|
504
|
+
Ticket:
|
|
505
|
+
${ticketCtx}
|
|
506
|
+
|
|
507
|
+
Reply with JSON only:
|
|
508
|
+
{
|
|
509
|
+
"score": 0.0-1.0,
|
|
510
|
+
"missing": ["list of missing or ambiguous items"],
|
|
511
|
+
"questions": ["specific questions to ask the team"]
|
|
512
|
+
}
|
|
513
|
+
`.trim();
|
|
514
|
+
var analyzeStage = {
|
|
515
|
+
id: "analyze",
|
|
516
|
+
name: "Analyze Clarity",
|
|
517
|
+
model: "sonnet",
|
|
518
|
+
async run(ctx) {
|
|
519
|
+
const { config, memoryDir, ticketKey } = ctx;
|
|
520
|
+
const ticketCtx = readTicketContext(memoryDir);
|
|
521
|
+
if (!ticketCtx)
|
|
522
|
+
throw new Error("ticket-context.md not found \u2014 run fetch stage first");
|
|
523
|
+
ctx.logger.info({ ticketKey }, "Analyzing ticket clarity");
|
|
524
|
+
const raw = await runAgent({
|
|
525
|
+
prompt: CLARITY_PROMPT(
|
|
526
|
+
ticketCtx,
|
|
527
|
+
config.jira.requiredFields ?? ["description", "acceptanceCriteria"]
|
|
528
|
+
),
|
|
529
|
+
workdir: config.workspace.rootDir,
|
|
530
|
+
model: "haiku",
|
|
531
|
+
timeoutMs: 6e4,
|
|
532
|
+
stallTimeoutMs: 3e4
|
|
533
|
+
});
|
|
534
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
535
|
+
if (!jsonMatch) {
|
|
536
|
+
ctx.logger.warn(
|
|
537
|
+
{ ticketKey },
|
|
538
|
+
"Could not parse clarity JSON \u2014 defaulting to low score"
|
|
539
|
+
);
|
|
540
|
+
return { score: 0, missing: [], questions: [], needsClarification: true };
|
|
541
|
+
}
|
|
542
|
+
const result = JSON.parse(jsonMatch[0]);
|
|
543
|
+
const threshold = config.jira.clarityScoreThreshold ?? 0.7;
|
|
544
|
+
const needsClarification = result.score < threshold;
|
|
545
|
+
ctx.logger.info(
|
|
546
|
+
{ ticketKey, score: result.score, threshold, needsClarification },
|
|
547
|
+
"Clarity analysis done"
|
|
548
|
+
);
|
|
549
|
+
return { ...result, needsClarification };
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// src/pipeline/stages/3-clarify.ts
|
|
554
|
+
import path2 from "path";
|
|
555
|
+
var clarifyStage = {
|
|
556
|
+
id: "clarify",
|
|
557
|
+
name: "Clarify",
|
|
558
|
+
model: "haiku",
|
|
559
|
+
async shouldSkip(ctx) {
|
|
560
|
+
const lastAnalyze = ctx.state.current.completedStages.includes("analyze");
|
|
561
|
+
if (!lastAnalyze) return true;
|
|
562
|
+
const events = ctx.state.events;
|
|
563
|
+
const analyzeEvent = events.findLast(
|
|
564
|
+
(e) => e.type === "STAGE_COMPLETED" && e.stage === "analyze"
|
|
565
|
+
);
|
|
566
|
+
const output = analyzeEvent?.output;
|
|
567
|
+
return !output?.needsClarification;
|
|
568
|
+
},
|
|
569
|
+
async run(ctx) {
|
|
570
|
+
const { config, ticketKey, memoryDir } = ctx;
|
|
571
|
+
const storeDir = path2.join(config.workspace.rootDir, ".jira-acp");
|
|
572
|
+
const events = ctx.state.events;
|
|
573
|
+
const analyzeEvent = events.findLast(
|
|
574
|
+
(e) => e.type === "STAGE_COMPLETED" && e.stage === "analyze"
|
|
575
|
+
);
|
|
576
|
+
const questions = analyzeEvent?.output?.questions ?? ["Please clarify the ticket requirements."];
|
|
577
|
+
ctx.logger.info(
|
|
578
|
+
{ ticketKey, questions },
|
|
579
|
+
"Requesting clarification via Telegram"
|
|
580
|
+
);
|
|
581
|
+
const hil = config.telegram.humanInTheLoop;
|
|
582
|
+
const answers = await requestClarification(
|
|
583
|
+
config.telegram.botToken,
|
|
584
|
+
config.telegram.chatId,
|
|
585
|
+
ticketKey,
|
|
586
|
+
questions,
|
|
587
|
+
storeDir,
|
|
588
|
+
{
|
|
589
|
+
timeoutMs: hil?.clarificationTimeoutMs ?? 36e5,
|
|
590
|
+
onTimeout: hil?.clarificationTimeoutAction ?? "skip"
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
appendClarifications(memoryDir, answers);
|
|
594
|
+
ctx.logger.info({ ticketKey }, "Clarifications received and saved");
|
|
595
|
+
return { clarified: true, answers };
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/pipeline/stages/4-code.ts
|
|
600
|
+
var codeStage = {
|
|
601
|
+
id: "code",
|
|
602
|
+
name: "Code",
|
|
603
|
+
model: "sonnet",
|
|
604
|
+
async run(ctx) {
|
|
605
|
+
const { config, ticketKey, memoryDir, projectDir } = ctx;
|
|
606
|
+
const ticketCtx = readTicketContext(memoryDir);
|
|
607
|
+
const contextFiles = getContextFilesForStage(projectDir, memoryDir, "code");
|
|
608
|
+
const model = detectComplexity(ticketCtx);
|
|
609
|
+
ctx.logger.info({ ticketKey, model }, "Starting code agent");
|
|
610
|
+
const branchName = buildBranchName(
|
|
611
|
+
config.github.branchPattern ?? "feature/{ticketKey}-{slug}",
|
|
612
|
+
ticketKey,
|
|
613
|
+
ticketCtx
|
|
614
|
+
);
|
|
615
|
+
const prompt = `
|
|
616
|
+
Implement the following Jira ticket: ${ticketKey}
|
|
617
|
+
|
|
618
|
+
Read the ticket context from the provided context files.
|
|
619
|
+
|
|
620
|
+
Requirements:
|
|
621
|
+
1. Create branch: ${branchName}
|
|
622
|
+
2. Implement all acceptance criteria
|
|
623
|
+
3. Write tests for new functionality
|
|
624
|
+
4. Commit with message: "${ticketKey}: <short description>"
|
|
625
|
+
5. Do NOT push \u2014 pipeline will handle git operations
|
|
626
|
+
|
|
627
|
+
Branch naming: ${branchName}
|
|
628
|
+
Workspace: ${config.workspace.rootDir}
|
|
629
|
+
${config.workspace.buildCommand ? `Build command: ${config.workspace.buildCommand}` : ""}
|
|
630
|
+
`.trim();
|
|
631
|
+
if (!ctx.dryRun) {
|
|
632
|
+
await runAgent({
|
|
633
|
+
prompt,
|
|
634
|
+
workdir: config.workspace.rootDir,
|
|
635
|
+
model,
|
|
636
|
+
contextFiles,
|
|
637
|
+
timeoutMs: config.pipeline?.stageTimeouts?.code ?? 18e5,
|
|
638
|
+
stallTimeoutMs: config.pipeline?.agentStallTimeoutMs ?? 3e5
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
ctx.logger.info({ ticketKey, branchName }, "Code agent completed");
|
|
642
|
+
return { branchName };
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
function buildBranchName(pattern, ticketKey, ticketCtx) {
|
|
646
|
+
const summaryLine = ticketCtx.split("\n").find((l) => l.startsWith("## Summary"));
|
|
647
|
+
const nextLine = summaryLine ? ticketCtx.split("\n")[ticketCtx.split("\n").indexOf(summaryLine) + 1] ?? "" : "";
|
|
648
|
+
const slug = nextLine.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 40);
|
|
649
|
+
return pattern.replace("{ticketKey}", ticketKey).replace("{prefix}", "feature").replace("{slug}", slug || "implementation");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/pipeline/stages/5-git.ts
|
|
653
|
+
var gitStage = {
|
|
654
|
+
id: "git",
|
|
655
|
+
name: "Git & PR",
|
|
656
|
+
model: "haiku",
|
|
657
|
+
async run(ctx) {
|
|
658
|
+
const { config, ticketKey, state } = ctx;
|
|
659
|
+
const branchName = state.current.branchName;
|
|
660
|
+
if (!branchName)
|
|
661
|
+
throw new Error("No branch name in state \u2014 code stage must run first");
|
|
662
|
+
const workdir = config.workspace.rootDir;
|
|
663
|
+
const ticketCtx = readTicketContext(ctx.memoryDir);
|
|
664
|
+
const summaryLine = ticketCtx.split("\n").find((l) => l.startsWith("## Summary"));
|
|
665
|
+
const summary = ticketCtx.split("\n")[ticketCtx.split("\n").indexOf(summaryLine ?? "") + 1] ?? ticketKey;
|
|
666
|
+
ctx.logger.info(
|
|
667
|
+
{ ticketKey, branchName },
|
|
668
|
+
"Pushing branch and creating PR"
|
|
669
|
+
);
|
|
670
|
+
if (!ctx.dryRun) {
|
|
671
|
+
await spawnSafe("git", ["push", "origin", branchName], { cwd: workdir });
|
|
672
|
+
const prNumber = await ctx.github.createPR({
|
|
673
|
+
title: `[${ticketKey}] ${summary.trim()}`,
|
|
674
|
+
body: buildPrBody(ticketKey, ticketCtx),
|
|
675
|
+
head: branchName,
|
|
676
|
+
base: config.github.defaultBranch ?? "main",
|
|
677
|
+
draft: config.github.prDraftByDefault ?? false
|
|
678
|
+
});
|
|
679
|
+
if (config.github.reviewers?.length) {
|
|
680
|
+
await ctx.github.addReviewers(prNumber, config.github.reviewers);
|
|
681
|
+
}
|
|
682
|
+
ctx.logger.info({ ticketKey, prNumber }, "PR created");
|
|
683
|
+
return { branchName, prNumber };
|
|
684
|
+
}
|
|
685
|
+
return { branchName, prNumber: null };
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
function buildPrBody(ticketKey, ticketCtx) {
|
|
689
|
+
return `## ${ticketKey}
|
|
690
|
+
|
|
691
|
+
${ticketCtx}
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
*Generated by jiraACP automated pipeline*`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/pipeline/stages/6-review.ts
|
|
698
|
+
var reviewStage = {
|
|
699
|
+
id: "review",
|
|
700
|
+
name: "Review",
|
|
701
|
+
model: "sonnet",
|
|
702
|
+
async run(ctx) {
|
|
703
|
+
const { config, ticketKey, state, memoryDir, projectDir } = ctx;
|
|
704
|
+
const prNumber = state.current.prNumber;
|
|
705
|
+
if (!prNumber)
|
|
706
|
+
throw new Error("No PR number in state \u2014 git stage must run first");
|
|
707
|
+
const contextFiles = getContextFilesForStage(
|
|
708
|
+
projectDir,
|
|
709
|
+
memoryDir,
|
|
710
|
+
"review"
|
|
711
|
+
);
|
|
712
|
+
ctx.logger.info({ ticketKey, prNumber }, "Running parallel review agents");
|
|
713
|
+
const logicPrompt = `Review PR #${prNumber} for ticket ${ticketKey}.
|
|
714
|
+
Focus on: Does the implementation correctly satisfy the acceptance criteria?
|
|
715
|
+
List issues as JSON: { "issues": [{ "severity": "minor"|"major", "message": "..." }] }`;
|
|
716
|
+
const qualityPrompt = `Review PR #${prNumber} for ticket ${ticketKey}.
|
|
717
|
+
Focus on: Missing tests, security issues, type safety, performance red flags.
|
|
718
|
+
List issues as JSON: { "issues": [{ "severity": "minor"|"major", "message": "..." }] }`;
|
|
719
|
+
const agentOpts = {
|
|
720
|
+
workdir: config.workspace.rootDir,
|
|
721
|
+
model: "sonnet",
|
|
722
|
+
contextFiles,
|
|
723
|
+
timeoutMs: (config.pipeline?.stageTimeouts?.review ?? 6e5) / 2,
|
|
724
|
+
stallTimeoutMs: 12e4
|
|
725
|
+
};
|
|
726
|
+
const [logicRaw, qualityRaw] = ctx.dryRun ? ['{"issues":[]}', '{"issues":[]}'] : await runAgentsParallel(
|
|
727
|
+
{ ...agentOpts, prompt: logicPrompt },
|
|
728
|
+
{ ...agentOpts, prompt: qualityPrompt }
|
|
729
|
+
);
|
|
730
|
+
const issues = [...parseIssues(logicRaw), ...parseIssues(qualityRaw)];
|
|
731
|
+
const majorCount = issues.filter((i) => i.severity === "major").length;
|
|
732
|
+
const threshold = config.github.majorIssueThreshold ?? 1;
|
|
733
|
+
const needsHumanApproval = majorCount >= threshold;
|
|
734
|
+
writeReviewFeedback(memoryDir, {
|
|
735
|
+
prNumber,
|
|
736
|
+
issues,
|
|
737
|
+
autoResolved: !needsHumanApproval
|
|
738
|
+
});
|
|
739
|
+
if (needsHumanApproval) {
|
|
740
|
+
ctx.logger.warn(
|
|
741
|
+
{ ticketKey, majorCount },
|
|
742
|
+
"Major issues found \u2014 requesting human approval via Telegram"
|
|
743
|
+
);
|
|
744
|
+
ctx.state.emit({
|
|
745
|
+
type: "HUMAN_APPROVAL_REQUESTED",
|
|
746
|
+
context: { prNumber, majorCount }
|
|
747
|
+
});
|
|
748
|
+
const hil = config.telegram.humanInTheLoop;
|
|
749
|
+
const approved = ctx.dryRun ? true : await requestApproval(
|
|
750
|
+
config.telegram.botToken,
|
|
751
|
+
config.telegram.chatId,
|
|
752
|
+
ticketKey,
|
|
753
|
+
issues,
|
|
754
|
+
{
|
|
755
|
+
timeoutMs: hil?.reviewApprovalTimeoutMs ?? 864e5,
|
|
756
|
+
onTimeout: hil?.reviewApprovalTimeoutAction ?? "abort",
|
|
757
|
+
topicId: config.telegram.topicId
|
|
758
|
+
}
|
|
759
|
+
);
|
|
760
|
+
if (approved) {
|
|
761
|
+
ctx.state.emit({ type: "HUMAN_APPROVED" });
|
|
762
|
+
ctx.logger.info(
|
|
763
|
+
{ ticketKey, prNumber },
|
|
764
|
+
"Human approved \u2014 proceeding to merge"
|
|
765
|
+
);
|
|
766
|
+
} else {
|
|
767
|
+
ctx.state.emit({
|
|
768
|
+
type: "HUMAN_REJECTED",
|
|
769
|
+
reason: "Reviewer rejected PR"
|
|
770
|
+
});
|
|
771
|
+
throw new Error(`REVIEW_REJECTED:PR #${prNumber} rejected by reviewer`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (!ctx.dryRun) {
|
|
775
|
+
await ctx.github.mergePR(
|
|
776
|
+
prNumber,
|
|
777
|
+
config.github.autoMergeStrategy ?? "squash"
|
|
778
|
+
);
|
|
779
|
+
ctx.logger.info({ ticketKey, prNumber }, "PR merged");
|
|
780
|
+
}
|
|
781
|
+
return { prNumber, issues, merged: true };
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
function parseIssues(raw) {
|
|
785
|
+
try {
|
|
786
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
787
|
+
if (!match) return [];
|
|
788
|
+
const parsed = JSON.parse(match[0]);
|
|
789
|
+
return (parsed.issues ?? []).map((i) => ({
|
|
790
|
+
severity: i.severity === "major" ? "major" : "minor",
|
|
791
|
+
message: i.message
|
|
792
|
+
}));
|
|
793
|
+
} catch {
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// src/pipeline/stages/7-deploy.ts
|
|
799
|
+
var deployStage = {
|
|
800
|
+
id: "deploy",
|
|
801
|
+
name: "Deploy",
|
|
802
|
+
model: "haiku",
|
|
803
|
+
async shouldSkip(ctx) {
|
|
804
|
+
return !ctx.config.deploy?.enabled;
|
|
805
|
+
},
|
|
806
|
+
async run(ctx) {
|
|
807
|
+
const { config, ticketKey } = ctx;
|
|
808
|
+
const deploy = config.deploy;
|
|
809
|
+
if (!deploy?.command) throw new Error("deploy.command not configured");
|
|
810
|
+
ctx.logger.info({ ticketKey }, "Deploying to dev server");
|
|
811
|
+
if (ctx.dryRun) return { deployed: false, dryRun: true };
|
|
812
|
+
const result = await spawnSafe(deploy.command, [], {
|
|
813
|
+
cwd: config.workspace.rootDir,
|
|
814
|
+
env: buildMinimalEnv(deploy.env ?? {}),
|
|
815
|
+
timeoutMs: deploy.timeoutMs ?? 12e5
|
|
816
|
+
});
|
|
817
|
+
if (result.exitCode !== 0) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`Deploy failed (exit ${result.exitCode}):
|
|
820
|
+
${result.stderr}`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (deploy.healthCheckUrl) {
|
|
824
|
+
await healthCheck(
|
|
825
|
+
deploy.healthCheckUrl,
|
|
826
|
+
deploy.healthCheckTimeoutMs ?? 3e4
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
ctx.logger.info({ ticketKey }, "Deploy successful");
|
|
830
|
+
return { deployed: true, deployUrl: deploy.healthCheckUrl };
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
async function healthCheck(url, timeoutMs) {
|
|
834
|
+
const start = Date.now();
|
|
835
|
+
while (Date.now() - start < timeoutMs) {
|
|
836
|
+
try {
|
|
837
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
838
|
+
if (res.ok) return;
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
await sleep(2e3);
|
|
842
|
+
}
|
|
843
|
+
throw new Error(`Health check timed out after ${timeoutMs}ms: ${url}`);
|
|
844
|
+
}
|
|
845
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
846
|
+
|
|
847
|
+
// src/pipeline/stages/8-test.ts
|
|
848
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
849
|
+
var testStage = {
|
|
850
|
+
id: "test",
|
|
851
|
+
name: "UI Test",
|
|
852
|
+
model: "sonnet",
|
|
853
|
+
async shouldSkip(ctx) {
|
|
854
|
+
return !ctx.config.test?.enabled;
|
|
855
|
+
},
|
|
856
|
+
async run(ctx) {
|
|
857
|
+
const { config, ticketKey, memoryDir, projectDir } = ctx;
|
|
858
|
+
const testConfig = config.test;
|
|
859
|
+
if (!testConfig?.baseUrl) throw new Error("test.baseUrl not configured");
|
|
860
|
+
const waitMs = testConfig.waitBeforeTestMs ?? 5e3;
|
|
861
|
+
ctx.logger.info({ ticketKey, waitMs }, "Waiting before UI tests");
|
|
862
|
+
await sleep2(waitMs);
|
|
863
|
+
const contextFiles = getContextFilesForStage(projectDir, memoryDir, "test");
|
|
864
|
+
const retries = testConfig.retries ?? 2;
|
|
865
|
+
const prompt = `
|
|
866
|
+
Run Playwright UI tests for ticket ${ticketKey} on ${testConfig.baseUrl}.
|
|
867
|
+
|
|
868
|
+
Test the acceptance criteria from the ticket context.
|
|
869
|
+
Spec pattern: ${testConfig.specPattern ?? "e2e/**/*.spec.ts"}
|
|
870
|
+
|
|
871
|
+
Report results as JSON: { "passed": boolean, "summary": "...", "failures": ["..."] }
|
|
872
|
+
`.trim();
|
|
873
|
+
let lastError;
|
|
874
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
875
|
+
try {
|
|
876
|
+
ctx.logger.info(
|
|
877
|
+
{ ticketKey, attempt: attempt + 1 },
|
|
878
|
+
"Running UI tests"
|
|
879
|
+
);
|
|
880
|
+
const raw = ctx.dryRun ? '{"passed":true,"summary":"dry-run","failures":[]}' : await runAgent({
|
|
881
|
+
prompt,
|
|
882
|
+
workdir: config.workspace.rootDir,
|
|
883
|
+
model: "sonnet",
|
|
884
|
+
contextFiles,
|
|
885
|
+
timeoutMs: testConfig.timeoutMs ?? 3e5,
|
|
886
|
+
stallTimeoutMs: 6e4
|
|
887
|
+
});
|
|
888
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
889
|
+
if (!match) throw new Error("Could not parse test results JSON");
|
|
890
|
+
const result = JSON.parse(match[0]);
|
|
891
|
+
if (result.passed) {
|
|
892
|
+
ctx.logger.info({ ticketKey }, "UI tests passed");
|
|
893
|
+
return { passed: true, summary: result.summary };
|
|
894
|
+
}
|
|
895
|
+
lastError = new Error(`Tests failed: ${result.failures.join(", ")}`);
|
|
896
|
+
ctx.logger.warn(
|
|
897
|
+
{ ticketKey, attempt: attempt + 1, failures: result.failures },
|
|
898
|
+
"Tests failed, retrying"
|
|
899
|
+
);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const failOnTest = config.pipeline?.failOnTestFailure ?? false;
|
|
905
|
+
if (failOnTest)
|
|
906
|
+
throw lastError ?? new Error("Tests failed after all retries");
|
|
907
|
+
await ctx.telegram.send(
|
|
908
|
+
`\u26A0\uFE0F <b>${ticketKey}</b> UI tests failed after ${retries + 1} attempts. Continuing pipeline.
|
|
909
|
+
${lastError?.message}`
|
|
910
|
+
);
|
|
911
|
+
return { passed: false, summary: lastError?.message ?? "Tests failed" };
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// src/pipeline/stages/9-notify.ts
|
|
916
|
+
var notifyStage = {
|
|
917
|
+
id: "notify",
|
|
918
|
+
name: "Notify",
|
|
919
|
+
model: "haiku",
|
|
920
|
+
async run(ctx) {
|
|
921
|
+
const { config, ticketKey, state } = ctx;
|
|
922
|
+
const current = state.current;
|
|
923
|
+
ctx.logger.info({ ticketKey }, "Notifying: Jira + Telegram");
|
|
924
|
+
if (!ctx.dryRun) {
|
|
925
|
+
await transitionTicket({
|
|
926
|
+
instance: config.jira.instance,
|
|
927
|
+
ticket_key: ticketKey,
|
|
928
|
+
transition_name: config.jira.doneTransition ?? "Done"
|
|
929
|
+
});
|
|
930
|
+
const prInfo = current.prNumber ? `
|
|
931
|
+
PR: #${current.prNumber}` : "";
|
|
932
|
+
const branchInfo = current.branchName ? `
|
|
933
|
+
Branch: ${current.branchName}` : "";
|
|
934
|
+
await addComment({
|
|
935
|
+
instance: config.jira.instance,
|
|
936
|
+
ticket_key: ticketKey,
|
|
937
|
+
comment: `\u2705 Implemented via jiraACP automated pipeline.${prInfo}${branchInfo}
|
|
938
|
+
|
|
939
|
+
All stages completed: fetch \u2192 analyze \u2192 code \u2192 review \u2192 deploy \u2192 test`
|
|
940
|
+
});
|
|
941
|
+
if (config.jira.reassignTo) {
|
|
942
|
+
await reassign({
|
|
943
|
+
instance: config.jira.instance,
|
|
944
|
+
ticket_key: ticketKey,
|
|
945
|
+
account_id: config.jira.reassignTo
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
await ctx.telegram.sendDone(ticketKey, {
|
|
949
|
+
summary: "Pipeline completed successfully",
|
|
950
|
+
prNumber: current.prNumber ?? void 0
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
ctx.logger.info({ ticketKey }, "Notifications sent");
|
|
954
|
+
return { notified: true };
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// src/pipeline/hooks.ts
|
|
959
|
+
var HookError = class extends Error {
|
|
960
|
+
constructor(hookName, exitCode) {
|
|
961
|
+
super(`Hook '${hookName}' failed with exit code ${exitCode}`);
|
|
962
|
+
this.hookName = hookName;
|
|
963
|
+
this.exitCode = exitCode;
|
|
964
|
+
this.name = "HookError";
|
|
965
|
+
}
|
|
966
|
+
hookName;
|
|
967
|
+
exitCode;
|
|
968
|
+
};
|
|
969
|
+
async function runHook(name, command, ctx) {
|
|
970
|
+
if (!command?.trim()) return;
|
|
971
|
+
const [bin, ...args] = command.trim().split(/\s+/);
|
|
972
|
+
const { logger } = ctx;
|
|
973
|
+
logger.info({ hookName: name, command }, "Running hook");
|
|
974
|
+
const result = await spawnSafe(bin, args, {
|
|
975
|
+
env: buildMinimalEnv({ JIRA_ACP_TICKET: ctx.ticketKey })
|
|
976
|
+
});
|
|
977
|
+
if (result.exitCode !== 0) {
|
|
978
|
+
logger.error(
|
|
979
|
+
{ hookName: name, exitCode: result.exitCode, stderr: result.stderr },
|
|
980
|
+
"Hook failed"
|
|
981
|
+
);
|
|
982
|
+
throw new HookError(name, result.exitCode);
|
|
983
|
+
}
|
|
984
|
+
logger.info({ hookName: name }, "Hook completed");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/pipeline/orchestrator.ts
|
|
988
|
+
var ALL_STAGES = [
|
|
989
|
+
fetchStage,
|
|
990
|
+
analyzeStage,
|
|
991
|
+
clarifyStage,
|
|
992
|
+
codeStage,
|
|
993
|
+
gitStage,
|
|
994
|
+
reviewStage,
|
|
995
|
+
deployStage,
|
|
996
|
+
testStage,
|
|
997
|
+
notifyStage
|
|
998
|
+
];
|
|
999
|
+
async function runPipeline(ticketKey, config, opts = {}) {
|
|
1000
|
+
const projectDir = config.workspace.rootDir;
|
|
1001
|
+
const runDir = getRunDir(config.name, ticketKey);
|
|
1002
|
+
const lockPath = getLockPath(config.name, ticketKey);
|
|
1003
|
+
const memoryDir = getMemoryDir(config.name, ticketKey);
|
|
1004
|
+
const logger = createLogger(`pipeline:${ticketKey}`);
|
|
1005
|
+
const state = new StateManager(runDir);
|
|
1006
|
+
if (config.telegram?.botToken) {
|
|
1007
|
+
await initBot(config.telegram.botToken);
|
|
1008
|
+
}
|
|
1009
|
+
const lock = await acquireLock(lockPath);
|
|
1010
|
+
const ctx = {
|
|
1011
|
+
config,
|
|
1012
|
+
ticketKey,
|
|
1013
|
+
projectDir,
|
|
1014
|
+
state,
|
|
1015
|
+
memoryDir,
|
|
1016
|
+
dryRun: opts.dryRun ?? false,
|
|
1017
|
+
logger,
|
|
1018
|
+
jira: getClient(config.jira.instance),
|
|
1019
|
+
github: createGitHubClient(
|
|
1020
|
+
config.github.token,
|
|
1021
|
+
config.github.owner,
|
|
1022
|
+
config.github.repo
|
|
1023
|
+
),
|
|
1024
|
+
telegram: createTelegramNotifier(
|
|
1025
|
+
config.telegram.botToken,
|
|
1026
|
+
config.telegram.chatId,
|
|
1027
|
+
ticketKey,
|
|
1028
|
+
config.name,
|
|
1029
|
+
config.telegram.topicId
|
|
1030
|
+
)
|
|
1031
|
+
};
|
|
1032
|
+
state.emit({ type: "STARTED", ticketKey });
|
|
1033
|
+
const stages = filterStages(ALL_STAGES, opts.fromStage, opts.toStage);
|
|
1034
|
+
const hooksConfig = config.pipeline?.hooks;
|
|
1035
|
+
try {
|
|
1036
|
+
await runHook("beforePipeline", hooksConfig?.beforePipeline, {
|
|
1037
|
+
ticketKey,
|
|
1038
|
+
logger
|
|
1039
|
+
});
|
|
1040
|
+
for (const stage of stages) {
|
|
1041
|
+
if (await stage.shouldSkip?.(ctx)) {
|
|
1042
|
+
state.emit({
|
|
1043
|
+
type: "STAGE_SKIPPED",
|
|
1044
|
+
stage: stage.id,
|
|
1045
|
+
reason: "shouldSkip returned true"
|
|
1046
|
+
});
|
|
1047
|
+
logger.info({ stage: stage.id }, "Stage skipped");
|
|
1048
|
+
await ctx.telegram.notifyStageSkipped(stage.id).catch(() => void 0);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (stage.id === "code") {
|
|
1052
|
+
await runHook("beforeCode", hooksConfig?.beforeCode, {
|
|
1053
|
+
ticketKey,
|
|
1054
|
+
logger
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
state.emit({ type: "STAGE_STARTED", stage: stage.id });
|
|
1058
|
+
logger.info({ stage: stage.id }, `\u25B6 ${stage.name}`);
|
|
1059
|
+
await ctx.telegram.notifyStageStarted(stage.id).catch(() => void 0);
|
|
1060
|
+
try {
|
|
1061
|
+
const timeout = config.pipeline?.stageTimeouts?.[stage.id];
|
|
1062
|
+
const output = timeout ? await withTimeout(stage.run(ctx), timeout) : await stage.run(ctx);
|
|
1063
|
+
state.emit({ type: "STAGE_COMPLETED", stage: stage.id, output });
|
|
1064
|
+
logger.info({ stage: stage.id }, `\u2713 ${stage.name}`);
|
|
1065
|
+
await ctx.telegram.notifyStageCompleted(stage.id).catch(() => void 0);
|
|
1066
|
+
if (config.pipeline?.maxCostUsdPerRun) {
|
|
1067
|
+
const decision = await checkCostLimit({
|
|
1068
|
+
runDir,
|
|
1069
|
+
maxCostUsd: config.pipeline.maxCostUsdPerRun,
|
|
1070
|
+
telegram: ctx.telegram,
|
|
1071
|
+
ticketKey
|
|
1072
|
+
});
|
|
1073
|
+
if (decision === "abort") {
|
|
1074
|
+
state.emit({
|
|
1075
|
+
type: "PIPELINE_ABORTED",
|
|
1076
|
+
reason: "Cost limit exceeded"
|
|
1077
|
+
});
|
|
1078
|
+
throw new Error("Cost limit exceeded");
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (stage.id === "code") {
|
|
1082
|
+
await runHook("afterCode", hooksConfig?.afterCode, {
|
|
1083
|
+
ticketKey,
|
|
1084
|
+
logger
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
if (stage.id === "deploy") {
|
|
1088
|
+
await runHook("afterDeploy", hooksConfig?.afterDeploy, {
|
|
1089
|
+
ticketKey,
|
|
1090
|
+
logger
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1095
|
+
if (message.startsWith("SKIP:")) {
|
|
1096
|
+
state.emit({
|
|
1097
|
+
type: "STAGE_SKIPPED",
|
|
1098
|
+
stage: stage.id,
|
|
1099
|
+
reason: message
|
|
1100
|
+
});
|
|
1101
|
+
logger.warn({ stage: stage.id }, `Skipped: ${message}`);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (err instanceof HookError) {
|
|
1105
|
+
state.emit({ type: "PIPELINE_ABORTED", reason: message });
|
|
1106
|
+
throw err;
|
|
1107
|
+
}
|
|
1108
|
+
state.emit({ type: "STAGE_FAILED", stage: stage.id, error: message });
|
|
1109
|
+
await ctx.telegram.notifyStageFailed(stage.id, message).catch(() => void 0);
|
|
1110
|
+
throw err;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
state.emit({ type: "PIPELINE_COMPLETED" });
|
|
1114
|
+
logger.info({ ticketKey }, "Pipeline completed");
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1117
|
+
if (!(err instanceof HookError)) {
|
|
1118
|
+
state.emit({ type: "PIPELINE_ABORTED", reason });
|
|
1119
|
+
}
|
|
1120
|
+
await ctx.telegram.sendError(ticketKey, err);
|
|
1121
|
+
logger.error({ ticketKey, reason }, "Pipeline aborted");
|
|
1122
|
+
process.exitCode = 1;
|
|
1123
|
+
} finally {
|
|
1124
|
+
await runHook("afterPipeline", hooksConfig?.afterPipeline, {
|
|
1125
|
+
ticketKey,
|
|
1126
|
+
logger
|
|
1127
|
+
});
|
|
1128
|
+
lock.release();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
async function resumePipeline(ticketKey, config) {
|
|
1132
|
+
const logger = createLogger(`pipeline:${ticketKey}`);
|
|
1133
|
+
const state = new StateManager(getRunDir(config.name, ticketKey));
|
|
1134
|
+
const current = state.current;
|
|
1135
|
+
if (current.isCompleted) {
|
|
1136
|
+
logger.info({ ticketKey }, "Pipeline already completed");
|
|
1137
|
+
process.stdout.write(`Pipeline for ${ticketKey} already completed.
|
|
1138
|
+
`);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const fromStage = current.currentStage ?? current.failedStage ?? "fetch";
|
|
1142
|
+
logger.info({ ticketKey, fromStage }, "Resuming pipeline");
|
|
1143
|
+
await runPipeline(ticketKey, config, { fromStage });
|
|
1144
|
+
}
|
|
1145
|
+
function filterStages(stages, from, to) {
|
|
1146
|
+
const fromIdx = from ? stages.findIndex((s) => s.id === from) : 0;
|
|
1147
|
+
const toIdx = to ? stages.findIndex((s) => s.id === to) : stages.length - 1;
|
|
1148
|
+
return stages.slice(
|
|
1149
|
+
fromIdx < 0 ? 0 : fromIdx,
|
|
1150
|
+
toIdx < 0 ? stages.length : toIdx + 1
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
async function withTimeout(promise, ms) {
|
|
1154
|
+
const signal = AbortSignal.timeout(ms);
|
|
1155
|
+
const aborted = new Promise((_, reject) => {
|
|
1156
|
+
signal.addEventListener("abort", () => {
|
|
1157
|
+
reject(new Error(`Stage timed out after ${ms}ms`));
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
return Promise.race([promise, aborted]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export {
|
|
1164
|
+
runPipeline,
|
|
1165
|
+
resumePipeline
|
|
1166
|
+
};
|
|
1167
|
+
//# sourceMappingURL=chunk-B6OA3XJK.js.map
|