magic-spells 0.0.0-stage → 0.2.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 +90 -2
- package/bin/magic.js +12 -0
- package/dist/chunk-JZ7XESF4.js +1829 -0
- package/dist/chunk-PQFOO5A7.js +1057 -0
- package/dist/main.js +1111 -0
- package/dist/operations-GGMLIYEW.js +9 -0
- package/dist/src-DJYPPJFM.js +16 -0
- package/dist/src-KJGFPRQP.js +17 -0
- package/dist/src-L2I546M7.js +1071 -0
- package/package.json +33 -5
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mapPyramidHttpError,
|
|
3
|
+
operationsByName,
|
|
4
|
+
pyramidError,
|
|
5
|
+
toPyramidError
|
|
6
|
+
} from "./chunk-PQFOO5A7.js";
|
|
7
|
+
import {
|
|
8
|
+
MagicError,
|
|
9
|
+
TtlCache,
|
|
10
|
+
readField,
|
|
11
|
+
readStr
|
|
12
|
+
} from "./chunk-JZ7XESF4.js";
|
|
13
|
+
|
|
14
|
+
// ../pyramid/src/cache/resolver.ts
|
|
15
|
+
var TTL_MS = 6e4;
|
|
16
|
+
var PROJECTS_KEY = "projects";
|
|
17
|
+
var Resolver = class {
|
|
18
|
+
constructor(client) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
}
|
|
21
|
+
/** Per-project workflow cache (also the hydration join source). */
|
|
22
|
+
workflows = new TtlCache(TTL_MS);
|
|
23
|
+
/** Workspace-wide project list cache (backs project-name resolution). */
|
|
24
|
+
projects = new TtlCache(TTL_MS);
|
|
25
|
+
// ---------- Workflow fetch + 60s cache ----------
|
|
26
|
+
/**
|
|
27
|
+
* Cached workflow for a project (60s TTL). One fetch warms every kind of
|
|
28
|
+
* resolution for that project. Backs `get_project_workflow` and hydration.
|
|
29
|
+
*/
|
|
30
|
+
async getWorkflow(projectId) {
|
|
31
|
+
const hit = this.workflows.get(projectId);
|
|
32
|
+
if (hit) return hit;
|
|
33
|
+
const [rawWf, rawLabels, rawMembers, rawSchema] = await Promise.all([
|
|
34
|
+
this.client.getWorkflow(projectId),
|
|
35
|
+
this.client.listLabels(projectId),
|
|
36
|
+
this.client.listMembers(projectId),
|
|
37
|
+
this.client.getTaskSchema(projectId)
|
|
38
|
+
]);
|
|
39
|
+
const wf = normalizeWorkflow(rawWf);
|
|
40
|
+
wf.labels = (Array.isArray(rawLabels) ? rawLabels : []).map(normalizeLabel);
|
|
41
|
+
wf.members = (Array.isArray(rawMembers) ? rawMembers : []).map(normalizeMember);
|
|
42
|
+
wf.templates = normalizeTaskSchema(rawSchema);
|
|
43
|
+
this.workflows.set(projectId, wf);
|
|
44
|
+
return wf;
|
|
45
|
+
}
|
|
46
|
+
// ---------- Project ----------
|
|
47
|
+
/** handle (ci exact) -> name (ci exact) -> unique fuzzy contains. */
|
|
48
|
+
async resolveProject(nameOrHandle) {
|
|
49
|
+
const projects = await this.getProjects();
|
|
50
|
+
const needle = norm(nameOrHandle);
|
|
51
|
+
const byHandle = projects.filter((p) => norm(p.handle) === needle);
|
|
52
|
+
const byName = projects.filter((p) => norm(p.name) === needle);
|
|
53
|
+
const fuzzy = projects.filter((p) => contains(p.handle, needle) || contains(p.name, needle));
|
|
54
|
+
const match = pick(
|
|
55
|
+
"project",
|
|
56
|
+
"ambiguous_project_name",
|
|
57
|
+
nameOrHandle,
|
|
58
|
+
[byHandle, byName, fuzzy],
|
|
59
|
+
(p) => p.handle
|
|
60
|
+
);
|
|
61
|
+
if (match) return match;
|
|
62
|
+
throw pyramidError("project_not_found", `No project matches "${nameOrHandle}".`, {
|
|
63
|
+
candidates: shortlist(
|
|
64
|
+
nameOrHandle,
|
|
65
|
+
projects.map((p) => p.handle)
|
|
66
|
+
)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// ---------- Status ----------
|
|
70
|
+
/**
|
|
71
|
+
* key (ci) -> name (ci) -> category (ci) -> stage-name (first status of that
|
|
72
|
+
* stage by position). Returns the full WorkflowStatus (carries stage_id).
|
|
73
|
+
*/
|
|
74
|
+
async resolveStatus(projectId, q) {
|
|
75
|
+
const wf = await this.getWorkflow(projectId);
|
|
76
|
+
const needle = norm(q);
|
|
77
|
+
const byKey = wf.statuses.filter((s) => norm(s.key) === needle);
|
|
78
|
+
const byName = wf.statuses.filter((s) => norm(s.name) === needle);
|
|
79
|
+
const byCategory = wf.statuses.filter(
|
|
80
|
+
(s) => s.category !== void 0 && norm(s.category) === needle
|
|
81
|
+
);
|
|
82
|
+
let match = pick(
|
|
83
|
+
"status",
|
|
84
|
+
"ambiguous_status_name",
|
|
85
|
+
q,
|
|
86
|
+
[byKey, byName, byCategory],
|
|
87
|
+
(s) => s.name
|
|
88
|
+
);
|
|
89
|
+
if (!match) {
|
|
90
|
+
const stage = wf.stages.find((s) => norm(s.key) === needle || norm(s.name) === needle);
|
|
91
|
+
if (stage) {
|
|
92
|
+
const first = firstStatusOfStage(wf, stage.id);
|
|
93
|
+
if (first) match = first;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!match) {
|
|
97
|
+
const fuzzy = wf.statuses.filter((s) => contains(s.key, needle) || contains(s.name, needle));
|
|
98
|
+
match = pick("status", "ambiguous_status_name", q, [fuzzy], (s) => s.name);
|
|
99
|
+
}
|
|
100
|
+
if (match) return match;
|
|
101
|
+
throw pyramidError("status_not_found", `No status matches "${q}".`, {
|
|
102
|
+
candidates: shortlist(
|
|
103
|
+
q,
|
|
104
|
+
wf.statuses.map((s) => s.name)
|
|
105
|
+
)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// ---------- Stage ----------
|
|
109
|
+
/** key (ci) -> name (ci) -> category (ci) -> unique fuzzy contains. */
|
|
110
|
+
async resolveStage(projectId, q) {
|
|
111
|
+
const wf = await this.getWorkflow(projectId);
|
|
112
|
+
const needle = norm(q);
|
|
113
|
+
const byKey = wf.stages.filter((s) => norm(s.key) === needle);
|
|
114
|
+
const byName = wf.stages.filter((s) => norm(s.name) === needle);
|
|
115
|
+
const byCategory = wf.stages.filter(
|
|
116
|
+
(s) => s.category !== void 0 && norm(s.category) === needle
|
|
117
|
+
);
|
|
118
|
+
const fuzzy = wf.stages.filter((s) => contains(s.key, needle) || contains(s.name, needle));
|
|
119
|
+
const match = pick(
|
|
120
|
+
"stage",
|
|
121
|
+
"ambiguous_stage_name",
|
|
122
|
+
q,
|
|
123
|
+
[byKey, byName, byCategory, fuzzy],
|
|
124
|
+
(s) => s.name
|
|
125
|
+
);
|
|
126
|
+
if (match) return match;
|
|
127
|
+
throw pyramidError("stage_not_found", `No stage matches "${q}".`, {
|
|
128
|
+
candidates: shortlist(
|
|
129
|
+
q,
|
|
130
|
+
wf.stages.map((s) => s.name)
|
|
131
|
+
)
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// ---------- User ----------
|
|
135
|
+
/** email (ci exact) -> display_name (ci exact) -> unique fuzzy contains. */
|
|
136
|
+
async resolveUser(projectId, q) {
|
|
137
|
+
const wf = await this.getWorkflow(projectId);
|
|
138
|
+
const needle = norm(q);
|
|
139
|
+
const byEmail = wf.members.filter((m) => norm(m.email) === needle);
|
|
140
|
+
const byName = wf.members.filter((m) => norm(m.display_name) === needle);
|
|
141
|
+
const fuzzy = wf.members.filter(
|
|
142
|
+
(m) => contains(m.email, needle) || contains(m.display_name, needle)
|
|
143
|
+
);
|
|
144
|
+
const match = pick(
|
|
145
|
+
"user",
|
|
146
|
+
"ambiguous_user_name",
|
|
147
|
+
q,
|
|
148
|
+
[byEmail, byName, fuzzy],
|
|
149
|
+
(m) => m.display_name
|
|
150
|
+
);
|
|
151
|
+
if (match) return match;
|
|
152
|
+
throw pyramidError("user_not_found", `No user matches "${q}".`, {
|
|
153
|
+
candidates: shortlist(
|
|
154
|
+
q,
|
|
155
|
+
wf.members.map((m) => m.display_name)
|
|
156
|
+
)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// ---------- Label ----------
|
|
160
|
+
/** name (ci exact) -> unique fuzzy contains. */
|
|
161
|
+
async resolveLabel(projectId, q) {
|
|
162
|
+
const wf = await this.getWorkflow(projectId);
|
|
163
|
+
const needle = norm(q);
|
|
164
|
+
const byName = wf.labels.filter((l) => norm(l.name) === needle);
|
|
165
|
+
const fuzzy = wf.labels.filter((l) => contains(l.name, needle));
|
|
166
|
+
const match = pick("label", "ambiguous_label_name", q, [byName, fuzzy], (l) => l.name);
|
|
167
|
+
if (match) return { id: match.id, name: match.name };
|
|
168
|
+
throw pyramidError("label_not_found", `No label matches "${q}".`, {
|
|
169
|
+
candidates: shortlist(
|
|
170
|
+
q,
|
|
171
|
+
wf.labels.map((l) => l.name)
|
|
172
|
+
)
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// ---------- Invalidation ----------
|
|
176
|
+
/**
|
|
177
|
+
* Drop cached data after a mutating op. One getWorkflow warms every per-project
|
|
178
|
+
* kind, so any per-project kind invalidates the whole project workflow entry;
|
|
179
|
+
* `project` also clears the workspace-wide project list. With no `kind`, clear
|
|
180
|
+
* everything cached for the project.
|
|
181
|
+
*/
|
|
182
|
+
invalidate(projectId, kind) {
|
|
183
|
+
if (kind === "project") {
|
|
184
|
+
this.projects.clear();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.workflows.delete(projectId);
|
|
188
|
+
if (kind === void 0 && (projectId === "" || projectId === "*")) {
|
|
189
|
+
this.projects.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ---------- Internal: project-list cache ----------
|
|
193
|
+
async getProjects() {
|
|
194
|
+
return this.projects.getOrLoad(
|
|
195
|
+
PROJECTS_KEY,
|
|
196
|
+
async () => extractArray(await this.client.listProjects()).map(normalizeProject)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
function pick(_kind, ambiguousCode, query, tiers, label) {
|
|
201
|
+
for (const tier of tiers) {
|
|
202
|
+
if (tier.length === 1) return tier[0];
|
|
203
|
+
if (tier.length > 1) {
|
|
204
|
+
throw pyramidError(ambiguousCode, `"${query}" is ambiguous \u2014 ${tier.length} matches.`, {
|
|
205
|
+
candidates: dedupeStrings(tier.map(label))
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
function extractArray(body) {
|
|
212
|
+
if (Array.isArray(body)) return body;
|
|
213
|
+
if (body && typeof body === "object") {
|
|
214
|
+
const obj = body;
|
|
215
|
+
for (const key of ["data", "items", "results", "projects"]) {
|
|
216
|
+
const val = obj[key];
|
|
217
|
+
if (Array.isArray(val)) return val;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
function rec(v) {
|
|
223
|
+
return v && typeof v === "object" ? v : {};
|
|
224
|
+
}
|
|
225
|
+
function str(v, fallback = "") {
|
|
226
|
+
return typeof v === "string" ? v : fallback;
|
|
227
|
+
}
|
|
228
|
+
function optStr(v) {
|
|
229
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
230
|
+
}
|
|
231
|
+
var PROJECT_ROLES = ["admin", "pm", "member", "viewer", "guest"];
|
|
232
|
+
function projectRole(v) {
|
|
233
|
+
return PROJECT_ROLES.includes(str(v)) ? str(v) : "member";
|
|
234
|
+
}
|
|
235
|
+
function normalizeProject(raw) {
|
|
236
|
+
const p = rec(raw);
|
|
237
|
+
return {
|
|
238
|
+
id: str(p.id),
|
|
239
|
+
handle: str(p.handle),
|
|
240
|
+
name: str(p.name),
|
|
241
|
+
task_prefix: str(p.task_prefix),
|
|
242
|
+
role: projectRole(p.role),
|
|
243
|
+
archived: p.archived_at != null || p.archived === true
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function normalizeStage(raw) {
|
|
247
|
+
const s = rec(raw);
|
|
248
|
+
const stage = {
|
|
249
|
+
id: str(s.id),
|
|
250
|
+
key: str(s.key),
|
|
251
|
+
name: str(s.name),
|
|
252
|
+
position: str(s.position)
|
|
253
|
+
};
|
|
254
|
+
const category = optStr(s.category);
|
|
255
|
+
if (category !== void 0) stage.category = category;
|
|
256
|
+
return stage;
|
|
257
|
+
}
|
|
258
|
+
function normalizeStatus(raw) {
|
|
259
|
+
const s = rec(raw);
|
|
260
|
+
const status = {
|
|
261
|
+
id: str(s.id),
|
|
262
|
+
key: str(s.key),
|
|
263
|
+
name: str(s.name),
|
|
264
|
+
stage_id: str(s.stage_id),
|
|
265
|
+
position: str(s.position)
|
|
266
|
+
};
|
|
267
|
+
const category = optStr(s.category);
|
|
268
|
+
if (category !== void 0) status.category = category;
|
|
269
|
+
return status;
|
|
270
|
+
}
|
|
271
|
+
function normalizeMember(raw) {
|
|
272
|
+
const m = rec(raw);
|
|
273
|
+
const u = rec(m.user);
|
|
274
|
+
const member = {
|
|
275
|
+
id: str(u.id) || str(m.user_id) || str(m.id),
|
|
276
|
+
display_name: str(u.display_name) || str(m.display_name),
|
|
277
|
+
email: str(u.email) || str(m.email),
|
|
278
|
+
role: str(m.role) || str(u.role)
|
|
279
|
+
};
|
|
280
|
+
const jobTitle = optStr(m.job_title) ?? optStr(u.job_title);
|
|
281
|
+
if (jobTitle !== void 0) member.job_title = jobTitle;
|
|
282
|
+
return member;
|
|
283
|
+
}
|
|
284
|
+
function normalizeTaskSchema(raw) {
|
|
285
|
+
const s = rec(raw);
|
|
286
|
+
const fieldsByTemplate = rec(s.fields_by_template);
|
|
287
|
+
const templates = Array.isArray(s.templates) ? s.templates : [];
|
|
288
|
+
return templates.map((t) => {
|
|
289
|
+
const tt = rec(t);
|
|
290
|
+
const id = str(tt.id);
|
|
291
|
+
const rawFields = fieldsByTemplate[id];
|
|
292
|
+
const fields = (Array.isArray(rawFields) ? rawFields : []).map(normalizeField);
|
|
293
|
+
return { id, name: str(tt.name), fields };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function normalizeLabel(raw) {
|
|
297
|
+
const l = rec(raw);
|
|
298
|
+
return { id: str(l.id), name: str(l.name), color: str(l.color) };
|
|
299
|
+
}
|
|
300
|
+
function normalizeField(raw) {
|
|
301
|
+
const f = rec(raw);
|
|
302
|
+
const field = {
|
|
303
|
+
id: str(f.id),
|
|
304
|
+
key: str(f.key),
|
|
305
|
+
name: str(f.name),
|
|
306
|
+
field_type: str(f.field_type)
|
|
307
|
+
};
|
|
308
|
+
if (Array.isArray(f.options)) {
|
|
309
|
+
field.options = f.options.filter((o) => typeof o === "string");
|
|
310
|
+
}
|
|
311
|
+
return field;
|
|
312
|
+
}
|
|
313
|
+
function normalizeWorkflow(raw) {
|
|
314
|
+
const w = rec(raw);
|
|
315
|
+
const rawStages = Array.isArray(w.stages) ? w.stages : [];
|
|
316
|
+
const stages = rawStages.map(normalizeStage);
|
|
317
|
+
let statuses;
|
|
318
|
+
if (Array.isArray(w.statuses)) {
|
|
319
|
+
statuses = w.statuses.map(normalizeStatus);
|
|
320
|
+
} else {
|
|
321
|
+
statuses = [];
|
|
322
|
+
for (const rawStage of rawStages) {
|
|
323
|
+
const s = rec(rawStage);
|
|
324
|
+
const nested = Array.isArray(s.statuses) ? s.statuses : [];
|
|
325
|
+
const stageId = str(s.id);
|
|
326
|
+
for (const ns of nested) {
|
|
327
|
+
const status = normalizeStatus(ns);
|
|
328
|
+
if (status.stage_id === "") status.stage_id = stageId;
|
|
329
|
+
statuses.push(status);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const labels = (Array.isArray(w.labels) ? w.labels : []).map(normalizeLabel);
|
|
334
|
+
const members = (Array.isArray(w.members) ? w.members : []).map(normalizeMember);
|
|
335
|
+
const templates = (Array.isArray(w.templates) ? w.templates : []).map((t) => {
|
|
336
|
+
const tt = rec(t);
|
|
337
|
+
return {
|
|
338
|
+
id: str(tt.id),
|
|
339
|
+
name: str(tt.name),
|
|
340
|
+
fields: (Array.isArray(tt.fields) ? tt.fields : []).map(normalizeField)
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
project: normalizeProject(w.project),
|
|
345
|
+
stages,
|
|
346
|
+
statuses,
|
|
347
|
+
labels,
|
|
348
|
+
members,
|
|
349
|
+
templates
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function firstStatusOfStage(wf, stageId) {
|
|
353
|
+
const inStage = wf.statuses.filter((s) => s.stage_id === stageId);
|
|
354
|
+
if (inStage.length === 0) return void 0;
|
|
355
|
+
return [...inStage].sort(
|
|
356
|
+
(a, b) => a.position < b.position ? -1 : a.position > b.position ? 1 : 0
|
|
357
|
+
)[0];
|
|
358
|
+
}
|
|
359
|
+
function norm(s) {
|
|
360
|
+
return s.trim().toLowerCase();
|
|
361
|
+
}
|
|
362
|
+
function contains(haystack, normNeedle) {
|
|
363
|
+
if (normNeedle.length === 0) return false;
|
|
364
|
+
return haystack.trim().toLowerCase().includes(normNeedle);
|
|
365
|
+
}
|
|
366
|
+
function dedupeStrings(xs) {
|
|
367
|
+
return [...new Set(xs.filter((x) => x.length > 0))];
|
|
368
|
+
}
|
|
369
|
+
function shortlist(query, candidates) {
|
|
370
|
+
const q = norm(query);
|
|
371
|
+
const pool = dedupeStrings(candidates);
|
|
372
|
+
if (pool.length === 0) return [];
|
|
373
|
+
const scored = pool.map((c) => {
|
|
374
|
+
const cn = norm(c);
|
|
375
|
+
const sub = cn.includes(q) || q.length > 0 && q.includes(cn) ? 0 : 1;
|
|
376
|
+
return { c, sub, dist: levenshtein(q, cn) };
|
|
377
|
+
});
|
|
378
|
+
scored.sort((a, b) => a.sub - b.sub || a.dist - b.dist || a.c.localeCompare(b.c));
|
|
379
|
+
return scored.slice(0, 8).map((s) => s.c);
|
|
380
|
+
}
|
|
381
|
+
function levenshtein(a, b) {
|
|
382
|
+
if (a === b) return 0;
|
|
383
|
+
if (a.length === 0) return b.length;
|
|
384
|
+
if (b.length === 0) return a.length;
|
|
385
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
386
|
+
let curr = new Array(b.length + 1);
|
|
387
|
+
for (let i = 1; i <= a.length; i++) {
|
|
388
|
+
curr[0] = i;
|
|
389
|
+
for (let j = 1; j <= b.length; j++) {
|
|
390
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
391
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
392
|
+
}
|
|
393
|
+
[prev, curr] = [curr, prev];
|
|
394
|
+
}
|
|
395
|
+
return prev[b.length];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ../pyramid/src/client/pyramid-client.ts
|
|
399
|
+
import { request as undiciRequest } from "undici";
|
|
400
|
+
var PyramidClient = class {
|
|
401
|
+
baseUrl;
|
|
402
|
+
apiKey;
|
|
403
|
+
constructor(config) {
|
|
404
|
+
this.baseUrl = config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl;
|
|
405
|
+
this.apiKey = config.apiKey;
|
|
406
|
+
}
|
|
407
|
+
// ---- Phase-1 read-only surface (raw server JSON; hydration is elsewhere) ----
|
|
408
|
+
/**
|
|
409
|
+
* GET /v1/me — the authenticated user (raw `model.User`). The payload may carry
|
|
410
|
+
* a workspace in a future revision, so callers read it defensively; today it is
|
|
411
|
+
* just the user record. Returned raw.
|
|
412
|
+
*/
|
|
413
|
+
getMe() {
|
|
414
|
+
return this.request("GET", "/v1/me");
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* GET /v1/workspaces — the caller's workspaces. The API key is workspace-pinned,
|
|
418
|
+
* so this is effectively a single-element list (the op layer takes `[0]` as the
|
|
419
|
+
* pinned workspace). Server shape is `{ data: Workspace[] }`; we unwrap to the
|
|
420
|
+
* array and tolerate a bare array.
|
|
421
|
+
*/
|
|
422
|
+
listWorkspaces() {
|
|
423
|
+
return this.request("GET", "/v1/workspaces").then(unwrapData);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* GET /v1/projects — accessible projects. Server shape is
|
|
427
|
+
* `{ data: Project[], cursor }`; we unwrap `data` (tolerating a bare array).
|
|
428
|
+
* First-page discovery only — Phase-1 needs no pagination here.
|
|
429
|
+
*/
|
|
430
|
+
listProjects() {
|
|
431
|
+
return this.request("GET", "/v1/projects").then(unwrapData);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* GET /v1/projects/{id}/workflow — the project's workflow (stages, statuses,
|
|
435
|
+
* labels, members, templates). Returned raw; the Resolver/hydrate layer joins
|
|
436
|
+
* it. `projectId` must already be a UUID (name->id resolution lives upstream).
|
|
437
|
+
*/
|
|
438
|
+
getWorkflow(projectId) {
|
|
439
|
+
return this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/workflow`);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* GET /v1/me/tasks — the tasks the caller OWNS or REVIEWS, across projects,
|
|
443
|
+
* newest first, with an opaque keyset cursor. Returns the raw `{ data, cursor }`
|
|
444
|
+
* envelope (cursor normalized to `string | null`). `limit` caps the page;
|
|
445
|
+
* `cursor` continues a prior page.
|
|
446
|
+
*
|
|
447
|
+
* There is deliberately NO `role` parameter: the server accepts `?role=` and
|
|
448
|
+
* IGNORES it (DOC-BACKEND-CONTRACT), so sending it would promise a narrowing
|
|
449
|
+
* that never happens.
|
|
450
|
+
*/
|
|
451
|
+
async listMyTasks(opts) {
|
|
452
|
+
const envelope = await this.request("GET", "/v1/me/tasks", {
|
|
453
|
+
query: {
|
|
454
|
+
limit: opts?.limit,
|
|
455
|
+
cursor: opts?.cursor
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
data: Array.isArray(envelope?.data) ? envelope.data : [],
|
|
460
|
+
cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
// ---- Phase-2 task read surface (raw server JSON; hydration is elsewhere) ----
|
|
464
|
+
/**
|
|
465
|
+
* GET /v1/projects/{id}/tasks — a project's tasks, newest first, with an opaque
|
|
466
|
+
* keyset cursor. Returns the raw `{ data, cursor }` envelope (cursor normalized
|
|
467
|
+
* to `string | null`). All filter values are already-resolved UUIDs (`status` is
|
|
468
|
+
* a status UUID, plus `stage_id`/`owner_id`/`reviewer_id`/`label_id`) or free
|
|
469
|
+
* text (`q`); `expand` opts relations into the rows. `projectId` must already be
|
|
470
|
+
* a UUID. (Real query names per DOC-BACKEND-CONTRACT — no `assignee_id`.)
|
|
471
|
+
*/
|
|
472
|
+
async listTasks(projectId, opts) {
|
|
473
|
+
const envelope = await this.request(
|
|
474
|
+
"GET",
|
|
475
|
+
`/v1/projects/${encodeURIComponent(projectId)}/tasks`,
|
|
476
|
+
{
|
|
477
|
+
query: {
|
|
478
|
+
status: opts?.status,
|
|
479
|
+
stage_id: opts?.stage_id,
|
|
480
|
+
owner_id: opts?.owner_id,
|
|
481
|
+
reviewer_id: opts?.reviewer_id,
|
|
482
|
+
label_id: opts?.label_id,
|
|
483
|
+
q: opts?.q,
|
|
484
|
+
limit: opts?.limit,
|
|
485
|
+
cursor: opts?.cursor,
|
|
486
|
+
expand: opts?.expand
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
return normalizeCursorEnvelope(envelope);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* GET /v1/projects/{id}/tasks/archived — a project's ARCHIVED tasks (a separate
|
|
494
|
+
* route from `listTasks`). Returns the raw `{ data, cursor }` envelope. `opts`
|
|
495
|
+
* carries `archived_after`/`limit`/`cursor`. `projectId` must already be a UUID.
|
|
496
|
+
*/
|
|
497
|
+
async listArchived(projectId, opts) {
|
|
498
|
+
const envelope = await this.request(
|
|
499
|
+
"GET",
|
|
500
|
+
`/v1/projects/${encodeURIComponent(projectId)}/tasks/archived`,
|
|
501
|
+
{
|
|
502
|
+
query: {
|
|
503
|
+
archived_after: opts?.archived_after,
|
|
504
|
+
limit: opts?.limit,
|
|
505
|
+
cursor: opts?.cursor
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
return normalizeCursorEnvelope(envelope);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* GET /v1/projects/{id}/labels — a project's labels (separate from /workflow).
|
|
513
|
+
* Returns a bare array of raw label rows (tolerating a `{ data }` envelope).
|
|
514
|
+
*/
|
|
515
|
+
listLabels(projectId) {
|
|
516
|
+
return this.request(
|
|
517
|
+
"GET",
|
|
518
|
+
`/v1/projects/${encodeURIComponent(projectId)}/labels`
|
|
519
|
+
).then(unwrapData);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* GET /v1/projects/{id}/members — a project's members (separate from /workflow).
|
|
523
|
+
* Returns a bare array of raw `ProjectMember` rows (each carries `user`).
|
|
524
|
+
*/
|
|
525
|
+
listMembers(projectId) {
|
|
526
|
+
return this.request(
|
|
527
|
+
"GET",
|
|
528
|
+
`/v1/projects/${encodeURIComponent(projectId)}/members`
|
|
529
|
+
).then(unwrapData);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* GET /v1/projects/{id}/task-schema — the project's custom-field schema. Returns
|
|
533
|
+
* the raw `{ templates, fields_by_template }` payload (the Resolver merges it
|
|
534
|
+
* into the cached Workflow shape).
|
|
535
|
+
*/
|
|
536
|
+
getTaskSchema(projectId) {
|
|
537
|
+
return this.request(
|
|
538
|
+
"GET",
|
|
539
|
+
`/v1/projects/${encodeURIComponent(projectId)}/task-schema`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* GET /v1/tasks/{id}/timeline — one page of a task's history, **oldest first**
|
|
544
|
+
* (the reverse of every other list here). Returns the raw `{ data, cursor }`
|
|
545
|
+
* envelope. `limit` defaults to 50 server-side and is capped at 200;
|
|
546
|
+
* `event_type` narrows to one kind (e.g. `owner_changed`). No If-Match.
|
|
547
|
+
*/
|
|
548
|
+
async getTaskTimeline(taskId, opts) {
|
|
549
|
+
const envelope = await this.request(
|
|
550
|
+
"GET",
|
|
551
|
+
`/v1/tasks/${encodeURIComponent(taskId)}/timeline`,
|
|
552
|
+
{
|
|
553
|
+
query: {
|
|
554
|
+
limit: opts?.limit,
|
|
555
|
+
cursor: opts?.cursor,
|
|
556
|
+
event_type: opts?.event_type
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
return normalizeCursorEnvelope(envelope);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* GET /v1/tasks/{id} — one task's full detail (raw `RawTaskDetail`). A non-empty
|
|
564
|
+
* `expand` enables relations (editor/timeline/comments/estimates/followers/…);
|
|
565
|
+
* omit it for the lean shape. `taskId` must already be a UUID.
|
|
566
|
+
*/
|
|
567
|
+
getTask(taskId, expand) {
|
|
568
|
+
return this.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}`, {
|
|
569
|
+
query: { expand }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* GET /v1/search/tasks — full-text task search across the workspace. Returns the
|
|
574
|
+
* raw `{ data }` envelope (no cursor per DOC-BACKEND-CONTRACT; normalized to a
|
|
575
|
+
* null cursor). `q` is the query; the optional filters are already-resolved user
|
|
576
|
+
* UUIDs.
|
|
577
|
+
*/
|
|
578
|
+
async searchTasks(opts) {
|
|
579
|
+
const envelope = await this.request("GET", "/v1/search/tasks", {
|
|
580
|
+
query: {
|
|
581
|
+
q: opts.q,
|
|
582
|
+
owner_id: opts.owner_id,
|
|
583
|
+
reviewer_id: opts.reviewer_id,
|
|
584
|
+
limit: opts.limit
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
return normalizeCursorEnvelope(envelope);
|
|
588
|
+
}
|
|
589
|
+
// ---- Phase-2 task write surface (raw server JSON; resolution is elsewhere) ----
|
|
590
|
+
/**
|
|
591
|
+
* POST /v1/projects/{id}/tasks — create one task. `body` is the already-resolved
|
|
592
|
+
* create payload (status_id/owner_id/label_ids/…); this client does no name
|
|
593
|
+
* resolution. Returns the raw `RawTaskDetail` of the created task.
|
|
594
|
+
*/
|
|
595
|
+
createTask(projectId, body) {
|
|
596
|
+
return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/tasks`, {
|
|
597
|
+
body
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* POST /v1/tasks/bulk — create many tasks. `body` is the resolved
|
|
602
|
+
* `{ project_id, template_id, tasks: [...] }` batch. The server replies
|
|
603
|
+
* `{ created: [Task], errors: [] }`; this returns the RAW envelope (the op
|
|
604
|
+
* hydrates `created`). No If-Match.
|
|
605
|
+
*/
|
|
606
|
+
bulkCreate(body) {
|
|
607
|
+
return this.request("POST", "/v1/tasks/bulk", { body });
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* PATCH /v1/tasks/{id} — sparse update of a task's content (title/description/
|
|
611
|
+
* priority/dates/estimate/guest_visible) AND its `owner_id`/`reviewer_id` (an explicit
|
|
612
|
+
* null clears one). `body` still carries NO labels and NO field_values — those
|
|
613
|
+
* keep their dedicated endpoints. Requires If-Match
|
|
614
|
+
* (DOC-CONCURRENCY): GET first to capture the ETag, send it, retry ONCE on 409.
|
|
615
|
+
* Returns the raw updated `RawTaskDetail`.
|
|
616
|
+
*/
|
|
617
|
+
updateTask(taskId, body) {
|
|
618
|
+
return this.withPrecondition(
|
|
619
|
+
taskId,
|
|
620
|
+
(etag) => this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, {
|
|
621
|
+
body,
|
|
622
|
+
headers: { "if-match": etag }
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* PATCH /v1/tasks/{id}/move — change a task's status (which carries its stage)
|
|
628
|
+
* and/or its position via neighbor ids. `body` is
|
|
629
|
+
* `{ status_id, before_id?, after_id? }` — already-resolved UUIDs; positions are
|
|
630
|
+
* server-generated, never a fractional key. The server replies an ENVELOPE
|
|
631
|
+
* `{ task, previous }`; this returns it raw (the op hydrates `raw.task`). No
|
|
632
|
+
* If-Match.
|
|
633
|
+
*/
|
|
634
|
+
moveTask(taskId, body) {
|
|
635
|
+
return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/move`, { body });
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* POST /v1/tasks/{id}/labels — add a label to an existing task. `labelId` is an
|
|
639
|
+
* already-resolved label UUID. No If-Match. Returns the raw response.
|
|
640
|
+
*/
|
|
641
|
+
addTaskLabel(taskId, labelId) {
|
|
642
|
+
return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/labels`, {
|
|
643
|
+
body: { label_id: labelId }
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* DELETE /v1/tasks/{id}/labels/{labelId} — remove a label from a task. Both ids
|
|
648
|
+
* are already-resolved UUIDs. No If-Match. Resolves to void (204).
|
|
649
|
+
*/
|
|
650
|
+
async removeTaskLabel(taskId, labelId) {
|
|
651
|
+
await this.request(
|
|
652
|
+
"DELETE",
|
|
653
|
+
`/v1/tasks/${encodeURIComponent(taskId)}/labels/${encodeURIComponent(labelId)}`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* PATCH /v1/tasks/{id}/field-values — bulk-set a task's custom-field values.
|
|
658
|
+
* `body` is `{ field_values: { <fieldId>: value } }` keyed by field UUID. No
|
|
659
|
+
* If-Match. Returns the raw response.
|
|
660
|
+
*/
|
|
661
|
+
setFieldValues(taskId, body) {
|
|
662
|
+
return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/field-values`, {
|
|
663
|
+
body
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* POST /v1/tasks/{id}/archive — soft-archive a task (reversible via the op's
|
|
668
|
+
* `unarchiveTask`). Returns the raw updated `RawTaskDetail`.
|
|
669
|
+
*/
|
|
670
|
+
archiveTask(taskId) {
|
|
671
|
+
return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/archive`);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* POST /v1/tasks/{id}/unarchive — restore a soft-archived task. Returns the raw
|
|
675
|
+
* updated `RawTaskDetail`. (The `task.archive` op picks archive vs unarchive.)
|
|
676
|
+
*/
|
|
677
|
+
unarchiveTask(taskId) {
|
|
678
|
+
return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/unarchive`);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* DELETE /v1/tasks/{id} — delete a task. `hard=true` requests a hard delete
|
|
682
|
+
* (the destructive op gates this upstream behind MAGIC_ALLOW_DESTRUCTIVE).
|
|
683
|
+
* Requires If-Match (DOC-CONCURRENCY): GET first to capture the ETag, send it,
|
|
684
|
+
* retry ONCE on 409. The server replies 204; resolves to void.
|
|
685
|
+
*/
|
|
686
|
+
async deleteTask(taskId, hard) {
|
|
687
|
+
await this.withPrecondition(
|
|
688
|
+
taskId,
|
|
689
|
+
(etag) => this.request("DELETE", `/v1/tasks/${encodeURIComponent(taskId)}`, {
|
|
690
|
+
query: { hard },
|
|
691
|
+
headers: { "if-match": etag }
|
|
692
|
+
})
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
// ---- Phase-2 comment surface (raw server JSON; resolution is elsewhere) ----
|
|
696
|
+
/**
|
|
697
|
+
* GET /v1/tasks/{id}/comments — a task's comments, stage-scoped. Returns the raw
|
|
698
|
+
* `{ data, cursor }` envelope. `stage_id` (already a UUID) narrows to one stage;
|
|
699
|
+
* omit it to read across stages.
|
|
700
|
+
*/
|
|
701
|
+
async listComments(taskId, opts) {
|
|
702
|
+
const envelope = await this.request(
|
|
703
|
+
"GET",
|
|
704
|
+
`/v1/tasks/${encodeURIComponent(taskId)}/comments`,
|
|
705
|
+
{
|
|
706
|
+
query: {
|
|
707
|
+
stage_id: opts?.stage_id,
|
|
708
|
+
limit: opts?.limit,
|
|
709
|
+
cursor: opts?.cursor
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
);
|
|
713
|
+
return normalizeCursorEnvelope(envelope);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* POST /v1/tasks/{id}/comments — add a root (stage-scoped) comment. `body` is
|
|
717
|
+
* `{ content, stage_id?, mention_user_ids? }` with already-resolved mention
|
|
718
|
+
* UUIDs; `stage_id` defaults to the task's current stage upstream. Returns the
|
|
719
|
+
* raw `RawComment`.
|
|
720
|
+
*/
|
|
721
|
+
addComment(taskId, body) {
|
|
722
|
+
return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/comments`, {
|
|
723
|
+
body
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* GET /v1/comments/{id} — one comment (raw `RawComment`). The `comment.reply`
|
|
728
|
+
* op fetches the target to verify it is a ROOT comment (`parent_id` /
|
|
729
|
+
* `thread_root_id` null) BEFORE replying, enforcing the one-level rule. Returned
|
|
730
|
+
* raw; resolution/hydration is elsewhere. `commentId` must already be a UUID.
|
|
731
|
+
*/
|
|
732
|
+
getComment(commentId) {
|
|
733
|
+
return this.request("GET", `/v1/comments/${encodeURIComponent(commentId)}`);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* POST /v1/comments/{id}/replies — reply to a ROOT comment (one level only; the
|
|
737
|
+
* op rejects reply-to-reply before this call). `body` is
|
|
738
|
+
* `{ content, mention_user_ids? }` with already-resolved mention UUIDs. Returns
|
|
739
|
+
* the raw `RawComment`.
|
|
740
|
+
*/
|
|
741
|
+
replyComment(commentId, body) {
|
|
742
|
+
return this.request("POST", `/v1/comments/${encodeURIComponent(commentId)}/replies`, {
|
|
743
|
+
body
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
// ---------------------------- core request ----------------------------
|
|
747
|
+
/**
|
|
748
|
+
* Issue one authenticated JSON request and return the parsed body as `T`.
|
|
749
|
+
*
|
|
750
|
+
* - Sets Bearer auth + `Accept: application/json` (and `Content-Type` for a body).
|
|
751
|
+
* - 2xx: returns the parsed JSON (an empty/204 body parses to `undefined`).
|
|
752
|
+
* - non-2xx: parses the `{ error: { code, message } }` envelope (best effort)
|
|
753
|
+
* and throws `mapPyramidHttpError(status, body)`.
|
|
754
|
+
* - undici/transport failure (DNS, connect, reset, timeout): throws
|
|
755
|
+
* `MagicError('network', ...)` via `toPyramidError`.
|
|
756
|
+
*
|
|
757
|
+
* The API key is on the header only — never logged, never in the URL.
|
|
758
|
+
*/
|
|
759
|
+
async request(method, path, opts) {
|
|
760
|
+
return (await this.requestWithETag(method, path, opts)).body;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The transport core, also returning the response's `ETag` header (null when
|
|
764
|
+
* absent). `request<T>()` is the body-only sugar over this; the If-Match
|
|
765
|
+
* read-first flow reads the etag here (DOC-CONCURRENCY).
|
|
766
|
+
*/
|
|
767
|
+
async requestWithETag(method, path, opts) {
|
|
768
|
+
const url = this.buildUrl(path, opts?.query);
|
|
769
|
+
const headers = {
|
|
770
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
771
|
+
accept: "application/json"
|
|
772
|
+
};
|
|
773
|
+
let bodyText;
|
|
774
|
+
if (opts?.body !== void 0) {
|
|
775
|
+
bodyText = JSON.stringify(opts.body);
|
|
776
|
+
headers["content-type"] = "application/json";
|
|
777
|
+
}
|
|
778
|
+
if (opts?.headers) {
|
|
779
|
+
for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
|
|
780
|
+
}
|
|
781
|
+
let status;
|
|
782
|
+
let raw;
|
|
783
|
+
let etag;
|
|
784
|
+
try {
|
|
785
|
+
const res = await undiciRequest(url, {
|
|
786
|
+
method,
|
|
787
|
+
headers,
|
|
788
|
+
body: bodyText
|
|
789
|
+
});
|
|
790
|
+
status = res.statusCode;
|
|
791
|
+
etag = readETagHeader(res.headers);
|
|
792
|
+
raw = await res.body.text();
|
|
793
|
+
} catch (err) {
|
|
794
|
+
const mapped = toPyramidError(err);
|
|
795
|
+
throw mapped.code === "network" ? mapped : pyramidError("network", mapped.message, {
|
|
796
|
+
hint: "Check MAGIC_PYRAMID_URL and your network connection."
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
const parsed = safeJsonParse(raw);
|
|
800
|
+
if (status < 200 || status >= 300) {
|
|
801
|
+
throw mapPyramidHttpError(status, parsed);
|
|
802
|
+
}
|
|
803
|
+
return { body: parsed, etag };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Capture the current ETag for a task (DOC-CONCURRENCY): GET /v1/tasks/{id} and
|
|
807
|
+
* read its `ETag` response header, falling back to the body's `updated_at` when
|
|
808
|
+
* the header is absent. Used by the If-Match read-first flow.
|
|
809
|
+
*/
|
|
810
|
+
async getTaskETag(taskId) {
|
|
811
|
+
const res = await this.requestWithETag(
|
|
812
|
+
"GET",
|
|
813
|
+
`/v1/tasks/${encodeURIComponent(taskId)}`
|
|
814
|
+
);
|
|
815
|
+
if (res.etag) return res.etag;
|
|
816
|
+
const body = res.body;
|
|
817
|
+
if (body && typeof body === "object") {
|
|
818
|
+
const updatedAt = body.updated_at;
|
|
819
|
+
if (typeof updatedAt === "string" && updatedAt.length > 0) return updatedAt;
|
|
820
|
+
}
|
|
821
|
+
return "";
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Run an If-Match-guarded mutation (PATCH update / DELETE) with the read-first
|
|
825
|
+
* flow (DOC-CONCURRENCY): GET to capture the ETag, run `fn(etag)`, and on a 409
|
|
826
|
+
* `conflict` refetch the ETag and retry ONCE before surfacing the conflict.
|
|
827
|
+
*/
|
|
828
|
+
async withPrecondition(taskId, fn) {
|
|
829
|
+
const etag = await this.getTaskETag(taskId);
|
|
830
|
+
try {
|
|
831
|
+
return await fn(etag);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
if (err instanceof MagicError && err.code === "conflict") {
|
|
834
|
+
const fresh = await this.getTaskETag(taskId);
|
|
835
|
+
return fn(fresh);
|
|
836
|
+
}
|
|
837
|
+
throw err;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** Build an absolute URL, appending defined query params (skips null/undefined). */
|
|
841
|
+
buildUrl(path, query) {
|
|
842
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
843
|
+
if (query) {
|
|
844
|
+
for (const [key, value] of Object.entries(query)) {
|
|
845
|
+
if (value === void 0 || value === null) continue;
|
|
846
|
+
url.searchParams.set(key, String(value));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return url.toString();
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
function readETagHeader(headers) {
|
|
853
|
+
const value = headers.etag ?? headers.ETag;
|
|
854
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
855
|
+
return typeof value === "string" ? value : null;
|
|
856
|
+
}
|
|
857
|
+
function unwrapData(body) {
|
|
858
|
+
if (Array.isArray(body)) return body;
|
|
859
|
+
if (body && typeof body === "object" && "data" in body) {
|
|
860
|
+
const data = body.data;
|
|
861
|
+
if (Array.isArray(data)) return data;
|
|
862
|
+
}
|
|
863
|
+
return [];
|
|
864
|
+
}
|
|
865
|
+
function normalizeCursorEnvelope(envelope) {
|
|
866
|
+
return {
|
|
867
|
+
data: Array.isArray(envelope?.data) ? envelope.data : [],
|
|
868
|
+
cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function safeJsonParse(text) {
|
|
872
|
+
if (text.trim() === "") return void 0;
|
|
873
|
+
try {
|
|
874
|
+
return JSON.parse(text);
|
|
875
|
+
} catch {
|
|
876
|
+
return void 0;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ../pyramid/src/credential.ts
|
|
881
|
+
var PYRAMID_CREDENTIAL = {
|
|
882
|
+
id: "pyramid",
|
|
883
|
+
title: "Pyramid",
|
|
884
|
+
credential: "pyramid_",
|
|
885
|
+
keyHelp: "Pyramid \u2192 Settings \u2192 API Keys"
|
|
886
|
+
};
|
|
887
|
+
var DEFAULT_API_URL = "https://api.pyramid.magicspells.io";
|
|
888
|
+
|
|
889
|
+
// ../pyramid/src/doctor.ts
|
|
890
|
+
var whoamiOp = async () => (await import("./operations-GGMLIYEW.js")).operationsByName.get("pyramid.whoami");
|
|
891
|
+
var doctorChecks = [
|
|
892
|
+
{
|
|
893
|
+
name: "pyramid auth",
|
|
894
|
+
async run(ctx) {
|
|
895
|
+
const me = await (await whoamiOp()).run({}, ctx);
|
|
896
|
+
const name = me.user.display_name || "(unknown user)";
|
|
897
|
+
const user = me.user.email ? `${name} <${me.user.email}>` : name;
|
|
898
|
+
const workspace = me.workspace.name || "(workspace)";
|
|
899
|
+
const names = me.projects.map((p) => p.name || p.handle).filter((n) => n.length > 0);
|
|
900
|
+
return {
|
|
901
|
+
ok: true,
|
|
902
|
+
detail: `Authenticated as ${user} \xB7 workspace ${workspace} \xB7 ${names.length} project${names.length === 1 ? "" : "s"}` + (names.length ? `: ${names.join(", ")}` : ""),
|
|
903
|
+
data: {
|
|
904
|
+
user: { name, email: me.user.email || null },
|
|
905
|
+
workspace: { name: workspace },
|
|
906
|
+
projects: names
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
name: "pyramid workflow",
|
|
913
|
+
async run(ctx) {
|
|
914
|
+
const projects = await ctx.client.api.listProjects();
|
|
915
|
+
const id = readStr(readField(projects[0], "id"));
|
|
916
|
+
if (!id) return { ok: true, detail: "No projects; workflow endpoint not checked." };
|
|
917
|
+
await ctx.client.resolver.getWorkflow(id);
|
|
918
|
+
return { ok: true, detail: "Workflow endpoint: OK." };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
];
|
|
922
|
+
|
|
923
|
+
// ../pyramid/src/mcp/prompts.ts
|
|
924
|
+
var DOCTOR_DESCRIPTION = "Check your Pyramid setup: confirm the API key authenticates and show the user, workspace, and accessible projects.";
|
|
925
|
+
var DOCTOR_INSTRUCTION = [
|
|
926
|
+
"Run a Pyramid setup check.",
|
|
927
|
+
"",
|
|
928
|
+
"Call the `pyramid_whoami` tool, then report back, in plain language:",
|
|
929
|
+
" \u2022 the authenticated user (display name and email),",
|
|
930
|
+
" \u2022 the workspace (name) you are connected to, and",
|
|
931
|
+
" \u2022 the accessible projects (names).",
|
|
932
|
+
"",
|
|
933
|
+
"Finish by confirming the setup looks good. If `pyramid_whoami` returns an error,",
|
|
934
|
+
"act on its `code`: for an `auth_*` code, tell the user to regenerate their",
|
|
935
|
+
"key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status."
|
|
936
|
+
].join("\n");
|
|
937
|
+
function registerPrompts(server) {
|
|
938
|
+
server.registerPrompt("pyramid_doctor", { description: DOCTOR_DESCRIPTION }, () => ({
|
|
939
|
+
messages: [
|
|
940
|
+
{
|
|
941
|
+
role: "user",
|
|
942
|
+
content: { type: "text", text: DOCTOR_INSTRUCTION }
|
|
943
|
+
}
|
|
944
|
+
]
|
|
945
|
+
}));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ../pyramid/src/mcp/resources.ts
|
|
949
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
950
|
+
function op(name) {
|
|
951
|
+
const found = operationsByName.get(name);
|
|
952
|
+
if (!found) throw new Error(`missing operation ${name}`);
|
|
953
|
+
return found;
|
|
954
|
+
}
|
|
955
|
+
async function renderOp(op2, context) {
|
|
956
|
+
try {
|
|
957
|
+
const input = op2.input.parse({});
|
|
958
|
+
const result = await op2.run(input, await context());
|
|
959
|
+
return JSON.stringify(result, null, 2);
|
|
960
|
+
} catch (err) {
|
|
961
|
+
return JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function registerResources(server, context) {
|
|
965
|
+
server.registerResource(
|
|
966
|
+
"pyramid_me",
|
|
967
|
+
"pyramid://me",
|
|
968
|
+
{
|
|
969
|
+
title: "Pyramid identity",
|
|
970
|
+
description: "The authenticated user, their workspace, and accessible projects (whoami).",
|
|
971
|
+
mimeType: "application/json"
|
|
972
|
+
},
|
|
973
|
+
async (uri) => {
|
|
974
|
+
const text = await renderOp(op("pyramid.whoami"), context);
|
|
975
|
+
return {
|
|
976
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text }]
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
);
|
|
980
|
+
server.registerResource(
|
|
981
|
+
"pyramid_projects",
|
|
982
|
+
"pyramid://projects",
|
|
983
|
+
{
|
|
984
|
+
title: "Pyramid projects",
|
|
985
|
+
description: "The projects accessible to the authenticated user.",
|
|
986
|
+
mimeType: "application/json"
|
|
987
|
+
},
|
|
988
|
+
async (uri) => {
|
|
989
|
+
const text = await renderOp(op("pyramid.project.list"), context);
|
|
990
|
+
return {
|
|
991
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text }]
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
);
|
|
995
|
+
server.registerResource(
|
|
996
|
+
"pyramid_project_workflow",
|
|
997
|
+
new ResourceTemplate("pyramid://projects/{handle}/workflow", { list: void 0 }),
|
|
998
|
+
{
|
|
999
|
+
title: "Project workflow",
|
|
1000
|
+
description: "A project's stages, statuses, labels, members, and field templates, by handle.",
|
|
1001
|
+
mimeType: "application/json"
|
|
1002
|
+
},
|
|
1003
|
+
async (uri, variables) => {
|
|
1004
|
+
const raw = variables.handle;
|
|
1005
|
+
const handle = Array.isArray(raw) ? raw[0] : raw;
|
|
1006
|
+
const workflow = op("pyramid.project.workflow");
|
|
1007
|
+
let text;
|
|
1008
|
+
if (handle) {
|
|
1009
|
+
try {
|
|
1010
|
+
const result = await workflow.run(
|
|
1011
|
+
workflow.input.parse({ project: handle }),
|
|
1012
|
+
await context()
|
|
1013
|
+
);
|
|
1014
|
+
text = JSON.stringify(result, null, 2);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
text = JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
text = JSON.stringify(
|
|
1020
|
+
{
|
|
1021
|
+
error: {
|
|
1022
|
+
code: "validation_failed",
|
|
1023
|
+
message: "missing project handle",
|
|
1024
|
+
product: "pyramid"
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
null,
|
|
1028
|
+
2
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text }]
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// ../pyramid/src/mcp/index.ts
|
|
1039
|
+
var instructions = (version) => `Pyramid MCP v${version} \u2014 drive the Pyramid project tracker. Rules: (1) You act as one user in ONE workspace (the key is workspace-pinned). (2) Inputs accept names/keys, not UUIDs; outputs are already hydrated to names. (3) RENDER tasks as a left-rail card, one field per line, no JSON: \`\u2502 <KEY>\` / \`\u2502 NAME: <title>\` / \`\u2502 DESCRIPTION: <one line; truncate in lists>\` / \`\u2502 <Stage>: <Status>\` \u2014 always show stage and status together as \`Stage: Status\`. (4) For a LIST ("get my work"), render one card per task SEPARATED BY A BLANK LINE so each reads as a distinct ticket \u2014 never let cards run together \u2014 with one-line descriptions; show the page, then say "showing N of M \u2014 ask for more"; NEVER auto-paginate. Full description + drill-in only for a single task. (5) Drill-in (comments, git PRs/commits) only when asked, for the named task. (6) Dates are UTC \u2014 render UTC-labeled or relative-with-label, never bare local. (7) On error, act on the \`code\` (don't parse the message): \`ambiguous_*\` \u2192 show candidates and ask; \`auth_*\` \u2192 tell the user to regenerate their key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status to the user.`;
|
|
1040
|
+
function registerPyramidMcp(server, context) {
|
|
1041
|
+
registerResources(server, context);
|
|
1042
|
+
registerPrompts(server);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ../pyramid/src/index.ts
|
|
1046
|
+
var pyramid = {
|
|
1047
|
+
...PYRAMID_CREDENTIAL,
|
|
1048
|
+
apiUrl: DEFAULT_API_URL,
|
|
1049
|
+
// `magic login` mints workspace keys here from the Account grant (API-PYRAMID-CLI-SESSIONS).
|
|
1050
|
+
sessions: { path: "/v1/cli/sessions" },
|
|
1051
|
+
// The operations graph loads only when a Pyramid command runs.
|
|
1052
|
+
load: async () => (await import("./operations-GGMLIYEW.js")).operations,
|
|
1053
|
+
client(session, config) {
|
|
1054
|
+
const api = new PyramidClient({
|
|
1055
|
+
apiKey: session.key,
|
|
1056
|
+
baseUrl: config.products.pyramid.apiUrl
|
|
1057
|
+
});
|
|
1058
|
+
return { api, resolver: new Resolver(api) };
|
|
1059
|
+
},
|
|
1060
|
+
// `--project` stays a Pyramid flag, usable anywhere in argv (pyramid-cli's global).
|
|
1061
|
+
globalFlags: ["project"],
|
|
1062
|
+
doctor: doctorChecks,
|
|
1063
|
+
mcp: { instructions, register: registerPyramidMcp }
|
|
1064
|
+
};
|
|
1065
|
+
var src_default = pyramid;
|
|
1066
|
+
export {
|
|
1067
|
+
DEFAULT_API_URL,
|
|
1068
|
+
PYRAMID_CREDENTIAL,
|
|
1069
|
+
src_default as default,
|
|
1070
|
+
pyramid
|
|
1071
|
+
};
|