fix(cli): compact gh view and web-probe failures

This commit is contained in:
Codex
2026-06-20 10:11:24 +00:00
parent a2ec41cc63
commit 6d0054c78c
3 changed files with 63 additions and 16 deletions
+1
View File
@@ -76,6 +76,7 @@ JS
- 自定义 `web-probe script` 仍运行在 UniDesk `trans` 60s 最外层短连接约束内;能在一轮内完成的 P4 验收优先把 `--command-timeout-seconds` 控制在 55 秒以内,并减少无界 selector/network 等待。确需等待更久时,改用 `web-probe run` 的异步 job/status 语义,或把动作拆成“提交/采样/截图/状态读取”多次短 probe。若输出出现 `UNIDESK_SSH_RUNTIME_TIMEOUT` 但同时恢复了 `reportPath``reportSha256`、screenshots 或 DOM steps,先按远端报告判断脚本/页面实际状态;最终关闭证据仍优先用一次未触发短连接超时的 bounded rerun。
- issue closeout 优先引用 `web-probe script` 输出的顶层 `issueEvidence``summary.issueEvidence`;只有需要展开调查时才粘贴 `probe.script.result``probe.steps` 或完整 `reportPath`,避免 stdout、summary 和 report 多层重复同一证据。
- stdin heredoc 与 `--script-file` 都按 ES module 加载,脚本必须导出 `export default async ({ page, gotoStable, recordStep, ... }) => { ... }`;不要在模块顶层直接写 `return`。失败为 `Illegal return statement``does not provide an export named default` 或 finalUrl 仍是 `about:blank` 且 stepCount=0 时,先按 probe 脚本入口误用处理,不要归因成 Cloud Web 行为失败。
- 自定义脚本需要主动失败时,优先返回 `{ ok: false, failedCondition: "..." }` 或抛出错误;兼容返回 `{ pass: false }``{ success: false }`,且 `{ ok: true, pass: false }` 仍按失败处理。`failedCondition``errorMessage``message``error``reason``summary` 会进入失败摘要;不要只把失败埋在普通业务字段里。
- web-probe 由 UniDesk CLI 从 YAML 声明的 bootstrap admin sourceRef 读取凭据并建立同源 `hwlab_session`;脚本不得自行读取、打印或复制 Web 登录凭据、cookie、token 或完整 API key。
- 需要禁用 `EventSource`、mock `Date`/clock、注入 preload hook 或修改浏览器启动前全局对象时,`page.addInitScript()` 必须在目标页面整页加载前注册,并用 `page.goto(new URL(path, origin).toString(), { waitUntil: "domcontentloaded" })` 进入目标 deep link;不要依赖 `gotoStable()` 复用当前 SPA 页面后再期待 init script 生效。若只在已加载页面上 route/block 请求,既有 SSE 或 store 状态可能继续更新,不能作为“无后端刷新依赖”的验收。
- 脚本构造 URL 时使用 `new URL(path, baseUrl).toString()`;不要拼出 `//v1/...`
+43 -13
View File
@@ -2452,9 +2452,10 @@ function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber:
};
}
function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
const body = issue.body ?? "";
const includeBody = options.includeBody ?? true;
const includePreview = options.includePreview ?? true;
const summary: Record<string, unknown> = {
id: issue.id,
number: issue.number,
@@ -2475,8 +2476,10 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev
} else {
summary.bodyOmitted = true;
summary.fullBodyIncluded = false;
summary.bodyPreview = preview(body);
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
if (includePreview) {
summary.bodyPreview = preview(body);
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
}
}
return summary;
}
@@ -4705,16 +4708,18 @@ function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
return pr.state === "closed" ? "closed" : "open";
}
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
const stateDetail = prStateDetail(pr);
const closedAt = pr.closed_at ?? pr.merged_at ?? null;
const mergedAt = pr.merged_at ?? null;
const mergeCommitSha = pr.merge_commit_sha ?? null;
return {
const body = pr.body ?? "";
const includeBody = options.includeBody ?? true;
const includePreview = options.includePreview ?? true;
const summary: Record<string, unknown> = {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state,
stateDetail,
draft: pr.draft ?? false,
@@ -4731,7 +4736,20 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
merged: stateDetail === "merged",
mergedAt,
mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
bodyChars: body.length,
bodySha: bodySha(body),
};
if (includeBody) {
summary.body = body;
} else {
summary.bodyOmitted = true;
summary.fullBodyIncluded = false;
if (includePreview) {
summary.bodyPreview = preview(body);
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
}
}
return summary;
}
function numberOrNull(value: number | undefined): number | null {
@@ -5694,21 +5712,24 @@ async function issueRead(repo: string, token: string, issueNumber: number, jsonF
const issue = await getIssue(token, repo, issueNumber);
if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
const needsComments = jsonFields === undefined || jsonFields.includes("comments");
const includeBody = jsonFields === undefined || jsonFields.includes("body");
const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null;
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue, { includeBody, includePreview: false }) });
return {
ok: true,
command: commandName,
repo,
...(disclosure === null ? {} : { disclosure }),
issue: issueSummary(issue),
issue: issueSummary(issue, { includeBody, includePreview: false }),
codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""),
...(comments === null ? {} : { comments: comments.map(commentSummary) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedIssueJson(issue, comments, jsonFields),
compatibility: {
legacyJsonBodyPath: ".data.issue.body",
legacyJsonBodyPath: includeBody ? ".data.issue.body" : null,
bodyOmitted: !includeBody,
readCommands: includeBody ? null : issueBodyReadCommands(repo, issueNumber),
},
}),
};
@@ -7626,10 +7647,12 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
const summary = prSummary(pr);
const fullSummary = prSummary(pr);
const includeBody = jsonFields === undefined || jsonFields.includes("body");
const summary = includeBody ? fullSummary : prSummary(pr, { includeBody: false, includePreview: false });
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) });
const selectionSummary = metadata === null ? summary : { ...summary, ...prMetadataSummary(metadata) };
const selectionSummary = metadata === null ? fullSummary : { ...fullSummary, ...prMetadataSummary(metadata) };
return {
ok: true,
command: commandName,
@@ -7637,7 +7660,14 @@ async function prRead(repo: string, token: string, number: number, jsonFields: P
...(disclosure === null ? {} : { disclosure }),
pullRequest: summary,
...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }),
...(jsonFields === undefined ? {} : { jsonFields, json: selectedPrJson(selectionSummary, jsonFields) }),
...(jsonFields === undefined ? {} : {
jsonFields,
json: selectedPrJson(selectionSummary, jsonFields),
compatibility: {
legacyJsonBodyPath: includeBody ? ".data.pullRequest.body" : null,
bodyOmitted: !includeBody,
},
}),
};
}
@@ -7708,7 +7738,7 @@ export function ghHelp(): unknown {
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; full body is included only when requested with --json body, --full, or --raw, and unsupported fields fail structurally.",
"issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.",
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
@@ -44,7 +44,7 @@ try {
if (fn === null) throw new Error("custom script must export default, run, or probe function");
const scriptResult = await fn(scriptHelpers());
const safeResult = sanitize(scriptResult);
const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false);
const scriptOk = scriptResultOk(safeResult);
const failure = scriptOk ? null : classifiedProbeError(assertionResultError(safeResult));
const lastScreenshot = scriptOk ? null : await captureFailureScreenshot("failure.png");
const lastUrl = currentPageUrl();
@@ -1399,15 +1399,31 @@ function stableProbeError(code, message, readiness) {
}
function assertionResultError(value) {
const message = extractFailureMessage(value) || "script returned ok:false";
const message = extractFailureMessage(value) || scriptFailureSignalMessage(value) || "script returned a failure result";
const error = new Error(message);
error.code = "assertion-failed";
return error;
}
function scriptResultOk(value) {
if (!value || typeof value !== "object") return true;
if (value.ok === false) return false;
if (value.pass === false) return false;
if (value.success === false) return false;
return true;
}
function scriptFailureSignalMessage(value) {
if (!value || typeof value !== "object") return null;
if (value.pass === false) return "script returned pass:false";
if (value.success === false) return "script returned success:false";
if (value.ok === false) return "script returned ok:false";
return null;
}
function extractFailureMessage(value) {
if (!value || typeof value !== "object") return null;
for (const key of ["errorMessage", "message", "error", "reason", "summary"]) {
for (const key of ["failedCondition", "errorMessage", "message", "error", "reason", "summary"]) {
const nested = value[key];
if (typeof nested === "string" && nested.length > 0) return nested;
}