@stephen1620/dsh-tomato-board 0.1.0 → 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/lib/index.js CHANGED
@@ -7,13 +7,17 @@ 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 PRIORITY_NAMES = new Map([
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
  ]);
19
+ let itemsCache = null;
20
+ let itemsInFlight = null;
17
21
  function firstText(value) {
18
22
  if (typeof value === "string") return value.trim();
19
23
  if (Array.isArray(value)) return value.map(firstText).find(Boolean) ?? "";
@@ -33,6 +37,9 @@ function firstText(value) {
33
37
  }
34
38
  return "";
35
39
  }
40
+ function textList(value) {
41
+ return [...new Set((Array.isArray(value) ? value : value ? [value] : []).map(firstText).filter(Boolean))];
42
+ }
36
43
  function unwrapItems(payload) {
37
44
  if (Array.isArray(payload)) return payload;
38
45
  if (!payload || typeof payload !== "object") return [];
@@ -63,19 +70,26 @@ function unwrapTransitions(payload) {
63
70
  }
64
71
  return [];
65
72
  }
66
- function normalizeItem(raw) {
73
+ function normalizeItem(raw, priorityNames = DEFAULT_PRIORITY_NAMES) {
67
74
  const item = raw && typeof raw === "object" ? raw : {};
68
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);
69
80
  return {
70
81
  itemKey: firstText(item.itemKey ?? item.key ?? item.id ?? nested.itemKey ?? nested.key),
71
82
  title: firstText(item.title ?? item.name ?? nested.title ?? nested.name ?? nested["标题"]),
72
83
  status: firstText(item.status ?? nested.status ?? nested["状态"]),
73
84
  itemType: firstText(item.itemType ?? item.type ?? nested.itemType ?? nested["类型"]),
74
- workspace: firstText(item.workspace ?? nested.workspace ?? nested["所属空间"]),
85
+ workspace: workspaceKey || workspaceName,
86
+ workspaceKey,
87
+ workspaceName,
75
88
  creator: firstText(item.creator ?? item.createdBy ?? nested["创建人"] ?? nested.createdBy),
89
+ assignees: textList(item.assignees ?? item.assignee ?? nested.assignees ?? nested.assignee ?? nested["负责人"]),
76
90
  priority: (() => {
77
91
  const priority = firstText(item.priority ?? nested.priority ?? nested["优先级"]);
78
- return PRIORITY_NAMES.get(priority) ?? priority;
92
+ return priorityNames.get(priority) ?? priority;
79
93
  })()
80
94
  };
81
95
  }
@@ -166,12 +180,20 @@ function itemKeyFromRoute(requestUrl, route) {
166
180
  const itemKey = decodeURIComponent(pathname.slice(route.length).replace(/^\/+/, "")).trim();
167
181
  return /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u.test(itemKey) ? itemKey : "";
168
182
  }
