feat: read GitHub Pages benchmark exports
This commit is contained in:
@@ -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 <run-id> --repo owner/name
|
||||
bun scripts/cli.ts gh run logs <run-id> --job <job-id> --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/<number> cat
|
||||
trans gh:/pikasTech/unidesk/pr/<number> rg <pattern>
|
||||
bun scripts/cli.ts gh pr review-plan <number> --repo pikasTech/unidesk
|
||||
@@ -67,6 +68,14 @@ bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --merge --delete-branch
|
||||
```
|
||||
|
||||
- `gh run benchmark` 读取 `github-action-benchmark` 发布到 GitHub Pages
|
||||
的 `data.js`:
|
||||
- `--url` 可以传页面目录或 `data.js`;
|
||||
- 默认输出有界历史和最新测量;
|
||||
- `--commit <sha-prefix> --raw` 返回筛选后的结构化
|
||||
`entries` 与 `measurements`;
|
||||
- 不解析 job log,也不默认输出完整导出文件。
|
||||
|
||||
机器结构化入口必须显式请求:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 中说明为何不改变规格。
|
||||
|
||||
@@ -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 <sha>" },
|
||||
}) as { renderedText: string };
|
||||
expect(benchmark.renderedText).toContain("18000.00");
|
||||
expect(benchmark.renderedText).toContain("loop");
|
||||
expect(benchmark.renderedText).toContain("never the full data.js source");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<typeof parseBenchmarkData>;
|
||||
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 <sha> --raw`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<T>(
|
||||
token: string,
|
||||
query: string,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
@@ -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 <run-id> --repo owner/name",
|
||||
"bun scripts/cli.ts gh run logs <run-id> --job <job-id> --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 <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]",
|
||||
"bun scripts/cli.ts gh pr diff <number> --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 <number|url|owner/repo#number> [--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 <number> --file <path> [--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;
|
||||
}
|
||||
|
||||
+13
-2
@@ -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<GitHubCommandResult
|
||||
if (optionWasProvided(args, "--job") && !(top === "run" && sub === "logs")) {
|
||||
return validationError([top, sub].filter(Boolean).join(" "), options.repo, "--job is only supported by gh run logs <run-id> --job <job-id>");
|
||||
}
|
||||
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<GitHubCommandResult
|
||||
|
||||
if (top === "run") {
|
||||
const commandName = `run ${sub ?? ""}`.trim();
|
||||
if (sub !== "list" && sub !== "view" && sub !== "logs") {
|
||||
return unsupportedCommand(commandName, options.repo, "run supported commands are list, view, and logs.");
|
||||
if (sub !== "list" && sub !== "view" && sub !== "logs" && sub !== "benchmark") {
|
||||
return unsupportedCommand(commandName, options.repo, "run supported commands are list, view, logs, and benchmark.");
|
||||
}
|
||||
if (sub === "benchmark") {
|
||||
return actionBenchmark(options.repo, {
|
||||
url: options.benchmarkUrl,
|
||||
commit: options.benchmarkCommit,
|
||||
limit: options.limit,
|
||||
});
|
||||
}
|
||||
const { token, probe } = resolveToken(options.repo, true);
|
||||
const missing = authRequired(options.repo, commandName, probe);
|
||||
|
||||
@@ -344,6 +344,8 @@ export function parseOptions(args: string[]): GitHubOptions {
|
||||
? MAX_ISSUE_LIST_LIMIT
|
||||
: top === "issue" && sub === "comments"
|
||||
? DEFAULT_ISSUE_COMMENTS_LIMIT
|
||||
: top === "run" && sub === "benchmark"
|
||||
? 10
|
||||
: 30,
|
||||
limitMax,
|
||||
),
|
||||
@@ -393,5 +395,7 @@ export function parseOptions(args: string[]): GitHubOptions {
|
||||
outputPath: optionValue(args, "--output"),
|
||||
filePath: optionValue(args, "--file"),
|
||||
hunk: optionalPositiveIntegerOption(args, "--hunk"),
|
||||
benchmarkUrl: optionValue(args, "--url"),
|
||||
benchmarkCommit: optionValue(args, "--commit")?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const GH_VALUE_OPTIONS = new Set([
|
||||
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
|
||||
"--search", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description",
|
||||
"--attachment", "--output", "--file", "--hunk", "--sync-node",
|
||||
"--job",
|
||||
"--job", "--url", "--commit",
|
||||
]);
|
||||
|
||||
export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--keep-branch", "--skip-local-closeout", "--private", "--public", "--auto-init"]);
|
||||
@@ -483,6 +483,8 @@ export interface GitHubOptions {
|
||||
outputPath?: string;
|
||||
filePath?: string;
|
||||
hunk?: number;
|
||||
benchmarkUrl?: string;
|
||||
benchmarkCommit?: string;
|
||||
}
|
||||
|
||||
export interface GitHubShorthandReference {
|
||||
|
||||
Reference in New Issue
Block a user