@trim21/personal-pi-extensions 0.1.684 → 0.1.687

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 CHANGED
@@ -16,7 +16,7 @@
16
16
  | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
17
17
  | [LSP(内置)](#lsp内置) | 文件工具内置 LSP 诊断 + `lsp-rename`/`lsp-inspect`/`lsp-find-definition`/`lsp-find-reference` |
18
18
  | [aft](#aft) | AFT 只读代码感知:`aft_outline`/`aft_zoom`/`aft_callgraph`/`aft_search` |
19
- | [gh-readonly](#gh-readonly) | GitHub 只读工具集(issue / PR / CI / release),基于 `gh` CLI |
19
+ | [gh-readonly](#gh-readonly) | GitHub 只读工具集(issue / PR / CI / release),数据走 octokit REST |
20
20
  | [spawn-agent](#spawn-agent) | 把任务委派给独立上下文窗口的子代理 |
21
21
  | [system-prompt](#system-prompt) | 完全替换 pi 默认 system prompt |
22
22
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时提供 `describe_image`,调用视觉模型识别图片 |
@@ -36,12 +36,14 @@
36
36
  - **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
37
37
  `execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
38
38
  不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
39
- - **可调用集合** = 总线上实际注册的工具减去排除名单,执行时再与当前 active 列表求交,
40
- 所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` / `--tools`、子代理的工具
41
- 白名单都同样约束脚本。排除名单:`codemode` 自身与 `spawn-agent`;两套文件工具集的
42
- 读写工具(`Read`/`Edit`/`Write` 与 `read`/`edit`/`write`);两套工具集的搜索工具
43
- (`Grep`/`Glob` 与 `grep`/`glob`)——脚本搜文件用 `call("Bash", { command: "rg …" })`,
44
- 走同一个沙箱、拿得到退出码,还能拼管道,而那两个工具是给模型看结果的。
39
+ - **可调用集合 = 声明了结构化输出的工具**(有 `structuredSchema` 的才进),执行时再与当前
40
+ active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
41
+ `--tools`、子代理的工具白名单都同样约束脚本。脚本拿到的返回值必须有确定的形状,因此没给
42
+ 结构化结果的工具不进集合——这条规则自己维持一致,不需要维护一份会漂移的黑名单。由此天然
43
+ 不在集合里的:`codemode` 自身与 `spawn-agent`、文件读写工具(脚本用 `fs.read` /
44
+ `fs.write`)、搜索工具(脚本用 `call("Bash", { command: "rg …" })`,走同一个沙箱、拿得到
45
+ 退出码,还能拼管道)、`lsp-rename`(写工具)以及 talk / 会话工具(会把执行时间交给外部
46
+ 输入)。
45
47
  - **文件读写只有 `fs` 一条路**:`fs.read(path)` 返回文件全文的原始 UTF-8 文本(不加行号、
46
48
  不截断、不设大小上限,只有内容不是合法 UTF-8 时报错),`fs.write(path, content)` 整体写入并自动创建
47
49
  父目录,相对路径相对当前 cwd。`fs.write` 与写类工具共用同一套保护:写前要求「已读且读后
@@ -459,7 +461,7 @@ pi -e ./src/aft/index.ts
459
461
 
460
462
  ## gh-readonly
461
463
 
462
- GitHub 只读工具集,基于系统 [`gh`](https://cli.github.com/) CLI(关键词搜索与 checks 查询走 octokit REST)。`gh` 不在 PATH 时整组不注册并在 session_start 报错;Windows 禁用。
464
+ GitHub 只读工具集。取数走 octokit REST(`src/lib/github-reads.ts`);只有「当前仓库」解析(`gh repo view`)与 token 获取(`gh auth token`)还用系统 [`gh`](https://cli.github.com/),因此 `gh` 不在 PATH 时整组不注册并在 session_start 报错;Windows 禁用。
463
465
 
464
466
  - **Issue / PR**:`read-github-issue`、`list-github-issues`、`read-github-issue-comments`、`read-github-pr`、`list-github-prs`、`read-github-pr-diff`、`read-github-pr-status`、`read-github-pr-comments`
465
467
  - **CI**:`read-github-ci-logs`、`list-github-workflow-runs`、`get-github-workflow-jobs`、`wait-github-pr-checks`、`wait-github-commit-checks`、`watch-github-run`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.684",
3
+ "version": "0.1.687",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -12,7 +12,8 @@ interface ToolLike {
12
12
  name: string;
13
13
  description?: string;
14
14
  parameters?: unknown;
15
- structuredSchema?: unknown;
15
+ /** 只有声明了它的工具才可调用(见 `tool.ts` 的准入条件),因此这里是必需的。 */
16
+ structuredSchema: unknown;
16
17
  }
17
18
 
18
19
  type JsonSchema = Record<string, unknown>;
@@ -88,8 +89,7 @@ function renderType(schema: unknown, indent: string): string {
88
89
  function renderOverload(tool: ToolLike): string {
89
90
  const summary = tool.description?.split("\n", 1)[0]?.trim();
90
91
  const doc = summary ? `/** ${summary} */\n` : "";
91
- const output =
92
- tool.structuredSchema === undefined ? "string" : renderType(tool.structuredSchema, "");
92
+ const output = renderType(tool.structuredSchema, "");
93
93
  return `${doc}declare function call(name: ${JSON.stringify(tool.name)}, args: ${renderType(tool.parameters, "")}): Promise<${output}>;`;
94
94
  }
95
95
 
@@ -5,7 +5,7 @@
5
5
  * 自己的确认层),以及 `fs.read` / `fs.write` 两个文件原语(同样由主线程执行,与文件工具
6
6
  * 共用写审批与已读记账,见 fs.ts)。
7
7
  *
8
- * 可调用集合:总线上实际注册的工具减去 `EXCLUDED_TOOL_NAMES`,执行时再与 active
8
+ * 可调用集合:总线上实际注册的、**声明了 `structuredSchema`** 的工具,执行时再与 active
9
9
  * 列表求交——pi 自己的 `defaultTools` / `--tools` / 子代理白名单的排除因此同样生效。
10
10
  *
11
11
  * wasm 在注册这个工具时编译一次(`createCodemodeSandbox`),worker 复用编译结果。
@@ -32,30 +32,16 @@ import { CODEMODE_SOURCE_GRAMMAR, DEFAULT_OUTPUT_TOKENS, parseCodemodeSource } f
32
32
  export const CODEMODE_TOOL_NAME = "codemode";
33
33
 
34
34
  /**
35
- * 不暴露给脚本的工具:
36
- * - codemode 自身(防递归);
37
- * - spawn-agent:它启动一个新的隔离会话,成本与运行时长都不适合放进脚本编排;
38
- * - 两套文件工具集的读写工具:脚本用 `fs.read` / `fs.write`(原文、不截断、按路径整体
39
- * 写入),不重复给一套为 LLM 上下文设计的行号/锚点语义;
40
- * - 两套工具集的搜索工具:脚本用 `call("Bash", { command })` 跑 `rg` / `grep` 更顺手——
41
- * 退出码可用、能拼管道,而这两个工具是给模型看结果的(相对路径、分组渲染、分页尾巴),
42
- * 声明块(尤其 Grep 的参数表与三选一输出)也白占 codemode 的描述篇幅。
35
+ * 准入条件:工具**声明了 `structuredSchema`** 才进 codemode。
36
+ *
37
+ * 脚本拿到的返回值必须有确定的形状——只给文本的工具在脚本里既没法当数据用(得解析文本),
38
+ * 也没有返回类型能写进声明。用「有没有结构化输出」当门槛,这条规则自己会维持一致:不给工具
39
+ * 加 schema 就不进集合,不需要维护一份会随工具增减而漂移的黑名单。因此下列工具天然不在集合
40
+ * 里,理由各自成立:`codemode` 自身与 `spawn-agent`(没有 schema)、文件读写工具(脚本用
41
+ * `fs.read` / `fs.write`,不重复一套为 LLM 上下文设计的行号/锚点语义)、搜索工具(脚本用
42
+ * `call("Bash", { command })` 跑 `rg`,退出码可用、能拼管道)、`lsp-rename`(写工具)以及
43
+ * talk / 会话工具(会把执行时间交给外部输入)。
43
44
  */
44
- const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
45
- CODEMODE_TOOL_NAME,
46
- "spawn-agent",
47
- "Read",
48
- "Edit",
49
- "Write",
50
- "read",
51
- "edit",
52
- "write",
53
- "Grep",
54
- "Glob",
55
- "grep",
56
- "glob",
57
- ]);
58
-
59
45
  /** 估计 token 用的字符数(与 pi 一致)。 */
60
46
  const CHARS_PER_TOKEN = 4;
61
47
 
@@ -78,7 +64,8 @@ interface CallableTool {
78
64
  name: string;
79
65
  description?: string;
80
66
  parameters?: unknown;
81
- structuredSchema?: unknown;
67
+ /** 准入条件:进集合的工具一定带着它(见上面的注释)。 */
68
+ structuredSchema: unknown;
82
69
  }
83
70
 
84
71
  /** 只在 pi 有 active 工具概念时才求交(子代理、`--tools` 等场景)。 */
@@ -94,7 +81,7 @@ function allowedToolNames(pi: ExtensionAPI): Set<string> | undefined {
94
81
  function collectTools(bus: ToolBus, allowed: Set<string> | undefined): CallableTool[] {
95
82
  return bus
96
83
  .list()
97
- .filter((definition) => !EXCLUDED_TOOL_NAMES.has(definition.name))
84
+ .filter((definition) => definition.structuredSchema !== undefined)
98
85
  .filter((definition) => allowed === undefined || allowed.has(definition.name))
99
86
  .map((definition) => ({
100
87
  name: definition.name,
package/src/gh/base.ts CHANGED
@@ -23,8 +23,11 @@ import {
23
23
  type GithubChecksClient,
24
24
  type GithubClientOptions,
25
25
  type GithubSearch,
26
+ normalizeRestList,
26
27
  renderHits,
28
+ type SearchHit,
27
29
  } from "../lib/github.js";
30
+ import { createGithubReads, type GithubReads } from "../lib/github-reads.js";
28
31
  import { parseWithSchema } from "../lib/parse-with-schema.js";
29
32
  import { type ToolPendant } from "../lib/pendant.js";
30
33
  import { type StructuredResult } from "../lib/tool-bus.js";
@@ -140,24 +143,6 @@ export async function ghExec(
140
143
  return result.stdout;
141
144
  }
142
145
 
143
- export function repoArgs(repo?: string): string[] {
144
- return repo ? ["--repo", repo] : [];
145
- }
146
-
147
- /**
148
- * `gh api` for a JSON-array endpoint, following pagination. The REST API pages
149
- * these lists at 30 items by default, so a single page silently drops the rest;
150
- * `--slurp` is required because `--paginate` alone prints the pages back to back
151
- * (not valid JSON), and the page arrays are flattened back into one list.
152
- */
153
- export async function ghApiList(
154
- path: string,
155
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
156
- ): Promise<unknown[]> {
157
- const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
158
- return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
159
- }
160
-
161
146
  /** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
162
147
  export function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
163
148
  const slash = nameWithOwner.indexOf("/");
@@ -323,58 +308,42 @@ export interface ListFilters {
323
308
  }
324
309
 
325
310
  /**
326
- * Build the `gh` argv for browsing issues/PRs (no keyword search).
327
- *
328
- * Keyword searches no longer go through the `gh` CLI — the octokit-based client
329
- * in `../lib/github.ts` handles them with state values (`all`, and `merged` for
330
- * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
331
- * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
332
- * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
311
+ * 浏览分支(不带 keywords):走 octokit 的 REST 列表端点,再归一到与搜索分支同一套
312
+ * `SearchHit`。repo 缺省时先解析当前仓库——文本要据此决定是否带 repo 列,载荷里也必须有值。
333
313
  */
334
- export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
335
- const { repo, state, label, author, assignee, milestone, limit } = params;
336
-
337
- const args = [kind, "list", ...repoArgs(repo)];
338
- if (state) {
339
- args.push("--state", state);
340
- }
341
- if (label) {
342
- args.push("--label", label);
343
- }
344
- if (author) {
345
- args.push("--author", author);
346
- }
347
- if (assignee) {
348
- args.push("--assignee", assignee);
349
- }
350
- if (milestone) {
351
- args.push("--milestone", milestone);
352
- }
353
- if (limit) {
354
- args.push("--limit", String(limit));
355
- }
356
- return args;
357
- }
358
-
359
- export async function listGithub(
314
+ export async function browseList(
315
+ gh: GhClient,
360
316
  kind: "issue" | "pr",
361
317
  params: ListFilters,
362
318
  ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
363
- ): Promise<string> {
364
- return ghExec(listGithubArgs(kind, params), ctx);
319
+ ): Promise<{ repo: string; hits: SearchHit[] }> {
320
+ const repo = params.repo ?? (await resolveRepo(undefined, ctx.signal, ctx.cwd, ctx.input));
321
+ const { owner, repo: repoName } = splitRepo(repo);
322
+ const query = {
323
+ state: params.state,
324
+ label: params.label,
325
+ author: params.author,
326
+ assignee: params.assignee,
327
+ milestone: params.milestone,
328
+ limit: params.limit,
329
+ };
330
+ const items =
331
+ kind === "issue"
332
+ ? await gh.reads.listIssues(owner, repoName, query, ctx.signal)
333
+ : await gh.reads.listPulls(owner, repoName, query, ctx.signal);
334
+ return { repo, hits: normalizeRestList(items, repo) };
365
335
  }
366
336
 
367
- /** Run a keyword search through the octokit client and render the rows. */
368
- export async function searchList(
337
+ /** 行列表的文本:两条分支共用一套 TSV 渲染;空结果是成功结果。 */
338
+ export function renderHitList(
369
339
  kind: "issue" | "pr",
370
- params: ListFilters,
371
- githubSearch: GithubSearch,
372
- ): Promise<string> {
373
- const hits = await githubSearch.search(kind, params);
340
+ hits: SearchHit[],
341
+ options: { repo?: string; fields?: string },
342
+ ): string {
374
343
  if (hits.length === 0) {
375
344
  return `(no matching ${kind === "issue" ? "issues" : "pull requests"})`;
376
345
  }
377
- return renderHits(hits, { repo: params.repo, fields: params.fields });
346
+ return renderHits(hits, options);
378
347
  }
379
348
 
380
349
  /**
@@ -390,6 +359,8 @@ export class GhClient {
390
359
  readonly fetch: typeof globalThis.fetch;
391
360
  readonly search: GithubSearch;
392
361
  readonly checks: GithubChecksClient;
362
+ /** REST 读取层:gh 工具的取数都走它(不再 spawn `gh` 取数据)。 */
363
+ readonly reads: GithubReads;
393
364
 
394
365
  constructor(
395
366
  fetchImpl: typeof globalThis.fetch = egress.fetch,
@@ -398,12 +369,31 @@ export class GhClient {
398
369
  this.fetch = fetchImpl;
399
370
  this.search = createGithubSearch({ fetch: fetchImpl, ...options });
400
371
  this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
372
+ this.reads = createGithubReads({ fetch: fetchImpl, ...options });
401
373
  }
402
374
  }
403
375
 
404
376
  // ── checks watch (pure rendering + poll loop) ────────────────────────────────
405
377
 
406
378
  const CHECKS_POLL_INTERVAL_MS = 30_000;
379
+ /** `watch-github-run` 的轮询间隔与上限。 */
380
+ const RUN_WATCH_INTERVAL_MS = 30_000;
381
+ const RUN_WATCH_DEADLINE_MS = 600_000;
382
+
383
+ /** 运行快照:只声明我们渲染与载荷用到的字段,其余由 REST 原样带过。 */
384
+ const runSnapshotSchema = Type.Object({
385
+ id: Type.Number(),
386
+ name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
387
+ display_title: Type.Optional(Type.String()),
388
+ status: Type.Optional(Type.Union([Type.String(), Type.Null()])),
389
+ conclusion: Type.Optional(Type.Union([Type.String(), Type.Null()])),
390
+ head_branch: Type.Optional(Type.Union([Type.String(), Type.Null()])),
391
+ event: Type.Optional(Type.String()),
392
+ created_at: Type.Optional(Type.String()),
393
+ updated_at: Type.Optional(Type.String()),
394
+ run_started_at: Type.Optional(Type.String()),
395
+ html_url: Type.Optional(Type.String()),
396
+ });
407
397
  const CHECKS_WATCH_DEADLINE_MS = 600_000;
408
398
 
409
399
  export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
@@ -644,6 +634,81 @@ export async function pollPrChecks(options: PollPrChecksOptions): Promise<Checks
644
634
  }
645
635
  }
646
636
 
637
+ /** One polled workflow run: the REST fields the watch loop reports and renders. */
638
+ export interface RunSnapshot {
639
+ id: number;
640
+ name?: string | null;
641
+ display_title?: string;
642
+ status?: string | null;
643
+ conclusion?: string | null;
644
+ head_branch?: string | null;
645
+ event?: string;
646
+ created_at?: string;
647
+ updated_at?: string;
648
+ run_started_at?: string;
649
+ html_url?: string;
650
+ }
651
+
652
+ /** 一行运行状态:`工作流 · 事件 · 分支 — 状态`(附耗时与链接)。 */
653
+ export function renderRunStatus(run: RunSnapshot, elapsedMs?: number): string {
654
+ const state = run.conclusion ?? run.status ?? "unknown";
655
+ const facts = [run.name, run.event, run.head_branch].filter(Boolean).join(" · ");
656
+ const elapsed = elapsedMs === undefined ? "" : ` (${Math.round(elapsedMs / 1000)}s)`;
657
+ return `${facts} — ${state}${elapsed}${run.html_url === undefined ? "" : `\n${run.html_url}`}`;
658
+ }
659
+
660
+ export interface WatchRunOptions {
661
+ owner: string;
662
+ repo: string;
663
+ runId: number;
664
+ reads: GithubReads;
665
+ /** Owned by the caller; the poll loop observes it but never aborts it. */
666
+ signal: AbortSignal;
667
+ /** Test overrides. */
668
+ intervalMs?: number;
669
+ deadlineMs?: number;
670
+ onUpdate?: (msg: ToolResult) => void;
671
+ }
672
+
673
+ export interface RunWatchResult {
674
+ outcome: "completed" | "timeout";
675
+ run: RunSnapshot;
676
+ elapsedMs: number;
677
+ }
678
+
679
+ /**
680
+ * 轮询 `getWorkflowRun` 直到运行结束(`gh run watch` 的替代):固定间隔轮询,每轮通过
681
+ * onUpdate 报一次当前状态,超过上限以 timeout 结束(不挂住)。
682
+ */
683
+ export async function watchRun(options: WatchRunOptions): Promise<RunWatchResult> {
684
+ const { owner, repo, runId, reads, signal, onUpdate } = options;
685
+ const intervalMs = options.intervalMs ?? RUN_WATCH_INTERVAL_MS;
686
+ const deadlineMs = options.deadlineMs ?? RUN_WATCH_DEADLINE_MS;
687
+
688
+ const watchStart = Date.now();
689
+ for (;;) {
690
+ signal.throwIfAborted();
691
+ const run = parseWithSchema(runSnapshotSchema, await reads.run(owner, repo, runId, signal));
692
+ const elapsedMs = Date.now() - watchStart;
693
+ onUpdate?.({
694
+ content: [
695
+ {
696
+ type: "text",
697
+ text: `Watching workflow run ${runId}...\n${renderRunStatus(run, elapsedMs)}`,
698
+ },
699
+ ],
700
+ details: {},
701
+ });
702
+ if (run.status === "completed") {
703
+ return { outcome: "completed", run, elapsedMs };
704
+ }
705
+ if (elapsedMs >= deadlineMs) {
706
+ return { outcome: "timeout", run, elapsedMs };
707
+ }
708
+ await sleepInterruptibly(intervalMs, signal);
709
+ }
710
+ }
711
+
647
712
  /** Job conclusions that count as "did not succeed" for CI result reporting. */
648
713
  const FAILED_JOB_CONCLUSIONS = new Set([
649
714
  "failure",
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 文本类 gh 工具改走 `--json` 之后的渲染器:同一份 JSON 既渲染成给模型看的文本,也原样
3
+ * 进结构化载荷。文本格式是我们自己的(不再是 gh 的表格),因此这些函数要稳定、好读,
4
+ * 且只依赖 schema 里声明过的字段。
5
+ */
6
+
7
+ import { dateOnly } from "../lib/github.js";
8
+
9
+ /**
10
+ * 一行一个 release:`tag 标记 发布日期 标题`。
11
+ *
12
+ * 标记有 `latest`(该仓库最新发布)/ `prerelease` / `draft`;REST 没有「isLatest」字段
13
+ * (那是 gh 按列表顺序算的),所以由调用方按第一个非 draft/prerelease 的条目传进来。
14
+ */
15
+ export function renderReleaseList(
16
+ releases: {
17
+ tag_name: string;
18
+ name?: string | null;
19
+ latest?: boolean;
20
+ prerelease?: boolean;
21
+ draft?: boolean;
22
+ published_at?: string | null;
23
+ }[],
24
+ ): string {
25
+ if (releases.length === 0) {
26
+ return "(no releases)";
27
+ }
28
+ return releases
29
+ .map((release) => {
30
+ const flags = [
31
+ release.latest ? "latest" : "",
32
+ release.prerelease ? "prerelease" : "",
33
+ release.draft ? "draft" : "",
34
+ ].filter(Boolean);
35
+ return [
36
+ release.tag_name,
37
+ flags.join(","),
38
+ dateOnly(release.published_at),
39
+ release.name ?? "",
40
+ ].join("\t");
41
+ })
42
+ .join("\n");
43
+ }
44
+
45
+ /** 一行一个运行:`id 状态 结论 工作流 分支 事件 创建时间 链接`。 */
46
+ export function renderRunList(
47
+ runs: {
48
+ id: number;
49
+ status?: string | null;
50
+ conclusion?: string | null;
51
+ name?: string | null;
52
+ head_branch?: string | null;
53
+ event?: string;
54
+ created_at?: string;
55
+ html_url?: string;
56
+ }[],
57
+ ): string {
58
+ if (runs.length === 0) {
59
+ return "(no workflow runs)";
60
+ }
61
+ return runs
62
+ .map((run) =>
63
+ [
64
+ String(run.id),
65
+ run.status ?? "",
66
+ run.conclusion ?? "",
67
+ run.name ?? "",
68
+ run.head_branch ?? "",
69
+ run.event ?? "",
70
+ dateOnly(run.created_at),
71
+ run.html_url ?? "",
72
+ ].join("\t"),
73
+ )
74
+ .join("\n");
75
+ }
76
+
77
+ /** 仓库概览:标题行 + 一行事实 + 一行链接 + 一行日期/许可。 */
78
+ export function renderRepoView(repo: {
79
+ full_name?: string;
80
+ description?: string | null;
81
+ html_url?: string;
82
+ visibility?: string;
83
+ language?: string | null;
84
+ default_branch?: string;
85
+ stargazers_count?: number;
86
+ forks_count?: number;
87
+ open_issues_count?: number;
88
+ license?: { name?: string } | null;
89
+ pushed_at?: string;
90
+ created_at?: string;
91
+ }): string {
92
+ const facts = [
93
+ repo.visibility?.toLowerCase(),
94
+ repo.language,
95
+ repo.default_branch && `default branch ${repo.default_branch}`,
96
+ repo.stargazers_count !== undefined && `stars ${repo.stargazers_count}`,
97
+ repo.forks_count !== undefined && `forks ${repo.forks_count}`,
98
+ repo.open_issues_count !== undefined && `open issues ${repo.open_issues_count}`,
99
+ ].filter(Boolean);
100
+ return [
101
+ [repo.full_name, repo.description].filter(Boolean).join(" — "),
102
+ facts.join(" · "),
103
+ repo.html_url ?? "",
104
+ [
105
+ repo.pushed_at && `pushed ${dateOnly(repo.pushed_at)}`,
106
+ repo.created_at && `created ${dateOnly(repo.created_at)}`,
107
+ repo.license?.name,
108
+ ]
109
+ .filter(Boolean)
110
+ .join(" · "),
111
+ ]
112
+ .filter((line) => line !== "")
113
+ .join("\n");
114
+ }
115
+
116
+ /** release 详情:标题、事实、资产清单、正文。 */
117
+ export function renderReleaseView(release: {
118
+ tag_name?: string;
119
+ name?: string | null;
120
+ html_url?: string;
121
+ /** 由调用方按「列表里第一个非 draft / prerelease」推断(REST 没有这个字段)。 */
122
+ latest?: boolean;
123
+ prerelease?: boolean;
124
+ draft?: boolean;
125
+ published_at?: string | null;
126
+ created_at?: string;
127
+ author?: { login?: string } | null;
128
+ assets?: { name?: string; size?: number; download_count?: number }[];
129
+ body?: string | null;
130
+ }): string {
131
+ const flags = [release.prerelease && "prerelease", release.draft && "draft"]
132
+ .filter(Boolean)
133
+ .join(",");
134
+ const assets = release.assets ?? [];
135
+ const lines = [
136
+ [release.tag_name, release.name].filter(Boolean).join(" — "),
137
+ [
138
+ `published ${dateOnly(release.published_at ?? release.created_at)}`,
139
+ flags,
140
+ release.author?.login && `by ${release.author.login}`,
141
+ release.html_url,
142
+ ]
143
+ .filter(Boolean)
144
+ .join(" · "),
145
+ ];
146
+ if (assets.length > 0) {
147
+ lines.push(`assets (${assets.length}):`);
148
+ for (const asset of assets) {
149
+ lines.push(
150
+ `- ${asset.name ?? ""} ${asset.size ?? 0} bytes, ${asset.download_count ?? 0} downloads`,
151
+ );
152
+ }
153
+ }
154
+ const body = release.body?.trim() ?? "";
155
+ if (body !== "") {
156
+ lines.push("", body);
157
+ }
158
+ return lines.join("\n");
159
+ }
160
+
161
+ export interface DiffFileStat {
162
+ path: string;
163
+ additions: number;
164
+ deletions: number;
165
+ oldPath?: string;
166
+ }
167
+
168
+ export interface DiffStats {
169
+ files: DiffFileStat[];
170
+ additions: number;
171
+ deletions: number;
172
+ changedFiles: number;
173
+ }
174
+
175
+ /** diff 里的路径:去掉 `a/` / `b/` 前缀与 git 给带空格路径加的双引号;`/dev/null` 给空串。 */
176
+ function stripDiffPrefix(raw: string): string {
177
+ const trimmed = raw.trimEnd();
178
+ if (trimmed === "/dev/null") {
179
+ return "";
180
+ }
181
+ return trimmed
182
+ .replace(/^[ab]\//, "")
183
+ .replace(/^"/, "")
184
+ .replace(/"$/, "");
185
+ }
186
+
187
+ /** `diff --git a/x b/y` 的两个路径(路径带空格时 git 会加双引号)。 */
188
+ function parseDiffHeader(rest: string): { oldPath: string; newPath: string } | undefined {
189
+ const match = /^(?:"a\/(.+)"|a\/(\S+)) (?:"b\/(.+)"|b\/(\S+))$/.exec(rest.trim());
190
+ if (!match) {
191
+ return undefined;
192
+ }
193
+ // 两组可选路径各有一个未参与匹配的捕获组(运行期是 undefined,类型上是 string),用 `||`
194
+ return { oldPath: match[1] || match[2], newPath: match[3] || match[4] };
195
+ }
196
+
197
+ /**
198
+ * 从 unified diff 文本里解析变更统计。
199
+ *
200
+ * `gh pr diff` 没有 `--json`,因此载荷只能从这里来。解析的边界都是 diff 的结构标记
201
+ * (`diff --git` / `---` / `+++` / `@@`),不依赖具体语言或文件内容:二进制文件与纯
202
+ * 模式变更(没有 `---` / `+++`)按 0 行变更计,路径取自 `diff --git` 头。
203
+ */
204
+ export function parseDiffStats(diff: string): DiffStats {
205
+ const files: DiffFileStat[] = [];
206
+ let current: DiffFileStat | undefined;
207
+ /** `--- ` 给出的旧路径,等 `+++ ` 到了再决定它是不是重命名。 */
208
+ let oldPathFromHunk = "";
209
+ let inHunk = false;
210
+
211
+ for (const line of diff.split("\n")) {
212
+ if (line.startsWith("diff --git ")) {
213
+ if (current) {
214
+ files.push(current);
215
+ }
216
+ const header = parseDiffHeader(line.slice("diff --git ".length));
217
+ current = { path: header?.newPath ?? "", additions: 0, deletions: 0 };
218
+ oldPathFromHunk = header?.oldPath ?? "";
219
+ inHunk = false;
220
+ continue;
221
+ }
222
+ if (current === undefined) {
223
+ continue;
224
+ }
225
+ if (line.startsWith("--- ")) {
226
+ oldPathFromHunk = stripDiffPrefix(line.slice(4));
227
+ continue;
228
+ }
229
+ if (line.startsWith("+++ ")) {
230
+ const newPath = stripDiffPrefix(line.slice(4));
231
+ // 删除的文件新路径是 /dev/null,此时用旧路径当 path
232
+ current.path = newPath === "" ? oldPathFromHunk : newPath;
233
+ if (oldPathFromHunk !== "" && oldPathFromHunk !== current.path) {
234
+ current.oldPath = oldPathFromHunk;
235
+ }
236
+ continue;
237
+ }
238
+ if (line.startsWith("@@")) {
239
+ inHunk = true;
240
+ continue;
241
+ }
242
+ if (!inHunk) {
243
+ // 文件头(index / mode / rename / Binary files …)不算变更行
244
+ continue;
245
+ }
246
+ if (line.startsWith("+")) {
247
+ current.additions += 1;
248
+ } else if (line.startsWith("-")) {
249
+ current.deletions += 1;
250
+ }
251
+ }
252
+ if (current) {
253
+ files.push(current);
254
+ }
255
+
256
+ const additions = files.reduce((sum, file) => sum + file.additions, 0);
257
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
258
+ return { files, additions, deletions, changedFiles: files.length };
259
+ }