169
- async function loadItems(config) {
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) {
170
189
  const { executable, profile } = cliSettings(config);
171
- const iql = config.iql || "负责人 = currentUser() and 所属空间 in ['Gitee-Team', 'Gitee-Test'] and 类型 in ['Story', 'EnablerStory', 'Bug', '测试缺陷']";
190
+ const iql = assigneeIql(config, assignee);
191
+ const priorityNames = new Map([...DEFAULT_PRIORITY_NAMES, ...Object.entries(config.priorityNames ?? {})]);
172
192
  const rawItems = [];
173
193
  let page = 1;
174
- while (rawItems.length < 5e3) {
194
+ let truncated = false;
195
+ const maxItems = Math.max(1, Math.min(config.maxItems ?? 5e3, 2e4));
196
+ while (rawItems.length < maxItems) {
175
197
  const result = await execFileAsync(executable, [
176
198
  "team",
177
199
  "item",
@@ -185,7 +207,7 @@ async function loadItems(config) {
185
207
  "--iql",
186
208
  iql,
187
209
  "--fields",
188
- "priority,createdBy",
210
+ "priority,createdBy,assignee",
189
211
  "--output",
190
212
  "json"
191
213
  ], {
@@ -197,19 +219,208 @@ async function loadItems(config) {
197
219
  rawItems.push(...pageItems);
198
220
  const record = payload && typeof payload === "object" ? payload : {};
199
221
  const total = Number(record.count ?? record.total ?? record.data?.count);
200
- if (!(record.hasNext === true || record.hasMore === true || Number.isFinite(total) && rawItems.length < total || !Number.isFinite(total) && pageItems.length === 50) || pageItems.length === 0) break;
222
+ const hasMore = record.hasNext === true || record.hasMore === true || Number.isFinite(total) && rawItems.length < total || !Number.isFinite(total) && pageItems.length === 50;
223
+ if (rawItems.length >= maxItems && hasMore) {
224
+ truncated = true;
225
+ break;
226
+ }
227
+ if (!hasMore || pageItems.length === 0) break;
201
228
  page += 1;
202
229
  }
203
- const excludedStatuses = new Set([
230
+ const excludedStatuses = new Set(config.excludedStatuses ?? [
204
231
  "测试通过",
205
232
  "测试完成",
206
233
  "不修复",
207
234
  "已取消"
208
235
  ]);
209
- return rawItems.map(normalizeItem).filter((item) => item.itemKey && item.title && item.status && !excludedStatuses.has(item.status)).map((item) => ({
210
- ...item,
211
- tomatoUrl: tomatoItemUrl(config, item.itemKey)
212
- }));
236
+ return {
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) => ({
238
+ ...item,
239
+ tomatoUrl: tomatoItemUrl(config, item.itemKey)
240
+ })),
241
+ truncated
242
+ };
243
+ }
244
+ function loadItemsCached(config, assignee) {
245
+ const ttl = Math.max(0, Math.min(config.cacheTtlMs ?? 15e3, 3e5));
246
+ const key = JSON.stringify([
247
+ cliSettings(config),
248
+ assignee,
249
+ config.iql,
250
+ config.maxItems,
251
+ config.excludedStatuses,
252
+ config.priorityNames,
253
+ config.tomatoOrigin,
254
+ config.tomatoTenant
255
+ ]);
256
+ if (itemsCache?.key === key && itemsCache.expiresAt > Date.now()) return Promise.resolve(itemsCache.value);
257
+ if (itemsInFlight?.key === key) return itemsInFlight.promise;
258
+ const promise = loadItems(config, assignee).then((value) => {
259
+ itemsCache = {
260
+ key,
261
+ expiresAt: Date.now() + ttl,
262
+ value
263
+ };
264
+ return value;
265
+ }).finally(() => {
266
+ if (itemsInFlight?.promise === promise) itemsInFlight = null;
267
+ });
268
+ itemsInFlight = {
269
+ key,
270
+ promise
271
+ };
272
+ return promise;
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
+ }
410
+ function isCrossSite(request) {
411
+ const header = (name) => {
412
+ const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
413
+ return Array.isArray(value) ? value[0] : value;
414
+ };
415
+ if (header("sec-fetch-site") === "cross-site") return true;
416
+ const origin = header("origin");
417
+ const host = header("host");
418
+ if (!origin || !host) return false;
419
+ try {
420
+ return new URL(origin).host !== host;
421
+ } catch {
422
+ return true;
423
+ }
213
424
  }
214
425
  function sendJson(response, status, body) {
215
426
  response.statusCode = status;
@@ -232,6 +443,70 @@ const name = "tomato-board";
232
443
  const inject = ["webServer"];
233
444
  function apply(ctx, config = {}) {
234
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");
235
510
  ctx.effect(() => webServer.register({
236
511
  kind: "exact",
237
512
  path: ROUTE,
@@ -240,13 +515,30 @@ function apply(ctx, config = {}) {
240
515
  sendJson(response, 405, { error: "Method not allowed" });
241
516
  return;
242
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
+ }
243
523
  try {
244
- sendJson(response, 200, { items: await loadItems(config) });
524
+ sendJson(response, 200, await loadItemsCached(config, assignee));
245
525
  } catch (error) {
246
526
  sendJson(response, 502, { error: error instanceof Error ? error.message : "番茄事项读取失败" });
247
527
  }
248
528
  }
249
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");
250
542
  ctx.effect(() => webServer.register({
251
543
  kind: "prefix",
252
544
  path: OPEN_ROUTE,
@@ -300,6 +592,10 @@ function apply(ctx, config = {}) {
300
592
  sendJson(response, 405, { error: "Method not allowed" });
301
593
  return;
302
594
  }
595
+ if (isCrossSite(request)) {
596
+ sendJson(response, 403, { error: "拒绝跨站状态流转请求" });
597
+ return;
598
+ }
303
599
  const url = new URL(request.url ?? TRANSITION_ROUTE, "http://localhost");
304
600
  const itemKey = itemKeyFromRoute(request.url, TRANSITION_ROUTE);
305
601
  const transition = url.searchParams.get("transition")?.trim() ?? "";
@@ -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
@@ -5,6 +5,10 @@ interface Config {
5
5
  iql?: string;
6
6
  tomatoOrigin?: string;
7
7
  tomatoTenant?: string;
8
+ cacheTtlMs?: number;
9
+ maxItems?: number;
10
+ excludedStatuses?: string[];
11
+ priorityNames?: Record<string, string>;
8
12
  }
9
13
  export declare const name = "tomato-board";
10
14
  export declare const inject: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephen1620/dsh-tomato-board",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Gitee Team/Tomato workboard and native conversation launcher for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "scripts": {
63
63
  "bundle": "tsdown",
64
- "prepack": "npm run bundle"
64
+ "prepack": "node ../../scripts/verify-package.mjs ."
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@deepseek-ai/cordis": "*",