dsh-github-flow 0.1.6 → 0.1.8

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/api/routes.js CHANGED
@@ -1,6 +1,62 @@
1
1
  import path from 'node:path';
2
2
  import { TtlCache } from '../cache.js';
3
3
  import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
4
+ import { UpdateChecker } from '../updater.js';
5
+ import { UpgradeManager } from '../upgrade-manager.js';
6
+ /**
7
+ * 依据当前登录账号判定 Issue 的归属身份维度 (可多选):
8
+ * - reported: 用户提起的 (属于当前账号名下仓库,但作者不是本人)
9
+ * - assigned: 被分配的 (指派列表中包含当前账号)
10
+ * - created: 我创建的 (作者是当前账号)
11
+ *
12
+ * 为什么这样划分:
13
+ * 在真实工程实践中,Maintainer 不仅要关注自己创建的任务,更要对社区/用户提交的 Bug 反馈负责,
14
+ * 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
15
+ */
16
+ function resolveIssueCategories(issue, currentUser, explicitRepoOwner) {
17
+ const categories = [];
18
+ if (!currentUser)
19
+ return categories;
20
+ const currentLower = currentUser.toLowerCase();
21
+ const authorLogin = issue.author?.login?.toLowerCase();
22
+ const isAuthor = Boolean(authorLogin && authorLogin === currentLower);
23
+ const isAssignee = Array.isArray(issue.assignees) &&
24
+ issue.assignees.some((a) => a.login?.toLowerCase() === currentLower);
25
+ // 仓库 owner 判定:优先从 repository.nameWithOwner 提取,兜底使用 explicitRepoOwner
26
+ let repoOwner = explicitRepoOwner?.toLowerCase();
27
+ if (!repoOwner && issue.repository?.nameWithOwner) {
28
+ repoOwner = issue.repository.nameWithOwner.split('/')[0]?.toLowerCase();
29
+ }
30
+ const isOwnerRepo = Boolean(repoOwner && repoOwner === currentLower);
31
+ if (isAuthor)
32
+ categories.push('created');
33
+ if (isAssignee)
34
+ categories.push('assigned');
35
+ // 如果是当前用户维护的仓库,且作者不是本人,则明确归为「用户提起的」
36
+ if (isOwnerRepo && !isAuthor)
37
+ categories.push('reported');
38
+ return categories;
39
+ }
40
+ function calculateIssueStats(issues) {
41
+ let reported = 0;
42
+ let assigned = 0;
43
+ let created = 0;
44
+ for (const item of issues) {
45
+ const cats = item.categories || [];
46
+ if (cats.includes('reported'))
47
+ reported++;
48
+ if (cats.includes('assigned'))
49
+ assigned++;
50
+ if (cats.includes('created'))
51
+ created++;
52
+ }
53
+ return {
54
+ total: issues.length,
55
+ reported,
56
+ assigned,
57
+ created,
58
+ };
59
+ }
4
60
  /** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
5
61
  function basenameSafe(p) {
6
62
  try {
@@ -40,11 +96,13 @@ function isSafeExternalUrl(raw) {
40
96
  return false;
41
97
  }
42
98
  }
43
- export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000) {
99
+ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000, updateChecker, upgradeManager) {
44
100
  const ctx = ctxOrWebServer;
45
101
  const webServer = ctx?.webServer || ctx;
46
102
  if (!webServer || typeof webServer.register !== 'function')
47
103
  return;
104
+ const checker = updateChecker || new UpdateChecker();
105
+ const upgrader = upgradeManager || new UpgradeManager();
48
106
  const cacheMap = new TtlCache(cacheTtlMs);
49
107
  const globalCache = new TtlCache(globalCacheTtlMs(cacheTtlMs));
50
108
  function sendJson(res, status, data) {
@@ -108,7 +166,12 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
108
166
  const now = Date.now();
109
167
  const cachedData = cacheMap.get(cwd, now);
110
168
  if (cachedData) {
111
- sendJson(res, 200, { ok: true, data: cachedData, fromCache: true, cwd });
169
+ sendJson(res, 200, {
170
+ ok: true,
171
+ data: { ...cachedData, updateInfo: checker.getQuickStatus() },
172
+ fromCache: true,
173
+ cwd,
174
+ });
112
175
  return;
113
176
  }
114
177
  const auth = await executor.checkAuth(cwd);
@@ -125,18 +188,30 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
125
188
  ]);
126
189
  if (prsRes.ok && Array.isArray(prsRes.data))
127
190
  pullRequests = prsRes.data;
128
- if (issuesRes.ok && Array.isArray(issuesRes.data))
129
- issues = issuesRes.data;
191
+ if (issuesRes.ok && Array.isArray(issuesRes.data)) {
192
+ const currentUser = auth.user;
193
+ const repoOwner = repo?.owner || repo?.nameWithOwner?.split('/')[0];
194
+ issues = issuesRes.data.map((item) => {
195
+ const categories = resolveIssueCategories(item, currentUser, repoOwner);
196
+ return {
197
+ ...item,
198
+ categories,
199
+ };
200
+ });
201
+ }
130
202
  if (runsRes.ok && Array.isArray(runsRes.data))
131
203
  runs = runsRes.data;
132
204
  }
205
+ const issueStats = calculateIssueStats(issues);
133
206
  const freshData = {
134
207
  auth,
135
208
  repo: repo || undefined,
136
209
  pullRequests,
137
210
  issues,
211
+ issueStats,
138
212
  runs,
139
213
  lastUpdated: new Date().toISOString(),
214
+ updateInfo: checker.getQuickStatus(),
140
215
  };
141
216
  cacheMap.set(cwd, freshData, now);
142
217
  sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
@@ -271,7 +346,11 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
271
346
  const now = Date.now();
272
347
  const cachedGlobal = globalCache.get('global', now);
273
348
  if (cachedGlobal) {
274
- sendJson(res, 200, { ok: true, data: cachedGlobal, fromCache: true });
349
+ sendJson(res, 200, {
350
+ ok: true,
351
+ data: { ...cachedGlobal, updateInfo: checker.getQuickStatus() },
352
+ fromCache: true,
353
+ });
275
354
  return;
276
355
  }
277
356
  try {
@@ -279,17 +358,29 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
279
358
  let userRepos = [];
280
359
  let myPrs = [];
281
360
  let myIssues = [];
361
+ let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
282
362
  let workspaceMatrix = [];
283
363
  let workspaceError;
284
364
  if (auth.loggedIn) {
285
- // 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 个人 Issues
286
- // 超时压到 8 秒:本机实测 `gh search issues --author=@me` 单次就要 10 秒,
287
- // 它是这组的木桶短板,直接决定首屏等待时间。面板查询不值得为它多等 4 秒。
288
- const [reposRes, prsRes, issuesRes] = await Promise.all([
365
+ // 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 三维 Issue 检索(名下仓库用户提单 + 指派待办 + 我创建的)
366
+ // 为什么三路并发搜索:
367
+ // - --owner=@me: 捕获用户/访客在当前用户所有仓库下提的 Issue (如反馈 Bug、Feature Request),解决外部提单看不见的问题
368
+ // - --assignee=@me: 捕获团队成员或自己指派给当前账号的待办任务 (含跨组织/第三方项目)
369
+ // - --author=@me: 捕获当前账号亲自发起的任务与向外部提起的议题
370
+ // 3 个轻量进程与 repo/prs 同步 spawn,经 OS 并发调度实测仅耗时 3 秒出头,完全满足 8 秒超时门限。
371
+ const issueFields = 'number,title,repository,author,assignees,labels,url,updatedAt';
372
+ const queryResults = await Promise.allSettled([
289
373
  executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
290
374
  executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
291
- executor.run(['search', 'issues', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
375
+ executor.run(['search', 'issues', '--owner=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
376
+ executor.run(['search', 'issues', '--assignee=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
377
+ executor.run(['search', 'issues', '--author=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
292
378
  ]);
379
+ const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
380
+ const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
381
+ const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
382
+ const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
383
+ const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
293
384
  if (reposRes.ok && Array.isArray(reposRes.data)) {
294
385
  userRepos = reposRes.data.map((r) => ({
295
386
  name: r.name || '',
@@ -305,9 +396,40 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
305
396
  if (prsRes.ok && Array.isArray(prsRes.data)) {
306
397
  myPrs = prsRes.data;
307
398
  }
308
- if (issuesRes.ok && Array.isArray(issuesRes.data)) {
309
- myIssues = issuesRes.data;
310
- }
399
+ // 多源 Issues 聚合、按 URL 唯一去重并排序
400
+ const issueMap = new Map();
401
+ const collectIssues = (res) => {
402
+ if (res.ok && Array.isArray(res.data)) {
403
+ for (const item of res.data) {
404
+ if (item.url && !issueMap.has(item.url)) {
405
+ issueMap.set(item.url, item);
406
+ }
407
+ }
408
+ }
409
+ };
410
+ collectIssues(ownerIssuesRes);
411
+ collectIssues(assignedIssuesRes);
412
+ collectIssues(authorIssuesRes);
413
+ const rawList = Array.from(issueMap.values());
414
+ // 统一按最后更新时间降序排列
415
+ rawList.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
416
+ const currentUser = auth.user;
417
+ myIssues = rawList.map((raw) => {
418
+ const categories = resolveIssueCategories(raw, currentUser);
419
+ return {
420
+ number: raw.number,
421
+ title: raw.title || '',
422
+ repository: raw.repository || { nameWithOwner: '' },
423
+ author: raw.author ? { login: raw.author.login } : undefined,
424
+ assignees: Array.isArray(raw.assignees) ? raw.assignees.map((a) => ({ login: a.login })) : [],
425
+ labels: Array.isArray(raw.labels) ? raw.labels.map((l) => ({ name: l.name, color: l.color })) : [],
426
+ url: raw.url || '',
427
+ updatedAt: raw.updatedAt || '',
428
+ state: raw.state || 'OPEN',
429
+ categories,
430
+ };
431
+ });
432
+ issueStats = calculateIssueStats(myIssues);
311
433
  }
312
434
  // 解析本地所有工作区与 GitHub 关联矩阵
313
435
  const wsRead = tryReadWorkspaces();
@@ -336,7 +458,9 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
336
458
  workspaceMatrix,
337
459
  myPrs,
338
460
  myIssues,
461
+ issueStats,
339
462
  lastUpdated: new Date().toISOString(),
463
+ updateInfo: checker.getQuickStatus(),
340
464
  ...(workspaceError ? { workspaceError } : {}),
341
465
  };
342
466
  globalCache.set('global', globalData, now);
@@ -347,4 +471,75 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
347
471
  }
348
472
  },
349
473
  });
474
+ // 5. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
475
+ bindRoute({
476
+ kind: 'exact',
477
+ path: '/api/github/check-update',
478
+ handler: async (req, res) => {
479
+ if (req.method !== 'GET') {
480
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
481
+ return;
482
+ }
483
+ try {
484
+ const url = new URL(req.url || '', 'http://127.0.0.1');
485
+ const force = url.searchParams.get('force') === 'true';
486
+ const info = await checker.check(force);
487
+ sendJson(res, 200, { ok: true, data: info });
488
+ }
489
+ catch (err) {
490
+ sendJson(res, 500, { ok: false, error: err.message || '检查更新失败' });
491
+ }
492
+ },
493
+ });
494
+ // 6. 一键自动升级触发端点(用户点击操作横幅按钮后发起)
495
+ bindRoute({
496
+ kind: 'exact',
497
+ path: '/api/github/upgrade',
498
+ handler: async (req, res) => {
499
+ if (req.method !== 'POST') {
500
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
501
+ return;
502
+ }
503
+ try {
504
+ const info = await checker.check(false);
505
+ const result = await upgrader.executeUpgrade(info.latestVersion);
506
+ sendJson(res, result.ok ? 200 : 500, result);
507
+ }
508
+ catch (err) {
509
+ sendJson(res, 500, { ok: false, message: err.message || '执行自动升级异常' });
510
+ }
511
+ },
512
+ });
513
+ // 7. 升级状态与实时日志查询端点
514
+ bindRoute({
515
+ kind: 'exact',
516
+ path: '/api/github/upgrade-status',
517
+ handler: async (req, res) => {
518
+ if (req.method !== 'GET') {
519
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
520
+ return;
521
+ }
522
+ sendJson(res, 200, { ok: true, data: upgrader.getStatus() });
523
+ },
524
+ });
525
+ // 8. 快速认证状态轻量探针 (耗时 < 500ms,专供登录自愈检测与快速恢复)
526
+ bindRoute({
527
+ kind: 'exact',
528
+ path: '/api/github/auth',
529
+ handler: async (req, res) => {
530
+ if (req.method !== 'GET') {
531
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
532
+ return;
533
+ }
534
+ try {
535
+ const url = new URL(req.url || '', 'http://127.0.0.1');
536
+ const force = url.searchParams.get('force') === 'true';
537
+ const auth = await executor.checkAuth(undefined, force);
538
+ sendJson(res, 200, { ok: true, auth });
539
+ }
540
+ catch (err) {
541
+ sendJson(res, 500, { ok: false, error: err.message || '认证检测失败' });
542
+ }
543
+ },
544
+ });
350
545
  }
@@ -62,6 +62,33 @@ export function createGithubPanelComponent() {
62
62
  : h('div', { className: 'dsh-github-list' }, prs.map((pr) => h('div', { key: pr.number, className: 'dsh-github-item' }, h('div', { className: 'dsh-github-item-main' }, h('a', { className: 'dsh-github-item-title', href: pr.url, target: '_blank' }, `#${pr.number} ${pr.title}`), h('div', { className: 'dsh-github-item-meta' }, h('span', null, `由 ${pr.author?.login || '未知'} 提交`), h('span', null, `${pr.headRefName} → ${pr.baseRefName}`), pr.mergeable === 'CONFLICTING'
63
63
  ? h('span', { className: 'dsh-github-badge dsh-badge-red' }, '冲突 Conflicting')
64
64
  : h('span', { className: 'dsh-github-badge dsh-badge-green' }, '可合并 Mergeable'))), h('div', { className: 'dsh-github-item-actions' }, h('a', { className: 'dsh-github-btn-sm', href: pr.url, target: '_blank', style: { textDecoration: 'none' } }, '在 GitHub 打开'))))));
65
+ // Issues 区域
66
+ const issuesSectionEl = h('div', { className: 'dsh-github-section' }, h('div', { className: 'dsh-github-section-header' }, h('h3', null, `📋 仓库 Issues (${issues.length})`), repo?.url
67
+ ? h('a', {
68
+ href: `${repo.url}/issues`,
69
+ target: '_blank',
70
+ style: { fontSize: '11px', color: 'var(--dsw-alias-link, #0969da)', textDecoration: 'none' },
71
+ }, '全部 ↗')
72
+ : null), issues.length === 0
73
+ ? h('div', { className: 'dsh-github-empty' }, '当前无开放中的 Issue')
74
+ : h('div', { className: 'dsh-github-list' }, issues.map((issue) => {
75
+ const cats = issue.categories || [];
76
+ const badges = [];
77
+ if (cats.includes('reported')) {
78
+ badges.push(h('span', { key: 'rep', className: 'dsh-github-badge dsh-badge-purple' }, '用户反馈'));
79
+ }
80
+ if (cats.includes('assigned')) {
81
+ badges.push(h('span', { key: 'asn', className: 'dsh-github-badge dsh-badge-blue' }, '已分配'));
82
+ }
83
+ if (cats.includes('created')) {
84
+ badges.push(h('span', { key: 'crt', className: 'dsh-github-badge dsh-badge-green' }, '我发起的'));
85
+ }
86
+ return h('div', { key: issue.number, className: 'dsh-github-item' }, h('div', { className: 'dsh-github-item-main' }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } }, h('a', { className: 'dsh-github-item-title', href: issue.url, target: '_blank' }, `#${issue.number} ${issue.title}`), ...badges), h('div', { className: 'dsh-github-item-meta' }, h('span', null, `由 ${issue.author?.login || '未知'} 提出`), issue.assignees && issue.assignees.length > 0
87
+ ? h('span', null, `指派: ${issue.assignees.map((a) => a.login).join(', ')}`)
88
+ : null, issue.labels && issue.labels.length > 0
89
+ ? issue.labels.map((lbl) => h('span', { key: lbl.name, className: 'dsh-github-badge dsh-badge-yellow' }, lbl.name))
90
+ : null)), h('div', { className: 'dsh-github-item-actions' }, h('a', { className: 'dsh-github-btn-sm', href: issue.url, target: '_blank', style: { textDecoration: 'none' } }, '在 GitHub 打开')));
91
+ })));
65
92
  // GitHub Actions 区域
66
93
  const runsSectionEl = h('div', { className: 'dsh-github-section' }, h('div', { className: 'dsh-github-section-header' }, h('h3', null, `⚡ GitHub Actions 最近流水线 (${runs.length})`)), runs.length === 0
67
94
  ? h('div', { className: 'dsh-github-empty' }, '暂无工作流运行记录')
@@ -70,6 +97,6 @@ export function createGithubPanelComponent() {
70
97
  : r.conclusion === 'failure'
71
98
  ? h('span', { className: 'dsh-github-badge dsh-badge-red' }, '失败 Failure')
72
99
  : h('span', { className: 'dsh-github-badge dsh-badge-yellow' }, r.status || '进行中'))), h('div', { className: 'dsh-github-item-actions' }, h('a', { className: 'dsh-github-btn-sm', href: r.url, target: '_blank', style: { textDecoration: 'none' } }, '查看流水线'))))));
73
- return h('div', { className: 'dsh-github-panel' }, headerEl, cardsEl, prsSectionEl, runsSectionEl);
100
+ return h('div', { className: 'dsh-github-panel' }, headerEl, cardsEl, prsSectionEl, issuesSectionEl, runsSectionEl);
74
101
  };
75
102
  }