beflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/config.example.json +68 -0
- package/config.schema.json +413 -0
- package/package.json +72 -0
- package/src/agent/acpx.ts +197 -0
- package/src/agent/driver.ts +38 -0
- package/src/agent/events.ts +228 -0
- package/src/agent/issuefence.ts +42 -0
- package/src/agent/report.ts +44 -0
- package/src/cli.ts +910 -0
- package/src/config/load.ts +45 -0
- package/src/config/persist.ts +58 -0
- package/src/config/schema.ts +181 -0
- package/src/config/store.ts +119 -0
- package/src/core/accept.ts +25 -0
- package/src/core/continuation.ts +57 -0
- package/src/core/deadletter.ts +55 -0
- package/src/core/decision.ts +8 -0
- package/src/core/doctor.ts +223 -0
- package/src/core/drift.ts +59 -0
- package/src/core/gc.ts +223 -0
- package/src/core/inputquality.ts +30 -0
- package/src/core/issuetemplate.ts +175 -0
- package/src/core/mcp.ts +191 -0
- package/src/core/newissue.ts +343 -0
- package/src/core/notify.ts +151 -0
- package/src/core/prompts.ts +165 -0
- package/src/core/qualitygate.ts +70 -0
- package/src/core/queue.ts +40 -0
- package/src/core/review.ts +266 -0
- package/src/core/run.ts +1075 -0
- package/src/core/runstore.ts +144 -0
- package/src/core/runsview.ts +111 -0
- package/src/core/setup.ts +203 -0
- package/src/core/sla.ts +39 -0
- package/src/core/template.ts +65 -0
- package/src/core/watch.ts +825 -0
- package/src/core/worktree.ts +74 -0
- package/src/core/writeback.ts +88 -0
- package/src/index.ts +154 -0
- package/src/model/types.ts +35 -0
- package/src/prompts/defaults/continuation.md +9 -0
- package/src/prompts/defaults/implement.md +13 -0
- package/src/prompts/defaults/issue-enrich.md +30 -0
- package/src/prompts/defaults/issues/bug.md +35 -0
- package/src/prompts/defaults/issues/feature.md +24 -0
- package/src/prompts/defaults/issues/generic.md +16 -0
- package/src/prompts/defaults/issues/spike.md +24 -0
- package/src/prompts/defaults/report.md +20 -0
- package/src/prompts/defaults/review.md +34 -0
- package/src/prompts/defaults/spec.md +11 -0
- package/src/prompts/defaults/task.md +6 -0
- package/src/prompts/defaults/triage.md +11 -0
- package/src/prompts/text-modules.d.ts +4 -0
- package/src/resolve/jobkind.ts +11 -0
- package/src/resolve/metadata.ts +103 -0
- package/src/resolve/precedence.ts +104 -0
- package/src/trackers/factory.ts +17 -0
- package/src/trackers/linear/adapter.ts +416 -0
- package/src/trackers/linear/client.ts +264 -0
- package/src/trackers/linear/map.ts +113 -0
- package/src/trackers/linear/types.ts +44 -0
- package/src/trackers/marker.ts +20 -0
- package/src/trackers/plane/adapter.ts +754 -0
- package/src/trackers/plane/client.ts +302 -0
- package/src/trackers/plane/map.ts +168 -0
- package/src/trackers/plane/types.ts +134 -0
- package/src/trackers/tracker.ts +135 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import type { Config, Registry } from "../../config/schema.ts";
|
|
2
|
+
import type { Issue, IssueMeta } from "../../model/types.ts";
|
|
3
|
+
import { parseIssueMeta } from "../../resolve/metadata.ts";
|
|
4
|
+
import { hasMarker, stripMarker, withMarker } from "../marker.ts";
|
|
5
|
+
import type {
|
|
6
|
+
Attachment,
|
|
7
|
+
BlockerRef,
|
|
8
|
+
BoardState,
|
|
9
|
+
BoardTemplate,
|
|
10
|
+
Comment,
|
|
11
|
+
EnsureBoardOptions,
|
|
12
|
+
EnsureBoardResult,
|
|
13
|
+
IntakeItem,
|
|
14
|
+
IssueContext,
|
|
15
|
+
IssueDraft,
|
|
16
|
+
ModuleChangeAction,
|
|
17
|
+
ParentContext,
|
|
18
|
+
ProjectCreateResult,
|
|
19
|
+
ProjectCreateSpec,
|
|
20
|
+
QueueFilter,
|
|
21
|
+
Tracker,
|
|
22
|
+
} from "../tracker.ts";
|
|
23
|
+
import { IssueNotFoundError } from "../tracker.ts";
|
|
24
|
+
import { PlaneClient, PlaneHttpError } from "./client.ts";
|
|
25
|
+
import { mapIntakeItem, mapWorkItem, pickActiveCycle, priorityRank, toCommentHtml } from "./map.ts";
|
|
26
|
+
import type { MapContext } from "./map.ts";
|
|
27
|
+
import type { RawLabel, RawModule, RawState, RawWorkItemType } from "./types.ts";
|
|
28
|
+
|
|
29
|
+
interface ProjectRef {
|
|
30
|
+
key: string; // Registry key === Plane project identifier (confirmed for CG)
|
|
31
|
+
projectId: string; // Plane project_id (uuid)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ProjectCaches {
|
|
35
|
+
statesById: Map<string, RawState>;
|
|
36
|
+
statesByName: Map<string, RawState>;
|
|
37
|
+
labelsById: Map<string, RawLabel>;
|
|
38
|
+
labelsByName: Map<string, RawLabel>;
|
|
39
|
+
modulesById: Map<string, RawModule>;
|
|
40
|
+
typesById: Map<string, RawWorkItemType>;
|
|
41
|
+
typesByName: Map<string, RawWorkItemType>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlaneTrackerOptions {
|
|
45
|
+
client: PlaneClient;
|
|
46
|
+
registry: Registry;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class PlaneTracker implements Tracker {
|
|
50
|
+
private readonly client: PlaneClient;
|
|
51
|
+
private readonly registry: Registry;
|
|
52
|
+
private readonly caches = new Map<string, ProjectCaches>();
|
|
53
|
+
|
|
54
|
+
public constructor(options: PlaneTrackerOptions) {
|
|
55
|
+
this.client = options.client;
|
|
56
|
+
this.registry = options.registry;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private resolveProjectKey(key: string): ProjectRef {
|
|
60
|
+
const project = this.registry.projects[key];
|
|
61
|
+
if (project === undefined) {
|
|
62
|
+
const known = Object.keys(this.registry.projects).join(", ");
|
|
63
|
+
throw new Error(`plane: unknown project key "${key}" (known: ${known})`);
|
|
64
|
+
}
|
|
65
|
+
if (project.plane_project_id === undefined) {
|
|
66
|
+
throw new Error(`plane: project "${key}" has no plane_project_id in config`);
|
|
67
|
+
}
|
|
68
|
+
return { key, projectId: project.plane_project_id };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private resolveIssueKey(issueKey: string): ProjectRef {
|
|
72
|
+
const dash = issueKey.lastIndexOf("-");
|
|
73
|
+
if (dash === -1) {
|
|
74
|
+
throw new Error(`plane: malformed issue key "${issueKey}"`);
|
|
75
|
+
}
|
|
76
|
+
return this.resolveProjectKey(issueKey.slice(0, dash));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async loadCaches(ref: ProjectRef): Promise<ProjectCaches> {
|
|
80
|
+
const cached = this.caches.get(ref.projectId);
|
|
81
|
+
if (cached) {
|
|
82
|
+
return cached;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [states, labels, modules, types] = await Promise.all([
|
|
86
|
+
this.client.listStates(ref.projectId),
|
|
87
|
+
this.client.listLabels(ref.projectId),
|
|
88
|
+
this.client.listModules(ref.projectId),
|
|
89
|
+
this.client.listTypes(ref.projectId),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const caches: ProjectCaches = {
|
|
93
|
+
labelsById: new Map(labels.map((l) => [l.id, l])),
|
|
94
|
+
labelsByName: new Map(labels.map((l) => [l.name, l])),
|
|
95
|
+
modulesById: new Map(modules.map((m) => [m.id, m])),
|
|
96
|
+
statesById: new Map(states.map((s) => [s.id, s])),
|
|
97
|
+
statesByName: new Map(states.map((s) => [s.name, s])),
|
|
98
|
+
typesById: new Map(types.map((tp) => [tp.id, tp])),
|
|
99
|
+
typesByName: new Map(types.map((tp) => [tp.name, tp])),
|
|
100
|
+
};
|
|
101
|
+
this.caches.set(ref.projectId, caches);
|
|
102
|
+
return caches;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private mapContext(ref: ProjectRef, caches: ProjectCaches): MapContext {
|
|
106
|
+
return {
|
|
107
|
+
identifier: ref.key,
|
|
108
|
+
labelsById: caches.labelsById,
|
|
109
|
+
modulesById: caches.modulesById,
|
|
110
|
+
statesById: caches.statesById,
|
|
111
|
+
typesById: caches.typesById,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public async getIssue(key: string): Promise<Issue> {
|
|
116
|
+
const ref = this.resolveIssueKey(key);
|
|
117
|
+
const caches = await this.loadCaches(ref);
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = await this.client.getWorkItemByIdentifier(key);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// 404/410 are DEFINITIVE: the work item was deleted. Any other status
|
|
123
|
+
// (auth, 5xx, rate-limit) is transient and must keep its original error
|
|
124
|
+
// so callers can retry rather than park.
|
|
125
|
+
if (err instanceof PlaneHttpError && (err.status === 404 || err.status === 410)) {
|
|
126
|
+
throw new IssueNotFoundError(key);
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
return mapWorkItem(raw, this.mapContext(ref, caches));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public async blockedBy(issue: Issue): Promise<BlockerRef[]> {
|
|
134
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
135
|
+
const caches = await this.loadCaches(ref);
|
|
136
|
+
const relations = await this.client.listRelations(ref.projectId, issue.id);
|
|
137
|
+
const blockerIds = relations.blocked_by ?? [];
|
|
138
|
+
const ctx = this.mapContext(ref, caches);
|
|
139
|
+
|
|
140
|
+
const refs: BlockerRef[] = [];
|
|
141
|
+
for (const blockerId of blockerIds) {
|
|
142
|
+
// The relations payload carries only UUIDs, so fetch each blocker to learn
|
|
143
|
+
// Its sequence id (for the key) and state group (for `done`). A failed
|
|
144
|
+
// Fetch propagates — never collapse a missing blocker into a false "done".
|
|
145
|
+
const raw = await this.client.getWorkItem(ref.projectId, blockerId);
|
|
146
|
+
const blocker = mapWorkItem(raw, ctx);
|
|
147
|
+
refs.push({
|
|
148
|
+
done: blocker.state.group === "completed" || blocker.state.group === "cancelled",
|
|
149
|
+
key: blocker.key,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return refs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Degrade-safe: any fetch failure (404/unexpected) collapses to []. Name comes
|
|
156
|
+
// From the top-level field or the legacy `attributes.name`; the download URL is a
|
|
157
|
+
// Separate Plane call, so it's often absent — we still surface a NAMED attachment
|
|
158
|
+
// (without a URL) so the tracker-blind agent at least knows it exists. Only a
|
|
159
|
+
// Nameless entry is dropped.
|
|
160
|
+
private async fetchAttachments(projectId: string, id: string): Promise<Attachment[]> {
|
|
161
|
+
try {
|
|
162
|
+
const raws = await this.client.listAttachments(projectId, id);
|
|
163
|
+
return raws
|
|
164
|
+
.map((a) => ({ name: a.name ?? a.attributes?.name ?? "", url: a.asset_url ?? "" }))
|
|
165
|
+
.filter((a) => a.name !== "");
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Degrade-safe: a failed parent fetch just omits the parent context.
|
|
172
|
+
private async fetchParent(
|
|
173
|
+
projectId: string,
|
|
174
|
+
parentId: string,
|
|
175
|
+
ctx: MapContext,
|
|
176
|
+
): Promise<ParentContext | undefined> {
|
|
177
|
+
try {
|
|
178
|
+
const raw = await this.client.getWorkItem(projectId, parentId);
|
|
179
|
+
const p = mapWorkItem(raw, ctx);
|
|
180
|
+
return { body: p.body, key: p.key, title: p.title, type: p.type };
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public async issueContext(issue: Issue): Promise<IssueContext> {
|
|
187
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
188
|
+
const caches = await this.loadCaches(ref);
|
|
189
|
+
const ctx = this.mapContext(ref, caches);
|
|
190
|
+
|
|
191
|
+
const attachments = await this.fetchAttachments(ref.projectId, issue.id);
|
|
192
|
+
const parent =
|
|
193
|
+
issue.parentId !== undefined ? await this.fetchParent(ref.projectId, issue.parentId, ctx) : undefined;
|
|
194
|
+
|
|
195
|
+
return { attachments, ...(parent !== undefined ? { parent } : {}) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public async createIssue(project: string, draft: IssueDraft): Promise<Issue> {
|
|
199
|
+
const ref = this.resolveProjectKey(project);
|
|
200
|
+
const caches = await this.loadCaches(ref);
|
|
201
|
+
|
|
202
|
+
const body: Record<string, unknown> = {
|
|
203
|
+
description_html: draft.body,
|
|
204
|
+
name: draft.title,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (draft.type !== undefined) {
|
|
208
|
+
const type = caches.typesByName.get(draft.type);
|
|
209
|
+
if (type === undefined) {
|
|
210
|
+
const known = [...caches.typesByName.keys()].join(", ");
|
|
211
|
+
throw new Error(
|
|
212
|
+
`plane: unknown work-item type "${draft.type}" in project ${ref.key} (known: ${known})`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
body.type_id = type.id;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (draft.priority !== undefined) {
|
|
219
|
+
body.priority = draft.priority;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (draft.labels !== undefined && draft.labels.length > 0) {
|
|
223
|
+
const labelIds: string[] = [];
|
|
224
|
+
for (const name of draft.labels) {
|
|
225
|
+
const label = caches.labelsByName.get(name);
|
|
226
|
+
if (label === undefined) {
|
|
227
|
+
const known = [...caches.labelsByName.keys()].join(", ");
|
|
228
|
+
throw new Error(`plane: unknown label name "${name}" in project ${ref.key} (known: ${known})`);
|
|
229
|
+
}
|
|
230
|
+
labelIds.push(label.id);
|
|
231
|
+
}
|
|
232
|
+
body.labels = labelIds;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (draft.state !== undefined) {
|
|
236
|
+
const state = caches.statesByName.get(draft.state);
|
|
237
|
+
if (state === undefined) {
|
|
238
|
+
const known = [...caches.statesByName.keys()].join(", ");
|
|
239
|
+
throw new Error(`plane: unknown state name "${draft.state}" in project ${ref.key} (known: ${known})`);
|
|
240
|
+
}
|
|
241
|
+
body.state = state.id;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (draft.assigneeId !== undefined) {
|
|
245
|
+
body.assignees = [draft.assigneeId];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const created = await this.client.createWorkItem(ref.projectId, body);
|
|
249
|
+
// The POST response may be unexpanded; re-fetch expanded so the returned
|
|
250
|
+
// Issue carries fully-populated state/labels/type.
|
|
251
|
+
const raw = await this.client.getWorkItem(ref.projectId, created.id);
|
|
252
|
+
return mapWorkItem(raw, this.mapContext(ref, caches));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public async listQueue(filter: QueueFilter): Promise<Issue[]> {
|
|
256
|
+
const ref = this.resolveProjectKey(filter.project);
|
|
257
|
+
const caches = await this.loadCaches(ref);
|
|
258
|
+
const raws = await this.client.listWorkItems(ref.projectId);
|
|
259
|
+
const ctx = this.mapContext(ref, caches);
|
|
260
|
+
let issues = raws.map((raw) => mapWorkItem(raw, ctx));
|
|
261
|
+
|
|
262
|
+
if (filter.state !== undefined) {
|
|
263
|
+
issues = issues.filter((i) => i.state.name === filter.state);
|
|
264
|
+
} else if (filter.stateGroup !== undefined) {
|
|
265
|
+
issues = issues.filter((i) => i.state.group === filter.stateGroup);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return issues
|
|
269
|
+
.map((issue, index) => ({ index, issue }))
|
|
270
|
+
.sort((a, b) => {
|
|
271
|
+
const byRank = priorityRank(a.issue.priority) - priorityRank(b.issue.priority);
|
|
272
|
+
return byRank !== 0 ? byRank : a.index - b.index;
|
|
273
|
+
})
|
|
274
|
+
.map((entry) => entry.issue);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Cycle-aware scheduling (opt-in). DEGRADE-SAFE: the cycles endpoints are
|
|
278
|
+
// UNVERIFIED, so any failure — no active cycle, unsupported, or a fetch error —
|
|
279
|
+
// Collapses to null, which the watch loop reads as "dispatch without a cycle filter".
|
|
280
|
+
public async activeCycleIssueIds(project: string): Promise<Set<string> | null> {
|
|
281
|
+
const ref = this.resolveProjectKey(project);
|
|
282
|
+
try {
|
|
283
|
+
const cycles = await this.client.listCycles(ref.projectId);
|
|
284
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
285
|
+
const active = pickActiveCycle(cycles, today);
|
|
286
|
+
if (active === null) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const items = await this.client.listCycleWorkItems(ref.projectId, active.id);
|
|
290
|
+
const ids = new Set<string>();
|
|
291
|
+
for (const it of items) {
|
|
292
|
+
const id = it.work_item ?? it.id;
|
|
293
|
+
if (id !== undefined) {
|
|
294
|
+
ids.add(id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return ids;
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public async updateState(issue: Issue, stateName: string): Promise<void> {
|
|
304
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
305
|
+
const caches = await this.loadCaches(ref);
|
|
306
|
+
const state = caches.statesByName.get(stateName);
|
|
307
|
+
if (state === undefined) {
|
|
308
|
+
const known = [...caches.statesByName.keys()].join(", ");
|
|
309
|
+
throw new Error(`plane: unknown state name "${stateName}" in project ${ref.key} (known: ${known})`);
|
|
310
|
+
}
|
|
311
|
+
await this.client.patchWorkItem(ref.projectId, issue.id, {
|
|
312
|
+
state: state.id,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public async assign(issue: Issue, assigneeId: string): Promise<void> {
|
|
317
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
318
|
+
await this.client.patchWorkItem(ref.projectId, issue.id, {
|
|
319
|
+
assignees: [assigneeId],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public async addProperty(issue: Issue, name: string): Promise<void> {
|
|
324
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
325
|
+
const caches = await this.loadCaches(ref);
|
|
326
|
+
const target = caches.labelsByName.get(name);
|
|
327
|
+
if (target === undefined) {
|
|
328
|
+
const known = [...caches.labelsByName.keys()].join(", ");
|
|
329
|
+
throw new Error(`plane: unknown label name "${name}" in project ${ref.key} (known: ${known})`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const uuids = new Set<string>();
|
|
333
|
+
for (const labelName of issue.labels) {
|
|
334
|
+
const existing = caches.labelsByName.get(labelName);
|
|
335
|
+
if (existing !== undefined) {
|
|
336
|
+
uuids.add(existing.id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
uuids.add(target.id);
|
|
340
|
+
|
|
341
|
+
await this.client.patchWorkItem(ref.projectId, issue.id, {
|
|
342
|
+
labels: [...uuids],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public async removeProperty(issue: Issue, name: string): Promise<void> {
|
|
347
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
348
|
+
const caches = await this.loadCaches(ref);
|
|
349
|
+
const target = caches.labelsByName.get(name);
|
|
350
|
+
if (target === undefined || !issue.labels.includes(name)) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const uuids = new Set<string>();
|
|
355
|
+
for (const labelName of issue.labels) {
|
|
356
|
+
const existing = caches.labelsByName.get(labelName);
|
|
357
|
+
if (existing !== undefined) {
|
|
358
|
+
uuids.add(existing.id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
uuids.delete(target.id);
|
|
362
|
+
|
|
363
|
+
await this.client.patchWorkItem(ref.projectId, issue.id, {
|
|
364
|
+
labels: [...uuids],
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public async createProperty(
|
|
369
|
+
project: string,
|
|
370
|
+
name: string,
|
|
371
|
+
opts?: { color?: string; description?: string },
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const ref = this.resolveProjectKey(project);
|
|
374
|
+
const labels = await this.client.listLabels(ref.projectId);
|
|
375
|
+
if (labels.some((l) => l.name === name)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
await this.client.createLabel(ref.projectId, {
|
|
379
|
+
color: opts?.color,
|
|
380
|
+
description: opts?.description,
|
|
381
|
+
name,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public async deleteProperty(project: string, name: string): Promise<void> {
|
|
386
|
+
const ref = this.resolveProjectKey(project);
|
|
387
|
+
const labels = await this.client.listLabels(ref.projectId);
|
|
388
|
+
const label = labels.find((l) => l.name === name);
|
|
389
|
+
if (label === undefined) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
await this.client.deleteLabel(ref.projectId, label.id);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
public async comment(issue: Issue, body: string): Promise<void> {
|
|
396
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
397
|
+
const html = toCommentHtml(withMarker(body));
|
|
398
|
+
// Idempotent: writeback is replayed on a resumed run, so don't post the
|
|
399
|
+
// Same comment twice.
|
|
400
|
+
const existing = await this.client.listComments(ref.projectId, issue.id);
|
|
401
|
+
if (existing.some((c) => c.comment_html === html)) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await this.client.createComment(ref.projectId, issue.id, {
|
|
405
|
+
comment_html: html,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
public async listComments(issue: Issue): Promise<Comment[]> {
|
|
410
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
411
|
+
const raws = await this.client.listComments(ref.projectId, issue.id);
|
|
412
|
+
return raws
|
|
413
|
+
.map((raw) => {
|
|
414
|
+
const source = raw.comment_html ?? raw.comment_stripped ?? "";
|
|
415
|
+
const textBody = raw.comment_stripped ?? source.replace(/<[^>]*>/g, "");
|
|
416
|
+
return {
|
|
417
|
+
authorId: raw.created_by,
|
|
418
|
+
body: stripMarker(textBody),
|
|
419
|
+
createdAt: raw.created_at ?? "",
|
|
420
|
+
id: raw.id,
|
|
421
|
+
isBot: hasMarker(source),
|
|
422
|
+
};
|
|
423
|
+
})
|
|
424
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
public async linkPR(issue: Issue, url: string, title?: string): Promise<void> {
|
|
428
|
+
const ref = this.resolveIssueKey(issue.key);
|
|
429
|
+
// Idempotent: Plane rejects a duplicate link URL with a 400, and writeback
|
|
430
|
+
// Is replayed on a resumed run, so skip if this URL is already linked.
|
|
431
|
+
const existing = await this.client.listLinks(ref.projectId, issue.id);
|
|
432
|
+
if (existing.some((link) => link.url === url)) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await this.client.createLink(ref.projectId, issue.id, {
|
|
436
|
+
title: title ?? "Pull Request",
|
|
437
|
+
url,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
public readMetadata(issue: Issue): IssueMeta {
|
|
442
|
+
return parseIssueMeta(issue.body, issue.labels);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public async listInbox(project: string): Promise<IntakeItem[]> {
|
|
446
|
+
const ref = this.resolveProjectKey(project);
|
|
447
|
+
const raws = await this.client.listIntake(ref.projectId);
|
|
448
|
+
return raws.map(mapIntakeItem);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
public async acceptInbox(project: string, item: IntakeItem): Promise<void> {
|
|
452
|
+
const ref = this.resolveProjectKey(project);
|
|
453
|
+
await this.client.updateIntakeStatus(ref.projectId, item.issueId, 1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public async inspectBoard(project: string): Promise<BoardState> {
|
|
457
|
+
const ref = this.resolveProjectKey(project);
|
|
458
|
+
const [states, labels, modules, types] = await Promise.all([
|
|
459
|
+
this.client.listStates(ref.projectId),
|
|
460
|
+
this.client.listLabels(ref.projectId),
|
|
461
|
+
this.client.listModules(ref.projectId),
|
|
462
|
+
this.client.listTypes(ref.projectId).catch(() => []),
|
|
463
|
+
]);
|
|
464
|
+
return {
|
|
465
|
+
labels: labels.map((l) => l.name),
|
|
466
|
+
modules: modules.map((m) => m.name),
|
|
467
|
+
states: states.map((s) => s.name),
|
|
468
|
+
types: types.map((t) => t.name),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
public async ensureBoard(
|
|
473
|
+
project: string,
|
|
474
|
+
template: BoardTemplate,
|
|
475
|
+
opts?: EnsureBoardOptions,
|
|
476
|
+
): Promise<EnsureBoardResult> {
|
|
477
|
+
const ref = this.resolveProjectKey(project);
|
|
478
|
+
const result: EnsureBoardResult = {
|
|
479
|
+
created: [],
|
|
480
|
+
orphans: [],
|
|
481
|
+
pruned: [],
|
|
482
|
+
skipped: [],
|
|
483
|
+
updated: [],
|
|
484
|
+
warnings: [],
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
await this.ensureFeatures(ref.projectId);
|
|
488
|
+
|
|
489
|
+
const [states, labels, modules] = await Promise.all([
|
|
490
|
+
this.client.listStates(ref.projectId),
|
|
491
|
+
this.client.listLabels(ref.projectId),
|
|
492
|
+
this.client.listModules(ref.projectId),
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
const statesByName = new Map(states.map((s) => [s.name, s]));
|
|
496
|
+
for (const state of template.states) {
|
|
497
|
+
const existing = statesByName.get(state.name);
|
|
498
|
+
if (existing === undefined) {
|
|
499
|
+
await this.client.createState(ref.projectId, {
|
|
500
|
+
color: state.color,
|
|
501
|
+
group: state.group,
|
|
502
|
+
name: state.name,
|
|
503
|
+
sequence: state.sequence,
|
|
504
|
+
});
|
|
505
|
+
result.created.push(`state:${state.name}`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const drift: Record<string, unknown> = {};
|
|
509
|
+
if (existing.color !== state.color) {
|
|
510
|
+
drift.color = state.color;
|
|
511
|
+
}
|
|
512
|
+
if (existing.group !== state.group) {
|
|
513
|
+
drift.group = state.group;
|
|
514
|
+
}
|
|
515
|
+
if (state.sequence !== undefined && existing.sequence !== state.sequence) {
|
|
516
|
+
drift.sequence = state.sequence;
|
|
517
|
+
}
|
|
518
|
+
await this.reconcile("state", state.name, existing.id, drift, result, async (id, body) =>
|
|
519
|
+
this.client.updateState(ref.projectId, id, body),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const labelsByName = new Map(labels.map((l) => [l.name, l]));
|
|
524
|
+
for (const label of template.labels) {
|
|
525
|
+
const existing = labelsByName.get(label.name);
|
|
526
|
+
if (existing === undefined) {
|
|
527
|
+
await this.client.createLabel(ref.projectId, {
|
|
528
|
+
color: label.color,
|
|
529
|
+
description: label.description,
|
|
530
|
+
name: label.name,
|
|
531
|
+
});
|
|
532
|
+
result.created.push(`label:${label.name}`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const drift: Record<string, unknown> = {};
|
|
536
|
+
if (label.color !== undefined && existing.color !== label.color) {
|
|
537
|
+
drift.color = label.color;
|
|
538
|
+
}
|
|
539
|
+
if (label.description !== undefined && existing.description !== label.description) {
|
|
540
|
+
drift.description = label.description;
|
|
541
|
+
}
|
|
542
|
+
await this.reconcile("label", label.name, existing.id, drift, result, async (id, body) =>
|
|
543
|
+
this.client.updateLabel(ref.projectId, id, body),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const modulesByName = new Map(modules.map((m) => [m.name, m]));
|
|
548
|
+
const templateModuleNames = new Set(template.modules.map((m) => m.name));
|
|
549
|
+
|
|
550
|
+
// Reconcile description drift for modules already present by name.
|
|
551
|
+
for (const mod of template.modules) {
|
|
552
|
+
const existing = modulesByName.get(mod.name);
|
|
553
|
+
if (existing === undefined) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const drift: Record<string, unknown> = {};
|
|
557
|
+
if (mod.description !== undefined && existing.description !== mod.description) {
|
|
558
|
+
drift.description = mod.description;
|
|
559
|
+
}
|
|
560
|
+
await this.reconcile("module", mod.name, existing.id, drift, result, async (id, body) =>
|
|
561
|
+
this.client.updateModule(ref.projectId, id, body),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const added = template.modules.filter((m) => !modulesByName.has(m.name));
|
|
566
|
+
const removed = modules.filter((m) => !templateModuleNames.has(m.name));
|
|
567
|
+
|
|
568
|
+
// Only an ambiguous shape (both an addition and a removal) needs a human decision.
|
|
569
|
+
const decisions: Record<string, ModuleChangeAction> =
|
|
570
|
+
added.length > 0 && removed.length > 0 && opts?.resolveModuleChanges !== undefined
|
|
571
|
+
? await opts.resolveModuleChanges({
|
|
572
|
+
added: added.map((m) => m.name),
|
|
573
|
+
removed: removed.map((m) => m.name),
|
|
574
|
+
})
|
|
575
|
+
: {};
|
|
576
|
+
|
|
577
|
+
const renamedTo = new Set<string>();
|
|
578
|
+
for (const orphan of removed) {
|
|
579
|
+
const decision = decisions[orphan.name];
|
|
580
|
+
if (decision?.kind === "rename") {
|
|
581
|
+
await this.client.updateModule(ref.projectId, orphan.id, { name: decision.to });
|
|
582
|
+
result.updated.push(`module:${orphan.name}→${decision.to}`);
|
|
583
|
+
renamedTo.add(decision.to);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (decision?.kind === "remove") {
|
|
587
|
+
try {
|
|
588
|
+
await this.client.deleteModule(ref.projectId, orphan.id);
|
|
589
|
+
result.pruned.push(`module:${orphan.name}`);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
592
|
+
result.warnings.push(`remove module:${orphan.name} failed: ${reason}`);
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// "keep", or no decision (non-interactive): record orphan; delete only if prune.
|
|
597
|
+
await this.handleOrphan("module", orphan.name, opts, result, async () =>
|
|
598
|
+
this.client.deleteModule(ref.projectId, orphan.id),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Create template modules that are neither already present nor satisfied by a rename.
|
|
603
|
+
for (const mod of added) {
|
|
604
|
+
if (renamedTo.has(mod.name)) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
await this.client.createModule(ref.projectId, { description: mod.description, name: mod.name });
|
|
608
|
+
result.created.push(`module:${mod.name}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const templateLabelNames = new Set(template.labels.map((l) => l.name));
|
|
612
|
+
for (const label of labels) {
|
|
613
|
+
if (!label.name.startsWith("agent:")) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (templateLabelNames.has(label.name)) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
await this.handleOrphan("label", label.name, opts, result, async () =>
|
|
620
|
+
this.client.deleteLabel(ref.projectId, label.id),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
await this.ensureTypes(ref.projectId, template, result);
|
|
625
|
+
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Record an orphan (candidate not in the template). When prune is on, delete
|
|
630
|
+
// It and record it as pruned; a failed delete is a warning, not fatal.
|
|
631
|
+
private async handleOrphan(
|
|
632
|
+
kind: string,
|
|
633
|
+
name: string,
|
|
634
|
+
opts: EnsureBoardOptions | undefined,
|
|
635
|
+
result: EnsureBoardResult,
|
|
636
|
+
del: () => Promise<unknown>,
|
|
637
|
+
): Promise<void> {
|
|
638
|
+
result.orphans.push(`${kind}:${name}`);
|
|
639
|
+
if (opts?.prune !== true) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
await del();
|
|
644
|
+
result.pruned.push(`${kind}:${name}`);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
647
|
+
result.warnings.push(`prune ${kind}:${name} failed: ${reason}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Skip when nothing drifted; otherwise PATCH the changed fields. A failed
|
|
652
|
+
// Update is recorded as a warning and must not abort the rest of the run.
|
|
653
|
+
private async reconcile(
|
|
654
|
+
kind: string,
|
|
655
|
+
name: string,
|
|
656
|
+
id: string,
|
|
657
|
+
drift: Record<string, unknown>,
|
|
658
|
+
result: EnsureBoardResult,
|
|
659
|
+
patch: (id: string, body: Record<string, unknown>) => Promise<unknown>,
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
if (Object.keys(drift).length === 0) {
|
|
662
|
+
result.skipped.push(`${kind}:${name}`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
await patch(id, drift);
|
|
667
|
+
result.updated.push(`${kind}:${name}`);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
670
|
+
result.warnings.push(`update ${kind}:${name} failed: ${reason}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async ensureFeatures(projectId: string): Promise<void> {
|
|
675
|
+
await this.client.updateProjectFeatures(projectId, {
|
|
676
|
+
intake_view: true,
|
|
677
|
+
is_issue_type_enabled: true,
|
|
678
|
+
module_view: true,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private async ensureTypes(projectId: string, template: BoardTemplate, result: EnsureBoardResult): Promise<void> {
|
|
683
|
+
if (template.types.length === 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
let existingByName: Map<string, RawWorkItemType>;
|
|
688
|
+
try {
|
|
689
|
+
const types = await this.client.listTypes(projectId);
|
|
690
|
+
existingByName = new Map(types.map((t) => [t.name, t]));
|
|
691
|
+
} catch (error) {
|
|
692
|
+
result.warnings.push(this.typesFeatureWarning(error));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const type of template.types) {
|
|
697
|
+
const existing = existingByName.get(type.name);
|
|
698
|
+
if (existing === undefined) {
|
|
699
|
+
try {
|
|
700
|
+
await this.client.createType(projectId, {
|
|
701
|
+
description: type.description,
|
|
702
|
+
name: type.name,
|
|
703
|
+
});
|
|
704
|
+
result.created.push(`type:${type.name}`);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
result.warnings.push(this.typesFeatureWarning(error));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const drift: Record<string, unknown> = {};
|
|
712
|
+
if (type.description !== undefined && existing.description !== type.description) {
|
|
713
|
+
drift.description = type.description;
|
|
714
|
+
}
|
|
715
|
+
await this.reconcile("type", type.name, existing.id, drift, result, async (id, body) =>
|
|
716
|
+
this.client.updateType(projectId, id, body),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
public async createProject(spec: ProjectCreateSpec): Promise<ProjectCreateResult> {
|
|
722
|
+
const created = await this.client.createProject({ identifier: spec.identifier, name: spec.name });
|
|
723
|
+
return { trackerProjectId: created.id };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private typesFeatureWarning(error: unknown): string {
|
|
727
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
728
|
+
return `plane: could not create work-item types (${reason}). Enable Workspace Settings → Features → Work Item Types, then re-run ensureBoard.`;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function createPlaneTracker(
|
|
733
|
+
config: Config,
|
|
734
|
+
registry: Registry,
|
|
735
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
736
|
+
): PlaneTracker {
|
|
737
|
+
const planeConfig = config.trackers.plane;
|
|
738
|
+
if (planeConfig === undefined) {
|
|
739
|
+
throw new Error("plane: config.trackers.plane is not configured");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const apiKey = env[planeConfig.apiKeyEnv];
|
|
743
|
+
if (apiKey === undefined || apiKey === "") {
|
|
744
|
+
throw new Error(`plane: API key env var "${planeConfig.apiKeyEnv}" is unset`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const client = new PlaneClient({
|
|
748
|
+
apiKey,
|
|
749
|
+
baseUrl: planeConfig.baseUrl,
|
|
750
|
+
workspaceSlug: planeConfig.workspaceSlug,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
return new PlaneTracker({ client, registry });
|
|
754
|
+
}
|