@kernel-sig/console 0.1.0 → 0.1.1

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.
@@ -141,6 +141,7 @@ export function PullDetailPage({ pullId }: { pullId: string }) {
141
141
 
142
142
  <aside className="space-y-4">
143
143
  <GatePanel pull={pull} />
144
+ <LinkedIssuesPanel pull={pull} />
144
145
  <LabelsPanel pull={pull} />
145
146
  <ClassificationPanel pull={pull} />
146
147
  <MetaPanel pull={pull} />
@@ -150,6 +151,56 @@ export function PullDetailPage({ pullId }: { pullId: string }) {
150
151
  )
151
152
  }
152
153
 
154
+ /**
155
+ * 关联 Issue。
156
+ *
157
+ * 两个来源分开标注,不合并成一句"已关联 N 个 Issue":上游显式登记的可信,
158
+ * 正文里写的链接只是作者自己写的,两者混起来的代价是评审人以为某个 Issue
159
+ * 已经被正式关联上了。
160
+ *
161
+ * 没有关联时不渲染 —— 空面板占着侧栏位置,只说明"没东西"。
162
+ */
163
+ function LinkedIssuesPanel({ pull }: { pull: PullDetail }) {
164
+ if (pull.linked_issues.length === 0) return null
165
+
166
+ return (
167
+ <Card>
168
+ <CardHeader>
169
+ <CardTitle>关联 Issue</CardTitle>
170
+ </CardHeader>
171
+ <CardContent className="space-y-2.5">
172
+ {pull.linked_issues.map((issue) => (
173
+ <div key={issue.number}>
174
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
175
+ <span className="font-mono text-xs text-[var(--color-text-muted)]">
176
+ #{issue.number}
177
+ </span>
178
+ {issue.issue_state && <Badge tone="neutral">{issue.issue_state}</Badge>}
179
+ <span className="text-2xs text-[var(--color-text-muted)]">
180
+ {issue.source === 'atomgit_link' ? '上游登记' : '正文声明'}
181
+ </span>
182
+ </div>
183
+ <p className="mt-0.5 text-xs leading-relaxed text-[var(--color-text-secondary)]">
184
+ {issue.title ?? '该 Issue 尚未同步,只有编号'}
185
+ </p>
186
+ {issue.html_url && (
187
+ <a
188
+ href={issue.html_url}
189
+ target="_blank"
190
+ rel="noreferrer noopener"
191
+ className="mt-0.5 inline-flex items-center gap-1 text-2xs text-[var(--color-accent)] hover:underline"
192
+ >
193
+ 在 AtomGit 打开
194
+ <ExternalLink className="size-2.5" aria-hidden />
195
+ </a>
196
+ )}
197
+ </div>
198
+ ))}
199
+ </CardContent>
200
+ </Card>
201
+ )
202
+ }
203
+
153
204
  /** 标题旁的分类标签。分类是"这是什么"的第一印象,放在标题行最省事。 */
