diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index ed1fb7e1..a461d52a 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -57,6 +57,7 @@ bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10 bun scripts/cli.ts gh run list --repo owner/name --limit 10 bun scripts/cli.ts gh run view --repo owner/name bun scripts/cli.ts gh run logs --job --repo owner/name +bun scripts/cli.ts gh run benchmark --repo owner/name --url https://owner.github.io/repo/dev/bench/ --limit 10 trans gh:/pikasTech/unidesk/pr/ cat trans gh:/pikasTech/unidesk/pr/ rg bun scripts/cli.ts gh pr review-plan --repo pikasTech/unidesk @@ -67,6 +68,14 @@ bun scripts/cli.ts gh pr preflight --repo pikasTech/unidesk bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk --merge --delete-branch ``` +- `gh run benchmark` 读取 `github-action-benchmark` 发布到 GitHub Pages + 的 `data.js`: + - `--url` 可以传页面目录或 `data.js`; + - 默认输出有界历史和最新测量; + - `--commit --raw` 返回筛选后的结构化 + `entries` 与 `measurements`; + - 不解析 job log,也不默认输出完整导出文件。 + 机器结构化入口必须显式请求: ```bash diff --git a/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md b/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md index ff95b4a4..0164cef8 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md +++ b/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md @@ -38,6 +38,8 @@ GitHub入口负责为 UniDesk/HWLAB 执行过程提供受控 GitHub issue、PR ### 2.2 范围内 - `bun scripts/cli.ts gh ...` 的 GitHub issue/PR/list/read/write/comment/close/reopen/merge/preflight 等受控命令。 +- GitHub Actions run、job、日志诊断和 `github-action-benchmark` + Pages 导出物的有界只读查询。 - `trans gh:/owner/repo/...` 虚拟文件写回所依赖的 issue/PR body read、body patch、sha guard 和 update guard。 - GitHub REST/GraphQL token resolution、request、错误分类、rate/repo/issue 探测和脱敏输出。 - issue/PR 正文 stdin/file/patch 输入、短 body safety、profile guard、body sha、updatedAt 和 comment concurrency guard。 @@ -63,15 +65,16 @@ GitHub入口负责为 UniDesk/HWLAB 执行过程提供受控 GitHub issue、PR | `trans gh:` | 把 GitHub issue/PR 正文映射为虚拟文件的 route,最终仍调用 UniDesk GitHub入口写回。 | | scoped help | `gh issue --help`、`gh pr merge --help` 这类按命令前缀过滤的有界帮助输出。 | | bounded dump | CLI 输出超过全局阈值时写入临时 dump,并在 stdout 返回摘要和读取命令。 | +| benchmark 导出物 | `github-action-benchmark` 发布到 GitHub Pages 的 `data.js` 历史数据;它是 Action 产出的只读性能记录,不等同于 job 日志。 | ## 4. 系统边界和接口 | 边界项 | 内容 | | --- | --- | | 外部使用者 | 维护者、agent、项目治理流程、PR closeout 流程和 `trans gh:` 路由。 | -| 外部输入 | GitHub token 来源、owner/repo、issue/PR number、正文、patch、commentId、board row、attachment 选择、merge method 和输出模式。 | +| 外部输入 | GitHub token 来源、owner/repo、issue/PR number、正文、patch、commentId、board row、attachment 选择、merge method、Actions run/job、benchmark Pages URL 和输出模式。 | | 受控资源 | `scripts/src/gh.ts` 兼容入口、`scripts/src/gh/` 子模块、GitHub REST/GraphQL request、body guard、merge guard、board mutation plan、attachment 下载和 help/dispatch。 | -| 外部输出 | GitHub 资源摘要、受控写入结果、body sha、comment id、PR preflight、merge 状态、bounded dump、错误分类和后续 drill-down 命令。 | +| 外部输出 | GitHub 资源摘要、受控写入结果、body sha、comment id、PR preflight、merge 状态、Actions 状态、benchmark 历史摘要、bounded dump、错误分类和后续 drill-down 命令。 | | 用户接口 | `bun scripts/cli.ts gh ...`、`bun scripts/cli.ts help` 中的 gh 入口、`trans gh:/owner/repo/...`。 | | 系统边界 | GitHub入口负责 GitHub 读写路径、保护策略、输出契约和模块边界;不拥有业务 issue 的需求内容、Git source truth 或发布判定。 | @@ -164,7 +167,9 @@ GitHub入口应保持 `bun scripts/cli.ts gh ...`、`ghHelp`、`ghScopedHelp`、 | --- | --- | --- | --- | | OPS-GH-REQ-002 | 请求选项 | PJ2026-01060602 请求与选项 | [源码同步](PJ2026-010602-source-sync.md) | -GitHub入口应集中处理 token resolution、REST/GraphQL request、错误分类、owner/repo、issue/PR number、URL/shorthand、JSON fields、known option validation 和 read/view target resolution。认证失败、repo 不存在、权限不足、GitHub transient 和 unsupported command 必须结构化输出,不得静默 fallback 到原生 `gh`、raw `curl` 或裸 API 调用。 +GitHub入口应集中处理 token resolution、REST/GraphQL request、公开 GitHub Pages 读取、错误分类、owner/repo、issue/PR number、URL/shorthand、JSON fields、known option validation 和 read/view target resolution。认证失败、repo 不存在、权限不足、GitHub transient、Pages 导出无效和 unsupported command 必须结构化输出,不得静默 fallback 到原生 `gh`、raw `curl` 或裸 API 调用。 + +GitHub Pages 读取只接受 HTTPS URL,拒绝 URL credential,并对响应体设置有界字节预算。`github-action-benchmark` 页面 URL 可以解析到同目录 `data.js`;CLI 必须验证导出 schema,再按 limit 或 commit 选择记录,不能执行导出文件中的 JavaScript。 ### 6.3 OPS-GH-REQ-003 正文保护与并发 guard @@ -198,6 +203,8 @@ GitHub入口应支持 board table parse、board-row list/get/update/add/upsert/m GitHub入口应保持低噪声默认输出、稳定 `--json` 字段选择、`--raw`、`--full`、bounded dump 和 scoped help 行为。`gh --help`、`gh issue --help`、`gh pr --help` 和子命令 help 必须快速返回有界 JSON,不因拆分丢失 usage、notes 或 next command。 +Actions benchmark 默认输出只能展示有界提交摘要和最近记录的测量表;结构化模式返回已经筛选的 entries 和 measurements,不回显完整 `data.js`。结果必须披露 source URL、导出更新时间、总记录数、返回记录数和 mutation=false,使调用方能判断数据新鲜度与裁剪范围。 + ## 7. 过程控制 本规格的执行 issue 为 [#906](https://github.com/pikasTech/unidesk/issues/906)。后续影响 GitHub入口稳定契约、模块边界、body guard、merge guard 或 `trans gh:` 写回语义的变更,应先更新本规格或在对应 issue 中说明为何不改变规格。 diff --git a/scripts/src/gh-actions-runs.test.ts b/scripts/src/gh-actions-runs.test.ts index b087bdaf..ab970e2d 100644 --- a/scripts/src/gh-actions-runs.test.ts +++ b/scripts/src/gh-actions-runs.test.ts @@ -1,8 +1,34 @@ import { describe, expect, test } from "bun:test"; import { actionLogFailureExcerpt, isActionLogNotReady, redactAndTailActionLog } from "./gh/actions-runs"; +import { benchmarkDataUrl, parseBenchmarkData } from "./gh/actions-benchmark"; import { withGhDefaultRendered } from "./gh/default-render"; describe("GitHub Actions run diagnostics", () => { + test("parses github-action-benchmark Pages exports without executing JavaScript", () => { + const parsed = parseBenchmarkData(`window.BENCHMARK_DATA = { + "lastUpdate": 123, + "repoUrl": "https://github.com/owner/repo", + "entries": { + "Benchmark": [{ + "commit": {"id": "abcdef", "message": "perf: faster", "timestamp": "2026-01-01T00:00:00Z", "url": "https://github.com/owner/repo/commit/abcdef"}, + "date": 123, + "tool": "googlecpp", + "benches": [{"name": "loop", "value": 1.25, "unit": "ms/iter", "extra": "iterations: 10"}] + }] + } + };`); + expect(parsed.entries).toHaveLength(1); + expect(parsed.entries[0]?.benches[0]?.value).toBe(1.25); + expect(parsed.suites).toEqual(["Benchmark"]); + }); + + test("resolves a benchmark page to data.js and rejects a mismatched Pages owner", () => { + expect(benchmarkDataUrl("owner/repo", "https://owner.github.io/repo/dev/bench/")) + .toBe("https://owner.github.io/repo/dev/bench/data.js"); + expect(() => benchmarkDataUrl("owner/repo", "https://other.github.io/repo/dev/bench/")) + .toThrow("host must be owner.github.io"); + }); + test("redacts credentials and keeps a bounded tail", () => { const lines = Array.from({ length: 200 }, (_, index) => `line-${index}`); lines.push("TOKEN=secret-value"); @@ -64,5 +90,29 @@ describe("GitHub Actions run diagnostics", () => { expect(logs.renderedText).toContain("compile failed at source.c:10"); expect(logs.renderedText).toContain("first failed assertion"); expect(logs.renderedText).toContain("valuesPrinted=false"); + + const benchmark = withGhDefaultRendered(["run", "benchmark"], { + ok: true, + command: "run benchmark", + repo: "owner/repo", + source: { dataUrl: "https://owner.github.io/repo/dev/bench/data.js", bytes: 1024, lastUpdate: 123 }, + suites: ["Benchmark"], + totalEntries: 2, + matchingEntries: 2, + returnedEntries: 1, + entries: [{ + commit: { id: "abcdef", message: "perf: faster" }, + date: 123, + measurementCount: 2, + }], + measurements: [ + { commit: "abcdef", name: "Performance Points", value: 18000, unit: "Point/iter" }, + { commit: "abcdef", name: "loop", value: 1.25, unit: "ms/iter" }, + ], + next: { commit: "gh run benchmark --commit " }, + }) as { renderedText: string }; + expect(benchmark.renderedText).toContain("18000.00"); + expect(benchmark.renderedText).toContain("loop"); + expect(benchmark.renderedText).toContain("never the full data.js source"); }); }); diff --git a/scripts/src/gh/actions-benchmark.ts b/scripts/src/gh/actions-benchmark.ts new file mode 100644 index 00000000..fa6fe08f --- /dev/null +++ b/scripts/src/gh/actions-benchmark.ts @@ -0,0 +1,185 @@ +import { commandError, errorPayload, githubPagesTextRequest, isGitHubError, validationError } from "./client"; +import { isRecord } from "./notify-claudeqq"; + +const BENCHMARK_EXPORT_MAX_BYTES = 8 * 1024 * 1024; + +interface BenchmarkMeasurement { + name: string; + value: number; + unit: string; + extra: string | null; +} + +interface BenchmarkEntry { + suite: string; + commit: { + id: string; + message: string; + timestamp: string | null; + url: string | null; + }; + date: number; + tool: string; + benches: BenchmarkMeasurement[]; +} + +function splitRepo(repo: string): [string, string] | null { + const [owner, name, extra] = repo.split("/"); + return owner && name && extra === undefined ? [owner, name] : null; +} + +export function benchmarkDataUrl(repo: string, input?: string): string { + const parts = splitRepo(repo); + if (parts === null) throw new Error("--repo must use owner/name format"); + const [owner, name] = parts; + const page = new URL(input ?? `https://${owner}.github.io/${name}/dev/bench/`); + if (page.protocol !== "https:" || page.username !== "" || page.password !== "") { + throw new Error("--url must be an HTTPS GitHub Pages URL without embedded credentials"); + } + if (page.hostname.toLowerCase() !== `${owner.toLowerCase()}.github.io`) { + throw new Error(`--url host must be ${owner.toLowerCase()}.github.io for repo ${repo}`); + } + const repoPrefix = `/${name.toLowerCase()}/`; + if (!page.pathname.toLowerCase().startsWith(repoPrefix)) { + throw new Error(`--url path must start with /${name}/ for repo ${repo}`); + } + page.search = ""; + page.hash = ""; + if (page.pathname.endsWith("/data.js")) return page.toString(); + if (!page.pathname.endsWith("/")) page.pathname += "/"; + return new URL("data.js", page).toString(); +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function parseMeasurement(value: unknown): BenchmarkMeasurement | null { + if (!isRecord(value)) return null; + const name = text(value.name); + const unit = text(value.unit); + if (name === null || unit === null || typeof value.value !== "number" || !Number.isFinite(value.value)) return null; + return { name, value: value.value, unit, extra: text(value.extra) }; +} + +function parseEntry(suite: string, value: unknown): BenchmarkEntry | null { + if (!isRecord(value) || !isRecord(value.commit) || !Array.isArray(value.benches)) return null; + const id = text(value.commit.id); + const message = text(value.commit.message); + if (id === null || message === null || typeof value.date !== "number" || !Number.isFinite(value.date)) return null; + const benches = value.benches.map(parseMeasurement).filter((item): item is BenchmarkMeasurement => item !== null); + if (benches.length === 0) return null; + return { + suite, + commit: { + id, + message, + timestamp: text(value.commit.timestamp), + url: text(value.commit.url), + }, + date: value.date, + tool: text(value.tool) ?? "unknown", + benches, + }; +} + +export function parseBenchmarkData(textValue: string): { + lastUpdate: number | null; + repoUrl: string | null; + entries: BenchmarkEntry[]; + suites: string[]; +} { + const prefix = /^\s*window\.BENCHMARK_DATA\s*=\s*/u; + if (!prefix.test(textValue)) throw new Error("benchmark export must assign window.BENCHMARK_DATA"); + const json = textValue.replace(prefix, "").replace(/;?\s*$/u, ""); + const parsed: unknown = JSON.parse(json); + if (!isRecord(parsed) || !isRecord(parsed.entries)) throw new Error("benchmark export is missing entries"); + const suites = Object.keys(parsed.entries); + const entries = suites.flatMap((suite) => { + const values = parsed.entries[suite]; + return Array.isArray(values) + ? values.map((value) => parseEntry(suite, value)).filter((entry): entry is BenchmarkEntry => entry !== null) + : []; + }).sort((left, right) => left.date - right.date); + if (entries.length === 0) throw new Error("benchmark export contains no valid entries"); + return { + lastUpdate: typeof parsed.lastUpdate === "number" && Number.isFinite(parsed.lastUpdate) ? parsed.lastUpdate : null, + repoUrl: text(parsed.repoUrl), + entries, + suites, + }; +} + +export async function actionBenchmark( + repo: string, + options: { url?: string; commit?: string; limit: number }, +): Promise> { + let dataUrl: string; + try { + dataUrl = benchmarkDataUrl(repo, options.url); + } catch (error) { + return validationError("run benchmark", repo, error instanceof Error ? error.message : String(error)); + } + const fetched = await githubPagesTextRequest(dataUrl, BENCHMARK_EXPORT_MAX_BYTES); + if (isGitHubError(fetched)) return commandError("run benchmark", repo, fetched, { mutation: false, dataUrl }); + let parsed: ReturnType; + try { + parsed = parseBenchmarkData(fetched.text); + } catch (error) { + return commandError("run benchmark", repo, errorPayload("invalid-response", error instanceof Error ? error.message : String(error), { + request: { method: "GET", url: dataUrl }, + details: { bytes: fetched.bytes, contentType: fetched.contentType }, + }), { mutation: false, dataUrl }); + } + const commit = options.commit?.toLowerCase(); + const matching = commit === undefined + ? parsed.entries + : parsed.entries.filter((entry) => entry.commit.id.toLowerCase().startsWith(commit)); + if (matching.length === 0) { + return validationError("run benchmark", repo, `no benchmark entry matched commit ${options.commit}`, { + dataUrl, + totalEntries: parsed.entries.length, + }); + } + const selectedEntries = matching.slice(-options.limit).reverse(); + const entries = selectedEntries.map((entry) => ({ + suite: entry.suite, + commit: entry.commit, + date: entry.date, + tool: entry.tool, + measurementCount: entry.benches.length, + })); + const measurements = selectedEntries.flatMap((entry) => entry.benches.map((bench) => ({ + suite: entry.suite, + commit: entry.commit.id, + date: entry.date, + name: bench.name, + value: bench.value, + unit: bench.unit, + }))); + return { + ok: true, + command: "run benchmark", + repo, + mutation: false, + source: { + pageUrl: options.url ?? dataUrl.replace(/data\.js$/u, ""), + dataUrl, + bytes: fetched.bytes, + contentType: fetched.contentType, + lastUpdate: parsed.lastUpdate, + repoUrl: parsed.repoUrl, + }, + suites: parsed.suites, + totalEntries: parsed.entries.length, + matchingEntries: matching.length, + returnedEntries: selectedEntries.length, + limit: options.limit, + commitFilter: options.commit ?? null, + entries, + measurements, + next: { + commit: `bun scripts/cli.ts gh run benchmark --repo ${repo} --url ${options.url ?? dataUrl.replace(/data\.js$/u, "")} --commit --raw`, + }, + }; +} diff --git a/scripts/src/gh/client.ts b/scripts/src/gh/client.ts index 3a6d7583..debd16f8 100644 --- a/scripts/src/gh/client.ts +++ b/scripts/src/gh/client.ts @@ -307,6 +307,66 @@ export async function githubTextRequest( return text; } +export async function githubPagesTextRequest( + url: string, + byteLimit: number, +): Promise<{ text: string; bytes: number; contentType: string | null } | GitHubErrorPayload> { + let response: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "text/javascript, application/javascript, text/plain", + "User-Agent": USER_AGENT, + }, + }); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "GET", url }, + details: classified.details, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } finally { + clearTimeout(timeout); + } + if (!response.ok) { + return errorPayload("invalid-response", `GitHub Pages benchmark export returned HTTP ${response.status}`, { + status: response.status, + request: { method: "GET", url }, + }); + } + const declaredBytes = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredBytes) && declaredBytes > byteLimit) { + return errorPayload("invalid-response", `GitHub Pages benchmark export exceeds ${byteLimit} bytes`, { + request: { method: "GET", url }, + details: { declaredBytes, byteLimit }, + }); + } + const body = await response.arrayBuffer(); + if (body.byteLength > byteLimit) { + return errorPayload("invalid-response", `GitHub Pages benchmark export exceeds ${byteLimit} bytes`, { + request: { method: "GET", url }, + details: { bytes: body.byteLength, byteLimit }, + }); + } + try { + return { + text: new TextDecoder("utf-8", { fatal: true }).decode(body), + bytes: body.byteLength, + contentType: response.headers.get("content-type"), + }; + } catch { + return errorPayload("invalid-response", "GitHub Pages benchmark export is not valid UTF-8", { + request: { method: "GET", url }, + details: { bytes: body.byteLength }, + }); + } +} + export async function githubGraphqlRequest( token: string, query: string, diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 2ba65498..2ee2f3a7 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -53,6 +53,7 @@ function renderGhDefaultText(result: GitHubCommandResult): string { if (command === "run list") return renderRunList(result); if (command === "run view") return renderRunView(result); if (command === "run logs") return renderRunLogs(result); + if (command === "run benchmark") return renderRunBenchmark(result); if (command === "pr files" || command === "pr diff --stat") return renderPrFiles(result); if (command === "pr review-plan") return renderPrReviewPlan(result); if (command === "pr diff" && isRecord(result.file)) return renderPrDiffFile(result); @@ -755,6 +756,55 @@ function renderRunLogs(result: GitHubCommandResult): string { ].join("\n"); } +function renderRunBenchmark(result: GitHubCommandResult): string { + const entries = arrayOfRecords(result.entries); + const latest = entries[0] ?? {}; + const latestCommit = ghText(record(latest.commit).id); + const measurements = arrayOfRecords(result.measurements) + .filter((measurement) => ghText(measurement.commit) === latestCommit); + const historyRows = entries.slice(0, 12).map((entry) => { + const commit = record(entry.commit); + const points = arrayOfRecords(result.measurements) + .find((bench) => bench.commit === commit.id && bench.name === "Performance Points"); + return [ + ghShort(ghText(commit.id), 10), + benchmarkDate(entry.date), + points === undefined ? "-" : Number(points.value).toFixed(2), + ghShort(ghText(commit.message).split("\n")[0], 72), + ]; + }); + const measurementRows = measurements.slice(0, 30).map((bench) => [ + ghShort(ghText(bench.name), 52), + typeof bench.value === "number" ? bench.value.toFixed(6) : ghText(bench.value), + ghText(bench.unit), + ]); + const source = record(result.source); + const next = record(result.next); + return [ + "gh run benchmark (observed)", + "", + ghTable(["COMMIT", "DATE", "POINTS", "TITLE"], historyRows), + "", + `Latest measurements (${ghShort(ghText(record(latest.commit).id), 10)}):`, + measurementRows.length > 0 ? ghTable(["BENCHMARK", "VALUE", "UNIT"], measurementRows) : "No measurements returned.", + "", + "Summary:", + ` repo=${ghText(result.repo)} total=${ghText(result.totalEntries)} matched=${ghText(result.matchingEntries)} returned=${ghText(result.returnedEntries)} suites=${arrayText(result.suites)} mutation=false`, + ` source=${ghText(source.dataUrl)} bytes=${ghText(source.bytes)} lastUpdate=${benchmarkDate(source.lastUpdate)}`, + "", + "Next:", + ` ${ghText(next.commit)}`, + "", + "Disclosure:", + " default output is bounded to commit history plus the latest measurements; use --raw for selected structured entries, never the full data.js source.", + ].join("\n"); +} + +function benchmarkDate(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString(); + return shortDate(value); +} + function record(value: unknown): Record { return isRecord(value) ? value : {}; } diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index 3acf106f..5964299c 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -47,6 +47,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh run list --repo owner/name [--limit N]", "bun scripts/cli.ts gh run view --repo owner/name", "bun scripts/cli.ts gh run logs --job --repo owner/name", + "bun scripts/cli.ts gh run benchmark --repo owner/name [--url https://owner.github.io/repo/dev/bench/] [--commit sha-prefix] [--limit N] [--raw]", "bun scripts/cli.ts gh pr files [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]", "bun scripts/cli.ts gh pr diff --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", "bun scripts/cli.ts gh pr review-plan [--repo owner/name] [--number N compat] [--limit N] [bounded changed-file index plus per-file patch drill-down commands]", @@ -192,6 +193,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { } else if (key === "pr review-plan" || key === "pr diff") { notes.push("Use `pr review-plan` first for a bounded changed-file index with per-file drill-down commands."); notes.push("Use `pr diff --file [--hunk N]` for bounded patch review; full patch disclosure requires explicit --full or --raw."); + } else if (key === "run benchmark") { + notes.push("读取 github-action-benchmark 发布到 GitHub Pages 的 data.js;--url 接受页面目录或 data.js URL。"); + notes.push("默认只展示有界提交历史和最新一条测量;使用 --commit 缩小到指定提交,使用 --raw 获取筛选后的结构化 entries/measurements。"); } return notes; } diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 9c141d73..2b3ae3cd 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -2,6 +2,7 @@ // Moved mechanically from scripts/src/gh.ts:8348-8845. import { issueAttachmentDownload, issueAttachmentList } from "./attachments"; +import { actionBenchmark } from "./actions-benchmark"; import { actionRunList, actionRunLogs, actionRunView } from "./actions-runs"; import { resolveToken } from "./auth-and-safety"; import { authStatus, prFiles, prList, prRead, prView } from "./auth-pr-read"; @@ -115,6 +116,9 @@ export async function runGhCommand(args: string[]): Promise --job "); } + if ((optionWasProvided(args, "--url") || optionWasProvided(args, "--commit")) && !(top === "run" && sub === "benchmark")) { + return validationError([top, sub].filter(Boolean).join(" "), options.repo, "--url and --commit are only supported by gh run benchmark"); + } if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view"; @@ -226,8 +230,15 @@ export async function runGhCommand(args: string[]): Promise