@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,4491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kanban-adapter.mjs — Unified Kanban Board Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a common interface over multiple task-tracking backends:
|
|
5
|
+
* - Internal Store — default, source-of-truth local kanban
|
|
6
|
+
* - Vibe-Kanban (VK) — optional external adapter
|
|
7
|
+
* - GitHub Issues — native GitHub integration with shared state persistence
|
|
8
|
+
* - Jira — enterprise project management via Jira REST v3
|
|
9
|
+
*
|
|
10
|
+
* This module handles TASK LIFECYCLE (tracking, status, metadata) only.
|
|
11
|
+
* Code execution is handled separately by agent-pool.mjs.
|
|
12
|
+
*
|
|
13
|
+
* Configuration:
|
|
14
|
+
* - `KANBAN_BACKEND` env var: "internal" | "vk" | "github" | "jira" (default: "internal")
|
|
15
|
+
* - `openfleet.config.json` → `kanban.backend` field
|
|
16
|
+
*
|
|
17
|
+
* EXPORTS:
|
|
18
|
+
* getKanbanAdapter() → Returns the configured adapter instance
|
|
19
|
+
* setKanbanBackend(name) → Switch backend at runtime
|
|
20
|
+
* getAvailableBackends() → List available backends
|
|
21
|
+
* getKanbanBackendName() → Get active backend name
|
|
22
|
+
* listProjects() → Convenience: adapter.listProjects()
|
|
23
|
+
* listTasks(projectId, f?) → Convenience: adapter.listTasks()
|
|
24
|
+
* getTask(taskId) → Convenience: adapter.getTask()
|
|
25
|
+
* updateTaskStatus(id, s, opts?) → Convenience: adapter.updateTaskStatus()
|
|
26
|
+
* createTask(projId, data) → Convenience: adapter.createTask()
|
|
27
|
+
* deleteTask(taskId) → Convenience: adapter.deleteTask()
|
|
28
|
+
* addComment(taskId, body) → Convenience: adapter.addComment()
|
|
29
|
+
* persistSharedStateToIssue(id, state) → GitHub/Jira: persist agent state to issue
|
|
30
|
+
* readSharedStateFromIssue(id) → GitHub/Jira: read agent state from issue
|
|
31
|
+
* markTaskIgnored(id, reason) → GitHub/Jira: mark task as ignored
|
|
32
|
+
*
|
|
33
|
+
* Each adapter implements the KanbanAdapter interface:
|
|
34
|
+
* - listTasks(projectId, filters?) → Task[]
|
|
35
|
+
* - getTask(taskId) → Task
|
|
36
|
+
* - updateTaskStatus(taskId, status, opts?)→ Task
|
|
37
|
+
* - createTask(projectId, task) → Task
|
|
38
|
+
* - deleteTask(taskId) → boolean
|
|
39
|
+
* - listProjects() → Project[]
|
|
40
|
+
* - addComment(taskId, body) → boolean
|
|
41
|
+
*
|
|
42
|
+
* GitHub adapter implements shared state methods:
|
|
43
|
+
* - persistSharedStateToIssue(num, state) → boolean
|
|
44
|
+
* - readSharedStateFromIssue(num) → SharedState|null
|
|
45
|
+
* - markTaskIgnored(num, reason) → boolean
|
|
46
|
+
*
|
|
47
|
+
* Jira adapter shared state methods:
|
|
48
|
+
* - persistSharedStateToIssue(key, state) → boolean
|
|
49
|
+
* - readSharedStateFromIssue(key) → SharedState|null
|
|
50
|
+
* - markTaskIgnored(key, reason) → boolean
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { loadConfig } from "./config.mjs";
|
|
54
|
+
import { fetchWithFallback } from "./fetch-runtime.mjs";
|
|
55
|
+
import {
|
|
56
|
+
getAllTasks as getInternalTasks,
|
|
57
|
+
getTask as getInternalTask,
|
|
58
|
+
addTask as addInternalTask,
|
|
59
|
+
setTaskStatus as setInternalTaskStatus,
|
|
60
|
+
removeTask as removeInternalTask,
|
|
61
|
+
updateTask as patchInternalTask,
|
|
62
|
+
} from "./task-store.mjs";
|
|
63
|
+
import { randomUUID } from "node:crypto";
|
|
64
|
+
|
|
65
|
+
const TAG = "[kanban]";
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Normalised Task & Project Types
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {Object} KanbanTask
|
|
73
|
+
* @property {string} id Unique task identifier.
|
|
74
|
+
* @property {string} title Task title/summary.
|
|
75
|
+
* @property {string} description Full task description/body.
|
|
76
|
+
* @property {string} status Normalised status: "todo"|"inprogress"|"inreview"|"done"|"cancelled".
|
|
77
|
+
* @property {string|null} assignee Assigned user/agent.
|
|
78
|
+
* @property {string|null} priority "low"|"medium"|"high"|"critical".
|
|
79
|
+
* @property {string|null} projectId Parent project identifier.
|
|
80
|
+
* @property {string|null} baseBranch Base/epic branch for PRs.
|
|
81
|
+
* @property {string|null} branchName Associated git branch.
|
|
82
|
+
* @property {string|null} prNumber Associated PR number.
|
|
83
|
+
* @property {object} meta Backend-specific metadata.
|
|
84
|
+
* @property {string} backend Which backend this came from.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {Object} KanbanProject
|
|
89
|
+
* @property {string} id Unique project identifier.
|
|
90
|
+
* @property {string} name Project name.
|
|
91
|
+
* @property {object} meta Backend-specific metadata.
|
|
92
|
+
* @property {string} backend Which backend.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Status Normalisation
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** Map from various backend status strings to our canonical set */
|
|
100
|
+
const STATUS_MAP = {
|
|
101
|
+
// VK statuses
|
|
102
|
+
todo: "todo",
|
|
103
|
+
draft: "draft",
|
|
104
|
+
inprogress: "inprogress",
|
|
105
|
+
started: "inprogress",
|
|
106
|
+
"in-progress": "inprogress",
|
|
107
|
+
in_progress: "inprogress",
|
|
108
|
+
inreview: "inreview",
|
|
109
|
+
"in-review": "inreview",
|
|
110
|
+
in_review: "inreview",
|
|
111
|
+
"in review": "inreview",
|
|
112
|
+
blocked: "blocked",
|
|
113
|
+
done: "done",
|
|
114
|
+
cancelled: "cancelled",
|
|
115
|
+
canceled: "cancelled",
|
|
116
|
+
backlog: "todo",
|
|
117
|
+
// GitHub Issues
|
|
118
|
+
open: "todo",
|
|
119
|
+
closed: "done",
|
|
120
|
+
// Jira-style
|
|
121
|
+
"to do": "todo",
|
|
122
|
+
"in progress": "inprogress",
|
|
123
|
+
review: "inreview",
|
|
124
|
+
resolved: "done",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function normaliseStatus(raw) {
|
|
128
|
+
if (!raw) return "todo";
|
|
129
|
+
const key = String(raw).toLowerCase().trim();
|
|
130
|
+
return STATUS_MAP[key] || "todo";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const STATUS_LABEL_KEYS = new Set([
|
|
134
|
+
"draft",
|
|
135
|
+
"todo",
|
|
136
|
+
"backlog",
|
|
137
|
+
"inprogress",
|
|
138
|
+
"started",
|
|
139
|
+
"in-progress",
|
|
140
|
+
"in_progress",
|
|
141
|
+
"inreview",
|
|
142
|
+
"in-review",
|
|
143
|
+
"in_review",
|
|
144
|
+
"blocked",
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const PRIORITY_LABEL_KEYS = new Set([
|
|
148
|
+
"critical",
|
|
149
|
+
"high",
|
|
150
|
+
"medium",
|
|
151
|
+
"low",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const CODEX_LABEL_KEYS = new Set([
|
|
155
|
+
"codex:ignore",
|
|
156
|
+
"codex:claimed",
|
|
157
|
+
"codex:working",
|
|
158
|
+
"codex:stale",
|
|
159
|
+
"openfleet",
|
|
160
|
+
"codex-mointor",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const SYSTEM_LABEL_KEYS = new Set([
|
|
164
|
+
...STATUS_LABEL_KEYS,
|
|
165
|
+
...PRIORITY_LABEL_KEYS,
|
|
166
|
+
...CODEX_LABEL_KEYS,
|
|
167
|
+
"done",
|
|
168
|
+
"closed",
|
|
169
|
+
"cancelled",
|
|
170
|
+
"canceled",
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
function statusFromLabels(labels) {
|
|
174
|
+
if (!Array.isArray(labels)) return null;
|
|
175
|
+
for (const label of labels) {
|
|
176
|
+
const key = String(label || "")
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase();
|
|
179
|
+
if (STATUS_LABEL_KEYS.has(key)) {
|
|
180
|
+
return normaliseStatus(key);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeSharedStatePayload(sharedState) {
|
|
187
|
+
if (!sharedState || typeof sharedState !== "object") return null;
|
|
188
|
+
const normalized = { ...sharedState };
|
|
189
|
+
if (!normalized.ownerId && normalized.owner_id) {
|
|
190
|
+
normalized.ownerId = normalized.owner_id;
|
|
191
|
+
}
|
|
192
|
+
if (!normalized.attemptToken && normalized.attempt_token) {
|
|
193
|
+
normalized.attemptToken = normalized.attempt_token;
|
|
194
|
+
}
|
|
195
|
+
if (!normalized.attemptStarted && normalized.attempt_started) {
|
|
196
|
+
normalized.attemptStarted = normalized.attempt_started;
|
|
197
|
+
}
|
|
198
|
+
if (!normalized.heartbeat && normalized.ownerHeartbeat) {
|
|
199
|
+
normalized.heartbeat = normalized.ownerHeartbeat;
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
normalized.retryCount == null &&
|
|
203
|
+
normalized.retry_count != null &&
|
|
204
|
+
Number.isFinite(Number(normalized.retry_count))
|
|
205
|
+
) {
|
|
206
|
+
normalized.retryCount = Number(normalized.retry_count);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
!normalized.status &&
|
|
210
|
+
normalized.attemptStatus &&
|
|
211
|
+
["claimed", "working", "stale"].includes(normalized.attemptStatus)
|
|
212
|
+
) {
|
|
213
|
+
normalized.status = normalized.attemptStatus;
|
|
214
|
+
}
|
|
215
|
+
return normalized;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Configurable mapping from internal statuses to GitHub Project v2 status names.
|
|
220
|
+
* Override via GITHUB_PROJECT_STATUS_* env vars.
|
|
221
|
+
*/
|
|
222
|
+
const PROJECT_STATUS_MAP = {
|
|
223
|
+
todo: process.env.GITHUB_PROJECT_STATUS_TODO || "Todo",
|
|
224
|
+
inprogress: process.env.GITHUB_PROJECT_STATUS_INPROGRESS || "In Progress",
|
|
225
|
+
inreview: process.env.GITHUB_PROJECT_STATUS_INREVIEW || "In Review",
|
|
226
|
+
done: process.env.GITHUB_PROJECT_STATUS_DONE || "Done",
|
|
227
|
+
cancelled: process.env.GITHUB_PROJECT_STATUS_CANCELLED || "Cancelled",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function parseBooleanEnv(value, fallback = false) {
|
|
231
|
+
if (value == null || value === "") return fallback;
|
|
232
|
+
const key = String(value).trim().toLowerCase();
|
|
233
|
+
if (["1", "true", "yes", "on"].includes(key)) return true;
|
|
234
|
+
if (["0", "false", "no", "off"].includes(key)) return false;
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseRepoSlug(raw) {
|
|
239
|
+
const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
|
240
|
+
if (!text) return null;
|
|
241
|
+
const cleaned = text.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
242
|
+
const [owner, repo] = cleaned.split("/", 2);
|
|
243
|
+
if (!owner || !repo) return null;
|
|
244
|
+
return { owner, repo };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractBranchFromText(text) {
|
|
248
|
+
const raw = String(text || "");
|
|
249
|
+
if (!raw) return null;
|
|
250
|
+
const tableMatch = raw.match(/\|\s*\*\*Branch\*\*\s*\|\s*`?([^`|\s]+)`?\s*\|/i);
|
|
251
|
+
if (tableMatch?.[1]) return tableMatch[1];
|
|
252
|
+
const inlineMatch = raw.match(/branch:\s*`?([^\s`]+)`?/i);
|
|
253
|
+
if (inlineMatch?.[1]) return inlineMatch[1];
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function extractPrFromText(text) {
|
|
258
|
+
const raw = String(text || "");
|
|
259
|
+
if (!raw) return null;
|
|
260
|
+
const tableMatch = raw.match(/\|\s*\*\*PR\*\*\s*\|\s*#?(\d+)/i);
|
|
261
|
+
if (tableMatch?.[1]) return tableMatch[1];
|
|
262
|
+
const inlineMatch = raw.match(/pr:\s*#?(\d+)/i);
|
|
263
|
+
if (inlineMatch?.[1]) return inlineMatch[1];
|
|
264
|
+
const urlMatch = raw.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i);
|
|
265
|
+
if (urlMatch?.[1]) return urlMatch[1];
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
class InternalAdapter {
|
|
270
|
+
constructor() {
|
|
271
|
+
this.name = "internal";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_normalizeTask(task) {
|
|
275
|
+
if (!task) return null;
|
|
276
|
+
const tags = normalizeTags(task.tags || task.meta?.tags || []);
|
|
277
|
+
const draft = Boolean(task.draft || task.meta?.draft || task.status === "draft");
|
|
278
|
+
const labelBag = []
|
|
279
|
+
.concat(Array.isArray(task.labels) ? task.labels : [])
|
|
280
|
+
.concat(Array.isArray(task.tags) ? task.tags : [])
|
|
281
|
+
.concat(Array.isArray(task.meta?.labels) ? task.meta.labels : [])
|
|
282
|
+
.concat(Array.isArray(task.meta?.tags) ? task.meta.tags : []);
|
|
283
|
+
const baseBranch = normalizeBranchName(
|
|
284
|
+
task.baseBranch ||
|
|
285
|
+
task.base_branch ||
|
|
286
|
+
task.meta?.baseBranch ||
|
|
287
|
+
task.meta?.base_branch ||
|
|
288
|
+
extractBaseBranchFromLabels(labelBag) ||
|
|
289
|
+
extractBaseBranchFromText(task.description || task.body || ""),
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
id: String(task.id || ""),
|
|
293
|
+
title: task.title || "",
|
|
294
|
+
description: task.description || "",
|
|
295
|
+
status: normaliseStatus(task.status),
|
|
296
|
+
assignee: task.assignee || null,
|
|
297
|
+
priority: task.priority || null,
|
|
298
|
+
tags,
|
|
299
|
+
draft,
|
|
300
|
+
projectId: task.projectId || "internal",
|
|
301
|
+
baseBranch,
|
|
302
|
+
branchName: task.branchName || null,
|
|
303
|
+
prNumber: task.prNumber || null,
|
|
304
|
+
prUrl: task.prUrl || null,
|
|
305
|
+
taskUrl: task.taskUrl || null,
|
|
306
|
+
createdAt: task.createdAt || null,
|
|
307
|
+
updatedAt: task.updatedAt || null,
|
|
308
|
+
backend: "internal",
|
|
309
|
+
meta: task.meta || {},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async listProjects() {
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
id: "internal",
|
|
317
|
+
name: "Internal Task Store",
|
|
318
|
+
backend: "internal",
|
|
319
|
+
meta: {},
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async listTasks(projectId, filters = {}) {
|
|
325
|
+
const statusFilter = filters?.status ? normaliseStatus(filters.status) : null;
|
|
326
|
+
const limit = Number(filters?.limit || 0);
|
|
327
|
+
const normalizedProjectId = String(projectId || "internal").trim().toLowerCase();
|
|
328
|
+
|
|
329
|
+
let tasks = getInternalTasks().map((task) => this._normalizeTask(task));
|
|
330
|
+
if (normalizedProjectId && normalizedProjectId !== "internal") {
|
|
331
|
+
tasks = tasks.filter(
|
|
332
|
+
(task) =>
|
|
333
|
+
String(task.projectId || "internal").trim().toLowerCase() ===
|
|
334
|
+
normalizedProjectId,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (statusFilter) {
|
|
338
|
+
tasks = tasks.filter((task) => normaliseStatus(task.status) === statusFilter);
|
|
339
|
+
}
|
|
340
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
341
|
+
tasks = tasks.slice(0, limit);
|
|
342
|
+
}
|
|
343
|
+
return tasks;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async getTask(taskId) {
|
|
347
|
+
return this._normalizeTask(getInternalTask(String(taskId || "")));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async updateTaskStatus(taskId, status) {
|
|
351
|
+
const normalizedId = String(taskId || "").trim();
|
|
352
|
+
if (!normalizedId) {
|
|
353
|
+
throw new Error("[kanban] internal updateTaskStatus requires taskId");
|
|
354
|
+
}
|
|
355
|
+
const normalizedStatus = normaliseStatus(status);
|
|
356
|
+
const updated = setInternalTaskStatus(
|
|
357
|
+
normalizedId,
|
|
358
|
+
normalizedStatus,
|
|
359
|
+
"orchestrator",
|
|
360
|
+
);
|
|
361
|
+
if (!updated) {
|
|
362
|
+
throw new Error(`[kanban] internal task not found: ${normalizedId}`);
|
|
363
|
+
}
|
|
364
|
+
return this._normalizeTask(updated);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async updateTask(taskId, patch = {}) {
|
|
368
|
+
const normalizedId = String(taskId || "").trim();
|
|
369
|
+
if (!normalizedId) {
|
|
370
|
+
throw new Error("[kanban] internal updateTask requires taskId");
|
|
371
|
+
}
|
|
372
|
+
const updates = {};
|
|
373
|
+
const baseBranch = resolveBaseBranchInput(patch);
|
|
374
|
+
if (typeof patch.title === "string") updates.title = patch.title;
|
|
375
|
+
if (typeof patch.description === "string") updates.description = patch.description;
|
|
376
|
+
if (typeof patch.status === "string" && patch.status.trim()) {
|
|
377
|
+
updates.status = normaliseStatus(patch.status);
|
|
378
|
+
}
|
|
379
|
+
if (typeof patch.priority === "string") updates.priority = patch.priority;
|
|
380
|
+
if (Array.isArray(patch.tags) || Array.isArray(patch.labels) || typeof patch.tags === "string") {
|
|
381
|
+
updates.tags = normalizeTags(patch.tags ?? patch.labels);
|
|
382
|
+
}
|
|
383
|
+
if (typeof patch.draft === "boolean") {
|
|
384
|
+
updates.draft = patch.draft;
|
|
385
|
+
if (!patch.status) {
|
|
386
|
+
updates.status = patch.draft ? "draft" : "todo";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const current = getInternalTask(normalizedId);
|
|
390
|
+
if (baseBranch) {
|
|
391
|
+
updates.baseBranch = baseBranch;
|
|
392
|
+
}
|
|
393
|
+
if (patch.meta && typeof patch.meta === "object") {
|
|
394
|
+
updates.meta = {
|
|
395
|
+
...(current?.meta || {}),
|
|
396
|
+
...patch.meta,
|
|
397
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
398
|
+
};
|
|
399
|
+
} else if (baseBranch) {
|
|
400
|
+
updates.meta = {
|
|
401
|
+
...(current?.meta || {}),
|
|
402
|
+
base_branch: baseBranch,
|
|
403
|
+
baseBranch,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const updated = patchInternalTask(normalizedId, updates);
|
|
407
|
+
if (!updated) {
|
|
408
|
+
throw new Error(`[kanban] internal task not found: ${normalizedId}`);
|
|
409
|
+
}
|
|
410
|
+
return this._normalizeTask(updated);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async createTask(projectId, taskData = {}) {
|
|
414
|
+
const id = String(taskData.id || randomUUID());
|
|
415
|
+
const tags = normalizeTags(taskData.tags || taskData.labels || []);
|
|
416
|
+
const draft = Boolean(taskData.draft || taskData.status === "draft");
|
|
417
|
+
const baseBranch = resolveBaseBranchInput(taskData);
|
|
418
|
+
const created = addInternalTask({
|
|
419
|
+
id,
|
|
420
|
+
title: taskData.title || "Untitled task",
|
|
421
|
+
description: taskData.description || "",
|
|
422
|
+
status: draft ? "draft" : normaliseStatus(taskData.status || "todo"),
|
|
423
|
+
assignee: taskData.assignee || null,
|
|
424
|
+
priority: taskData.priority || null,
|
|
425
|
+
tags,
|
|
426
|
+
draft,
|
|
427
|
+
projectId: taskData.projectId || projectId || "internal",
|
|
428
|
+
baseBranch,
|
|
429
|
+
meta: {
|
|
430
|
+
...(taskData.meta || {}),
|
|
431
|
+
...(tags.length ? { tags } : {}),
|
|
432
|
+
...(draft ? { draft: true } : {}),
|
|
433
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
if (!created) {
|
|
437
|
+
throw new Error("[kanban] internal task creation failed");
|
|
438
|
+
}
|
|
439
|
+
return this._normalizeTask(created);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async deleteTask(taskId) {
|
|
443
|
+
return removeInternalTask(String(taskId || ""));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async addComment(taskId, body) {
|
|
447
|
+
const id = String(taskId || "").trim();
|
|
448
|
+
const comment = String(body || "").trim();
|
|
449
|
+
if (!id || !comment) return false;
|
|
450
|
+
|
|
451
|
+
const current = getInternalTask(id);
|
|
452
|
+
if (!current) return false;
|
|
453
|
+
|
|
454
|
+
const comments = Array.isArray(current?.meta?.comments)
|
|
455
|
+
? [...current.meta.comments]
|
|
456
|
+
: [];
|
|
457
|
+
comments.push({
|
|
458
|
+
body: comment,
|
|
459
|
+
createdAt: new Date().toISOString(),
|
|
460
|
+
source: "kanban-adapter/internal",
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const patched = patchInternalTask(id, {
|
|
464
|
+
meta: {
|
|
465
|
+
...(current.meta || {}),
|
|
466
|
+
comments,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return Boolean(patched);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function normalizeLabels(raw) {
|
|
475
|
+
const values = Array.isArray(raw)
|
|
476
|
+
? raw
|
|
477
|
+
: String(raw || "")
|
|
478
|
+
.split(",")
|
|
479
|
+
.map((entry) => entry.trim())
|
|
480
|
+
.filter(Boolean);
|
|
481
|
+
const seen = new Set();
|
|
482
|
+
const labels = [];
|
|
483
|
+
for (const value of values) {
|
|
484
|
+
const normalized = String(value || "")
|
|
485
|
+
.trim()
|
|
486
|
+
.toLowerCase();
|
|
487
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
488
|
+
seen.add(normalized);
|
|
489
|
+
labels.push(normalized);
|
|
490
|
+
}
|
|
491
|
+
return labels;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function normalizeTags(raw) {
|
|
495
|
+
return normalizeLabels(raw);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const UPSTREAM_LABEL_REGEX =
|
|
499
|
+
/^(?:upstream|base|target)(?:_branch)?[:=]\s*([A-Za-z0-9._/-]+)$/i;
|
|
500
|
+
|
|
501
|
+
function normalizeBranchName(value) {
|
|
502
|
+
if (!value) return null;
|
|
503
|
+
const trimmed = String(value || "").trim();
|
|
504
|
+
return trimmed ? trimmed : null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isUpstreamLabel(label) {
|
|
508
|
+
if (!label) return false;
|
|
509
|
+
return UPSTREAM_LABEL_REGEX.test(String(label || "").trim());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function extractBaseBranchFromLabels(labels) {
|
|
513
|
+
if (!Array.isArray(labels)) return null;
|
|
514
|
+
for (const label of labels) {
|
|
515
|
+
const match = String(label || "").trim().match(UPSTREAM_LABEL_REGEX);
|
|
516
|
+
if (match?.[1]) return normalizeBranchName(match[1]);
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function extractBaseBranchFromText(text) {
|
|
522
|
+
if (!text) return null;
|
|
523
|
+
const match = String(text || "").match(
|
|
524
|
+
/\b(?:upstream|base|target)(?:_branch| branch)?\s*[:=]\s*([A-Za-z0-9._/-]+)/i,
|
|
525
|
+
);
|
|
526
|
+
if (!match?.[1]) return null;
|
|
527
|
+
return normalizeBranchName(match[1]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function resolveBaseBranchInput(payload) {
|
|
531
|
+
if (!payload) return null;
|
|
532
|
+
const candidate =
|
|
533
|
+
payload.base_branch ||
|
|
534
|
+
payload.baseBranch ||
|
|
535
|
+
payload.upstream_branch ||
|
|
536
|
+
payload.upstreamBranch ||
|
|
537
|
+
payload.upstream ||
|
|
538
|
+
payload.target_branch ||
|
|
539
|
+
payload.targetBranch ||
|
|
540
|
+
payload.base ||
|
|
541
|
+
payload.target;
|
|
542
|
+
return normalizeBranchName(candidate);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function upsertBaseBranchMarker(text, baseBranch) {
|
|
546
|
+
const branch = normalizeBranchName(baseBranch);
|
|
547
|
+
if (!branch) return String(text || "");
|
|
548
|
+
const source = String(text || "");
|
|
549
|
+
const pattern =
|
|
550
|
+
/\b(?:upstream|base|target)(?:_branch| branch)?\s*[:=]\s*([A-Za-z0-9._/-]+)/i;
|
|
551
|
+
if (pattern.test(source)) {
|
|
552
|
+
return source.replace(pattern, `base_branch: ${branch}`);
|
|
553
|
+
}
|
|
554
|
+
const separator = source.trim().length ? "\n\n" : "";
|
|
555
|
+
return `${source}${separator}base_branch: ${branch}`.trim();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function extractTagsFromLabels(labels, extraSystem = []) {
|
|
559
|
+
const normalized = normalizeLabels(labels);
|
|
560
|
+
const extra = new Set(normalizeLabels(extraSystem));
|
|
561
|
+
return normalized.filter(
|
|
562
|
+
(label) =>
|
|
563
|
+
!SYSTEM_LABEL_KEYS.has(label) &&
|
|
564
|
+
!extra.has(label) &&
|
|
565
|
+
!isUpstreamLabel(label),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// VK Adapter (Vibe-Kanban)
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
class VKAdapter {
|
|
574
|
+
constructor() {
|
|
575
|
+
this.name = "vk";
|
|
576
|
+
this._fetchVk = null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Lazy-load the fetchVk helper from monitor.mjs or fall back to a minimal
|
|
581
|
+
* implementation using the VK endpoint URL from config.
|
|
582
|
+
*/
|
|
583
|
+
async _getFetchVk() {
|
|
584
|
+
if (this._fetchVk) return this._fetchVk;
|
|
585
|
+
|
|
586
|
+
// Try importing a standalone vk-api module first
|
|
587
|
+
try {
|
|
588
|
+
const mod = await import("./vk-api.mjs");
|
|
589
|
+
const fn = mod.fetchVk || mod.default?.fetchVk || mod.default;
|
|
590
|
+
if (typeof fn === "function") {
|
|
591
|
+
this._fetchVk = fn;
|
|
592
|
+
return this._fetchVk;
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
// Not available — build a minimal fetch wrapper
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Minimal fetch wrapper using config
|
|
599
|
+
const cfg = loadConfig();
|
|
600
|
+
const baseUrl = cfg.vkEndpointUrl || "http://127.0.0.1:54089";
|
|
601
|
+
this._fetchVk = async (path, opts = {}) => {
|
|
602
|
+
const url = `${baseUrl}${path.startsWith("/") ? path : "/" + path}`;
|
|
603
|
+
const method = (opts.method || "GET").toUpperCase();
|
|
604
|
+
const controller = new AbortController();
|
|
605
|
+
const timeout = setTimeout(
|
|
606
|
+
() => controller.abort(),
|
|
607
|
+
opts.timeoutMs || 15_000,
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
let res;
|
|
611
|
+
try {
|
|
612
|
+
const fetchOpts = {
|
|
613
|
+
method,
|
|
614
|
+
signal: controller.signal,
|
|
615
|
+
headers: { "Content-Type": "application/json" },
|
|
616
|
+
};
|
|
617
|
+
if (opts.body && method !== "GET") {
|
|
618
|
+
fetchOpts.body =
|
|
619
|
+
typeof opts.body === "string"
|
|
620
|
+
? opts.body
|
|
621
|
+
: JSON.stringify(opts.body);
|
|
622
|
+
}
|
|
623
|
+
res = await fetchWithFallback(url, fetchOpts);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
// Network error, timeout, abort - res is undefined
|
|
626
|
+
throw new Error(
|
|
627
|
+
`VK API ${method} ${path} network error: ${err.message || err}`,
|
|
628
|
+
);
|
|
629
|
+
} finally {
|
|
630
|
+
clearTimeout(timeout);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!res || typeof res.ok === "undefined") {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`VK API ${method} ${path} invalid response object (res=${!!res}, res.ok=${res?.ok})`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!res.ok) {
|
|
640
|
+
const text =
|
|
641
|
+
typeof res.text === "function"
|
|
642
|
+
? await res.text().catch(() => "")
|
|
643
|
+
: "";
|
|
644
|
+
throw new Error(
|
|
645
|
+
`VK API ${method} ${path} failed: ${res.status} ${text.slice(0, 200)}`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const contentTypeRaw =
|
|
650
|
+
typeof res.headers?.get === "function"
|
|
651
|
+
? res.headers.get("content-type") || res.headers.get("Content-Type")
|
|
652
|
+
: res.headers?.["content-type"] ||
|
|
653
|
+
res.headers?.["Content-Type"] ||
|
|
654
|
+
"";
|
|
655
|
+
const contentType = String(contentTypeRaw || "").toLowerCase();
|
|
656
|
+
|
|
657
|
+
if (contentType && !contentType.includes("application/json")) {
|
|
658
|
+
const text =
|
|
659
|
+
typeof res.text === "function"
|
|
660
|
+
? await res.text().catch(() => "")
|
|
661
|
+
: "";
|
|
662
|
+
// VK sometimes mislabels JSON as text/plain in proxy setups.
|
|
663
|
+
if (text) {
|
|
664
|
+
try {
|
|
665
|
+
return JSON.parse(text);
|
|
666
|
+
} catch {
|
|
667
|
+
// Fall through to explicit non-JSON error below.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
throw new Error(
|
|
671
|
+
`VK API ${method} ${path} non-JSON response (${contentType})`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
return await res.json();
|
|
677
|
+
} catch (err) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`VK API ${method} ${path} invalid JSON: ${err.message}`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
return this._fetchVk;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async listProjects() {
|
|
687
|
+
const fetchVk = await this._getFetchVk();
|
|
688
|
+
const result = await fetchVk("/api/projects");
|
|
689
|
+
const projects = Array.isArray(result) ? result : result?.data || [];
|
|
690
|
+
return projects.map((p) => ({
|
|
691
|
+
id: p.id,
|
|
692
|
+
name: p.name || p.title || p.id,
|
|
693
|
+
meta: p,
|
|
694
|
+
backend: "vk",
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async listTasks(projectId, filters = {}) {
|
|
699
|
+
const fetchVk = await this._getFetchVk();
|
|
700
|
+
// Use /api/tasks?project_id=... (query param style) instead of
|
|
701
|
+
// /api/projects/:id/tasks which gets caught by the SPA catch-all.
|
|
702
|
+
const params = [`project_id=${encodeURIComponent(projectId)}`];
|
|
703
|
+
if (filters.status)
|
|
704
|
+
params.push(`status=${encodeURIComponent(filters.status)}`);
|
|
705
|
+
if (filters.limit) params.push(`limit=${filters.limit}`);
|
|
706
|
+
const url = `/api/tasks?${params.join("&")}`;
|
|
707
|
+
const result = await fetchVk(url);
|
|
708
|
+
const tasks = Array.isArray(result)
|
|
709
|
+
? result
|
|
710
|
+
: result?.data || result?.tasks || [];
|
|
711
|
+
return tasks.map((t) => this._normaliseTask(t, projectId));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async getTask(taskId) {
|
|
715
|
+
const fetchVk = await this._getFetchVk();
|
|
716
|
+
const result = await fetchVk(`/api/tasks/${taskId}`);
|
|
717
|
+
const task = result?.data || result;
|
|
718
|
+
return this._normaliseTask(task);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async updateTaskStatus(taskId, status) {
|
|
722
|
+
return this.updateTask(taskId, { status });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async updateTask(taskId, patch = {}) {
|
|
726
|
+
const fetchVk = await this._getFetchVk();
|
|
727
|
+
const body = {};
|
|
728
|
+
const baseBranch = resolveBaseBranchInput(patch);
|
|
729
|
+
if (typeof patch.status === "string" && patch.status.trim()) {
|
|
730
|
+
body.status = patch.status.trim();
|
|
731
|
+
}
|
|
732
|
+
if (typeof patch.title === "string") {
|
|
733
|
+
body.title = patch.title;
|
|
734
|
+
}
|
|
735
|
+
if (typeof patch.description === "string") {
|
|
736
|
+
body.description = patch.description;
|
|
737
|
+
}
|
|
738
|
+
if (typeof patch.priority === "string" && patch.priority.trim()) {
|
|
739
|
+
body.priority = patch.priority.trim();
|
|
740
|
+
}
|
|
741
|
+
if (Array.isArray(patch.tags) || typeof patch.tags === "string") {
|
|
742
|
+
body.tags = normalizeTags(patch.tags ?? patch.labels);
|
|
743
|
+
}
|
|
744
|
+
if (typeof patch.draft === "boolean") {
|
|
745
|
+
body.draft = patch.draft;
|
|
746
|
+
if (!patch.status) body.status = patch.draft ? "draft" : "todo";
|
|
747
|
+
}
|
|
748
|
+
if (baseBranch) {
|
|
749
|
+
body.base_branch = baseBranch;
|
|
750
|
+
}
|
|
751
|
+
if (Object.keys(body).length === 0) {
|
|
752
|
+
return this.getTask(taskId);
|
|
753
|
+
}
|
|
754
|
+
const result = await fetchVk(`/api/tasks/${taskId}`, {
|
|
755
|
+
method: "PUT",
|
|
756
|
+
body,
|
|
757
|
+
});
|
|
758
|
+
const task = result?.data || result;
|
|
759
|
+
return this._normaliseTask(task);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async createTask(projectId, taskData) {
|
|
763
|
+
const fetchVk = await this._getFetchVk();
|
|
764
|
+
const tags = normalizeTags(taskData?.tags || taskData?.labels || []);
|
|
765
|
+
const draft = Boolean(taskData?.draft || taskData?.status === "draft");
|
|
766
|
+
const baseBranch = resolveBaseBranchInput(taskData);
|
|
767
|
+
const payload = {
|
|
768
|
+
...taskData,
|
|
769
|
+
status: draft ? "draft" : taskData?.status,
|
|
770
|
+
...(tags.length ? { tags } : {}),
|
|
771
|
+
...(draft ? { draft: true } : {}),
|
|
772
|
+
...(baseBranch ? { base_branch: baseBranch } : {}),
|
|
773
|
+
};
|
|
774
|
+
// Use /api/tasks with project_id in body instead of
|
|
775
|
+
// /api/projects/:id/tasks which gets caught by the SPA catch-all.
|
|
776
|
+
const result = await fetchVk(`/api/tasks`, {
|
|
777
|
+
method: "POST",
|
|
778
|
+
body: { ...payload, project_id: projectId },
|
|
779
|
+
});
|
|
780
|
+
const task = result?.data || result;
|
|
781
|
+
return this._normaliseTask(task, projectId);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async deleteTask(taskId) {
|
|
785
|
+
const fetchVk = await this._getFetchVk();
|
|
786
|
+
await fetchVk(`/api/tasks/${taskId}`, { method: "DELETE" });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async addComment(_taskId, _body) {
|
|
791
|
+
return false; // VK backend doesn't support issue comments
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
_normaliseTask(raw, projectId = null) {
|
|
795
|
+
if (!raw) return null;
|
|
796
|
+
const tags = normalizeTags(raw.tags || raw.labels || raw.meta?.tags || []);
|
|
797
|
+
const draft = Boolean(raw.draft || raw.isDraft || raw.status === "draft");
|
|
798
|
+
const baseBranch = normalizeBranchName(
|
|
799
|
+
raw.base_branch ||
|
|
800
|
+
raw.baseBranch ||
|
|
801
|
+
raw.upstream_branch ||
|
|
802
|
+
raw.upstream ||
|
|
803
|
+
raw.target_branch ||
|
|
804
|
+
raw.targetBranch ||
|
|
805
|
+
raw.meta?.base_branch ||
|
|
806
|
+
raw.meta?.baseBranch,
|
|
807
|
+
);
|
|
808
|
+
return {
|
|
809
|
+
id: raw.id || raw.task_id || "",
|
|
810
|
+
title: raw.title || raw.name || "",
|
|
811
|
+
description: raw.description || raw.body || "",
|
|
812
|
+
status: normaliseStatus(raw.status),
|
|
813
|
+
assignee: raw.assignee || raw.assigned_to || null,
|
|
814
|
+
priority: raw.priority || null,
|
|
815
|
+
tags,
|
|
816
|
+
draft,
|
|
817
|
+
projectId: raw.project_id || projectId,
|
|
818
|
+
baseBranch,
|
|
819
|
+
branchName: raw.branch_name || raw.branchName || null,
|
|
820
|
+
prNumber: raw.pr_number || raw.prNumber || null,
|
|
821
|
+
meta: raw,
|
|
822
|
+
backend: "vk",
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// GitHub Issues Adapter
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* @typedef {Object} SharedState
|
|
833
|
+
* @property {string} ownerId - Workstation/agent identifier (e.g., "workstation-123/agent-456")
|
|
834
|
+
* @property {string} attemptToken - Unique UUID for this claim attempt
|
|
835
|
+
* @property {string} attemptStarted - ISO 8601 timestamp of claim start
|
|
836
|
+
* @property {string} heartbeat - ISO 8601 timestamp of last heartbeat
|
|
837
|
+
* @property {string} status - Current status: "claimed"|"working"|"stale"
|
|
838
|
+
* @property {number} retryCount - Number of retry attempts
|
|
839
|
+
*/
|
|
840
|
+
|
|
841
|
+
class GitHubIssuesAdapter {
|
|
842
|
+
constructor() {
|
|
843
|
+
this.name = "github";
|
|
844
|
+
const config = loadConfig();
|
|
845
|
+
const slugInfo =
|
|
846
|
+
parseRepoSlug(process.env.GITHUB_REPOSITORY) ||
|
|
847
|
+
parseRepoSlug(config?.repoSlug) ||
|
|
848
|
+
parseRepoSlug(
|
|
849
|
+
process.env.GITHUB_REPO_OWNER && process.env.GITHUB_REPO_NAME
|
|
850
|
+
? `${process.env.GITHUB_REPO_OWNER}/${process.env.GITHUB_REPO_NAME}`
|
|
851
|
+
: "",
|
|
852
|
+
);
|
|
853
|
+
this._owner = process.env.GITHUB_REPO_OWNER || slugInfo?.owner || "unknown";
|
|
854
|
+
this._repo = process.env.GITHUB_REPO_NAME || slugInfo?.repo || "unknown";
|
|
855
|
+
|
|
856
|
+
// openfleet label scheme
|
|
857
|
+
this._codexLabels = {
|
|
858
|
+
claimed: "codex:claimed",
|
|
859
|
+
working: "codex:working",
|
|
860
|
+
stale: "codex:stale",
|
|
861
|
+
ignore: "codex:ignore",
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
this._canonicalTaskLabel =
|
|
865
|
+
process.env.CODEX_MONITOR_TASK_LABEL || "openfleet";
|
|
866
|
+
this._taskScopeLabels = normalizeLabels(
|
|
867
|
+
process.env.CODEX_MONITOR_TASK_LABELS ||
|
|
868
|
+
`${this._canonicalTaskLabel},codex-mointor`,
|
|
869
|
+
);
|
|
870
|
+
this._enforceTaskLabel = parseBooleanEnv(
|
|
871
|
+
process.env.CODEX_MONITOR_ENFORCE_TASK_LABEL,
|
|
872
|
+
true,
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
this._autoAssignCreator = parseBooleanEnv(
|
|
876
|
+
process.env.GITHUB_AUTO_ASSIGN_CREATOR,
|
|
877
|
+
true,
|
|
878
|
+
);
|
|
879
|
+
this._defaultAssignee =
|
|
880
|
+
process.env.GITHUB_DEFAULT_ASSIGNEE || this._owner || null;
|
|
881
|
+
|
|
882
|
+
this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
|
|
883
|
+
.trim()
|
|
884
|
+
.toLowerCase();
|
|
885
|
+
this._projectOwner = process.env.GITHUB_PROJECT_OWNER || this._owner;
|
|
886
|
+
this._projectTitle =
|
|
887
|
+
process.env.GITHUB_PROJECT_TITLE ||
|
|
888
|
+
process.env.PROJECT_NAME ||
|
|
889
|
+
"OpenFleet";
|
|
890
|
+
this._projectNumber =
|
|
891
|
+
process.env.GITHUB_PROJECT_NUMBER ||
|
|
892
|
+
process.env.GITHUB_PROJECT_ID ||
|
|
893
|
+
null;
|
|
894
|
+
this._cachedProjectNumber = this._projectNumber;
|
|
895
|
+
|
|
896
|
+
// --- Caching infrastructure for GitHub Projects v2 ---
|
|
897
|
+
/** @type {Map<string, string>} projectNumber → project node ID */
|
|
898
|
+
this._projectNodeIdCache = new Map();
|
|
899
|
+
/** @type {Map<string, string>} "projectNum:issueNum" → project item ID */
|
|
900
|
+
this._projectItemCache = new Map();
|
|
901
|
+
/** @type {Map<string, {fields: any, time: number}>} projectNumber → {fields, time} */
|
|
902
|
+
this._projectFieldsCache = new Map();
|
|
903
|
+
this._projectFieldsCacheTTL = 300_000; // 5 minutes
|
|
904
|
+
this._repositoryNodeId = null;
|
|
905
|
+
|
|
906
|
+
// Auto-sync toggle: set GITHUB_PROJECT_AUTO_SYNC=false to disable project sync
|
|
907
|
+
this._projectAutoSync = parseBooleanEnv(
|
|
908
|
+
process.env.GITHUB_PROJECT_AUTO_SYNC,
|
|
909
|
+
true,
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
// Rate limit retry delay (ms) — configurable for tests
|
|
913
|
+
this._rateLimitRetryDelayMs =
|
|
914
|
+
Number(process.env.GH_RATE_LIMIT_RETRY_MS) || 60_000;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Get project fields with caching (private — returns legacy format for _syncStatusToProject).
|
|
919
|
+
* Returns status field ID and options for project board.
|
|
920
|
+
* @private
|
|
921
|
+
* @param {string} projectNumber - GitHub project number
|
|
922
|
+
* @returns {Promise<{statusFieldId: string, statusOptions: Array<{id: string, name: string}>}|null>}
|
|
923
|
+
*/
|
|
924
|
+
async _getProjectFields(projectNumber) {
|
|
925
|
+
if (!projectNumber) return null;
|
|
926
|
+
|
|
927
|
+
// Return cached value if still valid
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
const cacheKey = String(projectNumber);
|
|
930
|
+
const cached = this._projectFieldsCache.get(cacheKey);
|
|
931
|
+
if (cached && now - cached.time < this._projectFieldsCacheTTL) {
|
|
932
|
+
return cached.fields;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
try {
|
|
936
|
+
const owner = String(this._projectOwner || this._owner).trim();
|
|
937
|
+
const fields = await this._gh([
|
|
938
|
+
"project",
|
|
939
|
+
"field-list",
|
|
940
|
+
String(projectNumber),
|
|
941
|
+
"--owner",
|
|
942
|
+
owner,
|
|
943
|
+
"--format",
|
|
944
|
+
"json",
|
|
945
|
+
]);
|
|
946
|
+
|
|
947
|
+
if (!Array.isArray(fields)) {
|
|
948
|
+
console.warn(
|
|
949
|
+
`${TAG} project field-list returned non-array for project ${projectNumber}`,
|
|
950
|
+
);
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Find the Status field
|
|
955
|
+
const statusField = fields.find(
|
|
956
|
+
(f) =>
|
|
957
|
+
f.name === "Status" &&
|
|
958
|
+
(f.type === "SINGLE_SELECT" || f.data_type === "SINGLE_SELECT"),
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const result = {
|
|
962
|
+
statusFieldId: statusField?.id || null,
|
|
963
|
+
statusOptions: (statusField?.options || []).map((opt) => ({
|
|
964
|
+
id: opt.id,
|
|
965
|
+
name: opt.name,
|
|
966
|
+
})),
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// Cache the result (also cache the raw fields array for getProjectFields)
|
|
970
|
+
this._projectFieldsCache.set(cacheKey, {
|
|
971
|
+
fields: result,
|
|
972
|
+
rawFields: fields,
|
|
973
|
+
time: now,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
return result;
|
|
977
|
+
} catch (err) {
|
|
978
|
+
console.warn(
|
|
979
|
+
`${TAG} failed to fetch project fields for ${projectNumber}: ${err.message}`,
|
|
980
|
+
);
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Get full project fields map for a GitHub Project board.
|
|
987
|
+
* Returns a Map keyed by lowercase field name with {id, name, type, options}.
|
|
988
|
+
* @public
|
|
989
|
+
* @param {string} projectNumber - GitHub project number
|
|
990
|
+
* @returns {Promise<Map<string, {id: string, name: string, type: string, options: Array<{id: string, name: string}>}>>}
|
|
991
|
+
*/
|
|
992
|
+
async getProjectFields(projectNumber) {
|
|
993
|
+
if (!projectNumber) return new Map();
|
|
994
|
+
const cacheKey = String(projectNumber);
|
|
995
|
+
const now = Date.now();
|
|
996
|
+
const cached = this._projectFieldsCache.get(cacheKey);
|
|
997
|
+
|
|
998
|
+
let rawFields;
|
|
999
|
+
if (
|
|
1000
|
+
cached &&
|
|
1001
|
+
cached.rawFields &&
|
|
1002
|
+
now - cached.time < this._projectFieldsCacheTTL
|
|
1003
|
+
) {
|
|
1004
|
+
rawFields = cached.rawFields;
|
|
1005
|
+
} else {
|
|
1006
|
+
// Trigger a fresh fetch via _getProjectFields which populates both caches
|
|
1007
|
+
await this._getProjectFields(projectNumber);
|
|
1008
|
+
const freshCached = this._projectFieldsCache.get(cacheKey);
|
|
1009
|
+
rawFields = freshCached?.rawFields;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (!Array.isArray(rawFields)) return new Map();
|
|
1013
|
+
|
|
1014
|
+
/** @type {Map<string, {id: string, name: string, type: string, options: Array}>} */
|
|
1015
|
+
const fieldMap = new Map();
|
|
1016
|
+
for (const f of rawFields) {
|
|
1017
|
+
if (!f.name) continue;
|
|
1018
|
+
fieldMap.set(f.name.toLowerCase(), {
|
|
1019
|
+
id: f.id,
|
|
1020
|
+
name: f.name,
|
|
1021
|
+
type: f.type || f.data_type || "UNKNOWN",
|
|
1022
|
+
options: (f.options || []).map((opt) => ({
|
|
1023
|
+
id: opt.id,
|
|
1024
|
+
name: opt.name,
|
|
1025
|
+
})),
|
|
1026
|
+
raw: f,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
return fieldMap;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Get the GraphQL node ID for a GitHub Project v2 board.
|
|
1034
|
+
* Resolves org or user project. Cached for session lifetime.
|
|
1035
|
+
* @public
|
|
1036
|
+
* @param {string} projectNumber - GitHub project number
|
|
1037
|
+
* @returns {Promise<string|null>} Project node ID or null
|
|
1038
|
+
*/
|
|
1039
|
+
async getProjectNodeId(projectNumber) {
|
|
1040
|
+
if (!projectNumber) return null;
|
|
1041
|
+
const cacheKey = String(projectNumber);
|
|
1042
|
+
if (this._projectNodeIdCache.has(cacheKey)) {
|
|
1043
|
+
return this._projectNodeIdCache.get(cacheKey);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const owner = String(this._projectOwner || this._owner).trim();
|
|
1047
|
+
const query = `
|
|
1048
|
+
query {
|
|
1049
|
+
user(login: "${owner}") {
|
|
1050
|
+
projectV2(number: ${projectNumber}) {
|
|
1051
|
+
id
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
organization(login: "${owner}") {
|
|
1055
|
+
projectV2(number: ${projectNumber}) {
|
|
1056
|
+
id
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
`;
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
|
|
1064
|
+
const nodeId =
|
|
1065
|
+
data?.data?.user?.projectV2?.id ||
|
|
1066
|
+
data?.data?.organization?.projectV2?.id ||
|
|
1067
|
+
null;
|
|
1068
|
+
if (nodeId) {
|
|
1069
|
+
this._projectNodeIdCache.set(cacheKey, nodeId);
|
|
1070
|
+
}
|
|
1071
|
+
return nodeId;
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
console.warn(
|
|
1074
|
+
`${TAG} failed to resolve project node ID for ${owner}/${projectNumber}: ${err.message}`,
|
|
1075
|
+
);
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Normalize a GitHub Project v2 status name to internal codex status.
|
|
1082
|
+
* Also supports reverse mapping (internal → project).
|
|
1083
|
+
*
|
|
1084
|
+
* Bidirectional:
|
|
1085
|
+
* - project → internal: _normalizeProjectStatus("In Progress") → "inprogress"
|
|
1086
|
+
* - internal → project: _normalizeProjectStatus("inprogress", true) → "In Progress"
|
|
1087
|
+
*
|
|
1088
|
+
* @param {string} statusName - Status name to normalize
|
|
1089
|
+
* @param {boolean} [toProject=false] - If true, map internal→project; otherwise project→internal
|
|
1090
|
+
* @returns {string} Normalized status
|
|
1091
|
+
*/
|
|
1092
|
+
_normalizeProjectStatus(statusName, toProject = false) {
|
|
1093
|
+
if (!statusName) return toProject ? PROJECT_STATUS_MAP.todo : "todo";
|
|
1094
|
+
|
|
1095
|
+
if (toProject) {
|
|
1096
|
+
// internal → project
|
|
1097
|
+
const key = String(statusName).toLowerCase().trim();
|
|
1098
|
+
return PROJECT_STATUS_MAP[key] || PROJECT_STATUS_MAP.todo;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// project → internal: build reverse map from PROJECT_STATUS_MAP
|
|
1102
|
+
const lcInput = String(statusName).toLowerCase().trim();
|
|
1103
|
+
for (const [internal, projectName] of Object.entries(PROJECT_STATUS_MAP)) {
|
|
1104
|
+
if (String(projectName).toLowerCase() === lcInput) {
|
|
1105
|
+
return internal;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// Fallback to standard normalisation
|
|
1109
|
+
return normaliseStatus(statusName);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Normalize a project item (from `gh project item-list`) into KanbanTask format
|
|
1114
|
+
* without issuing individual issue fetches (fixes N+1 problem).
|
|
1115
|
+
* @private
|
|
1116
|
+
* @param {Object} projectItem - Raw project item from item-list
|
|
1117
|
+
* @returns {KanbanTask|null}
|
|
1118
|
+
*/
|
|
1119
|
+
_normaliseProjectItem(projectItem) {
|
|
1120
|
+
if (!projectItem) return null;
|
|
1121
|
+
|
|
1122
|
+
const content = projectItem.content || {};
|
|
1123
|
+
// content may have: number, title, body, url, type, repository, labels, assignees
|
|
1124
|
+
const issueNumber = content.number;
|
|
1125
|
+
if (!issueNumber && !content.url) return null; // skip draft items without info
|
|
1126
|
+
|
|
1127
|
+
// Extract issue number from URL if not directly available
|
|
1128
|
+
const num =
|
|
1129
|
+
issueNumber || String(content.url || "").match(/\/issues\/(\d+)/)?.[1];
|
|
1130
|
+
if (!num) return null;
|
|
1131
|
+
|
|
1132
|
+
// Extract labels
|
|
1133
|
+
const rawLabels = content.labels || projectItem.labels || [];
|
|
1134
|
+
const labels = rawLabels.map((l) =>
|
|
1135
|
+
typeof l === "string" ? l : l?.name || "",
|
|
1136
|
+
);
|
|
1137
|
+
const labelSet = new Set(
|
|
1138
|
+
labels.map((l) =>
|
|
1139
|
+
String(l || "")
|
|
1140
|
+
.trim()
|
|
1141
|
+
.toLowerCase(),
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
const labelStatus = statusFromLabels(labels);
|
|
1145
|
+
const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
|
|
1146
|
+
const body = content.body || "";
|
|
1147
|
+
const baseBranch = normalizeBranchName(
|
|
1148
|
+
extractBaseBranchFromLabels(labels) || extractBaseBranchFromText(body),
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
// Determine status from project Status field value
|
|
1152
|
+
const projectStatus =
|
|
1153
|
+
projectItem.status || projectItem.fieldValues?.Status || null;
|
|
1154
|
+
let status;
|
|
1155
|
+
if (projectStatus) {
|
|
1156
|
+
status = this._normalizeProjectStatus(projectStatus);
|
|
1157
|
+
} else {
|
|
1158
|
+
// Fallback to content state + labels
|
|
1159
|
+
if (content.state === "closed" || content.state === "CLOSED") {
|
|
1160
|
+
status = "done";
|
|
1161
|
+
} else if (labelStatus) {
|
|
1162
|
+
status = labelStatus;
|
|
1163
|
+
} else {
|
|
1164
|
+
status = "todo";
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (labelSet.has("draft")) status = "draft";
|
|
1168
|
+
|
|
1169
|
+
// Codex meta flags
|
|
1170
|
+
const codexMeta = {
|
|
1171
|
+
isIgnored: labelSet.has("codex:ignore"),
|
|
1172
|
+
isClaimed: labelSet.has("codex:claimed"),
|
|
1173
|
+
isWorking: labelSet.has("codex:working"),
|
|
1174
|
+
isStale: labelSet.has("codex:stale"),
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// Extract branch/PR from body if available
|
|
1178
|
+
const branchMatch = body.match(/branch:\s*`?([^\s`]+)`?/i);
|
|
1179
|
+
const prMatch = body.match(/pr:\s*#?(\d+)/i);
|
|
1180
|
+
|
|
1181
|
+
// Assignees
|
|
1182
|
+
const assignees = content.assignees || [];
|
|
1183
|
+
const assignee =
|
|
1184
|
+
assignees.length > 0
|
|
1185
|
+
? typeof assignees[0] === "string"
|
|
1186
|
+
? assignees[0]
|
|
1187
|
+
: assignees[0]?.login
|
|
1188
|
+
: null;
|
|
1189
|
+
|
|
1190
|
+
const issueUrl =
|
|
1191
|
+
content.url ||
|
|
1192
|
+
`https://github.com/${this._owner}/${this._repo}/issues/${num}`;
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
id: String(num),
|
|
1196
|
+
title: content.title || projectItem.title || "",
|
|
1197
|
+
description: body,
|
|
1198
|
+
status,
|
|
1199
|
+
assignee: assignee || null,
|
|
1200
|
+
priority: labelSet.has("critical")
|
|
1201
|
+
? "critical"
|
|
1202
|
+
: labelSet.has("high")
|
|
1203
|
+
? "high"
|
|
1204
|
+
: null,
|
|
1205
|
+
tags,
|
|
1206
|
+
draft: labelSet.has("draft") || status === "draft",
|
|
1207
|
+
projectId: `${this._owner}/${this._repo}`,
|
|
1208
|
+
baseBranch,
|
|
1209
|
+
branchName: branchMatch?.[1] || null,
|
|
1210
|
+
prNumber: prMatch?.[1] || null,
|
|
1211
|
+
meta: {
|
|
1212
|
+
number: Number(num),
|
|
1213
|
+
title: content.title || projectItem.title || "",
|
|
1214
|
+
body,
|
|
1215
|
+
state: content.state || null,
|
|
1216
|
+
url: issueUrl,
|
|
1217
|
+
labels: rawLabels,
|
|
1218
|
+
assignees,
|
|
1219
|
+
task_url: issueUrl,
|
|
1220
|
+
tags,
|
|
1221
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
1222
|
+
codex: codexMeta,
|
|
1223
|
+
projectNumber: null, // set by caller
|
|
1224
|
+
projectItemId: projectItem.id || null,
|
|
1225
|
+
projectStatus: projectStatus || null,
|
|
1226
|
+
projectFieldValues:
|
|
1227
|
+
projectItem.fieldValues && typeof projectItem.fieldValues === "object"
|
|
1228
|
+
? { ...projectItem.fieldValues }
|
|
1229
|
+
: {},
|
|
1230
|
+
},
|
|
1231
|
+
taskUrl: issueUrl,
|
|
1232
|
+
backend: "github",
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
_escapeGraphQLString(value) {
|
|
1237
|
+
return JSON.stringify(String(value == null ? "" : value));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
_stringifyProjectFieldValue(field, value) {
|
|
1241
|
+
const fieldType = String(field?.type || "TEXT").toUpperCase();
|
|
1242
|
+
if (fieldType === "SINGLE_SELECT") {
|
|
1243
|
+
const option = (field.options || []).find((opt) => {
|
|
1244
|
+
const optionId = String(opt?.id || "").trim();
|
|
1245
|
+
const optionName = String(opt?.name || "")
|
|
1246
|
+
.trim()
|
|
1247
|
+
.toLowerCase();
|
|
1248
|
+
const input = String(value || "").trim().toLowerCase();
|
|
1249
|
+
return optionId === String(value || "").trim() || optionName === input;
|
|
1250
|
+
});
|
|
1251
|
+
if (!option) return null;
|
|
1252
|
+
return `{singleSelectOptionId: ${this._escapeGraphQLString(option.id)}}`;
|
|
1253
|
+
}
|
|
1254
|
+
if (fieldType === "ITERATION") {
|
|
1255
|
+
const rawIterations = field?.raw?.configuration?.iterations;
|
|
1256
|
+
const iterations = Array.isArray(rawIterations)
|
|
1257
|
+
? rawIterations
|
|
1258
|
+
: Array.isArray(field?.options)
|
|
1259
|
+
? field.options
|
|
1260
|
+
: [];
|
|
1261
|
+
const iteration = iterations.find((entry) => {
|
|
1262
|
+
const entryId = String(entry?.id || "").trim();
|
|
1263
|
+
const name = String(entry?.title || entry?.name || "")
|
|
1264
|
+
.trim()
|
|
1265
|
+
.toLowerCase();
|
|
1266
|
+
const input = String(value || "").trim().toLowerCase();
|
|
1267
|
+
return entryId === String(value || "").trim() || name === input;
|
|
1268
|
+
});
|
|
1269
|
+
if (!iteration?.id) return null;
|
|
1270
|
+
return `{iterationId: ${this._escapeGraphQLString(iteration.id)}}`;
|
|
1271
|
+
}
|
|
1272
|
+
if (fieldType === "NUMBER") {
|
|
1273
|
+
const numeric = Number(value);
|
|
1274
|
+
if (!Number.isFinite(numeric)) return null;
|
|
1275
|
+
return `{number: ${numeric}}`;
|
|
1276
|
+
}
|
|
1277
|
+
if (fieldType === "DATE") {
|
|
1278
|
+
return `{date: ${this._escapeGraphQLString(value)}}`;
|
|
1279
|
+
}
|
|
1280
|
+
return `{text: ${this._escapeGraphQLString(value)}}`;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async _updateProjectItemFieldsBatch(projectId, itemId, updates = []) {
|
|
1284
|
+
if (!projectId || !itemId || updates.length === 0) return false;
|
|
1285
|
+
const operations = updates
|
|
1286
|
+
.map((update, index) => {
|
|
1287
|
+
const alias = `update_${index}`;
|
|
1288
|
+
return `
|
|
1289
|
+
${alias}: updateProjectV2ItemFieldValue(
|
|
1290
|
+
input: {
|
|
1291
|
+
projectId: ${this._escapeGraphQLString(projectId)},
|
|
1292
|
+
itemId: ${this._escapeGraphQLString(itemId)},
|
|
1293
|
+
fieldId: ${this._escapeGraphQLString(update.fieldId)},
|
|
1294
|
+
value: ${update.value}
|
|
1295
|
+
}
|
|
1296
|
+
) {
|
|
1297
|
+
projectV2Item {
|
|
1298
|
+
id
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
`;
|
|
1302
|
+
})
|
|
1303
|
+
.join("\n");
|
|
1304
|
+
|
|
1305
|
+
const mutation = `mutation { ${operations} }`;
|
|
1306
|
+
await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
_matchesProjectFieldFilters(task, projectFieldFilter) {
|
|
1311
|
+
if (!projectFieldFilter || typeof projectFieldFilter !== "object") {
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1314
|
+
const values = task?.meta?.projectFieldValues;
|
|
1315
|
+
if (!values || typeof values !== "object") return false;
|
|
1316
|
+
const entries = Object.entries(projectFieldFilter);
|
|
1317
|
+
if (entries.length === 0) return true;
|
|
1318
|
+
|
|
1319
|
+
return entries.every(([fieldName, expected]) => {
|
|
1320
|
+
const actualKey =
|
|
1321
|
+
Object.keys(values).find(
|
|
1322
|
+
(key) => key.toLowerCase() === String(fieldName).toLowerCase(),
|
|
1323
|
+
) || fieldName;
|
|
1324
|
+
const actual = values[actualKey];
|
|
1325
|
+
if (Array.isArray(expected)) {
|
|
1326
|
+
const expectedSet = new Set(
|
|
1327
|
+
expected.map((entry) =>
|
|
1328
|
+
String(entry == null ? "" : entry)
|
|
1329
|
+
.trim()
|
|
1330
|
+
.toLowerCase(),
|
|
1331
|
+
),
|
|
1332
|
+
);
|
|
1333
|
+
return expectedSet.has(
|
|
1334
|
+
String(actual == null ? "" : actual)
|
|
1335
|
+
.trim()
|
|
1336
|
+
.toLowerCase(),
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
return (
|
|
1340
|
+
String(actual == null ? "" : actual)
|
|
1341
|
+
.trim()
|
|
1342
|
+
.toLowerCase() ===
|
|
1343
|
+
String(expected == null ? "" : expected)
|
|
1344
|
+
.trim()
|
|
1345
|
+
.toLowerCase()
|
|
1346
|
+
);
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
async getRepositoryNodeId() {
|
|
1351
|
+
if (this._repositoryNodeId) return this._repositoryNodeId;
|
|
1352
|
+
const query = `
|
|
1353
|
+
query {
|
|
1354
|
+
repository(
|
|
1355
|
+
owner: ${this._escapeGraphQLString(this._owner)},
|
|
1356
|
+
name: ${this._escapeGraphQLString(this._repo)}
|
|
1357
|
+
) {
|
|
1358
|
+
id
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
`;
|
|
1362
|
+
const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
|
|
1363
|
+
const repoId = data?.data?.repository?.id || null;
|
|
1364
|
+
if (repoId) this._repositoryNodeId = repoId;
|
|
1365
|
+
return repoId;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Get project item ID for an issue within a project (cached).
|
|
1370
|
+
* @private
|
|
1371
|
+
* @param {string} projectNumber - GitHub project number
|
|
1372
|
+
* @param {string|number} issueNumber - Issue number
|
|
1373
|
+
* @returns {Promise<string|null>} Project item ID or null
|
|
1374
|
+
*/
|
|
1375
|
+
async _getProjectItemIdForIssue(projectNumber, issueNumber) {
|
|
1376
|
+
if (!projectNumber || !issueNumber) return null;
|
|
1377
|
+
const cacheKey = `${projectNumber}:${issueNumber}`;
|
|
1378
|
+
if (this._projectItemCache.has(cacheKey)) {
|
|
1379
|
+
return this._projectItemCache.get(cacheKey);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Try GraphQL resource query
|
|
1383
|
+
const issueUrl = `https://github.com/${this._owner}/${this._repo}/issues/${issueNumber}`;
|
|
1384
|
+
const projectId = await this.getProjectNodeId(projectNumber);
|
|
1385
|
+
if (!projectId) return null;
|
|
1386
|
+
|
|
1387
|
+
const query = `
|
|
1388
|
+
query {
|
|
1389
|
+
resource(url: "${issueUrl}") {
|
|
1390
|
+
... on Issue {
|
|
1391
|
+
projectItems(first: 10) {
|
|
1392
|
+
nodes {
|
|
1393
|
+
id
|
|
1394
|
+
project {
|
|
1395
|
+
id
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
`;
|
|
1403
|
+
|
|
1404
|
+
try {
|
|
1405
|
+
const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
|
|
1406
|
+
const items = data?.data?.resource?.projectItems?.nodes || [];
|
|
1407
|
+
const match = items.find((item) => item.project?.id === projectId);
|
|
1408
|
+
const itemId = match?.id || null;
|
|
1409
|
+
if (itemId) {
|
|
1410
|
+
this._projectItemCache.set(cacheKey, itemId);
|
|
1411
|
+
}
|
|
1412
|
+
return itemId;
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
console.warn(
|
|
1415
|
+
`${TAG} failed to get project item ID for issue #${issueNumber}: ${err.message}`,
|
|
1416
|
+
);
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Update a generic field value on a project item via GraphQL mutation.
|
|
1423
|
+
* Supports text, number, date, and single_select field types.
|
|
1424
|
+
* @public
|
|
1425
|
+
* @param {string|number} issueNumber - Issue number
|
|
1426
|
+
* @param {string} projectNumber - GitHub project number
|
|
1427
|
+
* @param {string} fieldName - Field name (case-insensitive)
|
|
1428
|
+
* @param {string|number} value - Value to set
|
|
1429
|
+
* @returns {Promise<boolean>} Success status
|
|
1430
|
+
*/
|
|
1431
|
+
async syncFieldToProject(issueNumber, projectNumber, fieldName, value) {
|
|
1432
|
+
if (!issueNumber || !projectNumber || !fieldName) return false;
|
|
1433
|
+
|
|
1434
|
+
try {
|
|
1435
|
+
const projectId = await this.getProjectNodeId(projectNumber);
|
|
1436
|
+
if (!projectId) {
|
|
1437
|
+
console.warn(`${TAG} syncFieldToProject: cannot resolve project ID`);
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const fieldMap = await this.getProjectFields(projectNumber);
|
|
1442
|
+
const fieldKey = String(fieldName).toLowerCase().trim();
|
|
1443
|
+
const field = fieldMap.get(fieldKey);
|
|
1444
|
+
if (!field) {
|
|
1445
|
+
console.warn(
|
|
1446
|
+
`${TAG} syncFieldToProject: field "${fieldName}" not found in project`,
|
|
1447
|
+
);
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const itemId = await this._getProjectItemIdForIssue(projectNumber, issueNumber);
|
|
1452
|
+
if (!itemId) {
|
|
1453
|
+
console.warn(
|
|
1454
|
+
`${TAG} syncFieldToProject: issue #${issueNumber} not found in project`,
|
|
1455
|
+
);
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const valueJson = this._stringifyProjectFieldValue(field, value);
|
|
1460
|
+
if (!valueJson) {
|
|
1461
|
+
console.warn(
|
|
1462
|
+
`${TAG} syncFieldToProject: value "${value}" invalid for field "${fieldName}"`,
|
|
1463
|
+
);
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
await this._updateProjectItemFieldsBatch(projectId, itemId, [
|
|
1468
|
+
{
|
|
1469
|
+
fieldId: field.id,
|
|
1470
|
+
value: valueJson,
|
|
1471
|
+
},
|
|
1472
|
+
]);
|
|
1473
|
+
console.log(
|
|
1474
|
+
`${TAG} synced field "${fieldName}" = "${value}" for issue #${issueNumber}`,
|
|
1475
|
+
);
|
|
1476
|
+
return true;
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
console.warn(
|
|
1479
|
+
`${TAG} syncFieldToProject failed for issue #${issueNumber}: ${err.message}`,
|
|
1480
|
+
);
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async syncIterationToProject(issueNumber, projectNumber, iterationName) {
|
|
1486
|
+
if (!issueNumber || !projectNumber || !iterationName) return false;
|
|
1487
|
+
return this.syncFieldToProject(
|
|
1488
|
+
issueNumber,
|
|
1489
|
+
projectNumber,
|
|
1490
|
+
"Iteration",
|
|
1491
|
+
iterationName,
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* List tasks from a GitHub Project board.
|
|
1497
|
+
* Fetches project items and normalizes them directly (no N+1 issue fetches).
|
|
1498
|
+
* @public
|
|
1499
|
+
* @param {string} projectNumber - GitHub project number
|
|
1500
|
+
* @returns {Promise<KanbanTask[]>}
|
|
1501
|
+
*/
|
|
1502
|
+
async listTasksFromProject(projectNumber, filters = {}) {
|
|
1503
|
+
if (!projectNumber) return [];
|
|
1504
|
+
|
|
1505
|
+
try {
|
|
1506
|
+
const owner = String(this._projectOwner || this._owner).trim();
|
|
1507
|
+
const items = await this._gh([
|
|
1508
|
+
"project",
|
|
1509
|
+
"item-list",
|
|
1510
|
+
String(projectNumber),
|
|
1511
|
+
"--owner",
|
|
1512
|
+
owner,
|
|
1513
|
+
"--format",
|
|
1514
|
+
"json",
|
|
1515
|
+
]);
|
|
1516
|
+
|
|
1517
|
+
if (!Array.isArray(items)) {
|
|
1518
|
+
console.warn(
|
|
1519
|
+
`${TAG} project item-list returned non-array for project ${projectNumber}`,
|
|
1520
|
+
);
|
|
1521
|
+
return [];
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const tasks = [];
|
|
1525
|
+
for (const item of items) {
|
|
1526
|
+
// Skip non-issue items (draft issues without content, PRs)
|
|
1527
|
+
if (item.content?.type === "PullRequest") continue;
|
|
1528
|
+
|
|
1529
|
+
const task = this._normaliseProjectItem(item);
|
|
1530
|
+
if (task) {
|
|
1531
|
+
task.meta.projectNumber = projectNumber;
|
|
1532
|
+
if (!task.meta.projectFieldValues.Status && item.status) {
|
|
1533
|
+
task.meta.projectFieldValues.Status = item.status;
|
|
1534
|
+
}
|
|
1535
|
+
// Cache the project item ID for later lookups
|
|
1536
|
+
if (task.id && item.id) {
|
|
1537
|
+
this._projectItemCache.set(`${projectNumber}:${task.id}`, item.id);
|
|
1538
|
+
}
|
|
1539
|
+
if (this._matchesProjectFieldFilters(task, filters.projectField)) {
|
|
1540
|
+
tasks.push(task);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return tasks;
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
console.warn(
|
|
1548
|
+
`${TAG} failed to list tasks from project ${projectNumber}: ${err.message}`,
|
|
1549
|
+
);
|
|
1550
|
+
return [];
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Sync task status to GitHub Project board.
|
|
1556
|
+
* Maps codex status to project Status field and updates via GraphQL.
|
|
1557
|
+
* Uses configurable PROJECT_STATUS_MAP for status name resolution.
|
|
1558
|
+
* @private
|
|
1559
|
+
* @param {string} issueUrl - Full GitHub issue URL
|
|
1560
|
+
* @param {string} projectNumber - GitHub project number
|
|
1561
|
+
* @param {string} status - Normalized status (todo/inprogress/inreview/done)
|
|
1562
|
+
* @returns {Promise<boolean>}
|
|
1563
|
+
*/
|
|
1564
|
+
async _syncStatusToProject(
|
|
1565
|
+
issueUrl,
|
|
1566
|
+
projectNumber,
|
|
1567
|
+
status,
|
|
1568
|
+
projectFields = {},
|
|
1569
|
+
) {
|
|
1570
|
+
if (!issueUrl || !projectNumber || !status) return false;
|
|
1571
|
+
|
|
1572
|
+
try {
|
|
1573
|
+
const owner = String(this._projectOwner || this._owner).trim();
|
|
1574
|
+
|
|
1575
|
+
// Get project fields
|
|
1576
|
+
const fields = await this._getProjectFields(projectNumber);
|
|
1577
|
+
if (!fields || !fields.statusFieldId) {
|
|
1578
|
+
console.warn(`${TAG} cannot sync to project: no status field found`);
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Map codex status to project status option using configurable mapping
|
|
1583
|
+
const targetStatusName = this._normalizeProjectStatus(status, true);
|
|
1584
|
+
const normalizedTarget = normaliseStatus(status);
|
|
1585
|
+
let statusOption = fields.statusOptions.find(
|
|
1586
|
+
(opt) => opt.name.toLowerCase() === targetStatusName.toLowerCase(),
|
|
1587
|
+
);
|
|
1588
|
+
|
|
1589
|
+
if (!statusOption) {
|
|
1590
|
+
statusOption = fields.statusOptions.find(
|
|
1591
|
+
(opt) => normaliseStatus(opt.name) === normalizedTarget,
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (!statusOption) {
|
|
1596
|
+
console.warn(
|
|
1597
|
+
`${TAG} no matching project status for "${targetStatusName}"`,
|
|
1598
|
+
);
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// First, ensure issue is in the project
|
|
1603
|
+
try {
|
|
1604
|
+
await this._gh(
|
|
1605
|
+
[
|
|
1606
|
+
"project",
|
|
1607
|
+
"item-add",
|
|
1608
|
+
String(projectNumber),
|
|
1609
|
+
"--owner",
|
|
1610
|
+
owner,
|
|
1611
|
+
"--url",
|
|
1612
|
+
issueUrl,
|
|
1613
|
+
],
|
|
1614
|
+
{ parseJson: false },
|
|
1615
|
+
);
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
const text = String(err?.message || err).toLowerCase();
|
|
1618
|
+
if (!text.includes("already") && !text.includes("item")) {
|
|
1619
|
+
throw err;
|
|
1620
|
+
}
|
|
1621
|
+
// Item already in project, continue
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const issueNum = issueUrl.match(/\/issues\/(\d+)/)?.[1];
|
|
1625
|
+
if (!issueNum) {
|
|
1626
|
+
console.warn(`${TAG} could not parse issue number from URL: ${issueUrl}`);
|
|
1627
|
+
return false;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const projectId = await this.getProjectNodeId(projectNumber);
|
|
1631
|
+
if (!projectId) {
|
|
1632
|
+
console.warn(
|
|
1633
|
+
`${TAG} could not resolve project ID for ${owner}/${projectNumber}`,
|
|
1634
|
+
);
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
const itemId = await this._getProjectItemIdForIssue(projectNumber, issueNum);
|
|
1638
|
+
if (!itemId) {
|
|
1639
|
+
console.warn(
|
|
1640
|
+
`${TAG} issue not found in project ${owner}/${projectNumber}`,
|
|
1641
|
+
);
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
const fieldMap = await this.getProjectFields(projectNumber);
|
|
1645
|
+
const updates = [];
|
|
1646
|
+
updates.push({
|
|
1647
|
+
fieldId: fields.statusFieldId,
|
|
1648
|
+
value: `{singleSelectOptionId: ${this._escapeGraphQLString(statusOption.id)}}`,
|
|
1649
|
+
});
|
|
1650
|
+
for (const [fieldName, fieldValue] of Object.entries(projectFields || {})) {
|
|
1651
|
+
if (!fieldName || /^status$/i.test(fieldName)) continue;
|
|
1652
|
+
const field = fieldMap.get(String(fieldName).toLowerCase().trim());
|
|
1653
|
+
if (!field) {
|
|
1654
|
+
console.warn(
|
|
1655
|
+
`${TAG} skipping unknown project field during status sync: ${fieldName}`,
|
|
1656
|
+
);
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
const value = this._stringifyProjectFieldValue(field, fieldValue);
|
|
1660
|
+
if (!value) {
|
|
1661
|
+
console.warn(
|
|
1662
|
+
`${TAG} skipping invalid project field value during status sync: ${fieldName}`,
|
|
1663
|
+
);
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
updates.push({
|
|
1667
|
+
fieldId: field.id,
|
|
1668
|
+
value,
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
await this._updateProjectItemFieldsBatch(projectId, itemId, updates);
|
|
1672
|
+
|
|
1673
|
+
console.log(
|
|
1674
|
+
`${TAG} synced issue ${issueUrl} to project status: ${targetStatusName}`,
|
|
1675
|
+
);
|
|
1676
|
+
return true;
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
console.warn(`${TAG} failed to sync status to project: ${err.message}`);
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Execute a gh CLI command and return parsed JSON (with rate limit retry) */
|
|
1684
|
+
async _gh(args, options = {}) {
|
|
1685
|
+
const { parseJson = true } = options;
|
|
1686
|
+
const { execFile } = await import("node:child_process");
|
|
1687
|
+
const { promisify } = await import("node:util");
|
|
1688
|
+
const execFileAsync = promisify(execFile);
|
|
1689
|
+
|
|
1690
|
+
const attempt = async () => {
|
|
1691
|
+
const { stdout, stderr } = await execFileAsync("gh", args, {
|
|
1692
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
1693
|
+
timeout: 30_000,
|
|
1694
|
+
});
|
|
1695
|
+
return { stdout, stderr };
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
let result;
|
|
1699
|
+
try {
|
|
1700
|
+
result = await attempt();
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
const errText = String(err?.message || err?.stderr || err).toLowerCase();
|
|
1703
|
+
// Rate limit detection: "API rate limit exceeded" or HTTP 403
|
|
1704
|
+
if (
|
|
1705
|
+
errText.includes("rate limit") ||
|
|
1706
|
+
errText.includes("api rate limit exceeded") ||
|
|
1707
|
+
(errText.includes("403") && errText.includes("limit"))
|
|
1708
|
+
) {
|
|
1709
|
+
console.warn(`${TAG} rate limit detected, waiting 60s before retry...`);
|
|
1710
|
+
await new Promise((resolve) =>
|
|
1711
|
+
setTimeout(resolve, this._rateLimitRetryDelayMs),
|
|
1712
|
+
);
|
|
1713
|
+
try {
|
|
1714
|
+
result = await attempt();
|
|
1715
|
+
} catch (retryErr) {
|
|
1716
|
+
throw new Error(
|
|
1717
|
+
`gh CLI failed (after rate limit retry): ${retryErr.message}`,
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
} else {
|
|
1721
|
+
throw new Error(`gh CLI failed: ${err.message}`);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
const text = String(result.stdout || "").trim();
|
|
1726
|
+
if (!parseJson) return text;
|
|
1727
|
+
if (!text) return null;
|
|
1728
|
+
return JSON.parse(text);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async _ensureLabelExists(label) {
|
|
1732
|
+
const name = String(label || "").trim();
|
|
1733
|
+
if (!name) return;
|
|
1734
|
+
const colorByLabel = {
|
|
1735
|
+
inprogress: "2563eb",
|
|
1736
|
+
"in-progress": "2563eb",
|
|
1737
|
+
inreview: "f59e0b",
|
|
1738
|
+
"in-review": "f59e0b",
|
|
1739
|
+
blocked: "dc2626",
|
|
1740
|
+
};
|
|
1741
|
+
const color = colorByLabel[name.toLowerCase()] || "94a3b8";
|
|
1742
|
+
try {
|
|
1743
|
+
await this._gh(
|
|
1744
|
+
[
|
|
1745
|
+
"label",
|
|
1746
|
+
"create",
|
|
1747
|
+
name,
|
|
1748
|
+
"--repo",
|
|
1749
|
+
`${this._owner}/${this._repo}`,
|
|
1750
|
+
"--color",
|
|
1751
|
+
color,
|
|
1752
|
+
"--description",
|
|
1753
|
+
`openfleet status: ${name}`,
|
|
1754
|
+
],
|
|
1755
|
+
{ parseJson: false },
|
|
1756
|
+
);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
const msg = String(err?.message || err).toLowerCase();
|
|
1759
|
+
if (
|
|
1760
|
+
msg.includes("already exists") ||
|
|
1761
|
+
msg.includes("label") && msg.includes("exists")
|
|
1762
|
+
) {
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
console.warn(`${TAG} failed to ensure label "${name}": ${err?.message || err}`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async listProjects() {
|
|
1770
|
+
// GitHub doesn't have "projects" in the same sense — return repo as project
|
|
1771
|
+
return [
|
|
1772
|
+
{
|
|
1773
|
+
id: `${this._owner}/${this._repo}`,
|
|
1774
|
+
name: this._repo,
|
|
1775
|
+
meta: { owner: this._owner, repo: this._repo },
|
|
1776
|
+
backend: "github",
|
|
1777
|
+
},
|
|
1778
|
+
];
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
async listTasks(_projectId, filters = {}) {
|
|
1782
|
+
// If project mode is enabled, read from project board
|
|
1783
|
+
if (this._projectMode === "kanban" && this._projectNumber) {
|
|
1784
|
+
const projectNumber = await this._resolveProjectNumber();
|
|
1785
|
+
if (projectNumber) {
|
|
1786
|
+
try {
|
|
1787
|
+
const tasks = await this.listTasksFromProject(projectNumber, filters);
|
|
1788
|
+
|
|
1789
|
+
// Apply filters
|
|
1790
|
+
let filtered = tasks;
|
|
1791
|
+
|
|
1792
|
+
if (this._enforceTaskLabel) {
|
|
1793
|
+
filtered = filtered.filter((task) =>
|
|
1794
|
+
this._isTaskScopedForCodex(task),
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
if (filters.status) {
|
|
1799
|
+
const normalizedFilter = normaliseStatus(filters.status);
|
|
1800
|
+
filtered = filtered.filter(
|
|
1801
|
+
(task) => task.status === normalizedFilter,
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// Enrich with shared state from comments
|
|
1806
|
+
for (const task of filtered) {
|
|
1807
|
+
try {
|
|
1808
|
+
const sharedState = normalizeSharedStatePayload(
|
|
1809
|
+
await this.readSharedStateFromIssue(task.id),
|
|
1810
|
+
);
|
|
1811
|
+
if (sharedState) {
|
|
1812
|
+
task.meta.sharedState = sharedState;
|
|
1813
|
+
task.sharedState = sharedState;
|
|
1814
|
+
}
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
console.warn(
|
|
1817
|
+
`[kanban] failed to read shared state for #${task.id}: ${err.message}`,
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
return filtered;
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
console.warn(
|
|
1825
|
+
`${TAG} failed to list tasks from project, falling back to issues: ${err.message}`,
|
|
1826
|
+
);
|
|
1827
|
+
// Fall through to regular issue listing
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Default: list from issues
|
|
1833
|
+
const limit =
|
|
1834
|
+
Number(filters.limit || process.env.GITHUB_ISSUES_LIST_LIMIT || 1000) ||
|
|
1835
|
+
1000;
|
|
1836
|
+
const args = [
|
|
1837
|
+
"issue",
|
|
1838
|
+
"list",
|
|
1839
|
+
"--repo",
|
|
1840
|
+
`${this._owner}/${this._repo}`,
|
|
1841
|
+
"--json",
|
|
1842
|
+
"number,title,body,state,url,assignees,labels,milestone,comments",
|
|
1843
|
+
"--limit",
|
|
1844
|
+
String(limit),
|
|
1845
|
+
];
|
|
1846
|
+
if (filters.status === "done") {
|
|
1847
|
+
args.push("--state", "closed");
|
|
1848
|
+
} else if (filters.status && filters.status !== "todo") {
|
|
1849
|
+
args.push("--state", "open");
|
|
1850
|
+
args.push("--label", filters.status);
|
|
1851
|
+
} else {
|
|
1852
|
+
args.push("--state", "open");
|
|
1853
|
+
}
|
|
1854
|
+
const issues = await this._gh(args);
|
|
1855
|
+
let normalized = (Array.isArray(issues) ? issues : []).map((i) =>
|
|
1856
|
+
this._normaliseIssue(i),
|
|
1857
|
+
);
|
|
1858
|
+
|
|
1859
|
+
if (this._enforceTaskLabel) {
|
|
1860
|
+
normalized = normalized.filter((task) =>
|
|
1861
|
+
this._isTaskScopedForCodex(task),
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Enrich with shared state from comments
|
|
1866
|
+
for (const task of normalized) {
|
|
1867
|
+
try {
|
|
1868
|
+
const sharedState = normalizeSharedStatePayload(
|
|
1869
|
+
await this.readSharedStateFromIssue(task.id),
|
|
1870
|
+
);
|
|
1871
|
+
if (sharedState) {
|
|
1872
|
+
task.meta.sharedState = sharedState;
|
|
1873
|
+
task.sharedState = sharedState;
|
|
1874
|
+
}
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
// Non-critical - continue without shared state
|
|
1877
|
+
console.warn(
|
|
1878
|
+
`[kanban] failed to read shared state for #${task.id}: ${err.message}`,
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
return normalized;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
async getTask(issueNumber) {
|
|
1887
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
1888
|
+
if (!/^\d+$/.test(num)) {
|
|
1889
|
+
throw new Error(
|
|
1890
|
+
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
let issue = null;
|
|
1894
|
+
try {
|
|
1895
|
+
issue = await this._gh([
|
|
1896
|
+
"issue",
|
|
1897
|
+
"view",
|
|
1898
|
+
num,
|
|
1899
|
+
"--repo",
|
|
1900
|
+
`${this._owner}/${this._repo}`,
|
|
1901
|
+
"--json",
|
|
1902
|
+
"number,title,body,state,url,assignees,labels,milestone,comments",
|
|
1903
|
+
]);
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
console.warn(
|
|
1906
|
+
`${TAG} failed to fetch issue #${num}: ${err.message || err}`,
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
const task = issue
|
|
1910
|
+
? this._normaliseIssue(issue)
|
|
1911
|
+
: {
|
|
1912
|
+
id: String(num),
|
|
1913
|
+
title: "",
|
|
1914
|
+
description: "",
|
|
1915
|
+
status: "todo",
|
|
1916
|
+
assignee: null,
|
|
1917
|
+
priority: null,
|
|
1918
|
+
projectId: `${this._owner}/${this._repo}`,
|
|
1919
|
+
branchName: null,
|
|
1920
|
+
prNumber: null,
|
|
1921
|
+
meta: {},
|
|
1922
|
+
taskUrl: null,
|
|
1923
|
+
backend: "github",
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
if (issue && (!task.branchName || !task.prNumber)) {
|
|
1927
|
+
const comments = Array.isArray(issue?.comments) ? issue.comments : [];
|
|
1928
|
+
for (const comment of comments) {
|
|
1929
|
+
const body = comment?.body || comment?.bodyText || comment?.body_html || "";
|
|
1930
|
+
if (!task.branchName) {
|
|
1931
|
+
const branch = extractBranchFromText(body);
|
|
1932
|
+
if (branch) task.branchName = branch;
|
|
1933
|
+
}
|
|
1934
|
+
if (!task.prNumber) {
|
|
1935
|
+
const pr = extractPrFromText(body);
|
|
1936
|
+
if (pr) task.prNumber = pr;
|
|
1937
|
+
}
|
|
1938
|
+
if (task.branchName && task.prNumber) break;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Enrich with shared state from comments
|
|
1943
|
+
try {
|
|
1944
|
+
const sharedState = normalizeSharedStatePayload(
|
|
1945
|
+
await this.readSharedStateFromIssue(num),
|
|
1946
|
+
);
|
|
1947
|
+
if (sharedState) {
|
|
1948
|
+
task.meta.sharedState = sharedState;
|
|
1949
|
+
task.sharedState = sharedState;
|
|
1950
|
+
}
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
// Non-critical - continue without shared state
|
|
1953
|
+
console.warn(
|
|
1954
|
+
`[kanban] failed to read shared state for #${num}: ${err.message}`,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
return task;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
async updateTaskStatus(issueNumber, status, options = {}) {
|
|
1962
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
1963
|
+
if (!/^\d+$/.test(num)) {
|
|
1964
|
+
throw new Error(
|
|
1965
|
+
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
const normalised = normaliseStatus(status);
|
|
1969
|
+
if (normalised === "done" || normalised === "cancelled") {
|
|
1970
|
+
const closeArgs = [
|
|
1971
|
+
"issue",
|
|
1972
|
+
"close",
|
|
1973
|
+
num,
|
|
1974
|
+
"--repo",
|
|
1975
|
+
`${this._owner}/${this._repo}`,
|
|
1976
|
+
];
|
|
1977
|
+
if (normalised === "cancelled") {
|
|
1978
|
+
closeArgs.push("--reason", "not planned");
|
|
1979
|
+
}
|
|
1980
|
+
await this._gh(closeArgs, { parseJson: false });
|
|
1981
|
+
} else {
|
|
1982
|
+
await this._gh(
|
|
1983
|
+
["issue", "reopen", num, "--repo", `${this._owner}/${this._repo}`],
|
|
1984
|
+
{ parseJson: false },
|
|
1985
|
+
);
|
|
1986
|
+
|
|
1987
|
+
// Keep status labels in sync for open issues.
|
|
1988
|
+
const labelByStatus = {
|
|
1989
|
+
draft: "draft",
|
|
1990
|
+
inprogress: "inprogress",
|
|
1991
|
+
inreview: "inreview",
|
|
1992
|
+
blocked: "blocked",
|
|
1993
|
+
};
|
|
1994
|
+
const nextLabel = labelByStatus[normalised] || null;
|
|
1995
|
+
const statusLabels = [
|
|
1996
|
+
"draft",
|
|
1997
|
+
"inprogress",
|
|
1998
|
+
"in-progress",
|
|
1999
|
+
"inreview",
|
|
2000
|
+
"in-review",
|
|
2001
|
+
"blocked",
|
|
2002
|
+
];
|
|
2003
|
+
const removeLabels = statusLabels.filter((label) => label !== nextLabel);
|
|
2004
|
+
const editArgs = [
|
|
2005
|
+
"issue",
|
|
2006
|
+
"edit",
|
|
2007
|
+
num,
|
|
2008
|
+
"--repo",
|
|
2009
|
+
`${this._owner}/${this._repo}`,
|
|
2010
|
+
];
|
|
2011
|
+
if (nextLabel) {
|
|
2012
|
+
editArgs.push("--add-label", nextLabel);
|
|
2013
|
+
}
|
|
2014
|
+
for (const label of removeLabels) {
|
|
2015
|
+
editArgs.push("--remove-label", label);
|
|
2016
|
+
}
|
|
2017
|
+
const applyStatusLabels = async () =>
|
|
2018
|
+
this._gh(editArgs, { parseJson: false });
|
|
2019
|
+
try {
|
|
2020
|
+
await applyStatusLabels();
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
if (nextLabel) {
|
|
2023
|
+
try {
|
|
2024
|
+
await this._ensureLabelExists(nextLabel);
|
|
2025
|
+
await applyStatusLabels();
|
|
2026
|
+
} catch {
|
|
2027
|
+
// Label might not exist — non-critical
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Optionally sync shared state if provided
|
|
2034
|
+
if (options.sharedState) {
|
|
2035
|
+
try {
|
|
2036
|
+
await this.persistSharedStateToIssue(num, options.sharedState);
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
console.warn(
|
|
2039
|
+
`[kanban] failed to persist shared state for #${num}: ${err.message}`,
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Sync to project if configured and auto-sync is enabled
|
|
2045
|
+
if (
|
|
2046
|
+
this._projectMode === "kanban" &&
|
|
2047
|
+
this._projectNumber &&
|
|
2048
|
+
this._projectAutoSync
|
|
2049
|
+
) {
|
|
2050
|
+
const projectNumber = await this._resolveProjectNumber();
|
|
2051
|
+
if (projectNumber) {
|
|
2052
|
+
const task = await this.getTask(num);
|
|
2053
|
+
if (task?.taskUrl) {
|
|
2054
|
+
try {
|
|
2055
|
+
await this._syncStatusToProject(
|
|
2056
|
+
task.taskUrl,
|
|
2057
|
+
projectNumber,
|
|
2058
|
+
normalised,
|
|
2059
|
+
options.projectFields,
|
|
2060
|
+
);
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
// Log but don't fail - issue update should still succeed
|
|
2063
|
+
console.warn(
|
|
2064
|
+
`${TAG} failed to sync status to project: ${err.message}`,
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
try {
|
|
2072
|
+
return await this.getTask(issueNumber);
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
console.warn(
|
|
2075
|
+
`${TAG} failed to fetch updated issue #${num} after status change: ${err.message}`,
|
|
2076
|
+
);
|
|
2077
|
+
return {
|
|
2078
|
+
id: num,
|
|
2079
|
+
title: "",
|
|
2080
|
+
description: "",
|
|
2081
|
+
status: normalised,
|
|
2082
|
+
assignee: null,
|
|
2083
|
+
priority: null,
|
|
2084
|
+
projectId: `${this._owner}/${this._repo}`,
|
|
2085
|
+
branchName: null,
|
|
2086
|
+
prNumber: null,
|
|
2087
|
+
meta: {},
|
|
2088
|
+
taskUrl: null,
|
|
2089
|
+
backend: "github",
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async updateTask(issueNumber, patch = {}) {
|
|
2095
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2096
|
+
if (!/^\d+$/.test(num)) {
|
|
2097
|
+
throw new Error(
|
|
2098
|
+
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
const editArgs = [
|
|
2102
|
+
"issue",
|
|
2103
|
+
"edit",
|
|
2104
|
+
num,
|
|
2105
|
+
"--repo",
|
|
2106
|
+
`${this._owner}/${this._repo}`,
|
|
2107
|
+
];
|
|
2108
|
+
let hasEditArgs = false;
|
|
2109
|
+
if (typeof patch.title === "string") {
|
|
2110
|
+
editArgs.push("--title", patch.title);
|
|
2111
|
+
hasEditArgs = true;
|
|
2112
|
+
}
|
|
2113
|
+
if (typeof patch.description === "string") {
|
|
2114
|
+
editArgs.push("--body", patch.description);
|
|
2115
|
+
hasEditArgs = true;
|
|
2116
|
+
}
|
|
2117
|
+
if (hasEditArgs) {
|
|
2118
|
+
await this._gh(editArgs, { parseJson: false });
|
|
2119
|
+
}
|
|
2120
|
+
const baseBranch = resolveBaseBranchInput(patch);
|
|
2121
|
+
const wantsTags =
|
|
2122
|
+
Array.isArray(patch.tags) ||
|
|
2123
|
+
Array.isArray(patch.labels) ||
|
|
2124
|
+
typeof patch.tags === "string";
|
|
2125
|
+
if (wantsTags || baseBranch) {
|
|
2126
|
+
const issue = await this._gh([
|
|
2127
|
+
"issue",
|
|
2128
|
+
"view",
|
|
2129
|
+
num,
|
|
2130
|
+
"--repo",
|
|
2131
|
+
`${this._owner}/${this._repo}`,
|
|
2132
|
+
"--json",
|
|
2133
|
+
"labels",
|
|
2134
|
+
]);
|
|
2135
|
+
const currentLabels = normalizeLabels(
|
|
2136
|
+
(issue?.labels || []).map((l) => (typeof l === "string" ? l : l.name)),
|
|
2137
|
+
);
|
|
2138
|
+
const systemLabels = new Set([
|
|
2139
|
+
...SYSTEM_LABEL_KEYS,
|
|
2140
|
+
...normalizeLabels(this._taskScopeLabels || []),
|
|
2141
|
+
]);
|
|
2142
|
+
const currentTags = currentLabels.filter(
|
|
2143
|
+
(label) => !systemLabels.has(label) && !isUpstreamLabel(label),
|
|
2144
|
+
);
|
|
2145
|
+
const desiredTags = wantsTags
|
|
2146
|
+
? normalizeTags(patch.tags ?? patch.labels)
|
|
2147
|
+
: currentTags;
|
|
2148
|
+
const nextLabels = new Set(
|
|
2149
|
+
currentLabels.filter(
|
|
2150
|
+
(label) => systemLabels.has(label) || isUpstreamLabel(label),
|
|
2151
|
+
),
|
|
2152
|
+
);
|
|
2153
|
+
for (const label of desiredTags) nextLabels.add(label);
|
|
2154
|
+
if (baseBranch) {
|
|
2155
|
+
const upstreamLabel = `base:${baseBranch}`.toLowerCase();
|
|
2156
|
+
for (const label of [...nextLabels]) {
|
|
2157
|
+
if (isUpstreamLabel(label)) nextLabels.delete(label);
|
|
2158
|
+
}
|
|
2159
|
+
nextLabels.add(upstreamLabel);
|
|
2160
|
+
}
|
|
2161
|
+
const desired = [...nextLabels];
|
|
2162
|
+
const desiredSet = new Set(desired);
|
|
2163
|
+
const toAdd = desired.filter((label) => !currentLabels.includes(label));
|
|
2164
|
+
const toRemove = currentLabels.filter(
|
|
2165
|
+
(label) => !desiredSet.has(label),
|
|
2166
|
+
);
|
|
2167
|
+
if (toAdd.length || toRemove.length) {
|
|
2168
|
+
const labelArgs = [
|
|
2169
|
+
"issue",
|
|
2170
|
+
"edit",
|
|
2171
|
+
num,
|
|
2172
|
+
"--repo",
|
|
2173
|
+
`${this._owner}/${this._repo}`,
|
|
2174
|
+
];
|
|
2175
|
+
for (const label of toAdd) {
|
|
2176
|
+
labelArgs.push("--add-label", label);
|
|
2177
|
+
}
|
|
2178
|
+
for (const label of toRemove) {
|
|
2179
|
+
labelArgs.push("--remove-label", label);
|
|
2180
|
+
}
|
|
2181
|
+
try {
|
|
2182
|
+
await this._gh(labelArgs, { parseJson: false });
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
for (const label of toAdd) {
|
|
2185
|
+
try {
|
|
2186
|
+
await this._ensureLabelExists(label);
|
|
2187
|
+
} catch {
|
|
2188
|
+
// ignore
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
await this._gh(labelArgs, { parseJson: false });
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (typeof patch.draft === "boolean" && !patch.status) {
|
|
2196
|
+
await this.updateTaskStatus(num, patch.draft ? "draft" : "todo");
|
|
2197
|
+
}
|
|
2198
|
+
if (typeof patch.status === "string" && patch.status.trim()) {
|
|
2199
|
+
return this.updateTaskStatus(num, patch.status.trim());
|
|
2200
|
+
}
|
|
2201
|
+
return this.getTask(num);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
async addProjectV2DraftIssue(projectNumber, title, body = "") {
|
|
2205
|
+
const projectId = await this.getProjectNodeId(projectNumber);
|
|
2206
|
+
if (!projectId) return null;
|
|
2207
|
+
const mutation = `
|
|
2208
|
+
mutation {
|
|
2209
|
+
addProjectV2DraftIssue(
|
|
2210
|
+
input: {
|
|
2211
|
+
projectId: ${this._escapeGraphQLString(projectId)},
|
|
2212
|
+
title: ${this._escapeGraphQLString(title || "New task")},
|
|
2213
|
+
body: ${this._escapeGraphQLString(body)}
|
|
2214
|
+
}
|
|
2215
|
+
) {
|
|
2216
|
+
projectItem {
|
|
2217
|
+
id
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
`;
|
|
2222
|
+
const result = await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
|
|
2223
|
+
return result?.data?.addProjectV2DraftIssue?.projectItem?.id || null;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
async convertProjectV2DraftIssueItemToIssue(_projectNumber, projectItemId) {
|
|
2227
|
+
if (!projectItemId) return null;
|
|
2228
|
+
const repositoryId = await this.getRepositoryNodeId();
|
|
2229
|
+
if (!repositoryId) return null;
|
|
2230
|
+
const mutation = `
|
|
2231
|
+
mutation {
|
|
2232
|
+
convertProjectV2DraftIssueItemToIssue(
|
|
2233
|
+
input: {
|
|
2234
|
+
itemId: ${this._escapeGraphQLString(projectItemId)},
|
|
2235
|
+
repositoryId: ${this._escapeGraphQLString(repositoryId)}
|
|
2236
|
+
}
|
|
2237
|
+
) {
|
|
2238
|
+
item {
|
|
2239
|
+
id
|
|
2240
|
+
}
|
|
2241
|
+
issue {
|
|
2242
|
+
number
|
|
2243
|
+
url
|
|
2244
|
+
title
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
`;
|
|
2249
|
+
const result = await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
|
|
2250
|
+
return result?.data?.convertProjectV2DraftIssueItemToIssue?.issue || null;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
async createTask(_projectId, taskData) {
|
|
2254
|
+
const wantsDraftCreate = Boolean(taskData?.draft || taskData?.createDraft);
|
|
2255
|
+
const shouldConvertDraft = Boolean(
|
|
2256
|
+
taskData?.convertDraft || taskData?.convertToIssue,
|
|
2257
|
+
);
|
|
2258
|
+
const requestedStatus = normaliseStatus(taskData.status || "todo");
|
|
2259
|
+
|
|
2260
|
+
let projectNumber = null;
|
|
2261
|
+
if (this._projectMode === "kanban") {
|
|
2262
|
+
projectNumber = await this._resolveProjectNumber();
|
|
2263
|
+
}
|
|
2264
|
+
if (wantsDraftCreate && projectNumber) {
|
|
2265
|
+
const draftItemId = await this.addProjectV2DraftIssue(
|
|
2266
|
+
projectNumber,
|
|
2267
|
+
taskData.title || "New task",
|
|
2268
|
+
taskData.description || "",
|
|
2269
|
+
);
|
|
2270
|
+
if (!draftItemId) {
|
|
2271
|
+
throw new Error("[kanban] failed to create draft issue in project");
|
|
2272
|
+
}
|
|
2273
|
+
if (!shouldConvertDraft) {
|
|
2274
|
+
return {
|
|
2275
|
+
id: `draft:${draftItemId}`,
|
|
2276
|
+
title: taskData.title || "New task",
|
|
2277
|
+
description: taskData.description || "",
|
|
2278
|
+
status: requestedStatus,
|
|
2279
|
+
assignee: null,
|
|
2280
|
+
priority: null,
|
|
2281
|
+
projectId: `${this._owner}/${this._repo}`,
|
|
2282
|
+
branchName: null,
|
|
2283
|
+
prNumber: null,
|
|
2284
|
+
meta: {
|
|
2285
|
+
projectNumber,
|
|
2286
|
+
projectItemId: draftItemId,
|
|
2287
|
+
isDraft: true,
|
|
2288
|
+
},
|
|
2289
|
+
backend: "github",
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
const converted = await this.convertProjectV2DraftIssueItemToIssue(
|
|
2293
|
+
projectNumber,
|
|
2294
|
+
draftItemId,
|
|
2295
|
+
);
|
|
2296
|
+
const convertedIssueNumber = String(converted?.number || "").trim();
|
|
2297
|
+
if (!/^\d+$/.test(convertedIssueNumber)) {
|
|
2298
|
+
throw new Error(
|
|
2299
|
+
"[kanban] failed to convert draft issue to repository issue",
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
const requestedLabels = normalizeLabels([
|
|
2303
|
+
...(taskData.labels || []),
|
|
2304
|
+
...(taskData.tags || []),
|
|
2305
|
+
]);
|
|
2306
|
+
const baseBranch = resolveBaseBranchInput(taskData);
|
|
2307
|
+
const labelsToApply = new Set(requestedLabels);
|
|
2308
|
+
labelsToApply.add(
|
|
2309
|
+
String(this._canonicalTaskLabel || "openfleet").toLowerCase(),
|
|
2310
|
+
);
|
|
2311
|
+
if (requestedStatus === "draft") labelsToApply.add("draft");
|
|
2312
|
+
if (requestedStatus === "inprogress") labelsToApply.add("inprogress");
|
|
2313
|
+
if (requestedStatus === "inreview") labelsToApply.add("inreview");
|
|
2314
|
+
if (requestedStatus === "blocked") labelsToApply.add("blocked");
|
|
2315
|
+
if (baseBranch) labelsToApply.add(`base:${baseBranch}`.toLowerCase());
|
|
2316
|
+
for (const label of labelsToApply) {
|
|
2317
|
+
await this._ensureLabelExists(label);
|
|
2318
|
+
}
|
|
2319
|
+
const assignee =
|
|
2320
|
+
taskData.assignee ||
|
|
2321
|
+
(this._autoAssignCreator ? await this._resolveDefaultAssignee() : null);
|
|
2322
|
+
const editArgs = [
|
|
2323
|
+
"issue",
|
|
2324
|
+
"edit",
|
|
2325
|
+
convertedIssueNumber,
|
|
2326
|
+
"--repo",
|
|
2327
|
+
`${this._owner}/${this._repo}`,
|
|
2328
|
+
];
|
|
2329
|
+
if (assignee) editArgs.push("--add-assignee", assignee);
|
|
2330
|
+
for (const label of labelsToApply) {
|
|
2331
|
+
editArgs.push("--add-label", label);
|
|
2332
|
+
}
|
|
2333
|
+
await this._gh(editArgs, { parseJson: false });
|
|
2334
|
+
return this.updateTaskStatus(convertedIssueNumber, requestedStatus, {
|
|
2335
|
+
projectFields: taskData.projectFields,
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const requestedLabels = normalizeLabels([
|
|
2340
|
+
...(taskData.labels || []),
|
|
2341
|
+
...(taskData.tags || []),
|
|
2342
|
+
]);
|
|
2343
|
+
const baseBranch = resolveBaseBranchInput(taskData);
|
|
2344
|
+
const labelsToApply = new Set(requestedLabels);
|
|
2345
|
+
labelsToApply.add(
|
|
2346
|
+
String(this._canonicalTaskLabel || "openfleet").toLowerCase(),
|
|
2347
|
+
);
|
|
2348
|
+
|
|
2349
|
+
if (requestedStatus === "draft") labelsToApply.add("draft");
|
|
2350
|
+
if (requestedStatus === "inprogress") labelsToApply.add("inprogress");
|
|
2351
|
+
if (requestedStatus === "inreview") labelsToApply.add("inreview");
|
|
2352
|
+
if (requestedStatus === "blocked") labelsToApply.add("blocked");
|
|
2353
|
+
if (baseBranch) labelsToApply.add(`base:${baseBranch}`.toLowerCase());
|
|
2354
|
+
|
|
2355
|
+
for (const label of labelsToApply) {
|
|
2356
|
+
await this._ensureLabelExists(label);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
const assignee =
|
|
2360
|
+
taskData.assignee ||
|
|
2361
|
+
(this._autoAssignCreator ? await this._resolveDefaultAssignee() : null);
|
|
2362
|
+
|
|
2363
|
+
const args = [
|
|
2364
|
+
"issue",
|
|
2365
|
+
"create",
|
|
2366
|
+
"--repo",
|
|
2367
|
+
`${this._owner}/${this._repo}`,
|
|
2368
|
+
"--title",
|
|
2369
|
+
taskData.title || "New task",
|
|
2370
|
+
"--body",
|
|
2371
|
+
taskData.description || "",
|
|
2372
|
+
];
|
|
2373
|
+
if (assignee) args.push("--assignee", assignee);
|
|
2374
|
+
if (labelsToApply.size > 0) {
|
|
2375
|
+
for (const label of labelsToApply) {
|
|
2376
|
+
args.push("--label", label);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
const result = await this._gh(args, { parseJson: false });
|
|
2380
|
+
const issueUrl = String(result || "").match(/https?:\/\/\S+/)?.[0] || "";
|
|
2381
|
+
const issueNum = issueUrl.match(/\/issues\/(\d+)/)?.[1] || null;
|
|
2382
|
+
if (issueUrl) {
|
|
2383
|
+
await this._ensureIssueLinkedToProject(issueUrl);
|
|
2384
|
+
}
|
|
2385
|
+
if (
|
|
2386
|
+
issueUrl &&
|
|
2387
|
+
projectNumber &&
|
|
2388
|
+
this._projectAutoSync &&
|
|
2389
|
+
(requestedStatus !== "todo" ||
|
|
2390
|
+
(taskData.projectFields &&
|
|
2391
|
+
typeof taskData.projectFields === "object" &&
|
|
2392
|
+
Object.keys(taskData.projectFields).length > 0))
|
|
2393
|
+
) {
|
|
2394
|
+
try {
|
|
2395
|
+
await this._syncStatusToProject(
|
|
2396
|
+
issueUrl,
|
|
2397
|
+
projectNumber,
|
|
2398
|
+
requestedStatus,
|
|
2399
|
+
taskData.projectFields,
|
|
2400
|
+
);
|
|
2401
|
+
} catch (err) {
|
|
2402
|
+
console.warn(
|
|
2403
|
+
`${TAG} failed to sync project fields for created issue: ${err.message}`,
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
if (issueNum) {
|
|
2408
|
+
return this.getTask(issueNum);
|
|
2409
|
+
}
|
|
2410
|
+
const numericFallback = String(result || "")
|
|
2411
|
+
.trim()
|
|
2412
|
+
.match(/^#?(\d+)$/)?.[1];
|
|
2413
|
+
if (numericFallback) {
|
|
2414
|
+
return this.getTask(numericFallback);
|
|
2415
|
+
}
|
|
2416
|
+
return { id: issueUrl || String(result || "").trim(), backend: "github" };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
async deleteTask(issueNumber) {
|
|
2420
|
+
// GitHub issues can't be deleted — close with "not planned"
|
|
2421
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2422
|
+
if (!/^\d+$/.test(num)) {
|
|
2423
|
+
throw new Error(
|
|
2424
|
+
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID`,
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
await this._gh([
|
|
2428
|
+
"issue",
|
|
2429
|
+
"close",
|
|
2430
|
+
num,
|
|
2431
|
+
"--repo",
|
|
2432
|
+
`${this._owner}/${this._repo}`,
|
|
2433
|
+
"--reason",
|
|
2434
|
+
"not planned",
|
|
2435
|
+
]);
|
|
2436
|
+
return true;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
async addComment(issueNumber, body) {
|
|
2440
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2441
|
+
if (!/^\d+$/.test(num) || !body) return false;
|
|
2442
|
+
try {
|
|
2443
|
+
await this._gh(
|
|
2444
|
+
[
|
|
2445
|
+
"issue",
|
|
2446
|
+
"comment",
|
|
2447
|
+
num,
|
|
2448
|
+
"--repo",
|
|
2449
|
+
`${this._owner}/${this._repo}`,
|
|
2450
|
+
"--body",
|
|
2451
|
+
String(body).slice(0, 65536),
|
|
2452
|
+
],
|
|
2453
|
+
{ parseJson: false },
|
|
2454
|
+
);
|
|
2455
|
+
return true;
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
console.warn(
|
|
2458
|
+
`[kanban] failed to comment on issue #${num}: ${err.message}`,
|
|
2459
|
+
);
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* Persist shared state to a GitHub issue using structured comments and labels.
|
|
2466
|
+
*
|
|
2467
|
+
* Creates or updates a openfleet-state comment with JSON state and applies
|
|
2468
|
+
* appropriate labels (codex:claimed, codex:working, codex:stale).
|
|
2469
|
+
*
|
|
2470
|
+
* Error handling: Retries once on failure, logs and continues on second failure.
|
|
2471
|
+
*
|
|
2472
|
+
* @param {string|number} issueNumber - GitHub issue number
|
|
2473
|
+
* @param {SharedState} sharedState - State object to persist
|
|
2474
|
+
* @returns {Promise<boolean>} Success status
|
|
2475
|
+
*
|
|
2476
|
+
* @example
|
|
2477
|
+
* await adapter.persistSharedStateToIssue(123, {
|
|
2478
|
+
* ownerId: "workstation-123/agent-456",
|
|
2479
|
+
* attemptToken: "uuid-here",
|
|
2480
|
+
* attemptStarted: "2026-02-14T17:00:00Z",
|
|
2481
|
+
* heartbeat: "2026-02-14T17:30:00Z",
|
|
2482
|
+
* status: "working",
|
|
2483
|
+
* retryCount: 1
|
|
2484
|
+
* });
|
|
2485
|
+
*/
|
|
2486
|
+
async persistSharedStateToIssue(issueNumber, sharedState) {
|
|
2487
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2488
|
+
if (!/^\d+$/.test(num)) {
|
|
2489
|
+
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
2490
|
+
}
|
|
2491
|
+
const normalizedState = normalizeSharedStatePayload(sharedState);
|
|
2492
|
+
if (!normalizedState) {
|
|
2493
|
+
throw new Error(`Invalid shared state payload for issue #${num}`);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
const attemptWithRetry = async (fn, maxRetries = 1) => {
|
|
2497
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2498
|
+
try {
|
|
2499
|
+
return await fn();
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
if (attempt === maxRetries) {
|
|
2502
|
+
console.error(
|
|
2503
|
+
`[kanban] persistSharedStateToIssue #${num} failed after ${maxRetries + 1} attempts: ${err.message}`,
|
|
2504
|
+
);
|
|
2505
|
+
return false;
|
|
2506
|
+
}
|
|
2507
|
+
console.warn(
|
|
2508
|
+
`[kanban] persistSharedStateToIssue #${num} attempt ${attempt + 1} failed, retrying: ${err.message}`,
|
|
2509
|
+
);
|
|
2510
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
// 1. Update labels based on status
|
|
2516
|
+
const labelsSuccess = await attemptWithRetry(async () => {
|
|
2517
|
+
const currentLabels = await this._getIssueLabels(num);
|
|
2518
|
+
const codexLabels = Object.values(this._codexLabels);
|
|
2519
|
+
const otherLabels = currentLabels.filter(
|
|
2520
|
+
(label) => !codexLabels.includes(label),
|
|
2521
|
+
);
|
|
2522
|
+
|
|
2523
|
+
// Determine new codex label based on status
|
|
2524
|
+
let newCodexLabel = null;
|
|
2525
|
+
if (normalizedState.status === "claimed") {
|
|
2526
|
+
newCodexLabel = this._codexLabels.claimed;
|
|
2527
|
+
} else if (normalizedState.status === "working") {
|
|
2528
|
+
newCodexLabel = this._codexLabels.working;
|
|
2529
|
+
} else if (normalizedState.status === "stale") {
|
|
2530
|
+
newCodexLabel = this._codexLabels.stale;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// Build new label set
|
|
2534
|
+
const newLabels = [...otherLabels];
|
|
2535
|
+
if (newCodexLabel) {
|
|
2536
|
+
newLabels.push(newCodexLabel);
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// Apply labels
|
|
2540
|
+
const editArgs = [
|
|
2541
|
+
"issue",
|
|
2542
|
+
"edit",
|
|
2543
|
+
num,
|
|
2544
|
+
"--repo",
|
|
2545
|
+
`${this._owner}/${this._repo}`,
|
|
2546
|
+
];
|
|
2547
|
+
|
|
2548
|
+
// Remove old codex labels
|
|
2549
|
+
for (const label of codexLabels) {
|
|
2550
|
+
if (label !== newCodexLabel && currentLabels.includes(label)) {
|
|
2551
|
+
editArgs.push("--remove-label", label);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// Add new codex label
|
|
2556
|
+
if (newCodexLabel && !currentLabels.includes(newCodexLabel)) {
|
|
2557
|
+
editArgs.push("--add-label", newCodexLabel);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (editArgs.length > 6) {
|
|
2561
|
+
// Only run if we have label changes
|
|
2562
|
+
await this._gh(editArgs, { parseJson: false });
|
|
2563
|
+
}
|
|
2564
|
+
return true;
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
// Short-circuit: if labels failed, skip comment update to avoid hanging
|
|
2568
|
+
if (!labelsSuccess) return false;
|
|
2569
|
+
|
|
2570
|
+
// 2. Create/update structured comment
|
|
2571
|
+
const commentSuccess = await attemptWithRetry(async () => {
|
|
2572
|
+
const comments = await this._getIssueComments(num);
|
|
2573
|
+
const stateCommentIndex = comments.findIndex((c) =>
|
|
2574
|
+
c.body?.includes("<!-- openfleet-state"),
|
|
2575
|
+
);
|
|
2576
|
+
|
|
2577
|
+
const [agentId, workstationId] = normalizedState.ownerId
|
|
2578
|
+
.split("/")
|
|
2579
|
+
.reverse();
|
|
2580
|
+
const stateJson = JSON.stringify(normalizedState, null, 2);
|
|
2581
|
+
const commentBody = `<!-- openfleet-state
|
|
2582
|
+
${stateJson}
|
|
2583
|
+
-->
|
|
2584
|
+
**Codex Monitor Status**: Agent \`${agentId}\` on \`${workstationId}\` is ${normalizedState.status === "working" ? "working on" : normalizedState.status === "claimed" ? "claiming" : "stale for"} this task.
|
|
2585
|
+
*Last heartbeat: ${normalizedState.heartbeat || normalizedState.ownerHeartbeat}*`;
|
|
2586
|
+
|
|
2587
|
+
if (stateCommentIndex >= 0) {
|
|
2588
|
+
// Update existing comment
|
|
2589
|
+
const commentId = comments[stateCommentIndex].id;
|
|
2590
|
+
await this._gh(
|
|
2591
|
+
[
|
|
2592
|
+
"api",
|
|
2593
|
+
`/repos/${this._owner}/${this._repo}/issues/comments/${commentId}`,
|
|
2594
|
+
"-X",
|
|
2595
|
+
"PATCH",
|
|
2596
|
+
"-f",
|
|
2597
|
+
`body=${commentBody}`,
|
|
2598
|
+
],
|
|
2599
|
+
{ parseJson: false },
|
|
2600
|
+
);
|
|
2601
|
+
} else {
|
|
2602
|
+
// Create new comment
|
|
2603
|
+
await this.addComment(num, commentBody);
|
|
2604
|
+
}
|
|
2605
|
+
return true;
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
return commentSuccess;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* Read shared state from a GitHub issue by parsing openfleet-state comments.
|
|
2613
|
+
*
|
|
2614
|
+
* Searches for the latest comment containing the structured state JSON and
|
|
2615
|
+
* returns the parsed SharedState object, or null if not found.
|
|
2616
|
+
*
|
|
2617
|
+
* @param {string|number} issueNumber - GitHub issue number
|
|
2618
|
+
* @returns {Promise<SharedState|null>} Parsed shared state or null
|
|
2619
|
+
*
|
|
2620
|
+
* @example
|
|
2621
|
+
* const state = await adapter.readSharedStateFromIssue(123);
|
|
2622
|
+
* if (state) {
|
|
2623
|
+
* console.log(`Task claimed by ${state.ownerId}`);
|
|
2624
|
+
* }
|
|
2625
|
+
*/
|
|
2626
|
+
async readSharedStateFromIssue(issueNumber) {
|
|
2627
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2628
|
+
if (!/^\d+$/.test(num)) {
|
|
2629
|
+
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
try {
|
|
2633
|
+
const comments = await this._getIssueComments(num);
|
|
2634
|
+
const stateComment = comments
|
|
2635
|
+
.reverse()
|
|
2636
|
+
.find((c) => c.body?.includes("<!-- openfleet-state"));
|
|
2637
|
+
|
|
2638
|
+
if (!stateComment) {
|
|
2639
|
+
return null;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// Extract JSON from comment
|
|
2643
|
+
const match = stateComment.body.match(
|
|
2644
|
+
/<!-- openfleet-state\s*\n([\s\S]*?)\n-->/,
|
|
2645
|
+
);
|
|
2646
|
+
if (!match) {
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
const stateJson = match[1].trim();
|
|
2651
|
+
const state = normalizeSharedStatePayload(JSON.parse(stateJson));
|
|
2652
|
+
|
|
2653
|
+
// Validate required fields
|
|
2654
|
+
if (
|
|
2655
|
+
!state?.ownerId ||
|
|
2656
|
+
!state?.attemptToken ||
|
|
2657
|
+
!state?.attemptStarted ||
|
|
2658
|
+
!(state?.heartbeat || state?.ownerHeartbeat) ||
|
|
2659
|
+
!state?.status
|
|
2660
|
+
) {
|
|
2661
|
+
console.warn(
|
|
2662
|
+
`[kanban] invalid shared state in #${num}: missing required fields`,
|
|
2663
|
+
);
|
|
2664
|
+
return null;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
return state;
|
|
2668
|
+
} catch (err) {
|
|
2669
|
+
console.warn(
|
|
2670
|
+
`[kanban] failed to read shared state for #${num}: ${err.message}`,
|
|
2671
|
+
);
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Mark a task as ignored by openfleet.
|
|
2678
|
+
*
|
|
2679
|
+
* Adds the `codex:ignore` label and posts a comment explaining why the task
|
|
2680
|
+
* is being ignored. This prevents openfleet from repeatedly attempting
|
|
2681
|
+
* to claim or work on tasks that are not suitable for automation.
|
|
2682
|
+
*
|
|
2683
|
+
* @param {string|number} issueNumber - GitHub issue number
|
|
2684
|
+
* @param {string} reason - Human-readable reason for ignoring
|
|
2685
|
+
* @returns {Promise<boolean>} Success status
|
|
2686
|
+
*
|
|
2687
|
+
* @example
|
|
2688
|
+
* await adapter.markTaskIgnored(123, "Task requires manual security review");
|
|
2689
|
+
*/
|
|
2690
|
+
async markTaskIgnored(issueNumber, reason) {
|
|
2691
|
+
const num = String(issueNumber).replace(/^#/, "");
|
|
2692
|
+
if (!/^\d+$/.test(num)) {
|
|
2693
|
+
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
try {
|
|
2697
|
+
// Add codex:ignore label
|
|
2698
|
+
await this._gh(
|
|
2699
|
+
[
|
|
2700
|
+
"issue",
|
|
2701
|
+
"edit",
|
|
2702
|
+
num,
|
|
2703
|
+
"--repo",
|
|
2704
|
+
`${this._owner}/${this._repo}`,
|
|
2705
|
+
"--add-label",
|
|
2706
|
+
this._codexLabels.ignore,
|
|
2707
|
+
],
|
|
2708
|
+
{ parseJson: false },
|
|
2709
|
+
);
|
|
2710
|
+
|
|
2711
|
+
// Add comment explaining why
|
|
2712
|
+
const commentBody = `**Codex Monitor**: This task has been marked as ignored.
|
|
2713
|
+
|
|
2714
|
+
**Reason**: ${reason}
|
|
2715
|
+
|
|
2716
|
+
To re-enable openfleet for this task, remove the \`${this._codexLabels.ignore}\` label.`;
|
|
2717
|
+
|
|
2718
|
+
await this.addComment(num, commentBody);
|
|
2719
|
+
|
|
2720
|
+
return true;
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
console.error(
|
|
2723
|
+
`[kanban] failed to mark task #${num} as ignored: ${err.message}`,
|
|
2724
|
+
);
|
|
2725
|
+
return false;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Get all labels for an issue.
|
|
2731
|
+
* @private
|
|
2732
|
+
*/
|
|
2733
|
+
async _getIssueLabels(issueNumber) {
|
|
2734
|
+
const issue = await this._gh([
|
|
2735
|
+
"issue",
|
|
2736
|
+
"view",
|
|
2737
|
+
issueNumber,
|
|
2738
|
+
"--repo",
|
|
2739
|
+
`${this._owner}/${this._repo}`,
|
|
2740
|
+
"--json",
|
|
2741
|
+
"labels",
|
|
2742
|
+
]);
|
|
2743
|
+
return (issue.labels || []).map((l) =>
|
|
2744
|
+
typeof l === "string" ? l : l.name,
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* Get all comments for an issue.
|
|
2750
|
+
* @private
|
|
2751
|
+
*/
|
|
2752
|
+
async _getIssueComments(issueNumber) {
|
|
2753
|
+
try {
|
|
2754
|
+
const result = await this._gh([
|
|
2755
|
+
"api",
|
|
2756
|
+
`/repos/${this._owner}/${this._repo}/issues/${issueNumber}/comments`,
|
|
2757
|
+
"--jq",
|
|
2758
|
+
".",
|
|
2759
|
+
]);
|
|
2760
|
+
return Array.isArray(result) ? result : [];
|
|
2761
|
+
} catch (err) {
|
|
2762
|
+
console.warn(
|
|
2763
|
+
`[kanban] failed to fetch comments for #${issueNumber}: ${err.message}`,
|
|
2764
|
+
);
|
|
2765
|
+
return [];
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
_isTaskScopedForCodex(task) {
|
|
2770
|
+
const labels = normalizeLabels(
|
|
2771
|
+
(task?.meta?.labels || []).map((entry) =>
|
|
2772
|
+
typeof entry === "string" ? entry : entry?.name,
|
|
2773
|
+
),
|
|
2774
|
+
);
|
|
2775
|
+
if (labels.length === 0) return false;
|
|
2776
|
+
return this._taskScopeLabels.some((label) => labels.includes(label));
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
async _resolveDefaultAssignee() {
|
|
2780
|
+
if (this._defaultAssignee) return this._defaultAssignee;
|
|
2781
|
+
try {
|
|
2782
|
+
const login = await this._gh(["api", "user", "--jq", ".login"], {
|
|
2783
|
+
parseJson: false,
|
|
2784
|
+
});
|
|
2785
|
+
const normalized = String(login || "").trim();
|
|
2786
|
+
if (normalized) {
|
|
2787
|
+
this._defaultAssignee = normalized;
|
|
2788
|
+
}
|
|
2789
|
+
} catch {
|
|
2790
|
+
this._defaultAssignee = null;
|
|
2791
|
+
}
|
|
2792
|
+
return this._defaultAssignee;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
async _ensureLabelExists(label) {
|
|
2796
|
+
const normalized = String(label || "").trim();
|
|
2797
|
+
if (!normalized) return;
|
|
2798
|
+
try {
|
|
2799
|
+
await this._gh(
|
|
2800
|
+
[
|
|
2801
|
+
"api",
|
|
2802
|
+
`/repos/${this._owner}/${this._repo}/labels`,
|
|
2803
|
+
"-X",
|
|
2804
|
+
"POST",
|
|
2805
|
+
"-f",
|
|
2806
|
+
`name=${normalized}`,
|
|
2807
|
+
"-f",
|
|
2808
|
+
"color=1D76DB",
|
|
2809
|
+
"-f",
|
|
2810
|
+
"description=Managed by openfleet",
|
|
2811
|
+
],
|
|
2812
|
+
{ parseJson: false },
|
|
2813
|
+
);
|
|
2814
|
+
} catch (err) {
|
|
2815
|
+
const text = String(err?.message || err).toLowerCase();
|
|
2816
|
+
if (
|
|
2817
|
+
text.includes("already_exists") ||
|
|
2818
|
+
text.includes("already exists") ||
|
|
2819
|
+
text.includes("unprocessable") ||
|
|
2820
|
+
text.includes("422")
|
|
2821
|
+
) {
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
console.warn(
|
|
2825
|
+
`[kanban] failed to ensure label "${normalized}": ${err.message || err}`,
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
_extractProjectNumber(value) {
|
|
2831
|
+
if (!value) return null;
|
|
2832
|
+
const text = String(value).trim();
|
|
2833
|
+
if (/^\d+$/.test(text)) return text;
|
|
2834
|
+
const match = text.match(/\/projects\/(\d+)(?:\b|$)/i);
|
|
2835
|
+
return match?.[1] || null;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
async _resolveProjectNumber() {
|
|
2839
|
+
if (this._cachedProjectNumber) return this._cachedProjectNumber;
|
|
2840
|
+
const owner = String(this._projectOwner || "").trim();
|
|
2841
|
+
const title = String(this._projectTitle || "OpenFleet").trim();
|
|
2842
|
+
if (!owner || !title) return null;
|
|
2843
|
+
|
|
2844
|
+
try {
|
|
2845
|
+
const projects = await this._gh(
|
|
2846
|
+
["project", "list", "--owner", owner, "--format", "json"],
|
|
2847
|
+
{ parseJson: true },
|
|
2848
|
+
);
|
|
2849
|
+
const list = Array.isArray(projects)
|
|
2850
|
+
? projects
|
|
2851
|
+
: Array.isArray(projects?.projects)
|
|
2852
|
+
? projects.projects
|
|
2853
|
+
: [];
|
|
2854
|
+
const existing = list.find(
|
|
2855
|
+
(project) =>
|
|
2856
|
+
String(project?.title || "")
|
|
2857
|
+
.trim()
|
|
2858
|
+
.toLowerCase() === title.toLowerCase(),
|
|
2859
|
+
);
|
|
2860
|
+
const existingNumber = this._extractProjectNumber(
|
|
2861
|
+
existing?.number || existing?.url,
|
|
2862
|
+
);
|
|
2863
|
+
if (existingNumber) {
|
|
2864
|
+
this._cachedProjectNumber = existingNumber;
|
|
2865
|
+
return existingNumber;
|
|
2866
|
+
}
|
|
2867
|
+
} catch {
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
try {
|
|
2872
|
+
const output = await this._gh(
|
|
2873
|
+
["project", "create", "--owner", owner, "--title", title],
|
|
2874
|
+
{ parseJson: false },
|
|
2875
|
+
);
|
|
2876
|
+
const createdNumber = this._extractProjectNumber(output);
|
|
2877
|
+
if (createdNumber) {
|
|
2878
|
+
this._cachedProjectNumber = createdNumber;
|
|
2879
|
+
return createdNumber;
|
|
2880
|
+
}
|
|
2881
|
+
} catch {
|
|
2882
|
+
return null;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
return null;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
async _ensureIssueLinkedToProject(issueUrl) {
|
|
2889
|
+
if (this._projectMode !== "kanban") return;
|
|
2890
|
+
const owner = String(this._projectOwner || "").trim();
|
|
2891
|
+
if (!owner || !issueUrl) return;
|
|
2892
|
+
const projectNumber = await this._resolveProjectNumber();
|
|
2893
|
+
if (!projectNumber) return;
|
|
2894
|
+
|
|
2895
|
+
try {
|
|
2896
|
+
await this._gh(
|
|
2897
|
+
[
|
|
2898
|
+
"project",
|
|
2899
|
+
"item-add",
|
|
2900
|
+
String(projectNumber),
|
|
2901
|
+
"--owner",
|
|
2902
|
+
owner,
|
|
2903
|
+
"--url",
|
|
2904
|
+
issueUrl,
|
|
2905
|
+
],
|
|
2906
|
+
{ parseJson: false },
|
|
2907
|
+
);
|
|
2908
|
+
} catch (err) {
|
|
2909
|
+
const text = String(err?.message || err).toLowerCase();
|
|
2910
|
+
if (text.includes("already") && text.includes("item")) {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
console.warn(
|
|
2914
|
+
`[kanban] failed to add issue to project ${owner}/${projectNumber}: ${err.message || err}`,
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
_normaliseIssue(issue) {
|
|
2920
|
+
if (!issue) return null;
|
|
2921
|
+
const labels = (issue.labels || []).map((l) =>
|
|
2922
|
+
typeof l === "string" ? l : l.name,
|
|
2923
|
+
);
|
|
2924
|
+
const labelSet = new Set(
|
|
2925
|
+
labels.map((l) =>
|
|
2926
|
+
String(l || "")
|
|
2927
|
+
.trim()
|
|
2928
|
+
.toLowerCase(),
|
|
2929
|
+
),
|
|
2930
|
+
);
|
|
2931
|
+
const labelStatus = statusFromLabels(labels);
|
|
2932
|
+
const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
|
|
2933
|
+
let status = "todo";
|
|
2934
|
+
if (issue.state === "closed" || issue.state === "CLOSED") {
|
|
2935
|
+
status = "done";
|
|
2936
|
+
} else if (labelStatus) {
|
|
2937
|
+
status = labelStatus;
|
|
2938
|
+
}
|
|
2939
|
+
if (labelSet.has("draft")) status = "draft";
|
|
2940
|
+
|
|
2941
|
+
// Check for openfleet labels
|
|
2942
|
+
const codexMeta = {
|
|
2943
|
+
isIgnored: labelSet.has("codex:ignore"),
|
|
2944
|
+
isClaimed: labelSet.has("codex:claimed"),
|
|
2945
|
+
isWorking: labelSet.has("codex:working"),
|
|
2946
|
+
isStale: labelSet.has("codex:stale"),
|
|
2947
|
+
};
|
|
2948
|
+
|
|
2949
|
+
// Extract branch name from issue body if present
|
|
2950
|
+
const branchMatch = (issue.body || "").match(/branch:\s*`?([^\s`]+)`?/i);
|
|
2951
|
+
const prMatch = (issue.body || "").match(/pr:\s*#?(\d+)/i);
|
|
2952
|
+
const baseBranch = normalizeBranchName(
|
|
2953
|
+
extractBaseBranchFromLabels(labels) ||
|
|
2954
|
+
extractBaseBranchFromText(issue.body || ""),
|
|
2955
|
+
);
|
|
2956
|
+
|
|
2957
|
+
return {
|
|
2958
|
+
id: String(issue.number || ""),
|
|
2959
|
+
title: issue.title || "",
|
|
2960
|
+
description: issue.body || "",
|
|
2961
|
+
status,
|
|
2962
|
+
assignee: issue.assignees?.[0]?.login || null,
|
|
2963
|
+
priority: labelSet.has("critical")
|
|
2964
|
+
? "critical"
|
|
2965
|
+
: labelSet.has("high")
|
|
2966
|
+
? "high"
|
|
2967
|
+
: null,
|
|
2968
|
+
tags,
|
|
2969
|
+
draft: labelSet.has("draft") || status === "draft",
|
|
2970
|
+
projectId: `${this._owner}/${this._repo}`,
|
|
2971
|
+
baseBranch,
|
|
2972
|
+
branchName: branchMatch?.[1] || null,
|
|
2973
|
+
prNumber: prMatch?.[1] || null,
|
|
2974
|
+
meta: {
|
|
2975
|
+
...issue,
|
|
2976
|
+
task_url: issue.url || null,
|
|
2977
|
+
tags,
|
|
2978
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
2979
|
+
codex: codexMeta,
|
|
2980
|
+
},
|
|
2981
|
+
taskUrl: issue.url || null,
|
|
2982
|
+
backend: "github",
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// ---------------------------------------------------------------------------
|
|
2988
|
+
// Jira Adapter
|
|
2989
|
+
// ---------------------------------------------------------------------------
|
|
2990
|
+
|
|
2991
|
+
class JiraAdapter {
|
|
2992
|
+
constructor() {
|
|
2993
|
+
this.name = "jira";
|
|
2994
|
+
this._baseUrl = String(process.env.JIRA_BASE_URL || "")
|
|
2995
|
+
.trim()
|
|
2996
|
+
.replace(/\/+$/, "");
|
|
2997
|
+
this._token = process.env.JIRA_API_TOKEN || null;
|
|
2998
|
+
this._email = process.env.JIRA_EMAIL || null;
|
|
2999
|
+
this._defaultProjectKey = String(process.env.JIRA_PROJECT_KEY || "")
|
|
3000
|
+
.trim()
|
|
3001
|
+
.toUpperCase();
|
|
3002
|
+
this._defaultIssueType = String(
|
|
3003
|
+
process.env.JIRA_ISSUE_TYPE || process.env.JIRA_DEFAULT_ISSUE_TYPE || "Task",
|
|
3004
|
+
).trim();
|
|
3005
|
+
this._taskListLimit =
|
|
3006
|
+
Number(process.env.JIRA_ISSUES_LIST_LIMIT || 250) || 250;
|
|
3007
|
+
this._useAdfComments = parseBooleanEnv(process.env.JIRA_USE_ADF_COMMENTS, true);
|
|
3008
|
+
this._defaultAssignee = String(process.env.JIRA_DEFAULT_ASSIGNEE || "").trim();
|
|
3009
|
+
this._subtaskParentKey = String(
|
|
3010
|
+
process.env.JIRA_SUBTASK_PARENT_KEY || "",
|
|
3011
|
+
).trim();
|
|
3012
|
+
this._canonicalTaskLabel = String(
|
|
3013
|
+
process.env.CODEX_MONITOR_TASK_LABEL || "openfleet",
|
|
3014
|
+
)
|
|
3015
|
+
.trim()
|
|
3016
|
+
.toLowerCase();
|
|
3017
|
+
this._taskScopeLabels = normalizeLabels(
|
|
3018
|
+
process.env.JIRA_TASK_LABELS ||
|
|
3019
|
+
process.env.CODEX_MONITOR_TASK_LABELS ||
|
|
3020
|
+
`${this._canonicalTaskLabel},openfleet`,
|
|
3021
|
+
).map((label) => this._sanitizeJiraLabel(label));
|
|
3022
|
+
this._enforceTaskLabel = parseBooleanEnv(
|
|
3023
|
+
process.env.JIRA_ENFORCE_TASK_LABEL ?? process.env.CODEX_MONITOR_ENFORCE_TASK_LABEL,
|
|
3024
|
+
true,
|
|
3025
|
+
);
|
|
3026
|
+
this._codexLabels = {
|
|
3027
|
+
claimed: this._sanitizeJiraLabel(
|
|
3028
|
+
process.env.JIRA_LABEL_CLAIMED ||
|
|
3029
|
+
process.env.JIRA_CODEX_LABEL_CLAIMED ||
|
|
3030
|
+
"codex-claimed",
|
|
3031
|
+
),
|
|
3032
|
+
working: this._sanitizeJiraLabel(
|
|
3033
|
+
process.env.JIRA_LABEL_WORKING ||
|
|
3034
|
+
process.env.JIRA_CODEX_LABEL_WORKING ||
|
|
3035
|
+
"codex-working",
|
|
3036
|
+
),
|
|
3037
|
+
stale: this._sanitizeJiraLabel(
|
|
3038
|
+
process.env.JIRA_LABEL_STALE ||
|
|
3039
|
+
process.env.JIRA_CODEX_LABEL_STALE ||
|
|
3040
|
+
"codex-stale",
|
|
3041
|
+
),
|
|
3042
|
+
ignore: this._sanitizeJiraLabel(
|
|
3043
|
+
process.env.JIRA_LABEL_IGNORE ||
|
|
3044
|
+
process.env.JIRA_CODEX_LABEL_IGNORE ||
|
|
3045
|
+
"codex-ignore",
|
|
3046
|
+
),
|
|
3047
|
+
};
|
|
3048
|
+
this._statusMap = {
|
|
3049
|
+
todo: process.env.JIRA_STATUS_TODO || "To Do",
|
|
3050
|
+
inprogress: process.env.JIRA_STATUS_INPROGRESS || "In Progress",
|
|
3051
|
+
inreview: process.env.JIRA_STATUS_INREVIEW || "In Review",
|
|
3052
|
+
done: process.env.JIRA_STATUS_DONE || "Done",
|
|
3053
|
+
cancelled: process.env.JIRA_STATUS_CANCELLED || "Cancelled",
|
|
3054
|
+
};
|
|
3055
|
+
this._sharedStateFields = {
|
|
3056
|
+
ownerId: process.env.JIRA_CUSTOM_FIELD_OWNER_ID || "",
|
|
3057
|
+
attemptToken: process.env.JIRA_CUSTOM_FIELD_ATTEMPT_TOKEN || "",
|
|
3058
|
+
attemptStarted: process.env.JIRA_CUSTOM_FIELD_ATTEMPT_STARTED || "",
|
|
3059
|
+
heartbeat: process.env.JIRA_CUSTOM_FIELD_HEARTBEAT || "",
|
|
3060
|
+
retryCount: process.env.JIRA_CUSTOM_FIELD_RETRY_COUNT || "",
|
|
3061
|
+
ignoreReason: process.env.JIRA_CUSTOM_FIELD_IGNORE_REASON || "",
|
|
3062
|
+
stateJson: process.env.JIRA_CUSTOM_FIELD_SHARED_STATE || "",
|
|
3063
|
+
};
|
|
3064
|
+
this._customFieldBaseBranch = String(
|
|
3065
|
+
process.env.JIRA_CUSTOM_FIELD_BASE_BRANCH || "",
|
|
3066
|
+
).trim();
|
|
3067
|
+
this._jiraFieldByNameCache = null;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
_requireConfigured() {
|
|
3071
|
+
if (!this._baseUrl || !this._email || !this._token) {
|
|
3072
|
+
throw new Error(
|
|
3073
|
+
`${TAG} Jira adapter requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN`,
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
_validateIssueKey(issueKey) {
|
|
3079
|
+
const key = String(issueKey || "")
|
|
3080
|
+
.trim()
|
|
3081
|
+
.toUpperCase();
|
|
3082
|
+
if (!/^[A-Z][A-Z0-9]+-\d+$/.test(key)) {
|
|
3083
|
+
throw new Error(
|
|
3084
|
+
`Jira: invalid issue key "${issueKey}" — expected format PROJ-123`,
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
return key;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
_normalizeProjectKey(projectKey) {
|
|
3091
|
+
const key = String(projectKey || this._defaultProjectKey || "")
|
|
3092
|
+
.trim()
|
|
3093
|
+
.toUpperCase();
|
|
3094
|
+
return /^[A-Z][A-Z0-9]+$/.test(key) ? key : "";
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
_normalizeIssueKey(issueKey) {
|
|
3098
|
+
const key = String(issueKey || "").trim().toUpperCase();
|
|
3099
|
+
return /^[A-Z][A-Z0-9]+-\d+$/.test(key) ? key : "";
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
_sanitizeJiraLabel(value) {
|
|
3103
|
+
return String(value || "")
|
|
3104
|
+
.trim()
|
|
3105
|
+
.toLowerCase()
|
|
3106
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
3107
|
+
.replace(/^-+|-+$/g, "");
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
_authHeaders() {
|
|
3111
|
+
const credentials = Buffer.from(`${this._email}:${this._token}`).toString(
|
|
3112
|
+
"base64",
|
|
3113
|
+
);
|
|
3114
|
+
return {
|
|
3115
|
+
Authorization: `Basic ${credentials}`,
|
|
3116
|
+
Accept: "application/json",
|
|
3117
|
+
"Content-Type": "application/json",
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
async _jira(path, options = {}) {
|
|
3122
|
+
this._requireConfigured();
|
|
3123
|
+
const method = options.method || "GET";
|
|
3124
|
+
const headers = {
|
|
3125
|
+
...this._authHeaders(),
|
|
3126
|
+
...(options.headers || {}),
|
|
3127
|
+
};
|
|
3128
|
+
const response = await fetchWithFallback(
|
|
3129
|
+
`${this._baseUrl}${path.startsWith("/") ? path : `/${path}`}`,
|
|
3130
|
+
{
|
|
3131
|
+
method,
|
|
3132
|
+
headers,
|
|
3133
|
+
body: options.body == null ? undefined : JSON.stringify(options.body),
|
|
3134
|
+
},
|
|
3135
|
+
);
|
|
3136
|
+
if (!response || typeof response.status !== "number") {
|
|
3137
|
+
throw new Error(`Jira API ${method} ${path} failed: no HTTP response`);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
if (response.status === 204) return null;
|
|
3141
|
+
|
|
3142
|
+
const contentType = String(response.headers.get("content-type") || "");
|
|
3143
|
+
let payload = null;
|
|
3144
|
+
if (contentType.includes("application/json")) {
|
|
3145
|
+
payload = await response.json().catch(() => null);
|
|
3146
|
+
} else {
|
|
3147
|
+
payload = await response.text().catch(() => "");
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
if (!response.ok) {
|
|
3151
|
+
const errorText =
|
|
3152
|
+
payload?.errorMessages?.join("; ") ||
|
|
3153
|
+
(payload?.errors ? Object.values(payload.errors || {}).join("; ") : "");
|
|
3154
|
+
const error = new Error(
|
|
3155
|
+
`Jira API ${method} ${path} failed (${response.status}): ${errorText || String(payload || response.statusText || "Unknown error")}`,
|
|
3156
|
+
);
|
|
3157
|
+
error.status = response.status;
|
|
3158
|
+
error.payload = payload;
|
|
3159
|
+
throw error;
|
|
3160
|
+
}
|
|
3161
|
+
return payload;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
_adfParagraph(text, marks = []) {
|
|
3165
|
+
return {
|
|
3166
|
+
type: "paragraph",
|
|
3167
|
+
content: [
|
|
3168
|
+
{
|
|
3169
|
+
type: "text",
|
|
3170
|
+
text: String(text || ""),
|
|
3171
|
+
...(Array.isArray(marks) && marks.length > 0 ? { marks } : {}),
|
|
3172
|
+
},
|
|
3173
|
+
],
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
_textToAdf(text) {
|
|
3178
|
+
const value = String(text || "");
|
|
3179
|
+
if (!value.trim()) {
|
|
3180
|
+
return { type: "doc", version: 1, content: [this._adfParagraph("")] };
|
|
3181
|
+
}
|
|
3182
|
+
const lines = value.split(/\r?\n/);
|
|
3183
|
+
return {
|
|
3184
|
+
type: "doc",
|
|
3185
|
+
version: 1,
|
|
3186
|
+
content: lines.map((line) => this._adfParagraph(line)),
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
_adfToText(node) {
|
|
3191
|
+
if (!node) return "";
|
|
3192
|
+
if (typeof node === "string") return node;
|
|
3193
|
+
if (Array.isArray(node)) {
|
|
3194
|
+
return node.map((entry) => this._adfToText(entry)).join("");
|
|
3195
|
+
}
|
|
3196
|
+
if (node.type === "text") return String(node.text || "");
|
|
3197
|
+
const content = Array.isArray(node.content) ? node.content : [];
|
|
3198
|
+
const inner = content.map((entry) => this._adfToText(entry)).join("");
|
|
3199
|
+
if (node.type === "paragraph" || node.type === "heading") {
|
|
3200
|
+
return `${inner}\n`;
|
|
3201
|
+
}
|
|
3202
|
+
if (node.type === "hardBreak") return "\n";
|
|
3203
|
+
return inner;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
_commentToText(commentBody) {
|
|
3207
|
+
if (typeof commentBody === "string") return commentBody;
|
|
3208
|
+
if (commentBody && typeof commentBody === "object") {
|
|
3209
|
+
return this._adfToText(commentBody).trim();
|
|
3210
|
+
}
|
|
3211
|
+
return "";
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
_normalizePriority(priorityName) {
|
|
3215
|
+
const value = String(priorityName || "")
|
|
3216
|
+
.trim()
|
|
3217
|
+
.toLowerCase();
|
|
3218
|
+
if (!value) return null;
|
|
3219
|
+
if (value.includes("highest") || value.includes("critical")) return "critical";
|
|
3220
|
+
if (value.includes("high")) return "high";
|
|
3221
|
+
if (value.includes("medium") || value.includes("normal")) return "medium";
|
|
3222
|
+
if (value.includes("low") || value.includes("lowest")) return "low";
|
|
3223
|
+
return null;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
_normalizeJiraStatus(statusObj) {
|
|
3227
|
+
if (!statusObj) return "todo";
|
|
3228
|
+
const statusCategory = String(statusObj?.statusCategory?.key || "")
|
|
3229
|
+
.trim()
|
|
3230
|
+
.toLowerCase();
|
|
3231
|
+
if (statusCategory === "done") return "done";
|
|
3232
|
+
return normaliseStatus(statusObj.name || statusObj.statusCategory?.name || "");
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
_normaliseIssue(issue) {
|
|
3236
|
+
const fields = issue?.fields || {};
|
|
3237
|
+
const labels = normalizeLabels(fields.labels || []);
|
|
3238
|
+
const labelSet = new Set(labels);
|
|
3239
|
+
const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
|
|
3240
|
+
const codexMeta = {
|
|
3241
|
+
isIgnored:
|
|
3242
|
+
labelSet.has(this._codexLabels.ignore) || labelSet.has("codex:ignore"),
|
|
3243
|
+
isClaimed:
|
|
3244
|
+
labelSet.has(this._codexLabels.claimed) || labelSet.has("codex:claimed"),
|
|
3245
|
+
isWorking:
|
|
3246
|
+
labelSet.has(this._codexLabels.working) || labelSet.has("codex:working"),
|
|
3247
|
+
isStale: labelSet.has(this._codexLabels.stale) || labelSet.has("codex:stale"),
|
|
3248
|
+
};
|
|
3249
|
+
const description = this._commentToText(fields.description);
|
|
3250
|
+
const branchMatch = description.match(/branch:\s*`?([^\s`]+)`?/i);
|
|
3251
|
+
const prMatch = description.match(/pr:\s*#?(\d+)/i);
|
|
3252
|
+
const baseBranchFromField = this._customFieldBaseBranch
|
|
3253
|
+
? fields[this._customFieldBaseBranch]
|
|
3254
|
+
: null;
|
|
3255
|
+
const baseBranch = normalizeBranchName(
|
|
3256
|
+
extractBaseBranchFromLabels(labels) ||
|
|
3257
|
+
extractBaseBranchFromText(description) ||
|
|
3258
|
+
(typeof baseBranchFromField === "string"
|
|
3259
|
+
? baseBranchFromField
|
|
3260
|
+
: baseBranchFromField?.value ||
|
|
3261
|
+
baseBranchFromField?.name ||
|
|
3262
|
+
baseBranchFromField),
|
|
3263
|
+
);
|
|
3264
|
+
const issueKey = String(issue?.key || "");
|
|
3265
|
+
let status = this._normalizeJiraStatus(fields.status);
|
|
3266
|
+
if (labelSet.has("draft")) status = "draft";
|
|
3267
|
+
const normalizedFieldValues = {};
|
|
3268
|
+
for (const [fieldKey, fieldValue] of Object.entries(fields || {})) {
|
|
3269
|
+
if (fieldValue == null) continue;
|
|
3270
|
+
const lcKey = String(fieldKey || "").toLowerCase();
|
|
3271
|
+
if (typeof fieldValue === "object") {
|
|
3272
|
+
if (typeof fieldValue.name === "string") {
|
|
3273
|
+
normalizedFieldValues[fieldKey] = fieldValue.name;
|
|
3274
|
+
normalizedFieldValues[lcKey] = fieldValue.name;
|
|
3275
|
+
} else if (typeof fieldValue.value === "string") {
|
|
3276
|
+
normalizedFieldValues[fieldKey] = fieldValue.value;
|
|
3277
|
+
normalizedFieldValues[lcKey] = fieldValue.value;
|
|
3278
|
+
} else {
|
|
3279
|
+
normalizedFieldValues[fieldKey] = this._commentToText(fieldValue);
|
|
3280
|
+
normalizedFieldValues[lcKey] = this._commentToText(fieldValue);
|
|
3281
|
+
}
|
|
3282
|
+
} else {
|
|
3283
|
+
normalizedFieldValues[fieldKey] = fieldValue;
|
|
3284
|
+
normalizedFieldValues[lcKey] = fieldValue;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
return {
|
|
3288
|
+
id: issueKey,
|
|
3289
|
+
title: fields.summary || "",
|
|
3290
|
+
description,
|
|
3291
|
+
status,
|
|
3292
|
+
assignee:
|
|
3293
|
+
fields.assignee?.displayName ||
|
|
3294
|
+
fields.assignee?.emailAddress ||
|
|
3295
|
+
fields.assignee?.accountId ||
|
|
3296
|
+
null,
|
|
3297
|
+
priority: this._normalizePriority(fields.priority?.name),
|
|
3298
|
+
tags,
|
|
3299
|
+
draft: labelSet.has("draft") || status === "draft",
|
|
3300
|
+
projectId: fields.project?.key || null,
|
|
3301
|
+
baseBranch,
|
|
3302
|
+
branchName: branchMatch?.[1] || null,
|
|
3303
|
+
prNumber: prMatch?.[1] || null,
|
|
3304
|
+
taskUrl: issueKey ? `${this._baseUrl}/browse/${issueKey}` : null,
|
|
3305
|
+
createdAt: fields.created || null,
|
|
3306
|
+
updatedAt: fields.updated || null,
|
|
3307
|
+
meta: {
|
|
3308
|
+
...issue,
|
|
3309
|
+
labels,
|
|
3310
|
+
fields: normalizedFieldValues,
|
|
3311
|
+
tags,
|
|
3312
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
3313
|
+
codex: codexMeta,
|
|
3314
|
+
},
|
|
3315
|
+
backend: "jira",
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
_isTaskScopedForCodex(task) {
|
|
3320
|
+
const labels = normalizeLabels(task?.meta?.labels || []);
|
|
3321
|
+
if (labels.length === 0) return false;
|
|
3322
|
+
return this._taskScopeLabels.some((label) => labels.includes(label));
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
_statusCandidates(normalizedStatus) {
|
|
3326
|
+
switch (normalizedStatus) {
|
|
3327
|
+
case "todo":
|
|
3328
|
+
return ["to do", "todo", "selected for development", "open", "backlog"];
|
|
3329
|
+
case "inprogress":
|
|
3330
|
+
return ["in progress", "in development", "doing", "active"];
|
|
3331
|
+
case "inreview":
|
|
3332
|
+
return ["in review", "review", "code review", "qa", "testing"];
|
|
3333
|
+
case "done":
|
|
3334
|
+
return ["done", "resolved", "closed", "complete", "completed"];
|
|
3335
|
+
case "cancelled":
|
|
3336
|
+
return ["cancelled", "canceled", "won't do", "wont do", "declined"];
|
|
3337
|
+
default:
|
|
3338
|
+
return [String(normalizedStatus || "").trim().toLowerCase()];
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
_normalizeIsoTimestamp(value) {
|
|
3343
|
+
const raw = String(value || "").trim();
|
|
3344
|
+
if (!raw) return "";
|
|
3345
|
+
const date = new Date(raw);
|
|
3346
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
3347
|
+
return date.toISOString();
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
async _getJiraFieldMap() {
|
|
3351
|
+
if (this._jiraFieldByNameCache) return this._jiraFieldByNameCache;
|
|
3352
|
+
const fields = await this._jira("/rest/api/3/field");
|
|
3353
|
+
const map = new Map();
|
|
3354
|
+
for (const field of Array.isArray(fields) ? fields : []) {
|
|
3355
|
+
const id = String(field?.id || "").trim();
|
|
3356
|
+
const name = String(field?.name || "")
|
|
3357
|
+
.trim()
|
|
3358
|
+
.toLowerCase();
|
|
3359
|
+
if (!id || !name) continue;
|
|
3360
|
+
map.set(name, id);
|
|
3361
|
+
}
|
|
3362
|
+
this._jiraFieldByNameCache = map;
|
|
3363
|
+
return map;
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
async _resolveJiraFieldId(fieldKeyOrName) {
|
|
3367
|
+
const raw = String(fieldKeyOrName || "").trim();
|
|
3368
|
+
if (!raw) return null;
|
|
3369
|
+
if (/^customfield_\d+$/i.test(raw)) return raw;
|
|
3370
|
+
const lc = raw.toLowerCase();
|
|
3371
|
+
if (
|
|
3372
|
+
[
|
|
3373
|
+
"summary",
|
|
3374
|
+
"description",
|
|
3375
|
+
"status",
|
|
3376
|
+
"assignee",
|
|
3377
|
+
"priority",
|
|
3378
|
+
"project",
|
|
3379
|
+
"labels",
|
|
3380
|
+
].includes(lc)
|
|
3381
|
+
) {
|
|
3382
|
+
return lc;
|
|
3383
|
+
}
|
|
3384
|
+
try {
|
|
3385
|
+
const map = await this._getJiraFieldMap();
|
|
3386
|
+
return map.get(lc) || null;
|
|
3387
|
+
} catch {
|
|
3388
|
+
return null;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
async _mapProjectFieldsInput(projectFields = {}) {
|
|
3393
|
+
const mapped = {};
|
|
3394
|
+
for (const [fieldName, value] of Object.entries(projectFields || {})) {
|
|
3395
|
+
const fieldId = await this._resolveJiraFieldId(fieldName);
|
|
3396
|
+
if (!fieldId || fieldId === "status") continue;
|
|
3397
|
+
mapped[fieldId] = value;
|
|
3398
|
+
}
|
|
3399
|
+
return mapped;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
_matchesProjectFieldFilters(task, projectFieldFilter) {
|
|
3403
|
+
if (!projectFieldFilter || typeof projectFieldFilter !== "object") {
|
|
3404
|
+
return true;
|
|
3405
|
+
}
|
|
3406
|
+
const values = task?.meta?.fields;
|
|
3407
|
+
if (!values || typeof values !== "object") return false;
|
|
3408
|
+
const entries = Object.entries(projectFieldFilter);
|
|
3409
|
+
if (entries.length === 0) return true;
|
|
3410
|
+
return entries.every(([fieldName, expected]) => {
|
|
3411
|
+
const direct = values[fieldName];
|
|
3412
|
+
const custom = values[String(fieldName).toLowerCase()];
|
|
3413
|
+
const actual = direct ?? custom ?? null;
|
|
3414
|
+
if (Array.isArray(expected)) {
|
|
3415
|
+
const expectedSet = new Set(
|
|
3416
|
+
expected.map((entry) =>
|
|
3417
|
+
String(entry == null ? "" : entry)
|
|
3418
|
+
.trim()
|
|
3419
|
+
.toLowerCase(),
|
|
3420
|
+
),
|
|
3421
|
+
);
|
|
3422
|
+
return expectedSet.has(
|
|
3423
|
+
String(actual == null ? "" : actual)
|
|
3424
|
+
.trim()
|
|
3425
|
+
.toLowerCase(),
|
|
3426
|
+
);
|
|
3427
|
+
}
|
|
3428
|
+
return (
|
|
3429
|
+
String(actual == null ? "" : actual)
|
|
3430
|
+
.trim()
|
|
3431
|
+
.toLowerCase() ===
|
|
3432
|
+
String(expected == null ? "" : expected)
|
|
3433
|
+
.trim()
|
|
3434
|
+
.toLowerCase()
|
|
3435
|
+
);
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
async _getIssueTransitions(issueKey) {
|
|
3440
|
+
const data = await this._jira(
|
|
3441
|
+
`/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`,
|
|
3442
|
+
);
|
|
3443
|
+
return Array.isArray(data?.transitions) ? data.transitions : [];
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
async _transitionIssue(issueKey, normalizedStatus) {
|
|
3447
|
+
const transitions = await this._getIssueTransitions(issueKey);
|
|
3448
|
+
const targetStatusName = String(this._statusMap[normalizedStatus] || "")
|
|
3449
|
+
.trim()
|
|
3450
|
+
.toLowerCase();
|
|
3451
|
+
const candidates = new Set(this._statusCandidates(normalizedStatus));
|
|
3452
|
+
const match = transitions.find((transition) => {
|
|
3453
|
+
const toName = String(transition?.to?.name || "")
|
|
3454
|
+
.trim()
|
|
3455
|
+
.toLowerCase();
|
|
3456
|
+
const toCategory = String(transition?.to?.statusCategory?.key || "")
|
|
3457
|
+
.trim()
|
|
3458
|
+
.toLowerCase();
|
|
3459
|
+
if (targetStatusName && toName === targetStatusName) return true;
|
|
3460
|
+
if (normalizedStatus === "done" && toCategory === "done") return true;
|
|
3461
|
+
return candidates.has(toName);
|
|
3462
|
+
});
|
|
3463
|
+
if (!match?.id) return false;
|
|
3464
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
3465
|
+
method: "POST",
|
|
3466
|
+
body: { transition: { id: String(match.id) } },
|
|
3467
|
+
});
|
|
3468
|
+
return true;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
async _fetchIssue(issueKey, fields = []) {
|
|
3472
|
+
const fieldList =
|
|
3473
|
+
fields.length > 0
|
|
3474
|
+
? fields.join(",")
|
|
3475
|
+
: "summary,description,status,assignee,priority,project,labels,comment,created,updated";
|
|
3476
|
+
return this._jira(
|
|
3477
|
+
`/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`,
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
_jiraSearchParams(jql, maxResults, fields) {
|
|
3482
|
+
const params = new URLSearchParams();
|
|
3483
|
+
params.set("jql", jql);
|
|
3484
|
+
params.set("maxResults", String(Math.min(Number(maxResults) || 0, 1000)));
|
|
3485
|
+
if (fields) params.set("fields", fields);
|
|
3486
|
+
return params.toString();
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
async _searchIssues(jql, maxResults, fields) {
|
|
3490
|
+
const query = this._jiraSearchParams(jql, maxResults, fields);
|
|
3491
|
+
try {
|
|
3492
|
+
return await this._jira(`/rest/api/3/search/jql?${query}`);
|
|
3493
|
+
} catch (err) {
|
|
3494
|
+
const status = err?.status;
|
|
3495
|
+
const message = String(err?.message || "");
|
|
3496
|
+
const shouldFallback =
|
|
3497
|
+
status === 404 ||
|
|
3498
|
+
status === 410 ||
|
|
3499
|
+
message.includes("/search/jql") ||
|
|
3500
|
+
message.toLowerCase().includes("api has been removed");
|
|
3501
|
+
if (!shouldFallback) {
|
|
3502
|
+
throw err;
|
|
3503
|
+
}
|
|
3504
|
+
return this._jira(`/rest/api/3/search?${query}`);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
async _listIssueComments(issueKey) {
|
|
3509
|
+
const comments = [];
|
|
3510
|
+
let startAt = 0;
|
|
3511
|
+
const maxResults = 100;
|
|
3512
|
+
while (true) {
|
|
3513
|
+
const page = await this._jira(
|
|
3514
|
+
`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`,
|
|
3515
|
+
);
|
|
3516
|
+
const values = Array.isArray(page?.comments) ? page.comments : [];
|
|
3517
|
+
comments.push(...values);
|
|
3518
|
+
if (comments.length >= Number(page?.total || values.length)) break;
|
|
3519
|
+
if (values.length < maxResults) break;
|
|
3520
|
+
startAt += values.length;
|
|
3521
|
+
}
|
|
3522
|
+
return comments;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
_extractSharedStateFromText(text) {
|
|
3526
|
+
const match = String(text || "").match(
|
|
3527
|
+
/<!-- openfleet-state\s*\n([\s\S]*?)\n-->/,
|
|
3528
|
+
);
|
|
3529
|
+
if (!match) return null;
|
|
3530
|
+
try {
|
|
3531
|
+
const parsed = normalizeSharedStatePayload(
|
|
3532
|
+
JSON.parse(String(match[1] || "").trim()),
|
|
3533
|
+
);
|
|
3534
|
+
if (
|
|
3535
|
+
!parsed?.ownerId ||
|
|
3536
|
+
!parsed?.attemptToken ||
|
|
3537
|
+
!parsed?.attemptStarted ||
|
|
3538
|
+
!(parsed?.heartbeat || parsed?.ownerHeartbeat) ||
|
|
3539
|
+
!parsed?.status
|
|
3540
|
+
) {
|
|
3541
|
+
return null;
|
|
3542
|
+
}
|
|
3543
|
+
if (!["claimed", "working", "stale"].includes(parsed.status)) {
|
|
3544
|
+
return null;
|
|
3545
|
+
}
|
|
3546
|
+
return parsed;
|
|
3547
|
+
} catch {
|
|
3548
|
+
return null;
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
async _setIssueLabels(issueKey, labelsToAdd = [], labelsToRemove = []) {
|
|
3553
|
+
const operations = [];
|
|
3554
|
+
for (const label of normalizeLabels(labelsToRemove).map((v) =>
|
|
3555
|
+
this._sanitizeJiraLabel(v),
|
|
3556
|
+
)) {
|
|
3557
|
+
operations.push({ remove: label });
|
|
3558
|
+
}
|
|
3559
|
+
for (const label of normalizeLabels(labelsToAdd).map((v) =>
|
|
3560
|
+
this._sanitizeJiraLabel(v),
|
|
3561
|
+
)) {
|
|
3562
|
+
operations.push({ add: label });
|
|
3563
|
+
}
|
|
3564
|
+
if (operations.length === 0) return true;
|
|
3565
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
3566
|
+
method: "PUT",
|
|
3567
|
+
body: {
|
|
3568
|
+
update: {
|
|
3569
|
+
labels: operations,
|
|
3570
|
+
},
|
|
3571
|
+
},
|
|
3572
|
+
});
|
|
3573
|
+
return true;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
_buildSharedStateComment(sharedState) {
|
|
3577
|
+
const normalized = normalizeSharedStatePayload(sharedState) || sharedState;
|
|
3578
|
+
const ownerParts = String(normalized?.ownerId || "").split("/");
|
|
3579
|
+
const workstationId = ownerParts[0] || "unknown-workstation";
|
|
3580
|
+
const agentId = ownerParts[1] || "unknown-agent";
|
|
3581
|
+
const json = JSON.stringify(normalized, null, 2);
|
|
3582
|
+
return (
|
|
3583
|
+
`<!-- openfleet-state\n${json}\n-->\n` +
|
|
3584
|
+
`Codex Monitor Status: Agent ${agentId} on ${workstationId} is ${normalized?.status} this task.\n` +
|
|
3585
|
+
`Last heartbeat: ${normalized?.heartbeat || normalized?.ownerHeartbeat || ""}`
|
|
3586
|
+
);
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
async _createOrUpdateSharedStateComment(issueKey, sharedState) {
|
|
3590
|
+
const commentBody = this._buildSharedStateComment(sharedState);
|
|
3591
|
+
const comments = await this._listIssueComments(issueKey);
|
|
3592
|
+
const existing = [...comments].reverse().find((comment) => {
|
|
3593
|
+
const text = this._commentToText(comment?.body);
|
|
3594
|
+
return text.includes("<!-- openfleet-state");
|
|
3595
|
+
});
|
|
3596
|
+
if (existing?.id) {
|
|
3597
|
+
await this._jira(
|
|
3598
|
+
`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment/${encodeURIComponent(String(existing.id))}`,
|
|
3599
|
+
{
|
|
3600
|
+
method: "PUT",
|
|
3601
|
+
body: this._useAdfComments
|
|
3602
|
+
? { body: this._textToAdf(commentBody) }
|
|
3603
|
+
: { body: commentBody },
|
|
3604
|
+
},
|
|
3605
|
+
);
|
|
3606
|
+
return true;
|
|
3607
|
+
}
|
|
3608
|
+
return this.addComment(issueKey, commentBody);
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
async listProjects() {
|
|
3612
|
+
const data = await this._jira(
|
|
3613
|
+
"/rest/api/3/project/search?maxResults=1000&orderBy=name",
|
|
3614
|
+
);
|
|
3615
|
+
return (Array.isArray(data?.values) ? data.values : []).map((project) => ({
|
|
3616
|
+
id: String(project.key || project.id || ""),
|
|
3617
|
+
name: project.name || project.key || "Unnamed Jira Project",
|
|
3618
|
+
backend: "jira",
|
|
3619
|
+
meta: project,
|
|
3620
|
+
}));
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
async listTasks(projectId, filters = {}) {
|
|
3624
|
+
const projectKey = this._normalizeProjectKey(projectId);
|
|
3625
|
+
const clauses = [];
|
|
3626
|
+
if (projectKey) clauses.push(`project = "${projectKey}"`);
|
|
3627
|
+
else if (this._defaultProjectKey) clauses.push(`project = "${this._defaultProjectKey}"`);
|
|
3628
|
+
|
|
3629
|
+
if (filters.status) {
|
|
3630
|
+
const normalized = normaliseStatus(filters.status);
|
|
3631
|
+
if (normalized === "draft") {
|
|
3632
|
+
clauses.push(`labels in ("draft")`);
|
|
3633
|
+
} else {
|
|
3634
|
+
const statusNames = this._statusCandidates(normalized)
|
|
3635
|
+
.map((name) => `"${name.replace(/"/g, '\\"')}"`)
|
|
3636
|
+
.join(", ");
|
|
3637
|
+
if (statusNames) clauses.push(`status in (${statusNames})`);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
if (this._enforceTaskLabel && this._taskScopeLabels.length > 0) {
|
|
3642
|
+
const labelsExpr = this._taskScopeLabels
|
|
3643
|
+
.map((label) => `"${label.replace(/"/g, '\\"')}"`)
|
|
3644
|
+
.join(", ");
|
|
3645
|
+
clauses.push(`labels in (${labelsExpr})`);
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
if (filters.assignee) {
|
|
3649
|
+
clauses.push(`assignee = "${String(filters.assignee).replace(/"/g, '\\"')}"`);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
const customJql = String(filters.jql || "").trim();
|
|
3653
|
+
if (customJql) clauses.push(`(${customJql})`);
|
|
3654
|
+
const jqlBase = clauses.length > 0 ? clauses.join(" AND ") : "updated IS NOT EMPTY";
|
|
3655
|
+
const jql = `${jqlBase} ORDER BY updated DESC`;
|
|
3656
|
+
|
|
3657
|
+
const maxResults =
|
|
3658
|
+
Number(filters.limit || 0) > 0
|
|
3659
|
+
? Number(filters.limit)
|
|
3660
|
+
: this._taskListLimit;
|
|
3661
|
+
const data = await this._searchIssues(
|
|
3662
|
+
jql,
|
|
3663
|
+
maxResults,
|
|
3664
|
+
"summary,description,status,assignee,priority,project,labels,comment,created,updated",
|
|
3665
|
+
);
|
|
3666
|
+
let tasks = (Array.isArray(data?.issues) ? data.issues : []).map((issue) =>
|
|
3667
|
+
this._normaliseIssue(issue),
|
|
3668
|
+
);
|
|
3669
|
+
|
|
3670
|
+
if (this._enforceTaskLabel) {
|
|
3671
|
+
tasks = tasks.filter((task) => this._isTaskScopedForCodex(task));
|
|
3672
|
+
}
|
|
3673
|
+
if (filters?.projectField && typeof filters.projectField === "object") {
|
|
3674
|
+
tasks = tasks.filter((task) =>
|
|
3675
|
+
this._matchesProjectFieldFilters(task, filters.projectField),
|
|
3676
|
+
);
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
for (const task of tasks) {
|
|
3680
|
+
try {
|
|
3681
|
+
const sharedState = normalizeSharedStatePayload(
|
|
3682
|
+
await this.readSharedStateFromIssue(task.id),
|
|
3683
|
+
);
|
|
3684
|
+
if (sharedState) {
|
|
3685
|
+
task.meta.sharedState = sharedState;
|
|
3686
|
+
task.sharedState = sharedState;
|
|
3687
|
+
}
|
|
3688
|
+
} catch (err) {
|
|
3689
|
+
console.warn(
|
|
3690
|
+
`${TAG} failed to read shared state for ${task.id}: ${err.message}`,
|
|
3691
|
+
);
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
return tasks;
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
async getTask(taskId) {
|
|
3698
|
+
const issueKey = this._validateIssueKey(taskId);
|
|
3699
|
+
const issue = await this._fetchIssue(issueKey);
|
|
3700
|
+
const task = this._normaliseIssue(issue);
|
|
3701
|
+
try {
|
|
3702
|
+
const sharedState = normalizeSharedStatePayload(
|
|
3703
|
+
await this.readSharedStateFromIssue(issueKey),
|
|
3704
|
+
);
|
|
3705
|
+
if (sharedState) {
|
|
3706
|
+
task.meta.sharedState = sharedState;
|
|
3707
|
+
task.sharedState = sharedState;
|
|
3708
|
+
}
|
|
3709
|
+
} catch (err) {
|
|
3710
|
+
console.warn(
|
|
3711
|
+
`${TAG} failed to read shared state for ${issueKey}: ${err.message}`,
|
|
3712
|
+
);
|
|
3713
|
+
}
|
|
3714
|
+
return task;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
async updateTaskStatus(taskId, status, options = {}) {
|
|
3718
|
+
const issueKey = this._validateIssueKey(taskId);
|
|
3719
|
+
const normalized = normaliseStatus(status);
|
|
3720
|
+
if (normalized === "draft") {
|
|
3721
|
+
await this.updateTask(issueKey, { draft: true });
|
|
3722
|
+
if (options.sharedState) {
|
|
3723
|
+
await this.persistSharedStateToIssue(issueKey, options.sharedState);
|
|
3724
|
+
}
|
|
3725
|
+
return this.getTask(issueKey);
|
|
3726
|
+
}
|
|
3727
|
+
const current = await this.getTask(issueKey);
|
|
3728
|
+
if (current.status !== normalized) {
|
|
3729
|
+
const transitioned = await this._transitionIssue(issueKey, normalized);
|
|
3730
|
+
if (!transitioned) {
|
|
3731
|
+
throw new Error(
|
|
3732
|
+
`Jira: no transition available from "${current.status}" to "${normalized}" for ${issueKey}`,
|
|
3733
|
+
);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
if (options.sharedState) {
|
|
3737
|
+
await this.persistSharedStateToIssue(issueKey, options.sharedState);
|
|
3738
|
+
}
|
|
3739
|
+
if (current.status === "draft") {
|
|
3740
|
+
await this.updateTask(issueKey, { draft: false });
|
|
3741
|
+
}
|
|
3742
|
+
if (
|
|
3743
|
+
options.projectFields &&
|
|
3744
|
+
typeof options.projectFields === "object" &&
|
|
3745
|
+
Object.keys(options.projectFields).length > 0
|
|
3746
|
+
) {
|
|
3747
|
+
await this.updateTask(issueKey, { projectFields: options.projectFields });
|
|
3748
|
+
}
|
|
3749
|
+
return this.getTask(issueKey);
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
async updateTask(taskId, patch = {}) {
|
|
3753
|
+
const issueKey = this._validateIssueKey(taskId);
|
|
3754
|
+
const fields = {};
|
|
3755
|
+
const baseBranch = resolveBaseBranchInput(patch);
|
|
3756
|
+
if (typeof patch.title === "string") {
|
|
3757
|
+
fields.summary = patch.title;
|
|
3758
|
+
}
|
|
3759
|
+
if (typeof patch.description === "string") {
|
|
3760
|
+
fields.description = this._textToAdf(patch.description);
|
|
3761
|
+
}
|
|
3762
|
+
if (typeof patch.priority === "string" && patch.priority.trim()) {
|
|
3763
|
+
fields.priority = { name: patch.priority.trim() };
|
|
3764
|
+
}
|
|
3765
|
+
const wantsTags =
|
|
3766
|
+
Array.isArray(patch.tags) ||
|
|
3767
|
+
Array.isArray(patch.labels) ||
|
|
3768
|
+
typeof patch.tags === "string";
|
|
3769
|
+
let fetchedIssue = null;
|
|
3770
|
+
if (wantsTags || typeof patch.draft === "boolean" || baseBranch) {
|
|
3771
|
+
fetchedIssue = await this._fetchIssue(issueKey);
|
|
3772
|
+
}
|
|
3773
|
+
if (wantsTags || typeof patch.draft === "boolean") {
|
|
3774
|
+
const currentLabels = normalizeLabels(fetchedIssue?.fields?.labels || []);
|
|
3775
|
+
const systemLabels = new Set([
|
|
3776
|
+
...SYSTEM_LABEL_KEYS,
|
|
3777
|
+
...normalizeLabels(this._taskScopeLabels || []),
|
|
3778
|
+
]);
|
|
3779
|
+
const desiredTags = wantsTags
|
|
3780
|
+
? normalizeTags(patch.tags ?? patch.labels)
|
|
3781
|
+
: currentLabels.filter(
|
|
3782
|
+
(label) => !systemLabels.has(label) && !isUpstreamLabel(label),
|
|
3783
|
+
);
|
|
3784
|
+
const nextLabels = new Set(
|
|
3785
|
+
currentLabels.filter(
|
|
3786
|
+
(label) => systemLabels.has(label) || isUpstreamLabel(label),
|
|
3787
|
+
),
|
|
3788
|
+
);
|
|
3789
|
+
for (const label of desiredTags) nextLabels.add(label);
|
|
3790
|
+
if (typeof patch.draft === "boolean") {
|
|
3791
|
+
if (patch.draft) nextLabels.add("draft");
|
|
3792
|
+
else nextLabels.delete("draft");
|
|
3793
|
+
}
|
|
3794
|
+
fields.labels = [...nextLabels].map((label) => this._sanitizeJiraLabel(label));
|
|
3795
|
+
}
|
|
3796
|
+
if (baseBranch && !patch.description) {
|
|
3797
|
+
const currentDesc = this._commentToText(fetchedIssue?.fields?.description);
|
|
3798
|
+
const nextDesc = upsertBaseBranchMarker(currentDesc, baseBranch);
|
|
3799
|
+
fields.description = this._textToAdf(nextDesc);
|
|
3800
|
+
}
|
|
3801
|
+
if (baseBranch && this._customFieldBaseBranch) {
|
|
3802
|
+
fields[this._customFieldBaseBranch] = baseBranch;
|
|
3803
|
+
}
|
|
3804
|
+
if (patch.assignee) {
|
|
3805
|
+
fields.assignee = { accountId: String(patch.assignee) };
|
|
3806
|
+
}
|
|
3807
|
+
if (patch.projectFields && typeof patch.projectFields === "object") {
|
|
3808
|
+
const mappedProjectFields = await this._mapProjectFieldsInput(
|
|
3809
|
+
patch.projectFields,
|
|
3810
|
+
);
|
|
3811
|
+
Object.assign(fields, mappedProjectFields);
|
|
3812
|
+
}
|
|
3813
|
+
if (Object.keys(fields).length > 0) {
|
|
3814
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
3815
|
+
method: "PUT",
|
|
3816
|
+
body: { fields },
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
if (typeof patch.status === "string" && patch.status.trim()) {
|
|
3820
|
+
return this.updateTaskStatus(issueKey, patch.status.trim());
|
|
3821
|
+
}
|
|
3822
|
+
return this.getTask(issueKey);
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
async createTask(projectId, taskData = {}) {
|
|
3826
|
+
const projectKey = this._normalizeProjectKey(projectId);
|
|
3827
|
+
if (!projectKey) {
|
|
3828
|
+
throw new Error(
|
|
3829
|
+
"Jira: createTask requires a project key (argument or JIRA_PROJECT_KEY)",
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
const requestedStatus = normaliseStatus(taskData.status || "todo");
|
|
3833
|
+
const baseBranch = resolveBaseBranchInput(taskData);
|
|
3834
|
+
const issueTypeName =
|
|
3835
|
+
taskData.issueType ||
|
|
3836
|
+
taskData.issue_type ||
|
|
3837
|
+
this._defaultIssueType ||
|
|
3838
|
+
"Task";
|
|
3839
|
+
const isSubtask = /sub[-\\s]?task/.test(
|
|
3840
|
+
String(issueTypeName || "").toLowerCase(),
|
|
3841
|
+
);
|
|
3842
|
+
const parentKey = this._normalizeIssueKey(
|
|
3843
|
+
taskData.parentId ||
|
|
3844
|
+
taskData.parentKey ||
|
|
3845
|
+
this._subtaskParentKey ||
|
|
3846
|
+
"",
|
|
3847
|
+
);
|
|
3848
|
+
if (isSubtask && !parentKey) {
|
|
3849
|
+
throw new Error(
|
|
3850
|
+
"Jira: sub-task issue type requires a parent issue key (set JIRA_SUBTASK_PARENT_KEY or pass parentId)",
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
const labels = normalizeLabels([
|
|
3854
|
+
...(Array.isArray(this._taskScopeLabels) ? this._taskScopeLabels : []),
|
|
3855
|
+
...normalizeLabels(taskData.labels || []),
|
|
3856
|
+
...normalizeLabels(taskData.tags || []),
|
|
3857
|
+
]).map((label) => this._sanitizeJiraLabel(label));
|
|
3858
|
+
if (!labels.includes(this._canonicalTaskLabel)) {
|
|
3859
|
+
labels.push(this._sanitizeJiraLabel(this._canonicalTaskLabel));
|
|
3860
|
+
}
|
|
3861
|
+
if (requestedStatus === "draft" && !labels.includes("draft")) {
|
|
3862
|
+
labels.push("draft");
|
|
3863
|
+
}
|
|
3864
|
+
const descriptionText = upsertBaseBranchMarker(
|
|
3865
|
+
taskData.description || "",
|
|
3866
|
+
baseBranch,
|
|
3867
|
+
);
|
|
3868
|
+
const fields = {
|
|
3869
|
+
project: { key: projectKey },
|
|
3870
|
+
summary: taskData.title || "New task",
|
|
3871
|
+
description: this._textToAdf(descriptionText),
|
|
3872
|
+
issuetype: {
|
|
3873
|
+
name: issueTypeName,
|
|
3874
|
+
},
|
|
3875
|
+
labels,
|
|
3876
|
+
};
|
|
3877
|
+
if (baseBranch && this._customFieldBaseBranch) {
|
|
3878
|
+
fields[this._customFieldBaseBranch] = baseBranch;
|
|
3879
|
+
}
|
|
3880
|
+
if (isSubtask && parentKey) {
|
|
3881
|
+
fields.parent = { key: parentKey };
|
|
3882
|
+
}
|
|
3883
|
+
if (taskData.priority) {
|
|
3884
|
+
fields.priority = { name: String(taskData.priority) };
|
|
3885
|
+
}
|
|
3886
|
+
const assigneeId = taskData.assignee || this._defaultAssignee;
|
|
3887
|
+
if (assigneeId) {
|
|
3888
|
+
fields.assignee = { accountId: String(assigneeId) };
|
|
3889
|
+
}
|
|
3890
|
+
const created = await this._jira("/rest/api/3/issue", {
|
|
3891
|
+
method: "POST",
|
|
3892
|
+
body: { fields },
|
|
3893
|
+
});
|
|
3894
|
+
const issueKey = this._validateIssueKey(created?.key || "");
|
|
3895
|
+
if (requestedStatus !== "todo" && requestedStatus !== "draft") {
|
|
3896
|
+
return this.updateTaskStatus(issueKey, requestedStatus, {
|
|
3897
|
+
sharedState: taskData.sharedState,
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
if (taskData.sharedState) {
|
|
3901
|
+
await this.persistSharedStateToIssue(issueKey, taskData.sharedState);
|
|
3902
|
+
}
|
|
3903
|
+
return this.getTask(issueKey);
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
async deleteTask(taskId) {
|
|
3907
|
+
const issueKey = this._validateIssueKey(taskId);
|
|
3908
|
+
const issue = await this.getTask(issueKey);
|
|
3909
|
+
if (issue.status === "done" || issue.status === "cancelled") {
|
|
3910
|
+
return true;
|
|
3911
|
+
}
|
|
3912
|
+
const target = String(process.env.JIRA_DELETE_TRANSITION_STATUS || "done").trim();
|
|
3913
|
+
await this.updateTaskStatus(issueKey, target);
|
|
3914
|
+
return true;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
async addComment(taskId, body) {
|
|
3918
|
+
const issueKey = this._validateIssueKey(taskId);
|
|
3919
|
+
const text = String(body || "").trim();
|
|
3920
|
+
if (!text) return false;
|
|
3921
|
+
try {
|
|
3922
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
3923
|
+
method: "POST",
|
|
3924
|
+
body: this._useAdfComments
|
|
3925
|
+
? { body: this._textToAdf(text) }
|
|
3926
|
+
: { body: text },
|
|
3927
|
+
});
|
|
3928
|
+
return true;
|
|
3929
|
+
} catch (err) {
|
|
3930
|
+
if (this._useAdfComments) {
|
|
3931
|
+
// Fallback for Jira instances that accept only plain text payloads
|
|
3932
|
+
try {
|
|
3933
|
+
await this._jira(
|
|
3934
|
+
`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`,
|
|
3935
|
+
{
|
|
3936
|
+
method: "POST",
|
|
3937
|
+
body: { body: text },
|
|
3938
|
+
},
|
|
3939
|
+
);
|
|
3940
|
+
return true;
|
|
3941
|
+
} catch (fallbackErr) {
|
|
3942
|
+
console.warn(
|
|
3943
|
+
`${TAG} failed to add Jira comment on ${issueKey}: ${fallbackErr.message}`,
|
|
3944
|
+
);
|
|
3945
|
+
return false;
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
console.warn(`${TAG} failed to add Jira comment on ${issueKey}: ${err.message}`);
|
|
3949
|
+
return false;
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/**
|
|
3954
|
+
* Persist shared state to a Jira issue.
|
|
3955
|
+
*
|
|
3956
|
+
* Implements the same shared state protocol as GitHubAdapter but using Jira-specific
|
|
3957
|
+
* mechanisms. The implementation should use a combination of:
|
|
3958
|
+
*
|
|
3959
|
+
* 1. **Jira Custom Fields** (preferred if available):
|
|
3960
|
+
* - Create custom fields for openfleet state (e.g., "Codex Owner ID", "Codex Attempt Token")
|
|
3961
|
+
* - Store structured data as JSON in a text custom field
|
|
3962
|
+
* - Use Jira API v3: `PUT /rest/api/3/issue/{issueKey}`
|
|
3963
|
+
* - Custom field IDs are like `customfield_10042`
|
|
3964
|
+
*
|
|
3965
|
+
* 2. **Jira Labels** (for status flags):
|
|
3966
|
+
* - Use labels: `codex:claimed`, `codex:working`, `codex:stale`, `codex:ignore`
|
|
3967
|
+
* - Labels API: `PUT /rest/api/3/issue/{issueKey}` with `update.labels` field
|
|
3968
|
+
* - Remove conflicting codex labels before adding new ones
|
|
3969
|
+
*
|
|
3970
|
+
* 3. **Structured Comments** (fallback if custom fields unavailable):
|
|
3971
|
+
* - Similar to GitHub: embed JSON in HTML comment markers
|
|
3972
|
+
* - Format: `<!-- openfleet-state\n{json}\n-->`
|
|
3973
|
+
* - Comments API: `POST /rest/api/3/issue/{issueKey}/comment`
|
|
3974
|
+
* - Update via `PUT /rest/api/3/issue/{issueKey}/comment/{commentId}`
|
|
3975
|
+
*
|
|
3976
|
+
* **Jira API v3 Authentication**:
|
|
3977
|
+
* - Use Basic Auth with email + API token: `Authorization: Basic base64(email:token)`
|
|
3978
|
+
* - Token from: https://id.atlassian.com/manage-profile/security/api-tokens
|
|
3979
|
+
* - Base URL: `https://{domain}.atlassian.net`
|
|
3980
|
+
*
|
|
3981
|
+
* **Required Permissions**:
|
|
3982
|
+
* - Browse Projects
|
|
3983
|
+
* - Edit Issues
|
|
3984
|
+
* - Add Comments
|
|
3985
|
+
* - Manage Custom Fields (if using custom fields approach)
|
|
3986
|
+
*
|
|
3987
|
+
* @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
|
|
3988
|
+
* @param {SharedState} sharedState - Agent state to persist
|
|
3989
|
+
* @param {string} sharedState.ownerId - Format: "workstation-id/agent-id"
|
|
3990
|
+
* @param {string} sharedState.attemptToken - Unique UUID for this attempt
|
|
3991
|
+
* @param {string} sharedState.attemptStarted - ISO 8601 timestamp
|
|
3992
|
+
* @param {string} sharedState.heartbeat - ISO 8601 timestamp
|
|
3993
|
+
* @param {string} sharedState.status - One of: "claimed", "working", "stale"
|
|
3994
|
+
* @param {number} sharedState.retryCount - Number of retry attempts
|
|
3995
|
+
* @returns {Promise<boolean>} Success status
|
|
3996
|
+
*
|
|
3997
|
+
* @example
|
|
3998
|
+
* await adapter.persistSharedStateToIssue("PROJ-123", {
|
|
3999
|
+
* ownerId: "workstation-123/agent-456",
|
|
4000
|
+
* attemptToken: "uuid-here",
|
|
4001
|
+
* attemptStarted: "2026-02-14T17:00:00Z",
|
|
4002
|
+
* heartbeat: "2026-02-14T17:30:00Z",
|
|
4003
|
+
* status: "working",
|
|
4004
|
+
* retryCount: 1
|
|
4005
|
+
* });
|
|
4006
|
+
*
|
|
4007
|
+
* @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/}
|
|
4008
|
+
* @see GitHubIssuesAdapter.persistSharedStateToIssue for reference implementation
|
|
4009
|
+
*/
|
|
4010
|
+
async persistSharedStateToIssue(issueKey, sharedState) {
|
|
4011
|
+
const key = this._validateIssueKey(issueKey);
|
|
4012
|
+
const normalizedState = normalizeSharedStatePayload(sharedState);
|
|
4013
|
+
if (
|
|
4014
|
+
!normalizedState?.ownerId ||
|
|
4015
|
+
!normalizedState?.attemptToken ||
|
|
4016
|
+
!normalizedState?.attemptStarted ||
|
|
4017
|
+
!(normalizedState?.heartbeat || normalizedState?.ownerHeartbeat) ||
|
|
4018
|
+
!["claimed", "working", "stale"].includes(normalizedState?.status)
|
|
4019
|
+
) {
|
|
4020
|
+
throw new Error(
|
|
4021
|
+
`Jira: invalid shared state payload for ${key} (missing required fields)`,
|
|
4022
|
+
);
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
const allCodexLabels = [
|
|
4026
|
+
this._codexLabels.claimed,
|
|
4027
|
+
this._codexLabels.working,
|
|
4028
|
+
this._codexLabels.stale,
|
|
4029
|
+
"codex:claimed",
|
|
4030
|
+
"codex:working",
|
|
4031
|
+
"codex:stale",
|
|
4032
|
+
];
|
|
4033
|
+
const targetLabel =
|
|
4034
|
+
normalizedState.status === "claimed"
|
|
4035
|
+
? this._codexLabels.claimed
|
|
4036
|
+
: normalizedState.status === "working"
|
|
4037
|
+
? this._codexLabels.working
|
|
4038
|
+
: this._codexLabels.stale;
|
|
4039
|
+
const labelsToRemove = allCodexLabels.filter((label) => label !== targetLabel);
|
|
4040
|
+
try {
|
|
4041
|
+
await this._setIssueLabels(key, [targetLabel], labelsToRemove);
|
|
4042
|
+
const stateFieldPayload = {};
|
|
4043
|
+
if (this._sharedStateFields.ownerId) {
|
|
4044
|
+
stateFieldPayload[this._sharedStateFields.ownerId] =
|
|
4045
|
+
normalizedState.ownerId;
|
|
4046
|
+
}
|
|
4047
|
+
if (this._sharedStateFields.attemptToken) {
|
|
4048
|
+
stateFieldPayload[this._sharedStateFields.attemptToken] =
|
|
4049
|
+
normalizedState.attemptToken;
|
|
4050
|
+
}
|
|
4051
|
+
if (this._sharedStateFields.attemptStarted) {
|
|
4052
|
+
const iso = this._normalizeIsoTimestamp(normalizedState.attemptStarted);
|
|
4053
|
+
if (iso) stateFieldPayload[this._sharedStateFields.attemptStarted] = iso;
|
|
4054
|
+
}
|
|
4055
|
+
if (this._sharedStateFields.heartbeat) {
|
|
4056
|
+
const iso = this._normalizeIsoTimestamp(
|
|
4057
|
+
normalizedState.heartbeat || normalizedState.ownerHeartbeat,
|
|
4058
|
+
);
|
|
4059
|
+
if (iso) stateFieldPayload[this._sharedStateFields.heartbeat] = iso;
|
|
4060
|
+
}
|
|
4061
|
+
if (this._sharedStateFields.retryCount) {
|
|
4062
|
+
const retryCount = Number(normalizedState.retryCount || 0);
|
|
4063
|
+
if (Number.isFinite(retryCount)) {
|
|
4064
|
+
stateFieldPayload[this._sharedStateFields.retryCount] = retryCount;
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
if (this._sharedStateFields.stateJson) {
|
|
4068
|
+
stateFieldPayload[this._sharedStateFields.stateJson] = JSON.stringify(
|
|
4069
|
+
normalizedState,
|
|
4070
|
+
);
|
|
4071
|
+
}
|
|
4072
|
+
if (Object.keys(stateFieldPayload).length > 0) {
|
|
4073
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(key)}`, {
|
|
4074
|
+
method: "PUT",
|
|
4075
|
+
body: { fields: stateFieldPayload },
|
|
4076
|
+
});
|
|
4077
|
+
}
|
|
4078
|
+
return this._createOrUpdateSharedStateComment(key, normalizedState);
|
|
4079
|
+
} catch (err) {
|
|
4080
|
+
console.warn(
|
|
4081
|
+
`${TAG} failed to persist shared state for ${key}: ${err.message}`,
|
|
4082
|
+
);
|
|
4083
|
+
return false;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
/**
|
|
4088
|
+
* Read shared state from a Jira issue.
|
|
4089
|
+
*
|
|
4090
|
+
* Retrieves agent state previously written by persistSharedStateToIssue().
|
|
4091
|
+
* Implementation should check multiple sources in order of preference:
|
|
4092
|
+
*
|
|
4093
|
+
* 1. **Jira Custom Fields** (if configured):
|
|
4094
|
+
* - Read custom field values via `GET /rest/api/3/issue/{issueKey}`
|
|
4095
|
+
* - Parse JSON from custom field (e.g., `fields.customfield_10042`)
|
|
4096
|
+
* - Validate required fields before returning
|
|
4097
|
+
*
|
|
4098
|
+
* 2. **Structured Comments** (fallback):
|
|
4099
|
+
* - Fetch comments via `GET /rest/api/3/issue/{issueKey}/comment`
|
|
4100
|
+
* - Search for latest comment containing `<!-- openfleet-state`
|
|
4101
|
+
* - Extract and parse JSON from HTML comment markers
|
|
4102
|
+
* - Return most recent valid state
|
|
4103
|
+
*
|
|
4104
|
+
* **Validation Requirements**:
|
|
4105
|
+
* - Must have: ownerId, attemptToken, attemptStarted, heartbeat, status
|
|
4106
|
+
* - Status must be one of: "claimed", "working", "stale"
|
|
4107
|
+
* - Timestamps must be valid ISO 8601 format
|
|
4108
|
+
* - Return null if state is missing, invalid, or corrupted
|
|
4109
|
+
*
|
|
4110
|
+
* **Jira API v3 Endpoints**:
|
|
4111
|
+
* - Issue details: `GET /rest/api/3/issue/{issueKey}?fields=customfield_*,comment`
|
|
4112
|
+
* - Comments only: `GET /rest/api/3/issue/{issueKey}/comment`
|
|
4113
|
+
*
|
|
4114
|
+
* @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
|
|
4115
|
+
* @returns {Promise<SharedState|null>} Parsed shared state or null if not found
|
|
4116
|
+
*
|
|
4117
|
+
* @typedef {Object} SharedState
|
|
4118
|
+
* @property {string} ownerId - Workstation/agent identifier
|
|
4119
|
+
* @property {string} attemptToken - Unique UUID for this attempt
|
|
4120
|
+
* @property {string} attemptStarted - ISO 8601 timestamp
|
|
4121
|
+
* @property {string} heartbeat - ISO 8601 timestamp
|
|
4122
|
+
* @property {string} status - One of: "claimed", "working", "stale"
|
|
4123
|
+
* @property {number} retryCount - Number of retry attempts
|
|
4124
|
+
*
|
|
4125
|
+
* @example
|
|
4126
|
+
* const state = await adapter.readSharedStateFromIssue("PROJ-123");
|
|
4127
|
+
* if (state) {
|
|
4128
|
+
* console.log(`Task claimed by ${state.ownerId}`);
|
|
4129
|
+
* console.log(`Status: ${state.status}, Heartbeat: ${state.heartbeat}`);
|
|
4130
|
+
* } else {
|
|
4131
|
+
* console.log("No shared state found - task is unclaimed");
|
|
4132
|
+
* }
|
|
4133
|
+
*
|
|
4134
|
+
* @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/}
|
|
4135
|
+
* @see GitHubIssuesAdapter.readSharedStateFromIssue for reference implementation
|
|
4136
|
+
*/
|
|
4137
|
+
async readSharedStateFromIssue(issueKey) {
|
|
4138
|
+
const key = this._validateIssueKey(issueKey);
|
|
4139
|
+
try {
|
|
4140
|
+
const fieldIds = [
|
|
4141
|
+
this._sharedStateFields.stateJson,
|
|
4142
|
+
this._sharedStateFields.ownerId,
|
|
4143
|
+
this._sharedStateFields.attemptToken,
|
|
4144
|
+
this._sharedStateFields.attemptStarted,
|
|
4145
|
+
this._sharedStateFields.heartbeat,
|
|
4146
|
+
this._sharedStateFields.retryCount,
|
|
4147
|
+
].filter(Boolean);
|
|
4148
|
+
if (fieldIds.length > 0) {
|
|
4149
|
+
const issue = await this._fetchIssue(key, fieldIds);
|
|
4150
|
+
const rawFields = issue?.fields || {};
|
|
4151
|
+
if (this._sharedStateFields.stateJson) {
|
|
4152
|
+
const raw = rawFields[this._sharedStateFields.stateJson];
|
|
4153
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
4154
|
+
try {
|
|
4155
|
+
const parsed = normalizeSharedStatePayload(JSON.parse(raw));
|
|
4156
|
+
if (
|
|
4157
|
+
parsed?.ownerId &&
|
|
4158
|
+
parsed?.attemptToken &&
|
|
4159
|
+
parsed?.attemptStarted &&
|
|
4160
|
+
(parsed?.heartbeat || parsed?.ownerHeartbeat) &&
|
|
4161
|
+
["claimed", "working", "stale"].includes(parsed?.status)
|
|
4162
|
+
) {
|
|
4163
|
+
return parsed;
|
|
4164
|
+
}
|
|
4165
|
+
} catch {
|
|
4166
|
+
// fall through to field-by-field and comment parsing
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
const fromFields = {
|
|
4171
|
+
ownerId: rawFields[this._sharedStateFields.ownerId],
|
|
4172
|
+
attemptToken: rawFields[this._sharedStateFields.attemptToken],
|
|
4173
|
+
attemptStarted: rawFields[this._sharedStateFields.attemptStarted],
|
|
4174
|
+
heartbeat: rawFields[this._sharedStateFields.heartbeat],
|
|
4175
|
+
status: null,
|
|
4176
|
+
retryCount: Number(rawFields[this._sharedStateFields.retryCount] || 0),
|
|
4177
|
+
};
|
|
4178
|
+
if (fromFields.ownerId) {
|
|
4179
|
+
const labels = normalizeLabels(rawFields.labels || []);
|
|
4180
|
+
if (labels.includes(this._codexLabels.working)) {
|
|
4181
|
+
fromFields.status = "working";
|
|
4182
|
+
} else if (labels.includes(this._codexLabels.claimed)) {
|
|
4183
|
+
fromFields.status = "claimed";
|
|
4184
|
+
} else if (labels.includes(this._codexLabels.stale)) {
|
|
4185
|
+
fromFields.status = "stale";
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
if (
|
|
4189
|
+
fromFields.ownerId &&
|
|
4190
|
+
fromFields.attemptToken &&
|
|
4191
|
+
fromFields.attemptStarted &&
|
|
4192
|
+
fromFields.heartbeat &&
|
|
4193
|
+
fromFields.status
|
|
4194
|
+
) {
|
|
4195
|
+
return normalizeSharedStatePayload(fromFields);
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
const comments = await this._listIssueComments(key);
|
|
4199
|
+
const stateComment = [...comments].reverse().find((comment) => {
|
|
4200
|
+
const text = this._commentToText(comment?.body);
|
|
4201
|
+
return text.includes("<!-- openfleet-state");
|
|
4202
|
+
});
|
|
4203
|
+
if (!stateComment) return null;
|
|
4204
|
+
const parsed = normalizeSharedStatePayload(
|
|
4205
|
+
this._extractSharedStateFromText(
|
|
4206
|
+
this._commentToText(stateComment.body),
|
|
4207
|
+
),
|
|
4208
|
+
);
|
|
4209
|
+
return parsed || null;
|
|
4210
|
+
} catch (err) {
|
|
4211
|
+
console.warn(`${TAG} failed to read shared state for ${key}: ${err.message}`);
|
|
4212
|
+
return null;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
/**
|
|
4217
|
+
* Mark a Jira issue as ignored by openfleet.
|
|
4218
|
+
*
|
|
4219
|
+
* Prevents openfleet from repeatedly attempting to claim or work on tasks
|
|
4220
|
+
* that are not suitable for automation. Uses Jira-specific mechanisms:
|
|
4221
|
+
*
|
|
4222
|
+
* 1. **Add Label**: `codex:ignore`
|
|
4223
|
+
* - Labels API: `PUT /rest/api/3/issue/{issueKey}`
|
|
4224
|
+
* - Request body: `{"update": {"labels": [{"add": "codex:ignore"}]}}`
|
|
4225
|
+
* - Labels are case-sensitive in Jira
|
|
4226
|
+
*
|
|
4227
|
+
* 2. **Add Comment**: Human-readable explanation
|
|
4228
|
+
* - Comments API: `POST /rest/api/3/issue/{issueKey}/comment`
|
|
4229
|
+
* - Request body: `{"body": {"type": "doc", "version": 1, "content": [...]}}`
|
|
4230
|
+
* - Jira uses Atlassian Document Format (ADF) for rich text
|
|
4231
|
+
* - For simple text: `{"body": "text content"}` (legacy format)
|
|
4232
|
+
*
|
|
4233
|
+
* 3. **Optional: Transition Issue** (if workflow supports it):
|
|
4234
|
+
* - Get transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
|
|
4235
|
+
* - Transition to "Won't Do" or similar: `POST /rest/api/3/issue/{issueKey}/transitions`
|
|
4236
|
+
* - Not required if labels are sufficient
|
|
4237
|
+
*
|
|
4238
|
+
* **Jira ADF Comment Example**:
|
|
4239
|
+
* ```json
|
|
4240
|
+
* {
|
|
4241
|
+
* "body": {
|
|
4242
|
+
* "type": "doc",
|
|
4243
|
+
* "version": 1,
|
|
4244
|
+
* "content": [
|
|
4245
|
+
* {
|
|
4246
|
+
* "type": "paragraph",
|
|
4247
|
+
* "content": [
|
|
4248
|
+
* {"type": "text", "text": "Codex Monitor: Task marked as ignored."}
|
|
4249
|
+
* ]
|
|
4250
|
+
* }
|
|
4251
|
+
* ]
|
|
4252
|
+
* }
|
|
4253
|
+
* }
|
|
4254
|
+
* ```
|
|
4255
|
+
*
|
|
4256
|
+
* **Required Permissions**:
|
|
4257
|
+
* - Edit Issues (for labels)
|
|
4258
|
+
* - Add Comments
|
|
4259
|
+
* - Transition Issues (optional, if changing status)
|
|
4260
|
+
*
|
|
4261
|
+
* @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
|
|
4262
|
+
* @param {string} reason - Human-readable reason for ignoring
|
|
4263
|
+
* @returns {Promise<boolean>} Success status
|
|
4264
|
+
*
|
|
4265
|
+
* @example
|
|
4266
|
+
* await adapter.markTaskIgnored("PROJ-123", "Task requires manual security review");
|
|
4267
|
+
* // Adds "codex:ignore" label and comment explaining why
|
|
4268
|
+
*
|
|
4269
|
+
* @example
|
|
4270
|
+
* await adapter.markTaskIgnored("PROJ-456", "Task dependencies not in automation scope");
|
|
4271
|
+
* // Prevents openfleet from claiming this task in future iterations
|
|
4272
|
+
*
|
|
4273
|
+
* @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/}
|
|
4274
|
+
* @see {@link https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/}
|
|
4275
|
+
* @see GitHubIssuesAdapter.markTaskIgnored for reference implementation
|
|
4276
|
+
*/
|
|
4277
|
+
async markTaskIgnored(issueKey, reason) {
|
|
4278
|
+
const key = this._validateIssueKey(issueKey);
|
|
4279
|
+
const ignoreReason = String(reason || "").trim() || "No reason provided";
|
|
4280
|
+
try {
|
|
4281
|
+
await this._setIssueLabels(
|
|
4282
|
+
key,
|
|
4283
|
+
[this._codexLabels.ignore],
|
|
4284
|
+
["codex:ignore"],
|
|
4285
|
+
);
|
|
4286
|
+
if (this._sharedStateFields.ignoreReason) {
|
|
4287
|
+
await this._jira(`/rest/api/3/issue/${encodeURIComponent(key)}`, {
|
|
4288
|
+
method: "PUT",
|
|
4289
|
+
body: {
|
|
4290
|
+
fields: {
|
|
4291
|
+
[this._sharedStateFields.ignoreReason]: ignoreReason,
|
|
4292
|
+
},
|
|
4293
|
+
},
|
|
4294
|
+
});
|
|
4295
|
+
}
|
|
4296
|
+
const commentBody =
|
|
4297
|
+
`Codex Monitor: This task has been marked as ignored.\n\n` +
|
|
4298
|
+
`Reason: ${ignoreReason}\n\n` +
|
|
4299
|
+
`To re-enable openfleet for this task, remove the ${this._codexLabels.ignore} label.`;
|
|
4300
|
+
await this.addComment(key, commentBody);
|
|
4301
|
+
return true;
|
|
4302
|
+
} catch (err) {
|
|
4303
|
+
console.error(`${TAG} failed to mark Jira issue ${key} as ignored: ${err.message}`);
|
|
4304
|
+
return false;
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
// ---------------------------------------------------------------------------
|
|
4310
|
+
// Adapter Registry & Resolution
|
|
4311
|
+
// ---------------------------------------------------------------------------
|
|
4312
|
+
|
|
4313
|
+
const ADAPTERS = {
|
|
4314
|
+
internal: () => new InternalAdapter(),
|
|
4315
|
+
vk: () => new VKAdapter(),
|
|
4316
|
+
github: () => new GitHubIssuesAdapter(),
|
|
4317
|
+
jira: () => new JiraAdapter(),
|
|
4318
|
+
};
|
|
4319
|
+
|
|
4320
|
+
/** @type {Object|null} Cached adapter instance */
|
|
4321
|
+
let activeAdapter = null;
|
|
4322
|
+
/** @type {string|null} Cached backend name */
|
|
4323
|
+
let activeBackendName = null;
|
|
4324
|
+
|
|
4325
|
+
/**
|
|
4326
|
+
* Resolve which kanban backend to use (synchronous).
|
|
4327
|
+
*
|
|
4328
|
+
* Resolution order:
|
|
4329
|
+
* 1. Runtime override via setKanbanBackend()
|
|
4330
|
+
* 2. KANBAN_BACKEND env var
|
|
4331
|
+
* 3. openfleet.config.json → kanban.backend field
|
|
4332
|
+
* 4. Default: "internal"
|
|
4333
|
+
*
|
|
4334
|
+
* @returns {string}
|
|
4335
|
+
*/
|
|
4336
|
+
function resolveBackendName() {
|
|
4337
|
+
if (activeBackendName) return activeBackendName;
|
|
4338
|
+
|
|
4339
|
+
// 1. Env var
|
|
4340
|
+
const envBackend = (process.env.KANBAN_BACKEND || "").trim().toLowerCase();
|
|
4341
|
+
if (envBackend && ADAPTERS[envBackend]) return envBackend;
|
|
4342
|
+
|
|
4343
|
+
// 2. Config file (loadConfig is imported statically — always sync-safe)
|
|
4344
|
+
try {
|
|
4345
|
+
const config = loadConfig();
|
|
4346
|
+
const configBackend = (config?.kanban?.backend || "").toLowerCase();
|
|
4347
|
+
if (configBackend && ADAPTERS[configBackend]) return configBackend;
|
|
4348
|
+
} catch {
|
|
4349
|
+
// Config not available — fall through to default
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
// 3. Default
|
|
4353
|
+
return "internal";
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
/**
|
|
4357
|
+
* Get the active kanban adapter.
|
|
4358
|
+
* @returns {InternalAdapter|VKAdapter|GitHubIssuesAdapter|JiraAdapter} Adapter instance.
|
|
4359
|
+
*/
|
|
4360
|
+
export function getKanbanAdapter() {
|
|
4361
|
+
const name = resolveBackendName();
|
|
4362
|
+
if (activeAdapter && activeBackendName === name) return activeAdapter;
|
|
4363
|
+
const factory = ADAPTERS[name];
|
|
4364
|
+
if (!factory) throw new Error(`${TAG} unknown kanban backend: ${name}`);
|
|
4365
|
+
activeAdapter = factory();
|
|
4366
|
+
activeBackendName = name;
|
|
4367
|
+
console.log(`${TAG} using ${name} backend`);
|
|
4368
|
+
return activeAdapter;
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
/**
|
|
4372
|
+
* Switch the kanban backend at runtime.
|
|
4373
|
+
* @param {string} name Backend name ("internal", "vk", "github", "jira").
|
|
4374
|
+
*/
|
|
4375
|
+
export function setKanbanBackend(name) {
|
|
4376
|
+
const normalised = (name || "").trim().toLowerCase();
|
|
4377
|
+
if (!ADAPTERS[normalised]) {
|
|
4378
|
+
throw new Error(
|
|
4379
|
+
`${TAG} unknown kanban backend: "${name}". Valid: ${Object.keys(ADAPTERS).join(", ")}`,
|
|
4380
|
+
);
|
|
4381
|
+
}
|
|
4382
|
+
activeBackendName = normalised;
|
|
4383
|
+
activeAdapter = null; // Force re-create on next getKanbanAdapter()
|
|
4384
|
+
console.log(`${TAG} switched to ${normalised} backend`);
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
/**
|
|
4388
|
+
* Get list of available kanban backends.
|
|
4389
|
+
* @returns {string[]}
|
|
4390
|
+
*/
|
|
4391
|
+
export function getAvailableBackends() {
|
|
4392
|
+
return Object.keys(ADAPTERS);
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
/**
|
|
4396
|
+
* Get the name of the active backend.
|
|
4397
|
+
* @returns {string}
|
|
4398
|
+
*/
|
|
4399
|
+
export function getKanbanBackendName() {
|
|
4400
|
+
return resolveBackendName();
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
// ---------------------------------------------------------------------------
|
|
4404
|
+
// Convenience exports: direct task operations via active adapter
|
|
4405
|
+
// ---------------------------------------------------------------------------
|
|
4406
|
+
|
|
4407
|
+
export async function listProjects() {
|
|
4408
|
+
return getKanbanAdapter().listProjects();
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
export async function listTasks(projectId, filters) {
|
|
4412
|
+
return getKanbanAdapter().listTasks(projectId, filters);
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
export async function getTask(taskId) {
|
|
4416
|
+
return getKanbanAdapter().getTask(taskId);
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4419
|
+
export async function updateTaskStatus(taskId, status, options) {
|
|
4420
|
+
return getKanbanAdapter().updateTaskStatus(taskId, status, options);
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
export async function updateTask(taskId, patch) {
|
|
4424
|
+
const adapter = getKanbanAdapter();
|
|
4425
|
+
if (typeof adapter.updateTask === "function") {
|
|
4426
|
+
return adapter.updateTask(taskId, patch);
|
|
4427
|
+
}
|
|
4428
|
+
if (patch?.status) {
|
|
4429
|
+
return adapter.updateTaskStatus(taskId, patch.status);
|
|
4430
|
+
}
|
|
4431
|
+
return adapter.getTask(taskId);
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
export async function createTask(projectId, taskData) {
|
|
4435
|
+
return getKanbanAdapter().createTask(projectId, taskData);
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
export async function deleteTask(taskId) {
|
|
4439
|
+
return getKanbanAdapter().deleteTask(taskId);
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
export async function addComment(taskId, body) {
|
|
4443
|
+
return getKanbanAdapter().addComment(taskId, body);
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
/**
|
|
4447
|
+
* Persist shared state to an issue (GitHub adapter only).
|
|
4448
|
+
* @param {string} taskId - Task identifier (issue number for GitHub)
|
|
4449
|
+
* @param {SharedState} sharedState - State to persist
|
|
4450
|
+
* @returns {Promise<boolean>} Success status
|
|
4451
|
+
*/
|
|
4452
|
+
export async function persistSharedStateToIssue(taskId, sharedState) {
|
|
4453
|
+
const adapter = getKanbanAdapter();
|
|
4454
|
+
if (typeof adapter.persistSharedStateToIssue === "function") {
|
|
4455
|
+
return adapter.persistSharedStateToIssue(taskId, sharedState);
|
|
4456
|
+
}
|
|
4457
|
+
console.warn(
|
|
4458
|
+
`[kanban] persistSharedStateToIssue not supported by ${adapter.name} backend`,
|
|
4459
|
+
);
|
|
4460
|
+
return false;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
/**
|
|
4464
|
+
* Read shared state from an issue (GitHub adapter only).
|
|
4465
|
+
* @param {string} taskId - Task identifier (issue number for GitHub)
|
|
4466
|
+
* @returns {Promise<SharedState|null>} Shared state or null
|
|
4467
|
+
*/
|
|
4468
|
+
export async function readSharedStateFromIssue(taskId) {
|
|
4469
|
+
const adapter = getKanbanAdapter();
|
|
4470
|
+
if (typeof adapter.readSharedStateFromIssue === "function") {
|
|
4471
|
+
return adapter.readSharedStateFromIssue(taskId);
|
|
4472
|
+
}
|
|
4473
|
+
return null;
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
/**
|
|
4477
|
+
* Mark a task as ignored by openfleet (GitHub adapter only).
|
|
4478
|
+
* @param {string} taskId - Task identifier (issue number for GitHub)
|
|
4479
|
+
* @param {string} reason - Human-readable reason for ignoring
|
|
4480
|
+
* @returns {Promise<boolean>} Success status
|
|
4481
|
+
*/
|
|
4482
|
+
export async function markTaskIgnored(taskId, reason) {
|
|
4483
|
+
const adapter = getKanbanAdapter();
|
|
4484
|
+
if (typeof adapter.markTaskIgnored === "function") {
|
|
4485
|
+
return adapter.markTaskIgnored(taskId, reason);
|
|
4486
|
+
}
|
|
4487
|
+
console.warn(
|
|
4488
|
+
`[kanban] markTaskIgnored not supported by ${adapter.name} backend`,
|
|
4489
|
+
);
|
|
4490
|
+
return false;
|
|
4491
|
+
}
|