@avocadostudio-ai/orchestrator-core 0.1.0
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/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JIRA ticket processor.
|
|
3
|
+
*
|
|
4
|
+
* Extracts instructions + attachments from a JIRA issue, runs the agent loop
|
|
5
|
+
* to edit the site, then posts results back to the ticket.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { JiraClient } from "./jira-client.js";
|
|
11
|
+
import { createAgentTools } from "../agent/agent-tools.js";
|
|
12
|
+
import { runAgentLoop } from "../agent/agent-loop.js";
|
|
13
|
+
import { buildAgentSystemPrompt, buildContextMessage } from "../agent/agent-context.js";
|
|
14
|
+
import { resolveAgentModel } from "../agent/agent-provider.js";
|
|
15
|
+
import { scopedSessionKey, listSitesForSession, getSessionPages, setIssueTouchedSlugs, getIssueTouchedSlugs, schedulePersistState, } from "../state/session-state.js";
|
|
16
|
+
import { pushJiraTelemetry, redactToolInput } from "../telemetry/jira-telemetry.js";
|
|
17
|
+
// Agent-authored comments are identified by author accountId at the call site.
|
|
18
|
+
// The *kind* of agent comment (review questions, proceed, executed, published)
|
|
19
|
+
// is inferred from the distinctive bold headline each formatter emits — see
|
|
20
|
+
// `reviewCommentHeadlineRe` in countAgentReviewComments. HTML-comment markers
|
|
21
|
+
// were tried first but leak as literal text in ADF, so we rely on the
|
|
22
|
+
// human-readable headline instead.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Processing queue (in-memory)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const processingQueue = new Map();
|
|
27
|
+
const recentResults = [];
|
|
28
|
+
const MAX_RECENT = 50;
|
|
29
|
+
export function getProcessingStatus() {
|
|
30
|
+
return {
|
|
31
|
+
queue: Array.from(processingQueue.values()),
|
|
32
|
+
recent: recentResults.slice(0, 20),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Image + Document classification
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "gif"]);
|
|
39
|
+
const TEXT_DOC_EXTENSIONS = new Set(["txt", "md"]);
|
|
40
|
+
const PDF_EXTENSIONS = new Set(["pdf"]);
|
|
41
|
+
function extensionOf(filename) {
|
|
42
|
+
return filename.split(".").pop()?.toLowerCase() ?? "";
|
|
43
|
+
}
|
|
44
|
+
function isImageAttachment(a) {
|
|
45
|
+
return IMAGE_EXTENSIONS.has(extensionOf(a.filename)) || a.mimeType.startsWith("image/");
|
|
46
|
+
}
|
|
47
|
+
function isTextDocAttachment(a) {
|
|
48
|
+
return TEXT_DOC_EXTENSIONS.has(extensionOf(a.filename)) || a.mimeType === "text/plain" || a.mimeType === "text/markdown";
|
|
49
|
+
}
|
|
50
|
+
function isPdfAttachment(a) {
|
|
51
|
+
return PDF_EXTENSIONS.has(extensionOf(a.filename)) || a.mimeType === "application/pdf";
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// ADF → plain text converter
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Convert Atlassian Document Format to plain text.
|
|
58
|
+
* Handles the common node types: paragraph, heading, text, hardBreak, listItem, codeBlock.
|
|
59
|
+
*/
|
|
60
|
+
export function adfToPlainText(node) {
|
|
61
|
+
if (!node || typeof node !== "object")
|
|
62
|
+
return String(node ?? "");
|
|
63
|
+
const obj = node;
|
|
64
|
+
// If it's a plain string (JIRA Server), return directly
|
|
65
|
+
if (typeof node === "string")
|
|
66
|
+
return node;
|
|
67
|
+
// Text node
|
|
68
|
+
if (obj.type === "text" && typeof obj.text === "string")
|
|
69
|
+
return obj.text;
|
|
70
|
+
// Hard break
|
|
71
|
+
if (obj.type === "hardBreak")
|
|
72
|
+
return "\n";
|
|
73
|
+
// Recurse into content array
|
|
74
|
+
const children = Array.isArray(obj.content)
|
|
75
|
+
? obj.content.map((child) => adfToPlainText(child)).join("")
|
|
76
|
+
: "";
|
|
77
|
+
// Add line breaks after block-level nodes
|
|
78
|
+
switch (obj.type) {
|
|
79
|
+
case "paragraph":
|
|
80
|
+
case "heading":
|
|
81
|
+
case "codeBlock":
|
|
82
|
+
case "blockquote":
|
|
83
|
+
case "rule":
|
|
84
|
+
return children + "\n";
|
|
85
|
+
case "listItem":
|
|
86
|
+
return "- " + children + "\n";
|
|
87
|
+
case "orderedList":
|
|
88
|
+
case "bulletList":
|
|
89
|
+
case "doc":
|
|
90
|
+
return children;
|
|
91
|
+
default:
|
|
92
|
+
return children;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB
|
|
96
|
+
async function processAttachments(client, attachments, generatedImageDir, orchestratorPublicOrigin, logger) {
|
|
97
|
+
const imageReferences = [];
|
|
98
|
+
const docTexts = [];
|
|
99
|
+
const skipped = [];
|
|
100
|
+
for (const attachment of attachments) {
|
|
101
|
+
if (attachment.size > MAX_ATTACHMENT_SIZE) {
|
|
102
|
+
skipped.push(`${attachment.filename} (too large: ${Math.round(attachment.size / 1024 / 1024)}MB)`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (isImageAttachment(attachment)) {
|
|
107
|
+
const buffer = await client.downloadAttachment(attachment.content);
|
|
108
|
+
const ext = extensionOf(attachment.filename) || "png";
|
|
109
|
+
// Use full UUID for collision-free filenames, avoiding Date.now() collision risk under concurrency
|
|
110
|
+
const fileName = `jira_${randomUUID()}.${ext}`;
|
|
111
|
+
await mkdir(generatedImageDir, { recursive: true });
|
|
112
|
+
await writeFile(resolve(generatedImageDir, fileName), buffer);
|
|
113
|
+
const url = `${orchestratorPublicOrigin}/generated-images/${fileName}`;
|
|
114
|
+
imageReferences.push(`[Image: ${attachment.filename}](${url})`);
|
|
115
|
+
logger.info({ filename: attachment.filename, saved: fileName }, "JIRA: saved image attachment");
|
|
116
|
+
}
|
|
117
|
+
else if (isTextDocAttachment(attachment)) {
|
|
118
|
+
const buffer = await client.downloadAttachment(attachment.content);
|
|
119
|
+
const text = buffer.toString("utf-8");
|
|
120
|
+
docTexts.push(`--- Document: ${attachment.filename} ---\n${text.slice(0, 50_000)}\n--- End of ${attachment.filename} ---`);
|
|
121
|
+
logger.info({ filename: attachment.filename, chars: text.length }, "JIRA: extracted text document");
|
|
122
|
+
}
|
|
123
|
+
else if (isPdfAttachment(attachment)) {
|
|
124
|
+
// Basic PDF text extraction — try to read text content
|
|
125
|
+
const buffer = await client.downloadAttachment(attachment.content);
|
|
126
|
+
const text = extractBasicPdfText(buffer);
|
|
127
|
+
if (text.trim()) {
|
|
128
|
+
docTexts.push(`--- Document: ${attachment.filename} ---\n${text.slice(0, 50_000)}\n--- End of ${attachment.filename} ---`);
|
|
129
|
+
logger.info({ filename: attachment.filename, chars: text.length }, "JIRA: extracted PDF text");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// PDF had no readable text content (empty or non-text PDF)
|
|
133
|
+
skipped.push(`${attachment.filename} (PDF contains no extractable text)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
skipped.push(attachment.filename);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
142
|
+
skipped.push(`${attachment.filename} (error: ${detail})`);
|
|
143
|
+
logger.warn({ filename: attachment.filename, error: detail }, "JIRA: failed to process attachment");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
imageReferences,
|
|
148
|
+
documentContext: docTexts.join("\n\n"),
|
|
149
|
+
skipped,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Basic text extraction from PDF buffer.
|
|
154
|
+
* Looks for text streams in the PDF structure — handles simple PDFs.
|
|
155
|
+
* For complex PDFs, a dedicated library would be needed.
|
|
156
|
+
*
|
|
157
|
+
* @throws {Error} If PDF appears invalid or critically malformed
|
|
158
|
+
* @returns {string} Extracted text, or throws if extraction fails
|
|
159
|
+
*/
|
|
160
|
+
function extractBasicPdfText(buffer) {
|
|
161
|
+
try {
|
|
162
|
+
const raw = buffer.toString("latin1");
|
|
163
|
+
// Validate buffer contains PDF markers
|
|
164
|
+
if (!raw.includes("BT") || !raw.includes("ET")) {
|
|
165
|
+
// No text operators found - this may be a valid PDF without text content,
|
|
166
|
+
// but we explicitly return empty to distinguish from successful extraction
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
const textParts = [];
|
|
170
|
+
// Extract text between BT (begin text) and ET (end text) operators
|
|
171
|
+
const btEtRegex = /BT\s([\s\S]*?)ET/g;
|
|
172
|
+
let match;
|
|
173
|
+
while ((match = btEtRegex.exec(raw)) !== null) {
|
|
174
|
+
const block = match[1];
|
|
175
|
+
// Extract text from Tj and TJ operators
|
|
176
|
+
// Pattern: (text-content) Tj
|
|
177
|
+
// Handles escaped parentheses by matching balanced pairs
|
|
178
|
+
const tjRegex = /\(([^)]*(?:\\.[^)]*)*)\)\s*Tj/g;
|
|
179
|
+
let tm;
|
|
180
|
+
while ((tm = tjRegex.exec(block)) !== null) {
|
|
181
|
+
textParts.push(tm[1]);
|
|
182
|
+
}
|
|
183
|
+
// TJ arrays: [(text) kern (text) ...]
|
|
184
|
+
const tjArrayRegex = /\[([^\]]*)\]\s*TJ/g;
|
|
185
|
+
while ((tm = tjArrayRegex.exec(block)) !== null) {
|
|
186
|
+
const inner = tm[1];
|
|
187
|
+
// Match text strings with proper escape sequence handling
|
|
188
|
+
const textInArray = /\(([^)]*(?:\\.[^)]*)*)\)/g;
|
|
189
|
+
let ti;
|
|
190
|
+
while ((ti = textInArray.exec(inner)) !== null) {
|
|
191
|
+
textParts.push(ti[1]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return textParts.join(" ").replace(/\\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// Re-throw with more context for debugging
|
|
199
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
200
|
+
throw new Error(`Failed to extract text from PDF: ${detail}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Build instruction message from JIRA issue
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
function buildInstructionMessage(issue, attachments, agentAccountId) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
// Summary as the primary instruction
|
|
209
|
+
parts.push(`Task: ${issue.fields.summary}`);
|
|
210
|
+
// Description as detailed instructions
|
|
211
|
+
const description = typeof issue.fields.description === "string"
|
|
212
|
+
? issue.fields.description
|
|
213
|
+
: adfToPlainText(issue.fields.description);
|
|
214
|
+
if (description.trim()) {
|
|
215
|
+
parts.push(`\nDetailed instructions:\n${description.trim()}`);
|
|
216
|
+
}
|
|
217
|
+
// Image references
|
|
218
|
+
if (attachments.imageReferences.length > 0) {
|
|
219
|
+
parts.push(`\nAttached images (use these for the site update):`);
|
|
220
|
+
for (const ref of attachments.imageReferences) {
|
|
221
|
+
parts.push(` ${ref}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Document context
|
|
225
|
+
if (attachments.documentContext) {
|
|
226
|
+
parts.push(`\nReference documents (use this content as context for the site update):`);
|
|
227
|
+
parts.push(attachments.documentContext);
|
|
228
|
+
}
|
|
229
|
+
// Skipped attachments
|
|
230
|
+
if (attachments.skipped.length > 0) {
|
|
231
|
+
parts.push(`\nNote: The following attachments could not be processed: ${attachments.skipped.join(", ")}`);
|
|
232
|
+
}
|
|
233
|
+
// Conversation history — so reporter clarifications posted as comments
|
|
234
|
+
// reach the agent. Without this, a reply like "use this specific Unsplash
|
|
235
|
+
// URL" is silently dropped and the agent falls back to its own guess.
|
|
236
|
+
const history = buildCommentHistory(issue, agentAccountId);
|
|
237
|
+
if (history) {
|
|
238
|
+
parts.push(`\nConversation so far (oldest → newest). Treat the reporter's latest instructions as authoritative when they conflict with the description:\n${history}`);
|
|
239
|
+
}
|
|
240
|
+
return parts.join("\n");
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Render the ticket's comment thread as plain text labelled by role
|
|
244
|
+
* (Agent / Reporter). Returns "" when there are no comments.
|
|
245
|
+
*
|
|
246
|
+
* Long bodies are truncated to keep token usage bounded — the reporter's
|
|
247
|
+
* latest reply is what matters most, and we don't want old agent formatting
|
|
248
|
+
* boilerplate to crowd out the real task context.
|
|
249
|
+
*/
|
|
250
|
+
export function buildCommentHistory(issue, agentAccountId) {
|
|
251
|
+
const comments = issue.fields.comment?.comments ?? [];
|
|
252
|
+
if (comments.length === 0)
|
|
253
|
+
return "";
|
|
254
|
+
const MAX_BODY_CHARS = 1200;
|
|
255
|
+
const lines = [];
|
|
256
|
+
for (const c of comments) {
|
|
257
|
+
const body = typeof c.body === "string" ? c.body : adfToPlainText(c.body);
|
|
258
|
+
const trimmed = body.trim();
|
|
259
|
+
if (!trimmed)
|
|
260
|
+
continue;
|
|
261
|
+
const authorId = c.author?.accountId;
|
|
262
|
+
const isAgent = isAgentAuthoredComment(trimmed, authorId, agentAccountId);
|
|
263
|
+
const role = isAgent ? "Agent" : "Reporter";
|
|
264
|
+
const when = c.created ?? "";
|
|
265
|
+
const truncated = trimmed.length > MAX_BODY_CHARS
|
|
266
|
+
? `${trimmed.slice(0, MAX_BODY_CHARS)}… [truncated]`
|
|
267
|
+
: trimmed;
|
|
268
|
+
lines.push(`[${when}] ${role}:\n${truncated}`);
|
|
269
|
+
}
|
|
270
|
+
return lines.join("\n\n");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Wrap each tool's handler so we record inputs, outputs, and durations into a
|
|
274
|
+
* per-ticket trace. Also emits a structured pino line per call so tail -f on
|
|
275
|
+
* the orchestrator's stdout reads well during live testing.
|
|
276
|
+
*
|
|
277
|
+
* The agent-loop `tool_done` event only exposes the result string, not the
|
|
278
|
+
* input — so capturing input has to happen at handler-invocation time.
|
|
279
|
+
*/
|
|
280
|
+
function instrumentToolsForTrace(tools, trace, logger, issueKey) {
|
|
281
|
+
return tools.map((t) => ({
|
|
282
|
+
definition: t.definition,
|
|
283
|
+
handler: async (input) => {
|
|
284
|
+
const startedMs = Date.now();
|
|
285
|
+
const result = await t.handler(input);
|
|
286
|
+
const durationMs = Date.now() - startedMs;
|
|
287
|
+
const rawResult = typeof result.result === "string" ? result.result : JSON.stringify(result.result ?? null);
|
|
288
|
+
const resultExcerpt = rawResult.slice(0, 400);
|
|
289
|
+
const isError = Boolean(result.isError);
|
|
290
|
+
trace.push({
|
|
291
|
+
name: t.definition.name,
|
|
292
|
+
input: redactToolInput(input),
|
|
293
|
+
durationMs,
|
|
294
|
+
resultExcerpt,
|
|
295
|
+
isError,
|
|
296
|
+
});
|
|
297
|
+
logger.info({
|
|
298
|
+
issueKey,
|
|
299
|
+
tool: t.definition.name,
|
|
300
|
+
durationMs,
|
|
301
|
+
isError,
|
|
302
|
+
inputKeys: Object.keys((input ?? {})),
|
|
303
|
+
resultExcerpt: resultExcerpt.slice(0, 180),
|
|
304
|
+
}, "JIRA: agent tool call");
|
|
305
|
+
return result;
|
|
306
|
+
},
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Main processor
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
export async function processJiraTicket(options) {
|
|
313
|
+
const { issueKey, config, generatedImageDir, orchestratorPublicOrigin, sitePublicOrigin, logger, assignBackTo } = options;
|
|
314
|
+
const mode = options.mode ?? "execute";
|
|
315
|
+
const startedAt = Date.now();
|
|
316
|
+
// Track in queue
|
|
317
|
+
const entry = {
|
|
318
|
+
issueKey,
|
|
319
|
+
state: "processing",
|
|
320
|
+
queuedAt: startedAt,
|
|
321
|
+
startedAt,
|
|
322
|
+
};
|
|
323
|
+
processingQueue.set(issueKey, entry);
|
|
324
|
+
// Telemetry scratchpad — assembled across branches so one entry is flushed
|
|
325
|
+
// per ticket regardless of which mode we ran or where we exited.
|
|
326
|
+
const telemetry = {
|
|
327
|
+
timestamp: new Date(startedAt).toISOString(),
|
|
328
|
+
issueKey,
|
|
329
|
+
mode,
|
|
330
|
+
siteId: "",
|
|
331
|
+
session: "",
|
|
332
|
+
status: "success",
|
|
333
|
+
durationMs: 0,
|
|
334
|
+
transitions: [],
|
|
335
|
+
};
|
|
336
|
+
const client = new JiraClient(config);
|
|
337
|
+
try {
|
|
338
|
+
// 1. Fetch issue
|
|
339
|
+
logger.info({ issueKey }, "JIRA: fetching issue");
|
|
340
|
+
const issue = await client.getIssue(issueKey);
|
|
341
|
+
// 1b. Resolve which site the ticket targets. If ambiguous, post a
|
|
342
|
+
// clarification comment listing available sites and stop.
|
|
343
|
+
const resolved = resolveSiteForTicket(issue, config);
|
|
344
|
+
if ("ambiguous" in resolved) {
|
|
345
|
+
logger.info({ issueKey, candidates: resolved.candidates.map((c) => c.id) }, "JIRA: site ambiguous, asking for clarification");
|
|
346
|
+
const comment = formatSiteClarificationComment(resolved.candidates);
|
|
347
|
+
await client.addComment(issueKey, comment).catch((err) => {
|
|
348
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to post clarification comment");
|
|
349
|
+
});
|
|
350
|
+
const result = {
|
|
351
|
+
issueKey,
|
|
352
|
+
status: "error",
|
|
353
|
+
summary: "",
|
|
354
|
+
changes: [],
|
|
355
|
+
durationMs: Date.now() - startedAt,
|
|
356
|
+
error: "ambiguous_site",
|
|
357
|
+
};
|
|
358
|
+
telemetry.status = "error";
|
|
359
|
+
telemetry.error = "ambiguous_site";
|
|
360
|
+
telemetry.durationMs = Date.now() - startedAt;
|
|
361
|
+
pushJiraTelemetry(telemetry);
|
|
362
|
+
entry.state = "error";
|
|
363
|
+
entry.completedAt = Date.now();
|
|
364
|
+
entry.result = result;
|
|
365
|
+
recentResults.unshift(result);
|
|
366
|
+
if (recentResults.length > MAX_RECENT)
|
|
367
|
+
recentResults.pop();
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
const siteId = resolved.siteId;
|
|
371
|
+
logger.info({ issueKey, siteId, mode, via: resolved.via }, "JIRA: resolved site");
|
|
372
|
+
// Share the session with the editor so Jira changes show up in the
|
|
373
|
+
// editor view without per-ticket URLs. Concurrent tickets interleave.
|
|
374
|
+
const sessionName = config.session;
|
|
375
|
+
const session = scopedSessionKey(sessionName, siteId);
|
|
376
|
+
telemetry.siteId = siteId;
|
|
377
|
+
telemetry.session = session;
|
|
378
|
+
// -----------------------------------------------------------------------
|
|
379
|
+
// Mode: PUBLISH — publish draft, transition to Done, post "Published" comment.
|
|
380
|
+
// -----------------------------------------------------------------------
|
|
381
|
+
if (mode === "publish") {
|
|
382
|
+
// Prefer the per-ticket slugs recorded during execute mode, so we only
|
|
383
|
+
// advertise the pages this ticket actually touched. Fall back to the
|
|
384
|
+
// full session list when we have no record (e.g. legacy tickets).
|
|
385
|
+
const ticketSlugs = getIssueTouchedSlugs(issueKey);
|
|
386
|
+
const sessionPages = getSessionPages(session);
|
|
387
|
+
const sessionSlugs = sessionPages.map((p) => p.slug).filter((s) => typeof s === "string");
|
|
388
|
+
const slugs = ticketSlugs.length > 0 ? ticketSlugs : [];
|
|
389
|
+
logger.info({ issueKey, siteId, ticketSlugCount: ticketSlugs.length, sessionPageCount: sessionSlugs.length }, "JIRA: publishing");
|
|
390
|
+
try {
|
|
391
|
+
await triggerPublish(session, siteId, logger);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
395
|
+
logger.error({ issueKey, error: errorMsg }, "JIRA: publish failed");
|
|
396
|
+
const failureResult = {
|
|
397
|
+
issueKey,
|
|
398
|
+
status: "error",
|
|
399
|
+
summary: "",
|
|
400
|
+
changes: [],
|
|
401
|
+
durationMs: Date.now() - startedAt,
|
|
402
|
+
error: `publish_failed: ${errorMsg}`,
|
|
403
|
+
};
|
|
404
|
+
await client.addComment(issueKey, formatFailureComment(failureResult)).catch(() => { });
|
|
405
|
+
telemetry.status = "error";
|
|
406
|
+
telemetry.error = `publish_failed: ${errorMsg}`;
|
|
407
|
+
telemetry.durationMs = failureResult.durationMs;
|
|
408
|
+
telemetry.touchedSlugs = slugs;
|
|
409
|
+
pushJiraTelemetry(telemetry);
|
|
410
|
+
return finalizeEntry(entry, failureResult);
|
|
411
|
+
}
|
|
412
|
+
const comment = formatPublishedComment({ sitePublicOrigin, slugs });
|
|
413
|
+
await client.addComment(issueKey, comment).catch((err) => {
|
|
414
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to post published comment");
|
|
415
|
+
});
|
|
416
|
+
logger.info({ issueKey, publishedSlugs: slugs, durationMs: Date.now() - startedAt }, "JIRA: publish succeeded");
|
|
417
|
+
if (config.doneStatus) {
|
|
418
|
+
const transitioned = await client.transitionIssue(issueKey, config.doneStatus).catch(() => false);
|
|
419
|
+
if (transitioned) {
|
|
420
|
+
logger.info({ issueKey, status: config.doneStatus }, "JIRA: transitioned to done");
|
|
421
|
+
telemetry.transitions?.push(config.doneStatus);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const result = {
|
|
425
|
+
issueKey,
|
|
426
|
+
status: "success",
|
|
427
|
+
summary: slugs.length > 0 ? `Published ${slugs.length} page(s).` : "Published.",
|
|
428
|
+
changes: slugs,
|
|
429
|
+
durationMs: Date.now() - startedAt,
|
|
430
|
+
published: true,
|
|
431
|
+
};
|
|
432
|
+
telemetry.status = "success";
|
|
433
|
+
telemetry.durationMs = result.durationMs;
|
|
434
|
+
telemetry.published = true;
|
|
435
|
+
telemetry.changes = slugs;
|
|
436
|
+
telemetry.touchedSlugs = slugs;
|
|
437
|
+
pushJiraTelemetry(telemetry);
|
|
438
|
+
return finalizeEntry(entry, result);
|
|
439
|
+
}
|
|
440
|
+
// 2. Process attachments (review + execute both need these)
|
|
441
|
+
const attachmentResult = await processAttachments(client, issue.fields.attachment ?? [], generatedImageDir, orchestratorPublicOrigin, logger);
|
|
442
|
+
// 3. Build instruction message (includes comment history so reporter
|
|
443
|
+
// clarifications posted as comments reach the agent).
|
|
444
|
+
const instruction = buildInstructionMessage(issue, attachmentResult, config.agentAccountId);
|
|
445
|
+
logger.info({ issueKey, instructionLength: instruction.length }, "JIRA: built instruction");
|
|
446
|
+
telemetry.instruction = instruction;
|
|
447
|
+
// 4. Determine AI provider and key from env
|
|
448
|
+
const apiKey = process.env.ANTHROPIC_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim();
|
|
449
|
+
if (!apiKey) {
|
|
450
|
+
throw new Error("No AI API key configured (need ANTHROPIC_API_KEY or OPENAI_API_KEY)");
|
|
451
|
+
}
|
|
452
|
+
const provider = process.env.ANTHROPIC_API_KEY?.trim() ? "anthropic" : "openai";
|
|
453
|
+
const model = resolveAgentModel(provider);
|
|
454
|
+
telemetry.provider = provider;
|
|
455
|
+
telemetry.model = model;
|
|
456
|
+
// -----------------------------------------------------------------------
|
|
457
|
+
// Mode: REVIEW — dry-run planning pass, no tools, no transitions.
|
|
458
|
+
// -----------------------------------------------------------------------
|
|
459
|
+
if (mode === "review") {
|
|
460
|
+
const priorReviews = countAgentReviewComments(issue, config.agentAccountId);
|
|
461
|
+
if (priorReviews >= config.maxReviewPasses) {
|
|
462
|
+
logger.info({ issueKey, priorReviews, cap: config.maxReviewPasses }, "JIRA: review pass cap reached");
|
|
463
|
+
const comment = formatReviewCapComment(config.maxReviewPasses);
|
|
464
|
+
await client.addComment(issueKey, comment).catch((err) => {
|
|
465
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to post cap comment");
|
|
466
|
+
});
|
|
467
|
+
const capped = {
|
|
468
|
+
issueKey,
|
|
469
|
+
status: "success",
|
|
470
|
+
summary: "review_cap_reached",
|
|
471
|
+
changes: [],
|
|
472
|
+
durationMs: Date.now() - startedAt,
|
|
473
|
+
modelUsed: model,
|
|
474
|
+
};
|
|
475
|
+
return finalizeEntry(entry, capped);
|
|
476
|
+
}
|
|
477
|
+
const reviewPrompt = buildReviewSystemPrompt();
|
|
478
|
+
const contextMsg = buildContextMessage(session, { slug: "/" });
|
|
479
|
+
const userMessage = `${contextMsg}\n\n---\n\nTicket to review:\n${instruction}`;
|
|
480
|
+
logger.info({ issueKey, session, provider, model, priorReviews }, "JIRA: running review pass (no tools)");
|
|
481
|
+
let rawOutput = "";
|
|
482
|
+
for await (const event of runAgentLoop({
|
|
483
|
+
apiKey,
|
|
484
|
+
provider,
|
|
485
|
+
model,
|
|
486
|
+
systemPrompt: reviewPrompt,
|
|
487
|
+
tools: [],
|
|
488
|
+
userMessage,
|
|
489
|
+
})) {
|
|
490
|
+
if (event.type === "done")
|
|
491
|
+
rawOutput = event.summary;
|
|
492
|
+
else if (event.type === "error")
|
|
493
|
+
throw new Error(event.message);
|
|
494
|
+
}
|
|
495
|
+
const decision = parseReviewDecision(rawOutput);
|
|
496
|
+
logger.info({ issueKey, decision: decision.decision, questions: decision.questions?.length ?? 0 }, "JIRA: review decision");
|
|
497
|
+
const comment = formatReviewComment(decision);
|
|
498
|
+
await client.addComment(issueKey, comment).catch((err) => {
|
|
499
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to post review comment");
|
|
500
|
+
});
|
|
501
|
+
const reviewResult = {
|
|
502
|
+
issueKey,
|
|
503
|
+
status: "success",
|
|
504
|
+
summary: decision.decision === "proceed" ? decision.plan.join("; ") : `questions: ${(decision.questions ?? []).length}`,
|
|
505
|
+
changes: [],
|
|
506
|
+
durationMs: Date.now() - startedAt,
|
|
507
|
+
modelUsed: model,
|
|
508
|
+
};
|
|
509
|
+
telemetry.durationMs = reviewResult.durationMs;
|
|
510
|
+
telemetry.reviewDecision = decision;
|
|
511
|
+
telemetry.summary = reviewResult.summary;
|
|
512
|
+
pushJiraTelemetry(telemetry);
|
|
513
|
+
return finalizeEntry(entry, reviewResult);
|
|
514
|
+
}
|
|
515
|
+
// -----------------------------------------------------------------------
|
|
516
|
+
// Mode: EXECUTE — run agent with tools, apply edits, transition to preview
|
|
517
|
+
// (or straight to done if autoPublish is on).
|
|
518
|
+
// -----------------------------------------------------------------------
|
|
519
|
+
const toolTrace = [];
|
|
520
|
+
const tools = instrumentToolsForTrace(createAgentTools(session), toolTrace, logger, issueKey);
|
|
521
|
+
const systemPrompt = buildAgentSystemPrompt();
|
|
522
|
+
const contextMsg = buildContextMessage(session, { slug: "/" });
|
|
523
|
+
const fullMessage = `${contextMsg}\n\n---\n\nUser request: ${instruction}`;
|
|
524
|
+
telemetry.toolCalls = toolTrace;
|
|
525
|
+
// Transition to executeStatus up-front so the board reflects reality
|
|
526
|
+
// while the agent works.
|
|
527
|
+
if (config.executeStatus) {
|
|
528
|
+
await client.transitionIssue(issueKey, config.executeStatus)
|
|
529
|
+
.then((ok) => { if (ok)
|
|
530
|
+
telemetry.transitions?.push(config.executeStatus); })
|
|
531
|
+
.catch((err) => {
|
|
532
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to transition to execute status");
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// 6. Run agent loop
|
|
536
|
+
logger.info({ issueKey, session, provider, model }, "JIRA: starting agent loop");
|
|
537
|
+
const changes = [];
|
|
538
|
+
const touchedSlugs = new Set();
|
|
539
|
+
let summary = "";
|
|
540
|
+
let toolCallCount = 0;
|
|
541
|
+
for await (const event of runAgentLoop({
|
|
542
|
+
apiKey,
|
|
543
|
+
provider,
|
|
544
|
+
model,
|
|
545
|
+
systemPrompt,
|
|
546
|
+
tools,
|
|
547
|
+
userMessage: fullMessage,
|
|
548
|
+
})) {
|
|
549
|
+
switch (event.type) {
|
|
550
|
+
case "tool_done":
|
|
551
|
+
if (!event.isError) {
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(event.result);
|
|
554
|
+
if (parsed.status === "applied") {
|
|
555
|
+
const desc = parsed.changeDescription ?? parsed.summary;
|
|
556
|
+
if (typeof desc === "string" && desc.length > 0) {
|
|
557
|
+
changes.push(desc);
|
|
558
|
+
}
|
|
559
|
+
if (Array.isArray(parsed.slugs)) {
|
|
560
|
+
for (const s of parsed.slugs) {
|
|
561
|
+
if (typeof s === "string" && s.length > 0)
|
|
562
|
+
touchedSlugs.add(s);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch { /* read-only tool result */ }
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
case "done":
|
|
571
|
+
summary = cleanAgentSummary(event.summary);
|
|
572
|
+
toolCallCount = event.toolCallCount;
|
|
573
|
+
break;
|
|
574
|
+
case "error":
|
|
575
|
+
throw new Error(event.message);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const durationMs = Date.now() - startedAt;
|
|
579
|
+
const touchedSlugList = Array.from(touchedSlugs);
|
|
580
|
+
logger.info({ issueKey, changes: changes.length, touchedSlugs: touchedSlugList, toolCallCount, durationMs }, "JIRA: agent completed");
|
|
581
|
+
// Persist per-ticket touched slugs so publish mode (a separate invocation)
|
|
582
|
+
// can list exactly what this ticket changed — instead of every page in the
|
|
583
|
+
// shared session.
|
|
584
|
+
if (touchedSlugList.length > 0) {
|
|
585
|
+
setIssueTouchedSlugs(issueKey, touchedSlugList);
|
|
586
|
+
schedulePersistState(logger);
|
|
587
|
+
}
|
|
588
|
+
// 7. Auto-publish if enabled (legacy single-stage flow)
|
|
589
|
+
let published = false;
|
|
590
|
+
if (config.autoPublish) {
|
|
591
|
+
try {
|
|
592
|
+
await triggerPublish(session, siteId, logger);
|
|
593
|
+
published = true;
|
|
594
|
+
logger.info({ issueKey }, "JIRA: auto-published");
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: auto-publish failed");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// 8. Post completion comment
|
|
601
|
+
const result = {
|
|
602
|
+
issueKey,
|
|
603
|
+
status: "success",
|
|
604
|
+
summary,
|
|
605
|
+
changes,
|
|
606
|
+
durationMs,
|
|
607
|
+
modelUsed: model,
|
|
608
|
+
published,
|
|
609
|
+
};
|
|
610
|
+
const comment = formatSuccessComment(result, {
|
|
611
|
+
orchestratorOrigin: orchestratorPublicOrigin,
|
|
612
|
+
sitePublicOrigin,
|
|
613
|
+
sessionName,
|
|
614
|
+
siteId,
|
|
615
|
+
touchedSlugs: Array.from(touchedSlugs),
|
|
616
|
+
toolCallCount,
|
|
617
|
+
});
|
|
618
|
+
await client.addComment(issueKey, comment).catch((err) => {
|
|
619
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to post success comment");
|
|
620
|
+
});
|
|
621
|
+
// 9. Assign ticket back to the requester
|
|
622
|
+
if (assignBackTo && (assignBackTo.accountId || assignBackTo.name)) {
|
|
623
|
+
await client.assignIssue(issueKey, {
|
|
624
|
+
accountId: assignBackTo.accountId,
|
|
625
|
+
name: assignBackTo.name,
|
|
626
|
+
}).then(() => {
|
|
627
|
+
logger.info({ issueKey, assignedTo: assignBackTo.displayName }, "JIRA: assigned back to requester");
|
|
628
|
+
}).catch((err) => {
|
|
629
|
+
logger.warn({ issueKey, error: err instanceof Error ? err.message : String(err) }, "JIRA: failed to assign back");
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// 10. Transition:
|
|
633
|
+
// - autoPublish=1: go straight to Done (published already).
|
|
634
|
+
// - otherwise: park in previewStatus so reporter can approve.
|
|
635
|
+
const targetStatus = published ? config.doneStatus : (config.previewStatus || config.doneStatus);
|
|
636
|
+
if (targetStatus) {
|
|
637
|
+
const transitioned = await client.transitionIssue(issueKey, targetStatus).catch(() => false);
|
|
638
|
+
if (transitioned) {
|
|
639
|
+
logger.info({ issueKey, status: targetStatus }, "JIRA: transitioned issue");
|
|
640
|
+
telemetry.transitions?.push(targetStatus);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
telemetry.status = "success";
|
|
644
|
+
telemetry.durationMs = result.durationMs;
|
|
645
|
+
telemetry.summary = summary;
|
|
646
|
+
telemetry.changes = changes;
|
|
647
|
+
telemetry.touchedSlugs = Array.from(touchedSlugs);
|
|
648
|
+
telemetry.toolCallCount = toolCallCount;
|
|
649
|
+
telemetry.published = published;
|
|
650
|
+
pushJiraTelemetry(telemetry);
|
|
651
|
+
return finalizeEntry(entry, result);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
const durationMs = Date.now() - startedAt;
|
|
655
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
656
|
+
logger.error({ issueKey, error: errorMsg, durationMs }, "JIRA: processing failed");
|
|
657
|
+
const result = {
|
|
658
|
+
issueKey,
|
|
659
|
+
status: "error",
|
|
660
|
+
summary: "",
|
|
661
|
+
changes: [],
|
|
662
|
+
durationMs,
|
|
663
|
+
error: errorMsg,
|
|
664
|
+
};
|
|
665
|
+
// Post failure comment
|
|
666
|
+
const comment = formatFailureComment(result);
|
|
667
|
+
await client.addComment(issueKey, comment).catch((commentErr) => {
|
|
668
|
+
logger.warn({ issueKey, error: commentErr instanceof Error ? commentErr.message : String(commentErr) }, "JIRA: failed to post error comment");
|
|
669
|
+
});
|
|
670
|
+
// Transition to failed status
|
|
671
|
+
if (config.failedStatus) {
|
|
672
|
+
await client.transitionIssue(issueKey, config.failedStatus).catch(() => { });
|
|
673
|
+
}
|
|
674
|
+
telemetry.status = "error";
|
|
675
|
+
telemetry.durationMs = durationMs;
|
|
676
|
+
telemetry.error = errorMsg;
|
|
677
|
+
pushJiraTelemetry(telemetry);
|
|
678
|
+
return finalizeEntry(entry, result);
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
// Clean up queue entry after a delay
|
|
682
|
+
setTimeout(() => processingQueue.delete(issueKey), 60_000);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// Publish trigger (internal)
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
async function triggerPublish(session, _siteId, logger) {
|
|
689
|
+
const { getSessionPages, setLastPublishedScopedSession } = await import("../state/session-state.js");
|
|
690
|
+
const { publishViaGit } = await import("../publish/publish-helpers.js");
|
|
691
|
+
const pages = getSessionPages(session);
|
|
692
|
+
if (pages.length === 0) {
|
|
693
|
+
logger.warn({ session }, "JIRA: no pages to publish");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
await publishViaGit(session);
|
|
697
|
+
setLastPublishedScopedSession(session);
|
|
698
|
+
}
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// Comment formatters
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
function formatSuccessComment(result, opts) {
|
|
703
|
+
void opts.orchestratorOrigin;
|
|
704
|
+
const { sitePublicOrigin, sessionName, siteId, touchedSlugs } = opts;
|
|
705
|
+
const lines = [
|
|
706
|
+
"**Draft updated. Ready for your review.**",
|
|
707
|
+
"",
|
|
708
|
+
];
|
|
709
|
+
if (result.changes.length > 0) {
|
|
710
|
+
lines.push("**Changes made:**");
|
|
711
|
+
for (const change of result.changes)
|
|
712
|
+
lines.push(`- ${change}`);
|
|
713
|
+
lines.push("");
|
|
714
|
+
}
|
|
715
|
+
if (touchedSlugs.length > 0) {
|
|
716
|
+
lines.push("**Preview:**");
|
|
717
|
+
for (const slug of touchedSlugs) {
|
|
718
|
+
const normalized = slug.startsWith("/") ? slug : `/${slug}`;
|
|
719
|
+
const pageLabel = normalized === "/" ? "Home" : normalized;
|
|
720
|
+
const url = `${sitePublicOrigin}${normalized}?session=${encodeURIComponent(sessionName)}&siteId=${encodeURIComponent(siteId)}&__editor=1`;
|
|
721
|
+
lines.push(`- [Open preview → ${pageLabel}](${url})`);
|
|
722
|
+
}
|
|
723
|
+
lines.push("");
|
|
724
|
+
}
|
|
725
|
+
if (result.summary) {
|
|
726
|
+
lines.push(`**Summary:** ${result.summary}`);
|
|
727
|
+
lines.push("");
|
|
728
|
+
}
|
|
729
|
+
lines.push(result.published
|
|
730
|
+
? "**Status:** Published to live site."
|
|
731
|
+
: "**Next step:** Reply `approved` / `publish` / `lgtm` to publish, or send a follow-up instruction for more changes.");
|
|
732
|
+
lines.push(`**AI Model:** ${result.modelUsed ?? "unknown"} | **Duration:** ${(result.durationMs / 1000).toFixed(1)}s | **Tool calls:** ${opts.toolCallCount}`);
|
|
733
|
+
return lines.join("\n");
|
|
734
|
+
}
|
|
735
|
+
function formatFailureComment(result) {
|
|
736
|
+
const lines = [
|
|
737
|
+
"**Website update failed for this ticket.**",
|
|
738
|
+
"",
|
|
739
|
+
`**Error:** ${result.error ?? "Unknown error"}`,
|
|
740
|
+
"",
|
|
741
|
+
"**Suggestion:** Please verify the instructions in the ticket description and retry, or check the orchestrator logs for details.",
|
|
742
|
+
];
|
|
743
|
+
return lines.join("\n");
|
|
744
|
+
}
|
|
745
|
+
function formatSiteClarificationComment(candidates) {
|
|
746
|
+
const lines = [
|
|
747
|
+
"**Which site should I update?**",
|
|
748
|
+
"",
|
|
749
|
+
"Multiple sites are configured and the ticket doesn't say which one to target. Please edit the ticket summary or description to include the site name or ID from this list, then re-trigger (change status again, or @mention me in a comment).",
|
|
750
|
+
"",
|
|
751
|
+
"**Available sites:**",
|
|
752
|
+
];
|
|
753
|
+
for (const c of candidates) {
|
|
754
|
+
const label = c.name ? `${c.name} (\`${c.id}\`)` : `\`${c.id}\``;
|
|
755
|
+
lines.push(`- ${label}`);
|
|
756
|
+
}
|
|
757
|
+
return lines.join("\n");
|
|
758
|
+
}
|
|
759
|
+
function formatReviewComment(decision) {
|
|
760
|
+
if (decision.decision === "questions") {
|
|
761
|
+
const lines = [
|
|
762
|
+
"**Review — I need a bit more info before I make changes.**",
|
|
763
|
+
"",
|
|
764
|
+
];
|
|
765
|
+
if (decision.plan.length > 0) {
|
|
766
|
+
lines.push("**What I think you want:**");
|
|
767
|
+
for (const item of decision.plan)
|
|
768
|
+
lines.push(`- ${item}`);
|
|
769
|
+
lines.push("");
|
|
770
|
+
}
|
|
771
|
+
lines.push("**Questions:**");
|
|
772
|
+
const questions = decision.questions && decision.questions.length > 0
|
|
773
|
+
? decision.questions
|
|
774
|
+
: ["Please clarify the request in a follow-up comment."];
|
|
775
|
+
for (const q of questions)
|
|
776
|
+
lines.push(`- ${q}`);
|
|
777
|
+
lines.push("");
|
|
778
|
+
lines.push("Reply with the answers and I'll re-review. Once everything's clear, reply `go` or move this ticket to In Progress and I'll apply the edits.");
|
|
779
|
+
return lines.join("\n");
|
|
780
|
+
}
|
|
781
|
+
// proceed
|
|
782
|
+
const lines = [
|
|
783
|
+
"**Review complete — ready to proceed.**",
|
|
784
|
+
"",
|
|
785
|
+
];
|
|
786
|
+
if (decision.plan.length > 0) {
|
|
787
|
+
lines.push("**Plan:**");
|
|
788
|
+
for (const item of decision.plan)
|
|
789
|
+
lines.push(`- ${item}`);
|
|
790
|
+
lines.push("");
|
|
791
|
+
}
|
|
792
|
+
lines.push("Reply `go` (or `proceed`, `lgtm`, `approved`, etc.) or move the ticket to In Progress and I'll apply the edits.");
|
|
793
|
+
return lines.join("\n");
|
|
794
|
+
}
|
|
795
|
+
function formatReviewCapComment(cap) {
|
|
796
|
+
return [
|
|
797
|
+
"**I've asked for clarification several times.**",
|
|
798
|
+
"",
|
|
799
|
+
`Review attempts exhausted (max ${cap}). Please consolidate the request into a single, concrete instruction in the ticket description, then re-trigger by moving the ticket back to To Do or @mentioning me.`,
|
|
800
|
+
].join("\n");
|
|
801
|
+
}
|
|
802
|
+
function formatPublishedComment(opts) {
|
|
803
|
+
const lines = [
|
|
804
|
+
"**Published. Changes are live.**",
|
|
805
|
+
"",
|
|
806
|
+
];
|
|
807
|
+
if (opts.slugs.length > 0) {
|
|
808
|
+
lines.push("**Pages updated by this ticket:**");
|
|
809
|
+
for (const slug of opts.slugs) {
|
|
810
|
+
const normalized = slug.startsWith("/") ? slug : `/${slug}`;
|
|
811
|
+
const pageLabel = normalized === "/" ? "Home" : normalized;
|
|
812
|
+
const url = `${opts.sitePublicOrigin}${normalized === "/" ? "" : normalized}`;
|
|
813
|
+
lines.push(`- [Open live → ${pageLabel}](${url})`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
lines.push(`[View site](${opts.sitePublicOrigin})`);
|
|
818
|
+
}
|
|
819
|
+
return lines.join("\n");
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* System prompt used for the review-only pass. The agent has no tools — it
|
|
823
|
+
* reads the ticket + site context and returns a JSON verdict.
|
|
824
|
+
*/
|
|
825
|
+
function buildReviewSystemPrompt() {
|
|
826
|
+
return [
|
|
827
|
+
"You are reviewing a Jira ticket that requests changes to a website. You are in REVIEW MODE:",
|
|
828
|
+
"- You have NO tools in this pass. Do not attempt to call any tools.",
|
|
829
|
+
"- You will NOT be applying changes in this pass — a follow-up execution pass will do that after the reporter confirms.",
|
|
830
|
+
"- Your job: decide whether the ticket has enough information to proceed safely, and if so, outline the plan.",
|
|
831
|
+
"",
|
|
832
|
+
"## What the execute pass CAN do",
|
|
833
|
+
"You are not limited to what you can do here. The execute pass has the full editor toolset — don't claim capabilities are missing. In particular:",
|
|
834
|
+
"- Edit any block's text, props, or items (Hero, CTA, FeatureGrid, FAQ, etc.), add / remove / reorder blocks, create / duplicate / delete pages.",
|
|
835
|
+
"- Update site settings (name, logo, navigation labels, dropdown groups) and page SEO metadata.",
|
|
836
|
+
"- **Search Unsplash for stock photos** (`unsplash_search`) and pick a match — so if the reporter asks for a \"tropical hero image\", you DO NOT need to ask them for a URL.",
|
|
837
|
+
"- **Resolve Unsplash photo-page URLs** (`unsplash_get_by_id`) — if the reporter pastes `https://unsplash.com/photos/slug-PHOTOID`, the execute pass will resolve it to a direct image URL automatically. Do not ask for a \"direct image URL\" when a photo-page URL was given.",
|
|
838
|
+
"- **Generate AI images** (`image_generate`) via DALL-E / Gemini when no stock photo fits or the reporter wants something specific.",
|
|
839
|
+
"",
|
|
840
|
+
"Only ask clarifying questions about genuinely ambiguous *content* (which page, which section, what copy, what link target, how many items). Never ask about a capability you actually have.",
|
|
841
|
+
"",
|
|
842
|
+
"Respond with ONLY a JSON object (no prose, no code fences, no markdown) in exactly this shape:",
|
|
843
|
+
'{',
|
|
844
|
+
' "decision": "proceed" | "questions",',
|
|
845
|
+
' "plan": ["short bullet describing one concrete step", "…"],',
|
|
846
|
+
' "questions": ["short, specific question", "..."]',
|
|
847
|
+
'}',
|
|
848
|
+
"",
|
|
849
|
+
"Rules:",
|
|
850
|
+
'- Use "proceed" when the task is concrete enough to act on.',
|
|
851
|
+
'- Use "questions" when something essential is missing or ambiguous (target page, copy, link targets, counts, etc.).',
|
|
852
|
+
'- Always include "plan" — even with "questions" it should describe your best interpretation as an array of short bullets.',
|
|
853
|
+
'- Each plan item is ONE concrete step (e.g. "Update b_hero_home heading to …"). Keep 2-5 items. No run-on sentences.',
|
|
854
|
+
"- Keep questions to at most 3. Prefer clarity over completeness.",
|
|
855
|
+
"- Do NOT ask about anything you can reasonably infer from the site context below.",
|
|
856
|
+
"- Do NOT ask the reporter to provide an image URL just because a theme was requested — the execute pass will search Unsplash. Only ask about images when the request is genuinely ambiguous (e.g. \"use the brand mascot\" with no asset reference on file).",
|
|
857
|
+
].join("\n");
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Extract the review JSON from raw agent output. Tolerant of: leading/trailing
|
|
861
|
+
* prose, ```json fences, and stray <thinking> tags.
|
|
862
|
+
*/
|
|
863
|
+
export function parseReviewDecision(raw) {
|
|
864
|
+
const cleaned = cleanAgentSummary(raw);
|
|
865
|
+
const obj = tryParseJsonBlob(cleaned);
|
|
866
|
+
if (obj && typeof obj === "object") {
|
|
867
|
+
const rec = obj;
|
|
868
|
+
const decision = rec.decision === "questions" ? "questions" : "proceed";
|
|
869
|
+
const plan = normalizePlanItems(rec.plan);
|
|
870
|
+
const questions = Array.isArray(rec.questions)
|
|
871
|
+
? rec.questions.filter((q) => typeof q === "string" && q.trim().length > 0)
|
|
872
|
+
: [];
|
|
873
|
+
return decision === "questions"
|
|
874
|
+
? { decision, plan, questions }
|
|
875
|
+
: { decision, plan };
|
|
876
|
+
}
|
|
877
|
+
// Couldn't parse — fall back to "proceed" with the raw text as the plan so the
|
|
878
|
+
// reporter at least sees what the agent thought. Better than dropping info.
|
|
879
|
+
const fallback = cleaned || "Could not parse review output — proceeding with best effort.";
|
|
880
|
+
return { decision: "proceed", plan: normalizePlanItems(fallback) };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Normalize whatever the LLM returned for `plan` into an array of short bullet
|
|
884
|
+
* strings. Accepts an array (preferred), a string (split on sentence/newline
|
|
885
|
+
* boundaries for back-compat), or anything else (returns []).
|
|
886
|
+
*/
|
|
887
|
+
function normalizePlanItems(raw) {
|
|
888
|
+
if (Array.isArray(raw)) {
|
|
889
|
+
return raw
|
|
890
|
+
.filter((item) => typeof item === "string")
|
|
891
|
+
.map((item) => item.trim())
|
|
892
|
+
.filter((item) => item.length > 0);
|
|
893
|
+
}
|
|
894
|
+
if (typeof raw === "string") {
|
|
895
|
+
const trimmed = raw.trim();
|
|
896
|
+
if (!trimmed)
|
|
897
|
+
return [];
|
|
898
|
+
// Prefer newline splits, otherwise split on sentence boundaries (". ").
|
|
899
|
+
const lineParts = trimmed.split(/\n+/).map((s) => s.trim()).filter(Boolean);
|
|
900
|
+
const parts = lineParts.length > 1
|
|
901
|
+
? lineParts
|
|
902
|
+
: trimmed.split(/(?<=[.!?])\s+(?=[A-Z0-9])/).map((s) => s.trim()).filter(Boolean);
|
|
903
|
+
return parts.map((s) => s.replace(/^[-*•]\s+/, "").trim()).filter(Boolean);
|
|
904
|
+
}
|
|
905
|
+
return [];
|
|
906
|
+
}
|
|
907
|
+
function tryParseJsonBlob(text) {
|
|
908
|
+
if (!text)
|
|
909
|
+
return null;
|
|
910
|
+
// Try direct parse first
|
|
911
|
+
try {
|
|
912
|
+
return JSON.parse(text);
|
|
913
|
+
}
|
|
914
|
+
catch { /* fall through */ }
|
|
915
|
+
// Strip ```json ... ``` fences
|
|
916
|
+
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
917
|
+
if (fenceMatch) {
|
|
918
|
+
try {
|
|
919
|
+
return JSON.parse(fenceMatch[1]);
|
|
920
|
+
}
|
|
921
|
+
catch { /* fall through */ }
|
|
922
|
+
}
|
|
923
|
+
// Greedy extract: first "{" to last "}"
|
|
924
|
+
const first = text.indexOf("{");
|
|
925
|
+
const last = text.lastIndexOf("}");
|
|
926
|
+
if (first !== -1 && last > first) {
|
|
927
|
+
try {
|
|
928
|
+
return JSON.parse(text.slice(first, last + 1));
|
|
929
|
+
}
|
|
930
|
+
catch { /* fall through */ }
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Prefixes every agent-written comment begins with. We match on these rather
|
|
936
|
+
* than `**…**` bold markup because Jira Cloud stores comments as ADF — when we
|
|
937
|
+
* read them back, `adfToPlainText` strips the bold markers. The same prefix
|
|
938
|
+
* check therefore works on both freshly-posted markdown and ADF-round-tripped
|
|
939
|
+
* plain text.
|
|
940
|
+
*/
|
|
941
|
+
const AGENT_COMMENT_PREFIXES = {
|
|
942
|
+
review: [
|
|
943
|
+
"Review — I need",
|
|
944
|
+
"Review complete — ready to proceed",
|
|
945
|
+
"I've asked for clarification several times",
|
|
946
|
+
],
|
|
947
|
+
other: [
|
|
948
|
+
"Draft updated. Ready for your review",
|
|
949
|
+
"Published. Changes are live",
|
|
950
|
+
"Website update failed for this ticket",
|
|
951
|
+
"Which site should I update",
|
|
952
|
+
],
|
|
953
|
+
};
|
|
954
|
+
const LEGACY_REVIEW_MARKER_RE = /<!--\s*site-editor:(?:review|proceed)\s*-->/;
|
|
955
|
+
const LEGACY_AGENT_MARKER_RE = /<!--\s*site-editor:(?:review|proceed|executed|published)\s*-->/;
|
|
956
|
+
function bodyStartsWithAny(body, prefixes) {
|
|
957
|
+
// Strip leading whitespace and any lingering `**` bold markers so markdown
|
|
958
|
+
// and ADF-plain-text bodies match the same prefix list.
|
|
959
|
+
const stripped = body.replace(/^[\s*]+/, "");
|
|
960
|
+
return prefixes.some((p) => stripped.startsWith(p));
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Count how many prior review-mode comments the agent has posted on this issue,
|
|
964
|
+
* so we can cap the number of review passes per ticket.
|
|
965
|
+
*/
|
|
966
|
+
export function countAgentReviewComments(issue, agentAccountId) {
|
|
967
|
+
const comments = issue.fields.comment?.comments ?? [];
|
|
968
|
+
let count = 0;
|
|
969
|
+
for (const c of comments) {
|
|
970
|
+
if (agentAccountId && c.author?.accountId !== agentAccountId)
|
|
971
|
+
continue;
|
|
972
|
+
const body = typeof c.body === "string" ? c.body : adfToPlainText(c.body);
|
|
973
|
+
if (bodyStartsWithAny(body, AGENT_COMMENT_PREFIXES.review) || LEGACY_REVIEW_MARKER_RE.test(body))
|
|
974
|
+
count++;
|
|
975
|
+
}
|
|
976
|
+
return count;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Decide whether a comment was authored by our agent.
|
|
980
|
+
*
|
|
981
|
+
* Looks at both the author's accountId AND the body shape. This matters because
|
|
982
|
+
* in solo/dev Jira tenants the reporter and agent share a single human's
|
|
983
|
+
* accountId — filtering by accountId alone would discard every reporter reply
|
|
984
|
+
* as a self-loop and the workflow would stall.
|
|
985
|
+
*/
|
|
986
|
+
export function isAgentAuthoredComment(body, authorAccountId, agentAccountId) {
|
|
987
|
+
if (!agentAccountId)
|
|
988
|
+
return false;
|
|
989
|
+
if (authorAccountId !== agentAccountId)
|
|
990
|
+
return false;
|
|
991
|
+
const allPrefixes = [...AGENT_COMMENT_PREFIXES.review, ...AGENT_COMMENT_PREFIXES.other];
|
|
992
|
+
return bodyStartsWithAny(body, allPrefixes) || LEGACY_AGENT_MARKER_RE.test(body);
|
|
993
|
+
}
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
// Queue entry finalization — one place to update state for all mode outcomes
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
function finalizeEntry(entry, result) {
|
|
998
|
+
entry.state = result.status === "success" ? "done" : "error";
|
|
999
|
+
entry.completedAt = Date.now();
|
|
1000
|
+
entry.result = result;
|
|
1001
|
+
recentResults.unshift(result);
|
|
1002
|
+
if (recentResults.length > MAX_RECENT)
|
|
1003
|
+
recentResults.pop();
|
|
1004
|
+
return result;
|
|
1005
|
+
}
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
// Summary cleanup + site resolver
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
/**
|
|
1010
|
+
* Strip internal reasoning artifacts from an agent summary before posting to Jira.
|
|
1011
|
+
* Claude occasionally emits <thinking>…</thinking> and similar XML-style reasoning
|
|
1012
|
+
* tags as plain text, plus interstitial tool-calling narration accumulates across
|
|
1013
|
+
* turns. Drop the noise, collapse whitespace.
|
|
1014
|
+
*/
|
|
1015
|
+
export function cleanAgentSummary(raw) {
|
|
1016
|
+
if (!raw)
|
|
1017
|
+
return "";
|
|
1018
|
+
return raw
|
|
1019
|
+
// Strip paired thinking/scratchpad blocks with their contents
|
|
1020
|
+
.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, "")
|
|
1021
|
+
.replace(/<scratchpad\b[^>]*>[\s\S]*?<\/scratchpad>/gi, "")
|
|
1022
|
+
// Strip any leftover open/close tags (unpaired — e.g. truncated responses)
|
|
1023
|
+
.replace(/<\/?thinking\b[^>]*>/gi, "")
|
|
1024
|
+
.replace(/<\/?scratchpad\b[^>]*>/gi, "")
|
|
1025
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
1026
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1027
|
+
.trim();
|
|
1028
|
+
}
|
|
1029
|
+
function escapeRegex(value) {
|
|
1030
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Decide which site a ticket targets.
|
|
1034
|
+
*
|
|
1035
|
+
* Priority:
|
|
1036
|
+
* 1. Ticket text (summary + description + reporter comments) uniquely names a
|
|
1037
|
+
* registered site → use it. Reporter comments are included so that a reply
|
|
1038
|
+
* to the "Which site?" clarification comment ("use avocado-stories") is
|
|
1039
|
+
* honoured instead of looping the same question.
|
|
1040
|
+
* 2. Multiple registered sites → ambiguous, ask the reporter.
|
|
1041
|
+
* (JIRA_SITE_ID is intentionally NOT consulted here — it's a fallback, not a lock,
|
|
1042
|
+
* so adding a second site doesn't silently keep editing the first one.)
|
|
1043
|
+
* 3. Exactly one registered site → use it.
|
|
1044
|
+
* 4. No registered sites → use `config.siteId` (JIRA_SITE_ID env or built-in default).
|
|
1045
|
+
*/
|
|
1046
|
+
export function resolveSiteForTicket(issue, config) {
|
|
1047
|
+
const registered = listSitesForSession(config.session);
|
|
1048
|
+
const summary = issue.fields.summary ?? "";
|
|
1049
|
+
const description = typeof issue.fields.description === "string"
|
|
1050
|
+
? issue.fields.description
|
|
1051
|
+
: adfToPlainText(issue.fields.description);
|
|
1052
|
+
// Include reporter-authored comments so clarification replies ("use
|
|
1053
|
+
// avocado-stories") count as a text match. Agent comments are excluded —
|
|
1054
|
+
// the clarification comment lists every candidate site id, which would
|
|
1055
|
+
// match everything and permanently jam the ticket as "ambiguous".
|
|
1056
|
+
const reporterComments = (issue.fields.comment?.comments ?? [])
|
|
1057
|
+
.filter((c) => {
|
|
1058
|
+
const body = typeof c.body === "string" ? c.body : adfToPlainText(c.body);
|
|
1059
|
+
return !isAgentAuthoredComment(body.trim(), c.author?.accountId, config.agentAccountId);
|
|
1060
|
+
})
|
|
1061
|
+
.map((c) => typeof c.body === "string" ? c.body : adfToPlainText(c.body))
|
|
1062
|
+
.join("\n");
|
|
1063
|
+
const text = `${summary}\n${description}\n${reporterComments}`;
|
|
1064
|
+
const matches = registered.filter((s) => {
|
|
1065
|
+
const idRe = new RegExp(`\\b${escapeRegex(s.id)}\\b`, "i");
|
|
1066
|
+
if (idRe.test(text))
|
|
1067
|
+
return true;
|
|
1068
|
+
if (s.name && s.name.trim()) {
|
|
1069
|
+
const nameRe = new RegExp(`\\b${escapeRegex(s.name)}\\b`, "i");
|
|
1070
|
+
if (nameRe.test(text))
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
return false;
|
|
1074
|
+
});
|
|
1075
|
+
if (matches.length === 1)
|
|
1076
|
+
return { siteId: matches[0].id, via: "text-match" };
|
|
1077
|
+
if (registered.length > 1) {
|
|
1078
|
+
const candidates = matches.length > 1 ? matches : registered;
|
|
1079
|
+
return { ambiguous: true, candidates: candidates.map((c) => ({ id: c.id, name: c.name })) };
|
|
1080
|
+
}
|
|
1081
|
+
if (registered.length === 1)
|
|
1082
|
+
return { siteId: registered[0].id, via: "single-site" };
|
|
1083
|
+
const envExplicit = typeof process.env.JIRA_SITE_ID === "string" && process.env.JIRA_SITE_ID.trim() !== "";
|
|
1084
|
+
return { siteId: config.siteId, via: envExplicit ? "env-fallback" : "default-fallback" };
|
|
1085
|
+
}
|