@stephen1620/dsh-tomato-board 0.1.1 → 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/README.md +39 -14
- package/cordis.patch.yml +1 -1
- package/lib/client.js +1212 -191
- package/lib/client.js.map +1 -1
- package/lib/index.js +248 -11
- package/lib/types/client/StoryPoints.d.ts +7 -0
- package/lib/types/client/TomatoBoard.d.ts +13 -0
- package/lib/types/index.d.ts +1 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -7,10 +7,12 @@ const ROUTE = "/api/tomato-board/items";
|
|
|
7
7
|
const OPEN_ROUTE = "/api/tomato-board/open";
|
|
8
8
|
const TRANSITIONS_ROUTE = "/api/tomato-board/transitions";
|
|
9
9
|
const TRANSITION_ROUTE = "/api/tomato-board/transition";
|
|
10
|
-
const
|
|
10
|
+
const FILTERS_ROUTE = "/api/tomato-board/filters";
|
|
11
|
+
const DEFAULT_PRIORITY_NAMES = new Map([
|
|
11
12
|
["69e65065-4b34-4109-bca9-0154e548554a", "P0"],
|
|
12
13
|
["8f7912a5-9176-4a79-a269-2269ac42b5a2", "P1"],
|
|
13
14
|
["ca8c3e43-3e7b-444d-8940-d0967d944921", "P2"],
|
|
15
|
+
["ec31e4c1-b55b-479d-be97-86d5f7bf38ef", "P2"],
|
|
14
16
|
["1a3e1092-7d70-42ee-ad38-0e8d953c4c23", "P3"],
|
|
15
17
|
["faae52da-28c8-46fc-96dd-db9cdb28b557", "P4"]
|
|
16
18
|
]);
|
|
@@ -35,6 +37,9 @@ function firstText(value) {
|
|
|
35
37
|
}
|
|
36
38
|
return "";
|
|
37
39
|
}
|
|
40
|
+
function textList(value) {
|
|
41
|
+
return [...new Set((Array.isArray(value) ? value : value ? [value] : []).map(firstText).filter(Boolean))];
|
|
42
|
+
}
|
|
38
43
|
function unwrapItems(payload) {
|
|
39
44
|
if (Array.isArray(payload)) return payload;
|
|
40
45
|
if (!payload || typeof payload !== "object") return [];
|
|
@@ -65,19 +70,26 @@ function unwrapTransitions(payload) {
|
|
|
65
70
|
}
|
|
66
71
|
return [];
|
|
67
72
|
}
|
|
68
|
-
function normalizeItem(raw) {
|
|
73
|
+
function normalizeItem(raw, priorityNames = DEFAULT_PRIORITY_NAMES) {
|
|
69
74
|
const item = raw && typeof raw === "object" ? raw : {};
|
|
70
75
|
const nested = item.value && typeof item.value === "object" ? item.value : item.values && typeof item.values === "object" ? item.values : item;
|
|
76
|
+
const workspaceValue = item.workspace ?? nested.workspace ?? nested["所属空间"];
|
|
77
|
+
const workspace = workspaceValue && typeof workspaceValue === "object" ? workspaceValue : {};
|
|
78
|
+
const workspaceKey = firstText(workspace.key);
|
|
79
|
+
const workspaceName = firstText(workspace.name ?? workspaceValue);
|
|
71
80
|
return {
|
|
72
81
|
itemKey: firstText(item.itemKey ?? item.key ?? item.id ?? nested.itemKey ?? nested.key),
|
|
73
82
|
title: firstText(item.title ?? item.name ?? nested.title ?? nested.name ?? nested["标题"]),
|
|
74
83
|
status: firstText(item.status ?? nested.status ?? nested["状态"]),
|
|
75
84
|
itemType: firstText(item.itemType ?? item.type ?? nested.itemType ?? nested["类型"]),
|
|
76
|
-
workspace:
|
|
85
|
+
workspace: workspaceKey || workspaceName,
|
|
86
|
+
workspaceKey,
|
|
87
|
+
workspaceName,
|
|
77
88
|
creator: firstText(item.creator ?? item.createdBy ?? nested["创建人"] ?? nested.createdBy),
|
|
89
|
+
assignees: textList(item.assignees ?? item.assignee ?? nested.assignees ?? nested.assignee ?? nested["负责人"]),
|
|
78
90
|
priority: (() => {
|
|
79
91
|
const priority = firstText(item.priority ?? nested.priority ?? nested["优先级"]);
|
|
80
|
-
return
|
|
92
|
+
return priorityNames.get(priority) ?? priority;
|
|
81
93
|
})()
|
|
82
94
|
};
|
|
83
95
|
}
|
|
@@ -168,9 +180,15 @@ function itemKeyFromRoute(requestUrl, route) {
|
|
|
168
180
|
const itemKey = decodeURIComponent(pathname.slice(route.length).replace(/^\/+/, "")).trim();
|
|
169
181
|
return /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u.test(itemKey) ? itemKey : "";
|
|
170
182
|
}
|
|
171
|
-
|
|
183
|
+
function assigneeIql(config, assignee) {
|
|
184
|
+
const base = config.iql?.trim();
|
|
185
|
+
const clause = `负责人 in [${assignee === "currentUser()" ? "currentUser()" : JSON.stringify(assignee)}]`;
|
|
186
|
+
return base ? `(${base}) and ${clause}` : clause;
|
|
187
|
+
}
|
|
188
|
+
async function loadItems(config, assignee) {
|
|
172
189
|
const { executable, profile } = cliSettings(config);
|
|
173
|
-
const iql = config
|
|
190
|
+
const iql = assigneeIql(config, assignee);
|
|
191
|
+
const priorityNames = new Map([...DEFAULT_PRIORITY_NAMES, ...Object.entries(config.priorityNames ?? {})]);
|
|
174
192
|
const rawItems = [];
|
|
175
193
|
let page = 1;
|
|
176
194
|
let truncated = false;
|
|
@@ -189,7 +207,7 @@ async function loadItems(config) {
|
|
|
189
207
|
"--iql",
|
|
190
208
|
iql,
|
|
191
209
|
"--fields",
|
|
192
|
-
"priority,createdBy",
|
|
210
|
+
"priority,createdBy,assignee",
|
|
193
211
|
"--output",
|
|
194
212
|
"json"
|
|
195
213
|
], {
|
|
@@ -216,26 +234,28 @@ async function loadItems(config) {
|
|
|
216
234
|
"已取消"
|
|
217
235
|
]);
|
|
218
236
|
return {
|
|
219
|
-
items: rawItems.slice(0, maxItems).map(normalizeItem).filter((item) => item.itemKey && item.title && item.status && !excludedStatuses.has(item.status)).map((item) => ({
|
|
237
|
+
items: rawItems.slice(0, maxItems).map((item) => normalizeItem(item, priorityNames)).filter((item) => item.itemKey && item.title && item.status && !excludedStatuses.has(item.status)).map((item) => ({
|
|
220
238
|
...item,
|
|
221
239
|
tomatoUrl: tomatoItemUrl(config, item.itemKey)
|
|
222
240
|
})),
|
|
223
241
|
truncated
|
|
224
242
|
};
|
|
225
243
|
}
|
|
226
|
-
function loadItemsCached(config) {
|
|
244
|
+
function loadItemsCached(config, assignee) {
|
|
227
245
|
const ttl = Math.max(0, Math.min(config.cacheTtlMs ?? 15e3, 3e5));
|
|
228
246
|
const key = JSON.stringify([
|
|
229
247
|
cliSettings(config),
|
|
248
|
+
assignee,
|
|
230
249
|
config.iql,
|
|
231
250
|
config.maxItems,
|
|
232
251
|
config.excludedStatuses,
|
|
252
|
+
config.priorityNames,
|
|
233
253
|
config.tomatoOrigin,
|
|
234
254
|
config.tomatoTenant
|
|
235
255
|
]);
|
|
236
256
|
if (itemsCache?.key === key && itemsCache.expiresAt > Date.now()) return Promise.resolve(itemsCache.value);
|
|
237
257
|
if (itemsInFlight?.key === key) return itemsInFlight.promise;
|
|
238
|
-
const promise = loadItems(config).then((value) => {
|
|
258
|
+
const promise = loadItems(config, assignee).then((value) => {
|
|
239
259
|
itemsCache = {
|
|
240
260
|
key,
|
|
241
261
|
expiresAt: Date.now() + ttl,
|
|
@@ -251,6 +271,142 @@ function loadItemsCached(config) {
|
|
|
251
271
|
};
|
|
252
272
|
return promise;
|
|
253
273
|
}
|
|
274
|
+
async function loadFilters(config) {
|
|
275
|
+
const users = [];
|
|
276
|
+
const workspaces = [];
|
|
277
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
278
|
+
const payload = await runJson(config, [
|
|
279
|
+
"team",
|
|
280
|
+
"user",
|
|
281
|
+
"list",
|
|
282
|
+
"--page",
|
|
283
|
+
String(page),
|
|
284
|
+
"--size",
|
|
285
|
+
"300"
|
|
286
|
+
]);
|
|
287
|
+
const record = payload && typeof payload === "object" ? payload : {};
|
|
288
|
+
const values = Array.isArray(record.users) ? record.users : [];
|
|
289
|
+
users.push(...values);
|
|
290
|
+
if (!record.nextPageIndex || values.length === 0) break;
|
|
291
|
+
}
|
|
292
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
293
|
+
const payload = await runJson(config, [
|
|
294
|
+
"team",
|
|
295
|
+
"workspace",
|
|
296
|
+
"list",
|
|
297
|
+
"--page",
|
|
298
|
+
String(page),
|
|
299
|
+
"--size",
|
|
300
|
+
"100"
|
|
301
|
+
]);
|
|
302
|
+
const record = payload && typeof payload === "object" ? payload : {};
|
|
303
|
+
const values = Array.isArray(record.workspaces) ? record.workspaces : [];
|
|
304
|
+
workspaces.push(...values);
|
|
305
|
+
if (!record.nextPageIndex || values.length === 0) break;
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
users: users.map((value) => {
|
|
309
|
+
const user = value && typeof value === "object" ? value : {};
|
|
310
|
+
return {
|
|
311
|
+
username: firstText(user.username),
|
|
312
|
+
name: firstText(user.nickname ?? user.name ?? user.username)
|
|
313
|
+
};
|
|
314
|
+
}).filter((user) => user.username),
|
|
315
|
+
workspaces: workspaces.map((value) => {
|
|
316
|
+
const workspace = value && typeof value === "object" ? value : {};
|
|
317
|
+
return {
|
|
318
|
+
key: firstText(workspace.key),
|
|
319
|
+
name: firstText(workspace.name ?? workspace.key)
|
|
320
|
+
};
|
|
321
|
+
}).filter((workspace) => workspace.key || workspace.name)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function fieldObjectIds(raw) {
|
|
325
|
+
return [...new Set((Array.isArray(raw) ? raw : raw ? [raw] : []).flatMap((value) => {
|
|
326
|
+
if (typeof value === "string") return [value.trim()];
|
|
327
|
+
if (!value || typeof value !== "object") return [];
|
|
328
|
+
const record = value;
|
|
329
|
+
return [
|
|
330
|
+
record.objectId,
|
|
331
|
+
record.value,
|
|
332
|
+
record.id
|
|
333
|
+
].map(firstText).filter(Boolean);
|
|
334
|
+
}).filter(Boolean))];
|
|
335
|
+
}
|
|
336
|
+
function storyPointValue(raw) {
|
|
337
|
+
if (raw === null || raw === void 0 || raw === "" || typeof raw === "boolean") return null;
|
|
338
|
+
if (typeof raw !== "number" && typeof raw !== "string") return null;
|
|
339
|
+
if (typeof raw === "string" && !raw.trim()) return null;
|
|
340
|
+
const value = Number(raw);
|
|
341
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
342
|
+
}
|
|
343
|
+
async function loadSprints(config) {
|
|
344
|
+
const sprints = [];
|
|
345
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
346
|
+
const payload = await runJson(config, [
|
|
347
|
+
"team",
|
|
348
|
+
"sprint",
|
|
349
|
+
"list",
|
|
350
|
+
"--status",
|
|
351
|
+
"active,notStarted,completed",
|
|
352
|
+
"--page",
|
|
353
|
+
String(page),
|
|
354
|
+
"--size",
|
|
355
|
+
"100"
|
|
356
|
+
]);
|
|
357
|
+
const values = Array.isArray(payload.sprints) ? payload.sprints : [];
|
|
358
|
+
sprints.push(...values);
|
|
359
|
+
if (!(Number(payload.nextPageIndex) > page) || !values.length) return { sprints };
|
|
360
|
+
}
|
|
361
|
+
throw new Error("迭代数量超出读取上限,请缩小查询范围");
|
|
362
|
+
}
|
|
363
|
+
async function loadStoryItems(config, sprint, assignee) {
|
|
364
|
+
const owner = assignee === "currentUser()" ? assignee : JSON.stringify(assignee);
|
|
365
|
+
const iql = `迭代 = ${JSON.stringify(sprint)} and 负责人 in [${owner}] and 类型 in [Story, EnablerStory, Task]`;
|
|
366
|
+
const items = /* @__PURE__ */ new Map();
|
|
367
|
+
const limit = Math.max(1, Math.min(config.maxItems ?? 5e3, 2e4));
|
|
368
|
+
for (let page = 1;; page += 1) {
|
|
369
|
+
const payload = await runJson(config, [
|
|
370
|
+
"team",
|
|
371
|
+
"item",
|
|
372
|
+
"search",
|
|
373
|
+
"--iql",
|
|
374
|
+
iql,
|
|
375
|
+
"--fields",
|
|
376
|
+
"StoryPoint,assignee,sprint",
|
|
377
|
+
"--page",
|
|
378
|
+
String(page),
|
|
379
|
+
"--size",
|
|
380
|
+
"50"
|
|
381
|
+
]);
|
|
382
|
+
const values = unwrapItems(payload);
|
|
383
|
+
for (const raw of values) {
|
|
384
|
+
const item = normalizeItem(raw);
|
|
385
|
+
const record = raw;
|
|
386
|
+
const fields = record.values ?? record.value ?? record;
|
|
387
|
+
const itemSprints = fieldObjectIds(fields.sprint ?? fields.Sprint ?? fields["迭代"]);
|
|
388
|
+
const isPlanningItem = [
|
|
389
|
+
"Story",
|
|
390
|
+
"EnablerStory",
|
|
391
|
+
"Task"
|
|
392
|
+
].includes(item.itemType);
|
|
393
|
+
if (item.itemKey && isPlanningItem && itemSprints.includes(sprint)) items.set(item.itemKey, {
|
|
394
|
+
...item,
|
|
395
|
+
storyPoints: storyPointValue(fields.StoryPoint),
|
|
396
|
+
tomatoUrl: tomatoItemUrl(config, item.itemKey)
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const total = Number(payload.count ?? payload.total);
|
|
400
|
+
if (!(Number(payload.nextPageIndex) > page || payload.hasMore === true || payload.hasNext === true || (Number.isFinite(total) ? page * 50 < total : values.length === 50)) || !values.length) return {
|
|
401
|
+
items: [...items.values()].slice(0, limit),
|
|
402
|
+
truncated: items.size > limit
|
|
403
|
+
};
|
|
404
|
+
if (page * 50 >= limit) return {
|
|
405
|
+
items: [...items.values()].slice(0, limit),
|
|
406
|
+
truncated: true
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
254
410
|
function isCrossSite(request) {
|
|
255
411
|
const header = (name) => {
|
|
256
412
|
const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
|
|
@@ -287,6 +443,70 @@ const name = "tomato-board";
|
|
|
287
443
|
const inject = ["webServer"];
|
|
288
444
|
function apply(ctx, config = {}) {
|
|
289
445
|
const webServer = ctx.get("webServer");
|
|
446
|
+
ctx.effect(() => webServer.register({
|
|
447
|
+
kind: "exact",
|
|
448
|
+
path: "/api/tomato-board/sprints",
|
|
449
|
+
async handler(request, response) {
|
|
450
|
+
if (request.method !== "GET") return sendJson(response, 405, { error: "Method not allowed" });
|
|
451
|
+
try {
|
|
452
|
+
sendJson(response, 200, await loadSprints(config));
|
|
453
|
+
} catch (error) {
|
|
454
|
+
sendJson(response, 502, errorBody(error, "迭代读取失败"));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}), "tomato-board: sprints");
|
|
458
|
+
ctx.effect(() => webServer.register({
|
|
459
|
+
kind: "exact",
|
|
460
|
+
path: "/api/tomato-board/story-points",
|
|
461
|
+
async handler(request, response) {
|
|
462
|
+
if (request.method !== "GET") return sendJson(response, 405, { error: "Method not allowed" });
|
|
463
|
+
const query = new URL(request.url ?? "/", "http://localhost").searchParams;
|
|
464
|
+
const sprint = query.get("sprint") ?? "";
|
|
465
|
+
const assignee = query.get("assignee") || "currentUser()";
|
|
466
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/u.test(sprint) || assignee !== "currentUser()" && !/^[A-Za-z0-9_.@-]{1,128}$/u.test(assignee)) return sendJson(response, 400, { error: "无效的迭代或负责人" });
|
|
467
|
+
try {
|
|
468
|
+
sendJson(response, 200, await loadStoryItems(config, sprint, assignee));
|
|
469
|
+
} catch (error) {
|
|
470
|
+
sendJson(response, 502, errorBody(error, "故事点读取失败"));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}), "tomato-board: story points");
|
|
474
|
+
ctx.effect(() => webServer.register({
|
|
475
|
+
kind: "prefix",
|
|
476
|
+
path: "/api/tomato-board/story-point",
|
|
477
|
+
async handler(request, response) {
|
|
478
|
+
if (request.method !== "POST") return sendJson(response, 405, { error: "Method not allowed" });
|
|
479
|
+
if (isCrossSite(request)) return sendJson(response, 403, { error: "拒绝跨站修改请求" });
|
|
480
|
+
const itemKey = itemKeyFromRoute(request.url, "/api/tomato-board/story-point");
|
|
481
|
+
const points = storyPointValue(new URL(request.url ?? "/", "http://localhost").searchParams.get("value"));
|
|
482
|
+
if (!itemKey || points === null) return sendJson(response, 400, { error: "请输入非负有限故事点数值" });
|
|
483
|
+
try {
|
|
484
|
+
await runJson(config, [
|
|
485
|
+
"team",
|
|
486
|
+
"item",
|
|
487
|
+
"edit",
|
|
488
|
+
itemKey,
|
|
489
|
+
"--values",
|
|
490
|
+
JSON.stringify({ StoryPoint: points })
|
|
491
|
+
]);
|
|
492
|
+
const raw = await runJson(config, [
|
|
493
|
+
"team",
|
|
494
|
+
"item",
|
|
495
|
+
"view",
|
|
496
|
+
itemKey
|
|
497
|
+
]);
|
|
498
|
+
const actual = storyPointValue((raw.values ?? raw.value ?? raw).StoryPoint);
|
|
499
|
+
if (actual !== points) throw new Error("故事点保存后校验失败,请刷新后重试");
|
|
500
|
+
itemsCache = null;
|
|
501
|
+
sendJson(response, 200, {
|
|
502
|
+
itemKey,
|
|
503
|
+
storyPoints: actual
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
sendJson(response, 502, errorBody(error, "故事点保存失败"));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}), "tomato-board: edit story point");
|
|
290
510
|
ctx.effect(() => webServer.register({
|
|
291
511
|
kind: "exact",
|
|
292
512
|
path: ROUTE,
|
|
@@ -295,13 +515,30 @@ function apply(ctx, config = {}) {
|
|
|
295
515
|
sendJson(response, 405, { error: "Method not allowed" });
|
|
296
516
|
return;
|
|
297
517
|
}
|
|
518
|
+
const assignee = new URL(request.url ?? ROUTE, "http://localhost").searchParams.get("assignee")?.trim() || "currentUser()";
|
|
519
|
+
if (assignee !== "currentUser()" && !/^[A-Za-z0-9_.@-]{1,128}$/u.test(assignee)) {
|
|
520
|
+
sendJson(response, 400, { error: "无效的负责人用户名" });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
298
523
|
try {
|
|
299
|
-
sendJson(response, 200, await loadItemsCached(config));
|
|
524
|
+
sendJson(response, 200, await loadItemsCached(config, assignee));
|
|
300
525
|
} catch (error) {
|
|
301
526
|
sendJson(response, 502, { error: error instanceof Error ? error.message : "番茄事项读取失败" });
|
|
302
527
|
}
|
|
303
528
|
}
|
|
304
529
|
}), "tomato-board: HTTP items route");
|
|
530
|
+
ctx.effect(() => webServer.register({
|
|
531
|
+
kind: "exact",
|
|
532
|
+
path: FILTERS_ROUTE,
|
|
533
|
+
async handler(request, response) {
|
|
534
|
+
if (request.method !== "GET") return sendJson(response, 405, { error: "Method not allowed" });
|
|
535
|
+
try {
|
|
536
|
+
sendJson(response, 200, await loadFilters(config));
|
|
537
|
+
} catch (error) {
|
|
538
|
+
sendJson(response, 502, { error: error instanceof Error ? error.message : "筛选项读取失败" });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}), "tomato-board: HTTP filters route");
|
|
305
542
|
ctx.effect(() => webServer.register({
|
|
306
543
|
kind: "prefix",
|
|
307
544
|
path: OPEN_ROUTE,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
|
2
|
+
import type { TomatoItem } from './TomatoBoard';
|
|
3
|
+
export declare function StoryPoints({ toolbarTarget, onOpenItem }: {
|
|
4
|
+
toolbarTarget: Dispatch<SetStateAction<ReactNode>>;
|
|
5
|
+
onOpenItem: (item: TomatoItem) => void;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
//# sourceMappingURL=StoryPoints.d.ts.map
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
export interface TomatoItem {
|
|
3
|
+
itemKey: string;
|
|
4
|
+
title: string;
|
|
5
|
+
status: string;
|
|
6
|
+
itemType: string;
|
|
7
|
+
workspace: string;
|
|
8
|
+
workspaceKey: string;
|
|
9
|
+
workspaceName: string;
|
|
10
|
+
creator: string;
|
|
11
|
+
assignees: string[];
|
|
12
|
+
priority: string;
|
|
13
|
+
tomatoUrl: string;
|
|
14
|
+
}
|
|
2
15
|
export declare const inject: string[];
|
|
3
16
|
export declare function apply(ctx: Context): void;
|
|
4
17
|
//# sourceMappingURL=TomatoBoard.d.ts.map
|
package/lib/types/index.d.ts
CHANGED