154
205
  function HeaderClassification({ pullId }: { pullId: string }) {
155
206
  const { data } = useQuery({
@@ -161,21 +212,122 @@ function HeaderClassification({ pullId }: { pullId: string }) {
161
212
  }
162
213
 
163
214
  function OverviewTab({ pull }: { pull: PullDetail }) {
164
- if (!pull.body?.trim()) {
165
- return (
166
- <EmptyState
167
- title="该 PR 没有描述内容"
168
- description="openEuler 的 PR 模板要求填写功能描述与关联 Issue,缺失描述会增加评审成本。"
169
- />
170
- )
171
- }
215
+ return (
216
+ <div className="space-y-3">
217
+ <ReviewPanel pull={pull} />
218
+ {pull.body?.trim() ? (
219
+ <Card>
220
+ <CardBody>
221
+ <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-[var(--color-text-secondary)]">
222
+ {pull.body}
223
+ </pre>
224
+ </CardBody>
225
+ </Card>
226
+ ) : (
227
+ <EmptyState
228
+ title="该 PR 没有描述内容"
229
+ description="openEuler 的 PR 模板要求填写功能描述与关联 Issue,缺失描述会增加评审成本。"
230
+ />
231
+ )}
232
+ </div>
233
+ )
234
+ }
235
+
236
+ /**
237
+ * 评审情况。
238
+ *
239
+ * 会做三件事:谁表了态、谁提了意见、还该找谁。侧栏的「合并门禁」只报
240
+ * 门禁状态,人名与意见集中在这里 —— 同一个名单在两处各渲染一遍,迟早
241
+ * 会一处更新一处不更新。
242
+ */
243
+ function ReviewPanel({ pull }: { pull: PullDetail }) {
244
+ const summary = pull.review_summary
245
+ if (!summary) return null
246
+
247
+ const empty =
248
+ summary.supported.length === 0 &&
249
+ summary.commenters.length === 0 &&
250
+ summary.pending.length === 0
251
+ if (empty) return null
252
+
172
253
  return (
173
254
  <Card>
174
- <CardBody>
175
- <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-[var(--color-text-secondary)]">
176
- {pull.body}
177
- </pre>
178
- </CardBody>
255
+ <CardHeader>
256
+ <CardTitle>评审情况</CardTitle>
257
+ </CardHeader>
258
+ <CardContent className="space-y-4">
259
+ <section>
260
+ <p className="mb-1.5 text-xs text-[var(--color-text-muted)]">
261
+ 已表态支持
262
+ {summary.committer_support > 0 && ` · 其中 Committer ${summary.committer_support} 人`}
263
+ </p>
264
+ {summary.supported.length === 0 ? (
265
+ <p className="text-sm text-[var(--color-warning)]">还没有人给出 LGTM</p>
266
+ ) : (
267
+ <ul className="flex flex-wrap gap-1.5">
268
+ {summary.supported.map((actor) => (
269
+ <li key={actor.login}>
270
+ <Badge tone={actor.is_committer ? 'success' : 'neutral'}>
271
+ {actor.login}
272
+ {actor.is_committer && ' · Committer'}
273
+ {actor.events > 1 && ` ×${actor.events}`}
274
+ </Badge>
275
+ </li>
276
+ ))}
277
+ </ul>
278
+ )}
279
+ </section>
280
+
281
+ {summary.pending.length > 0 && (
282
+ <section>
283
+ <p className="mb-1.5 text-xs text-[var(--color-text-muted)]">
284
+ 还需评审
285
+ {summary.subsystems.length > 0 && ` · 负责 ${summary.subsystems.join('、')} 的 Committer`}
286
+ </p>
287
+ <ul className="flex flex-wrap gap-1.5">
288
+ {summary.pending.map((actor) => (
289
+ <li key={actor.login}>
290
+ <Badge tone="warning">{actor.login}</Badge>
291
+ </li>
292
+ ))}
293
+ </ul>
294
+ </section>
295
+ )}
296
+
297
+ {summary.commenters.length > 0 && (
298
+ <section>
299
+ <p className="mb-1.5 text-xs text-[var(--color-text-muted)]">提过意见</p>
300
+ <ul className="space-y-2">
301
+ {summary.commenters.map((commenter) => (
302
+ <li
303
+ key={commenter.login}
304
+ className="rounded-[var(--radius-control)] bg-[var(--color-surface-2)] p-2.5"
305
+ >
306
+ <div className="flex flex-wrap items-center gap-2">
307
+ <span className="text-sm font-medium">{commenter.login}</span>
308
+ <span className="text-2xs text-[var(--color-text-muted)]">
309
+ {commenter.comments} 条
310
+ </span>
311
+ {commenter.last_at && (
312
+ <span className="ml-auto text-2xs text-[var(--color-text-muted)]">
313
+ {formatRelativeTime(commenter.last_at)}
314
+ </span>
315
+ )}
316
+ </div>
317
+ {commenter.latest_excerpt && (
318
+ <p className="mt-1 text-xs leading-relaxed text-[var(--color-text-secondary)]">
319
+ {commenter.latest_excerpt}
320
+ </p>
321
+ )}
322
+ </li>
323
+ ))}
324
+ </ul>
325
+ <p className="mt-1.5 text-2xs text-[var(--color-text-muted)]">
326
+ 这里只给最新一条的摘要,完整讨论在「讨论」页签。
327
+ </p>
328
+ </section>
329
+ )}
330
+ </CardContent>
179
331
  </Card>
180
332
  )
181
333
  }
@@ -189,8 +341,32 @@ function CommitsTab({ pull }: { pull: PullDetail }) {
189
341
  />
190
342
  )
191
343
  }
344
+
345
+ // 提交级的增删行数上游只按单提交给,同步时按需取;这里的合计用文件级数据,
346
+ // 两者口径不同(文件级是 PR 终态,提交级是逐个补丁),所以分开显示。
347
+ const additions = pull.files.reduce((sum, file) => sum + file.additions, 0)
348
+ const deletions = pull.files.reduce((sum, file) => sum + file.deletions, 0)
349
+
192
350
  return (
193
351
  <div className="space-y-2">
352
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--color-text-muted)]">
353
+ <span>
354
+ <span className="font-mono tabular-nums">{pull.commits.length}</span> 个提交
355
+ </span>
356
+ {pull.files.length > 0 && (
357
+ <>
358
+ <span>
359
+ <span className="font-mono tabular-nums">{pull.files.length}</span> 个文件
360
+ </span>
361
+ <span className="font-mono tabular-nums">
362
+ <span className="text-[var(--color-success)]">+{additions}</span>
363
+ <span className="mx-0.5">/</span>
364
+ <span className="text-[var(--color-danger)]">-{deletions}</span>
365
+ </span>
366
+ </>
367
+ )}
368
+ </div>
369
+
194
370
  {pull.commits.map((commit: PRCommit) => (
195
371
  <Card key={commit.sha}>
196
372
  <CardBody>
@@ -199,12 +375,31 @@ function CommitsTab({ pull }: { pull: PullDetail }) {
199
375
  {String(commit.sequence).padStart(4, '0')}
200
376
  </span>
201
377
  <div className="min-w-0 flex-1">
202
- <p className="text-sm font-medium">{commit.subject}</p>
378
+ <div className="flex flex-wrap items-baseline gap-x-2">
379
+ <p className="text-sm font-medium">{commit.subject}</p>
380
+ {commit.additions !== null && commit.deletions !== null && (
381
+ <span className="font-mono text-2xs tabular-nums">
382
+ <span className="text-[var(--color-success)]">+{commit.additions}</span>
383
+ <span className="mx-0.5 text-[var(--color-text-muted)]">/</span>
384
+ <span className="text-[var(--color-danger)]">-{commit.deletions}</span>
385
+ </span>
386
+ )}
387
+ </div>
203
388
  <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--color-text-muted)]">
204
389
  <code>{commit.sha.slice(0, 12)}</code>
205
390
  {commit.author_name && <span>{commit.author_name}</span>}
206
391
  {commit.committed_at && <span>{formatDateTime(commit.committed_at)}</span>}
207
392
  </div>
393
+
394
+ {/* 完整提交信息。评审真正要看的是正文里的 `bugzilla:` /
395
+ `CVE:` / `Reference:` 这些行 —— 只给标题等于把评审人
396
+ 赶回 AtomGit 页面。首行就是 subject,不再重复渲染。 */}
397
+ {commit.message.trim().split('\n').length > 1 && (
398
+ <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words rounded-[var(--radius-control)] bg-[var(--color-surface-2)] p-2.5 font-mono text-2xs leading-relaxed text-[var(--color-text-secondary)]">
399
+ {commit.message.trim().split('\n').slice(1).join('\n').trim()}
400
+ </pre>
401
+ )}
402
+
208
403
  {commit.signed_off_bys && commit.signed_off_bys.length === 0 && (
209
404
  <Badge tone="warning" className="mt-2">
210
405
  缺少 Signed-off-by
@@ -486,20 +681,21 @@ function ClassificationPanel({ pull }: { pull: PullDetail }) {
486
681
  // 下拉选项与列表筛选共用同一份顺序定义,避免两处手工维护后漂移
487
682
  const KIND_OPTIONS = KIND_ORDER.map((value) => ({ value, label: KIND_META[value].label }))
488
683
 
489
- const TASK_OPTIONS = (['summarize', 'risk_review', 'backport_verify', 'classify'] as AITask[]).map(
490
- (value) => ({ value, label: TASK_META[value].label }),
491
- )
684
+ // 展示顺序按实际阅读顺序排,不按枚举顺序
685
+ const TASK_ORDER: AITask[] = ['summarize', 'risk_review', 'backport_verify', 'classify']
492
686
 
493
687
  /**
494
688
  * AI 分析面板。
495
689
  *
496
- * 只展示已有结果,不自动触发 —— 每次调用都要花钱,
497
- * 让"什么时候分析"成为使用者的显式决定。
690
+ * 四类任务平铺,各自独立触发 —— 用一个下拉选任务的话,看完摘要要切一次、
691
+ * 看完风险再切回来,来回点,而且一眼看不出哪些跑过、哪些没跑。
692
+ *
693
+ * 不自动触发:每次调用都要花钱,让"什么时候分析"成为使用者的显式决定。
498
694
  */
499
695
  function AiTab({ pull }: { pull: PullDetail }) {
500
696
  const queryClient = useQueryClient()
501
- const [task, setTask] = useState<AITask>('summarize')
502
697
  const [error, setError] = useState<string | null>(null)
698
+ const [busyTask, setBusyTask] = useState<AITask | null>(null)
503
699
 
504
700
  const { data, isLoading } = useQuery({
505
701
  queryKey: ['pull-analyses', pull.id],
@@ -507,89 +703,107 @@ function AiTab({ pull }: { pull: PullDetail }) {
507
703
  })
508
704
 
509
705
  const analyze = useMutation({
510
- mutationFn: (force: boolean) => aiApi.analyzePull(pull.id, task, force),
706
+ mutationFn: ({ task, force }: { task: AITask; force: boolean }) =>
707
+ aiApi.analyzePull(pull.id, task, force),
708
+ onMutate: ({ task }) => setBusyTask(task),
511
709
  onSuccess: async (analysis) => {
512
710
  setError(analysis.error)
513
711
  await queryClient.invalidateQueries({ queryKey: ['pull-analyses', pull.id] })
514
712
  await queryClient.invalidateQueries({ queryKey: ['pull-classification', pull.id] })
515
713
  },
516
714
  onError: (cause) => setError(cause instanceof Error ? cause.message : '分析失败'),
715
+ onSettled: () => setBusyTask(null),
517
716
  })
518
717
 
718
+ // 一个对象每个任务只有一行(唯一约束保证),所以按任务取即可
719
+ const byTask = new Map((data ?? []).map((analysis) => [analysis.task, analysis]))
720
+
519
721
  return (
520
722
  <div className="space-y-3">
521
- <Card>
522
- <CardBody className="space-y-3">
523
- <div className="flex flex-wrap items-center gap-2">
524
- <Select
525
- aria-label="分析任务"
526
- className="w-44"
527
- options={TASK_OPTIONS}
528
- value={task}
529
- onChange={(value) => setTask(value as AITask)}
530
- />
531
- <Button size="sm" onClick={() => analyze.mutate(false)} loading={analyze.isPending}>
532
- <Sparkles className="size-3.5" aria-hidden />
533
- 分析
534
- </Button>
535
- <Button
536
- variant="ghost"
537
- size="sm"
538
- onClick={() => analyze.mutate(true)}
539
- loading={analyze.isPending}
540
- >
541
- 强制重跑
542
- </Button>
543
- <span className="text-xs text-[var(--color-text-muted)]">
544
- {TASK_META[task].purpose}
545
- </span>
546
- </div>
547
- <p className="text-2xs leading-relaxed text-[var(--color-text-muted)]">
548
- 内容未变时不会重复调用模型;「强制重跑」才会绕开该检查。
549
- 风险审查与 Backport 核对需要已同步的补丁内容,否则结论不可信。
550
- </p>
551
- </CardBody>
552
- </Card>
723
+ <p className="text-2xs leading-relaxed text-[var(--color-text-muted)]">
724
+ 内容未变时不会重复调用模型,「强制重跑」才绕开该检查;AI 调用按次计费,所以不自动执行。
725
+ 风险审查与 Backport 核对需要已同步的补丁内容,否则结论不可信。
726
+ </p>
553
727
 
554
728
  {error && <Alert tone="danger">{error}</Alert>}
555
729
 
556
- {isLoading ? (
557
- <Skeleton className="h-32 w-full" />
558
- ) : !data || data.length === 0 ? (
559
- <EmptyState
560
- title="尚无分析结果"
561
- description="选择任务后点击「分析」。AI 调用按次计费,因此不自动执行。"
730
+ {TASK_ORDER.map((task) => (
731
+ <TaskAnalysisCard
732
+ key={task}
733
+ task={task}
734
+ analysis={byTask.get(task)}
735
+ loading={isLoading}
736
+ busy={busyTask === task}
737
+ onRun={(force) => analyze.mutate({ task, force })}
562
738
  />
563
- ) : (
564
- data.map((analysis) => <AnalysisCard key={analysis.id} analysis={analysis} />)
565
- )}
739
+ ))}
566
740
  </div>
567
741
  )
568
742
  }
569
743
 
570
- function AnalysisCard({ analysis }: { analysis: AIAnalysis }) {
571
- const status = STATUS_META[analysis.status]
744
+ function TaskAnalysisCard({
745
+ task,
746
+ analysis,
747
+ loading,
748
+ busy,
749
+ onRun,
750
+ }: {
751
+ task: AITask
752
+ analysis: AIAnalysis | undefined
753
+ loading: boolean
754
+ busy: boolean
755
+ onRun: (force: boolean) => void
756
+ }) {
757
+ const meta = TASK_META[task]
758
+ const status = analysis ? STATUS_META[analysis.status] : null
759
+
572
760
  return (
573
761
  <Card>
574
762
  <CardHeader className="p-4 pb-2">
575
763
  <div className="flex flex-wrap items-center gap-2">
576
- <CardTitle className="text-sm">{TASK_META[analysis.task].label}</CardTitle>
577
- <Badge tone={status.tone}>{status.label}</Badge>
578
- {analysis.model && (
764
+ <CardTitle className="text-sm">{meta.label}</CardTitle>
765
+ {status ? (
766
+ <Badge tone={status.tone}>{status.label}</Badge>
767
+ ) : (
768
+ <Badge tone="neutral">未分析</Badge>
769
+ )}
770
+ {analysis?.model && (
579
771
  <span className="font-mono text-2xs text-[var(--color-text-muted)]">
580
772
  {analysis.provider_name}/{analysis.model}
581
773
  </span>
582
774
  )}
583
- <span className="ml-auto text-2xs text-[var(--color-text-muted)]">
584
- {formatRelativeTime(analysis.created_at)}
585
- {analysis.prompt_tokens + analysis.completion_tokens > 0 &&
586
- ` · ${analysis.prompt_tokens + analysis.completion_tokens} tokens`}
775
+ <span className="ml-auto flex items-center gap-2">
776
+ {analysis && (
777
+ <span className="text-2xs text-[var(--color-text-muted)]">
778
+ {formatRelativeTime(analysis.created_at)}
779
+ {analysis.prompt_tokens + analysis.completion_tokens > 0 &&
780
+ ` · ${analysis.prompt_tokens + analysis.completion_tokens} tokens`}
781
+ </span>
782
+ )}
783
+ <Button size="sm" variant="secondary" onClick={() => onRun(false)} loading={busy}>
784
+ <Sparkles className="size-3.5" aria-hidden />
785
+ {analysis ? '重跑' : '分析'}
786
+ </Button>
787
+ {analysis && (
788
+ <Button variant="ghost" size="sm" onClick={() => onRun(true)} loading={busy}>
789
+ 强制重跑
790
+ </Button>
791
+ )}
587
792
  </span>
588
793
  </div>
794
+ <p className="mt-1 text-2xs text-[var(--color-text-muted)]">{meta.purpose}</p>
589
795
  </CardHeader>
590
796
  <CardBody className="pt-0">
591
- {analysis.error && <Alert tone="danger">{analysis.error}</Alert>}
592
- {analysis.result && <AnalysisResult task={analysis.task} result={analysis.result} />}
797
+ {loading ? (
798
+ <Skeleton className="h-12 w-full" />
799
+ ) : !analysis ? (
800
+ <p className="text-xs text-[var(--color-text-muted)]">尚未分析。</p>
801
+ ) : (
802
+ <>
803
+ {analysis.error && <Alert tone="danger">{analysis.error}</Alert>}
804
+ {analysis.result && <AnalysisResult task={analysis.task} result={analysis.result} />}
805
+ </>
806
+ )}
593
807
  </CardBody>
594
808
  </Card>
595
809
  )
@@ -787,18 +1001,9 @@ function GatePanel({ pull }: { pull: PullDetail }) {
787
1001
  <p className="text-sm text-[var(--color-success)]">门禁条件均已满足</p>
788
1002
  )}
789
1003
 
790
- {pull.lgtm_actors.length > 0 && (
791
- <div className="border-t border-[var(--color-border-subtle)] pt-3">
792
- <p className="mb-1.5 text-xs text-[var(--color-text-muted)]">已 LGTM 的评审人</p>
793
- <div className="flex flex-wrap gap-1.5">
794
- {pull.lgtm_actors.map((actor: string) => (
795
- <Badge key={actor} tone="success">
796
- {actor}
797
- </Badge>
798
- ))}
799
- </div>
800
- </div>
801
- )}
1004
+ {/* 评审人名单不在这里渲染:「概览」页签的「评审情况」面板才是它的
1005
+ 归宿(那里还有意见摘要和待评审名单)。同一份名单在侧栏再渲染
1006
+ 一遍,两处迟早会有一个忘了更新。 */}
802
1007
  </CardContent>
803
1008
  </Card>
804
1009
  )
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { Select } from 'antd'
2
3
  import { useState, type FormEvent } from 'react'
3
4
 
4
5
  import { credentialsApi, repositoriesApi } from '@/api/repositories'
@@ -65,6 +66,20 @@ export function SettingsPage() {
65
66
  },
66
67
  })
67
68
 
69
+ // 把已有凭据挂到已有仓库上。没有这一步的话,先启动、后加凭据的人会卡死:
70
+ // 仓库是启动引导建的(那时还没有凭据),而「纳管仓库」按钮只在仓库列表为
71
+ // 空时出现 —— 仓库已经在了,按钮永远不出现,界面上再无第二个入口。
72
+ const attachCredential = useMutation({
73
+ mutationFn: ({
74
+ repositoryId,
75
+ credentialId,
76
+ }: {
77
+ repositoryId: string
78
+ credentialId: string
79
+ }) => repositoriesApi.update(repositoryId, { credential_id: credentialId }),
80
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['repositories'] }),
81
+ })
82
+
68
83
  function handleCredentialSubmit(event: FormEvent) {
69
84
  event.preventDefault()
70
85
  if (!tokenValue.trim()) return
@@ -232,10 +247,40 @@ export function SettingsPage() {
232
247
  界面上就是一个永远 0 条的仓库,没人知道该去配凭据。 */}
233
248
  {!repository.credential_id && (
234
249
  <Alert tone="warning" className="mt-2">
235
- 未配置凭据,同步不会运行。请在上方「访问凭据」中添加 AtomGit token。
250
+ 这个仓库还没关联凭据,同步不会运行。选一条凭据即可开始。
236
251
  </Alert>
237
252
  )}
238
253
 
254
+ {attachCredential.error instanceof ApiError &&
255
+ attachCredential.variables?.repositoryId === repository.id && (
256
+ <Alert tone="danger" className="mt-2">
257
+ {attachCredential.error.message}
258
+ </Alert>
259
+ )}
260
+
261
+ {credentials && credentials.length > 0 && (
262
+ <div className="mt-2.5 flex flex-wrap items-center gap-2">
263
+ <span className="text-xs text-[var(--color-text-muted)]">使用凭据</span>
264
+ <Select
265
+ aria-label="使用凭据"
266
+ className="w-56"
267
+ size="small"
268
+ placeholder="未选择"
269
+ value={repository.credential_id ?? undefined}
270
+ options={credentials.map((item) => ({
271
+ value: item.id,
272
+ label: item.name,
273
+ }))}
274
+ onChange={(credentialId) =>
275
+ attachCredential.mutate({
276
+ repositoryId: repository.id,
277
+ credentialId,
278
+ })
279
+ }
280
+ />
281
+ </div>
282
+ )}
283
+
239
284
  {repository.last_sync_error && (
240
285
  <Alert tone="danger" className="mt-2">
241
286
  {repository.last_sync_error}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@kernel-sig/console",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "openEuler Kernel SIG 的 PR/Issue 管理平台:一条命令在本机起一整套实例",
5
5
  "bin": {
6
- "ksc": "./cli/index.js"
6
+ "ksc": "cli/index.js"
7
7
  },
8
8
  "files": [
9
9
  "cli/",
@@ -500,6 +500,18 @@ def verify_settings(page: Page, token: str | None) -> None:
500
500
  # 计数来自接口,不硬编码具体数值,避免数据变化后误报
501
501
  check("显示 PR 与 Issue 计数", "PR" in body and "Issue" in body)
502
502
 
503
+ # 每个仓库卡片上都得有凭据选择器。少了它,"启动引导先把仓库建好、之后
504
+ # 才补凭据"的人就再也挂不上凭据 —— 仓库已经存在,「纳管仓库」按钮只在
505
+ # 仓库列表为空时才出现,界面上再无第二个入口。这条断言盯的是入口存在,
506
+ # 不是某个仓库当下的关联状态(那个会随环境变)。
507
+ repos = page.request.get(f"{BASE}/api/v1/repositories").json()
508
+ pickers = page.get_by_label("使用凭据").count()
509
+ check(
510
+ "每个仓库卡片都有凭据选择器",
511
+ len(repos) > 0 and pickers == len(repos),
512
+ f"{pickers} 个选择器 / {len(repos)} 个仓库",
513
+ )
514
+
503
515
  if token:
504
516
  leaked = token in page.content()
505
517
  check("页面无凭据明文泄漏", not leaked)