@stephen1620/dsh-tomato-board 0.1.1 → 0.3.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,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 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
  ]);
@@ -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: firstText(item.workspace ?? nested.workspace ?? nested["所属空间"]),
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 PRIORITY_NAMES.get(priority) ?? priority;
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
- 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) {
172
189
  const { executable, profile } = cliSettings(config);
173
- 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 ?? {})]);
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
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface TagProps {
3
+ children: ReactNode;
4
+ /** Tint color (hex or CSS color var). Omit for the neutral card-meta look. */
5
+ tone?: string | undefined;
6
+ /** Render as a toggle button (filter chips) instead of a static span. */
7
+ interactive?: boolean;
8
+ /** Toggle state for interactive tags; false renders the dimmed-off look. */
9
+ pressed?: boolean | undefined;
10
+ title?: string | undefined;
11
+ className?: string | undefined;
12
+ onClick?: (() => void) | undefined;
13
+ }
14
+ /**
15
+ * The one tag chip used across the workbench — card metadata, filter chips and
16
+ * status labels. Static usage renders a span; interactive usage renders a
17
+ * toggle button. Only callers that pass `tone` get a colored chip (by
18
+ * convention just the item-type tags).
19
+ */
20
+ export declare function Tag({ children, tone, interactive, pressed, title, className, onClick }: TagProps): import("react").JSX.Element;
21
+ //# sourceMappingURL=Tag.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
@@ -8,6 +8,7 @@ interface Config {
8
8
  cacheTtlMs?: number;
9
9
  maxItems?: number;
10
10
  excludedStatuses?: string[];
11
+ priorityNames?: Record<string, string>;
11
12
  }
12
13
  export declare const name = "tomato-board";
13
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.1",
3
+ "version": "0.3.0",
4
4
  "description": "Gitee Team/Tomato workboard and native conversation launcher for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "repository": {