From d460bdedf0ca39245df2606e218118047e6ea79a Mon Sep 17 00:00:00 2001 From: AsperforMias <109743670+AsperforMias@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:30:12 +0800 Subject: [PATCH] fix(roast): distinguish open issues from PRs --- config/release-versions.json | 14 +++--- docs/releases/v9-v10-v4-rollout.md | 22 ++++++++++ src/lib/__tests__/github.test.ts | 8 +++- src/lib/__tests__/prompt.test.ts | 26 +++++++++++ src/lib/__tests__/release-versions.test.ts | 18 ++++---- src/lib/cache-version.ts | 2 +- src/lib/github.ts | 50 ++++++++++++++++++++++ src/lib/prompt.ts | 11 +++-- src/lib/types.ts | 9 ++++ 9 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 docs/releases/v9-v10-v4-rollout.md diff --git a/config/release-versions.json b/config/release-versions.json index 2a24a6e..e3e9890 100644 --- a/config/release-versions.json +++ b/config/release-versions.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "previousRelease": { - "score": "v8", - "roast": "v8", - "collection": "v3" + "score": "v9", + "roast": "v9", + "collection": "v4" }, "targetRelease": { "score": "v9", - "roast": "v9", + "roast": "v10", "collection": "v4" }, "runtimeEnforcement": { @@ -19,10 +19,10 @@ "roastReplay": [ { "score": "v9", - "roast": "v9" + "roast": "v10" } ], - "collectionReadOrder": ["v4", "v3"] + "collectionReadOrder": ["v4", "v4"] }, "legacyReadFallback": { "score": "v5", @@ -34,5 +34,5 @@ "approvedMultiComponentIssue": null }, "aliases": [], - "releasePlan": "docs/releases/v9-v9-v4-rollout.md" + "releasePlan": "docs/releases/v9-v10-v4-rollout.md" } diff --git a/docs/releases/v9-v10-v4-rollout.md b/docs/releases/v9-v10-v4-rollout.md new file mode 100644 index 0000000..83dc8e6 --- /dev/null +++ b/docs/releases/v9-v10-v4-rollout.md @@ -0,0 +1,22 @@ +# v9 / v10 / v4 roast release + +## Change + +GitHub REST's `open_issues_count` combines open Issues and pull requests. The +writer now receives only a bounded GraphQL `open_issue_count`, which excludes +pull requests, and is explicitly prohibited from suggesting Issue cleanup when +that count is absent or zero. + +## Rollout + +1. Deploy with roast cache version `v10`. +2. Confirm `/api/score/4evour` remains a canonical v9/v4 score and a new roast + does not replay the previous v9 report. +3. Re-roast `4evour`; `Tour-Pass` must not receive Issue-cleanup advice when + its verified open-Issue count is zero. + +## Rollback + +Revert this release as one roast-version change: restore `ROAST_CACHE_VERSION` +and the release manifest target to `v9`, then redeploy. Do not create aliases +between v9 and v10 report artifacts. diff --git a/src/lib/__tests__/github.test.ts b/src/lib/__tests__/github.test.ts index 53749ea..18c9b23 100644 --- a/src/lib/__tests__/github.test.ts +++ b/src/lib/__tests__/github.test.ts @@ -388,7 +388,8 @@ describe("collect", () => { size: 5000, stargazers_count: 12345, forks_count: 100, - open_issues_count: 12, + // REST combines open Issues and open PRs in this aggregate. + open_issues_count: 5, language: "Rust", description: "Production sync engine with API and tests", pushed_at: "2026-06-01T00:00:00Z", @@ -478,6 +479,10 @@ ${"Useful project detail. ".repeat(50)} return jsonResponse({ data: { user: { pullRequests: { nodes: [] } } } }); } + if (query.includes("issues(states: OPEN)")) { + return jsonResponse({ data: { r0: { issues: { totalCount: 0 } } } }); + } + if (query.includes("pullRequests(first:")) { return jsonResponse({ data: { user: { pullRequests: { nodes: [] } } } }); } @@ -536,6 +541,7 @@ ${"Useful project detail. ".repeat(50)} owner_login: "acme", name_with_owner: "acme/core", attributed_original: true, + open_issue_count: 0, }); expect(result.top_repos[0].attribution_evidence?.join(" ")).toContain("75 commits"); expect(fetchMock).toHaveBeenCalledWith( diff --git a/src/lib/__tests__/prompt.test.ts b/src/lib/__tests__/prompt.test.ts index 34467a4..460426d 100644 --- a/src/lib/__tests__/prompt.test.ts +++ b/src/lib/__tests__/prompt.test.ts @@ -35,6 +35,32 @@ const scan = { } as unknown as ScanResult; describe("buildRoastMessages", () => { + it("exposes only verified Issue counts to the writer", () => { + const withRestIssueAggregate = { + ...scan, + top_repos: [ + { + ...scan.top_repos[0], + name: "Tour-Pass", + owner_login: "4evour", + name_with_owner: "4evour/Tour-Pass", + // GitHub REST's aggregate can be five open PRs and zero Issues. + open_issues: 5, + open_issue_count: 0, + }, + ], + } as ScanResult; + + for (const lang of ["zh", "en"] as const) { + const [system, user] = buildRoastMessages(withRestIssueAggregate, lang); + const payload = JSON.parse(user.content.match(/```json\n([\s\S]*)\n```/)![1]); + expect(payload.top_repos[0].open_issues).toBeUndefined(); + expect(payload.top_repos[0].open_issue_count).toBe(0); + expect(system.content).toContain("open_issue_count"); + expect(system.content).toMatch(/open pull requests|开放 PR/); + } + }); + it("defaults to the Chinese system prompt", () => { const [sys] = buildRoastMessages(scan); expect(sys.role).toBe("system"); diff --git a/src/lib/__tests__/release-versions.test.ts b/src/lib/__tests__/release-versions.test.ts index 9758b5a..8eadf26 100644 --- a/src/lib/__tests__/release-versions.test.ts +++ b/src/lib/__tests__/release-versions.test.ts @@ -12,15 +12,15 @@ function manifestCopy(): ReleaseVersionManifest { } describe("release version contract", () => { - it("records only the formal v8/v8/v3 to v9/v9/v4 lineage", () => { + it("records the isolated v9/v9/v4 to v9/v10/v4 roast release", () => { expect(RELEASE_VERSION_MANIFEST.previousRelease).toEqual({ - score: "v8", - roast: "v8", - collection: "v3", + score: "v9", + roast: "v9", + collection: "v4", }); expect(RELEASE_VERSION_MANIFEST.targetRelease).toEqual({ score: "v9", - roast: "v9", + roast: "v10", collection: "v4", }); expect(RELEASE_VERSION_MANIFEST.aliases).toEqual([]); @@ -33,7 +33,7 @@ describe("release version contract", () => { collection: "v3", }); expect(RELEASE_VERSION_MANIFEST.compatibility.roastReplay).toEqual([ - { score: "v9", roast: "v9" }, + { score: "v9", roast: "v10" }, ]); expect(RELEASE_VERSION_MANIFEST.compatibility.publicScoreReadOrder).toEqual(["v9"]); }); @@ -56,8 +56,8 @@ describe("release version contract", () => { expect(releaseVersionErrors(manifest, manifest.targetRelease)).toEqual([]); }); - it("allows only the formal v3 collection as the v4 stale-read fallback", () => { - expect(RELEASE_VERSION_MANIFEST.compatibility.collectionReadOrder).toEqual(["v4", "v3"]); + it("keeps the unchanged v4 collection in both isolated-release read slots", () => { + expect(RELEASE_VERSION_MANIFEST.compatibility.collectionReadOrder).toEqual(["v4", "v4"]); }); it("rejects aliases and accidental-version replay paths", () => { @@ -109,7 +109,7 @@ describe("release version contract", () => { beforeManifest: before, afterManifest: manifestCopy(), beforeRuntime: { score: "v99", roast: "v99", collection: "v99" }, - afterRuntime: { score: "v9", roast: "v9", collection: "v4" }, + afterRuntime: manifestCopy().targetRelease, }), ).toEqual([]); }); diff --git a/src/lib/cache-version.ts b/src/lib/cache-version.ts index 87601d6..44c3bc3 100644 --- a/src/lib/cache-version.ts +++ b/src/lib/cache-version.ts @@ -10,7 +10,7 @@ // #111 scoring behavior is unchanged by this normalization. export const SCORE_CACHE_VERSION = "v9"; // Formal successor to the previous production report contract. -export const ROAST_CACHE_VERSION = "v9"; +export const ROAST_CACHE_VERSION = "v10"; /** Bump when the PK (versus) verdict prompt / output semantics change. */ export const VERDICT_CACHE_VERSION = "v1"; diff --git a/src/lib/github.ts b/src/lib/github.ts index ad6806a..a0c9acc 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1060,6 +1060,55 @@ function repoToTopRepo( }; } +/** + * `open_issues_count` from GitHub's REST repository payload includes pull + * requests. Fetch the real open-Issue count only for the bounded top-repo + * prompt surface; on a best-effort failure we leave the value absent rather + * than turning the REST aggregate into false Issue evidence. + */ +async function hydrateOpenIssueCounts(repos: TopRepo[]): Promise { + const selected = repos + .slice(0, 10) + .map((repo, index) => ({ + repo, + index, + owner: repo.owner_login, + name: repo.name, + })) + .filter((entry): entry is typeof entry & { owner: string } => Boolean(entry.owner && entry.name)); + if (selected.length === 0) return; + + const variables: Record = {}; + const declarations: string[] = []; + const selections: string[] = []; + for (const entry of selected) { + const ownerKey = `owner${entry.index}`; + const nameKey = `name${entry.index}`; + variables[ownerKey] = entry.owner; + variables[nameKey] = entry.name; + declarations.push(`$${ownerKey}: String!, $${nameKey}: String!`); + selections.push( + `r${entry.index}: repository(owner: $${ownerKey}, name: $${nameKey}) { issues(states: OPEN) { totalCount } }`, + ); + } + + try { + const data = await graphql>( + `query(${declarations.join(", ")}) { ${selections.join(" ")} }`, + variables, + ); + for (const entry of selected) { + const count = data[`r${entry.index}`]?.issues?.totalCount; + if (typeof count === "number" && Number.isFinite(count) && count >= 0) { + entry.repo.open_issue_count = Math.floor(count); + } + } + } catch { + // Issue counts are copy-only enrichment. A GraphQL hiccup must not block a + // deterministic scan or encourage the writer to guess from REST's PR mix. + } +} + function hasDocLikeTopic(repo: RestRepo): boolean { return (repo.topics ?? []).some((topic) => /^(docs?|documentation|website|blog|examples?|templates?|tutorials?|guides?|manual)$/i.test( @@ -2594,6 +2643,7 @@ export async function collect(username: string): Promise<{ topStarred && topStarred.stars >= ENGAGEMENT_MIN_STARS ? fetchRepoEngagementRatio(topStarred.owner_login ?? login, topStarred.name) : Promise.resolve(undefined), + hydrateOpenIssueCounts(topRepos), ...topRepos.slice(0, 6).map(async (repo) => { const owner = repo.owner_login ?? login; const [readme, languages] = await Promise.all([ diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index 174fbac..4320b59 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -324,6 +324,7 @@ const SYSTEM_PROMPT_ZH = `你是「GitHub 毒舌锐评写手」。分数、档 - payload.signature_work 是必须参考的具体贡献证据:优先使用全量公开扫描的高工作量代表仓库和重复工作簇;只有 source=recent_sample 时才按近期样本谨慎处理。生态/维护影响力行至少引用其中一个正向或中性例子;不要只盯最高星仓库,也不要忽略低星但高风险/边界/一致性/运行时/核心行为修复类 PR。 - 低 star 仓库不是自动低价值:如果 signature_work 的 note 提到同 owner 高星生态、实质低星信号、高频 PR 或核心修复标题,就要按具体贡献写,不能写成“玩具项目”“打白工”或“没有含金量”。同一组织/owner 里,低 star 但高质量的小仓库要和该 owner 的高星 flagship 一起作为生态证据考虑,例如写成“owner/small-repo 与 owner/flagship 同属生态”,而不是只写 flagship。 - 仓库证据必须使用完整 \`owner/repo\` 名称;不要把 \`rust-lang/rust\` 写成“rust”,不要把 \`langgenius/dify\` 写成“dify”。如果想写语言或生态,请明确“Rust 语言生态/xxx owner 生态”,不要和具体仓库混写。 +- 只有 \`top_repos[].open_issue_count\` 才是已验证的开放 Issue 数;字段缺失表示未知,绝不是 0。只有点名仓库且该值大于 0 时,才能建议“清 Issue”,并必须写出这个数;开放 PR 不能叫 Issue,也不能据此给出 Issue 建议。 - 正文必须保持「锐评」口吻:**一句话结论**、维度说明、风险标记、人工复核、建议都要带短促、有梗、阴阳怪气的表达;每句先落数据,再补一刀,别只写审计结论。 - 低可信/需人工复核场景也要有恶趣味:可以写“需人工复核”,但别写成行政审批意见。 - 身份称号要安全降级,但梗不能一起降级:不要写未经证实的 Committer/Maintainer/Core Team;可以写“Apache 观光客”“站点装修队”“文档区长工”等不构成身份声明的 roast。 @@ -399,7 +400,7 @@ const SYSTEM_PROMPT_ZH = `你是「GitHub 毒舌锐评写手」。分数、档 <若 payload.risk_notes 非空,逐条写出这些风险并补刀;只有 risk_notes 为空且 red_flags 为空时才写"无"> **建议** -<表达本次事实结论的含义;可以嘴臭表达,但不能为玩梗篡改事实,禁止写内部字段名> +<表达本次事实结论的含义;可以嘴臭表达,但不能为玩梗篡改事实,禁止写内部字段名。每条行动建议都必须可回溯到 payload;只有明确的 open_issue_count > 0 才能建议清 Issue,开放 PR 不是 Issue> \`\`\` 注意:①回复前三行必须依次是 \`@@ADJUST 0@@\`、\`@@TAGS zh=...|en=...@@\`、\`@@ROAST zh=...|en=...@@\`;②标题最终分直接使用 scoring.final_score,保留两位小数;③表格各维度得分直接用 sub_scores;④毒舌点评只写在 @@ROAST@@ 控制行里,报告正文不要再写一句话点评。只输出这三行控制指令加报告本身,不要解释你的思考过程。`; @@ -423,6 +424,7 @@ The Markdown report after the three control lines must be written in **English o - payload.signature_work is required concrete contribution evidence. Prefer the strongest representative repos and repeated work clusters; only treat it as a recent sample when source=recent_sample. The Ecosystem / maintenance row must cite at least one positive or neutral example from it; do not focus only on the highest-star repo, and do not ignore lower-star security/boundary/consistency/runtime/core-behavior fixes. - Low-star repos are not automatically low-value. If signature_work notes mention a same-owner high-star ecosystem, a substantive low-star signal, high PR volume, or core-fix titles, describe the concrete work instead of calling it a toy repo, unpaid org labor, or worthless filler. In the same org/owner, high-quality smaller repos must be considered with the flagship repo as ecosystem evidence. - Repository evidence must use full \`owner/repo\` names. Do not write \`rust-lang/rust\` as "rust" or \`langgenius/dify\` as "dify"; if you mean a language or ecosystem, explicitly say "the Rust ecosystem" or "the owner ecosystem" rather than mixing it with a repo name. +- Only \`top_repos[].open_issue_count\` is a verified open-Issue count. If it is absent, the count is unknown, not zero. Suggest clearing Issues only for a named repo with a positive count and state that count; open pull requests are not Issues and must never justify Issue-cleanup advice. - Keep the body in roast mode: **TL;DR**, dimension notes, red flags, manual review, and verdict must use punchy, witty, data-grounded jabs. Anchor every jab in a number or concrete signal; do not merely list audit facts. - Low-trust / needs-review cases still need personality. The verdict may be "needs human review", but phrase it like a roast, not a ticket triage note. - Downgrade unsafe identity titles without flattening the joke: do not state unverified Committer/Maintainer/Core Team titles; safe phrases such as "repo tourist", "docs janitor", or "site decorator" are fine when supported by data. @@ -498,7 +500,7 @@ The Markdown report after the three control lines must be written in **English o **Verdict** - + \`\`\` Notes: ① the first three lines of your reply must be exactly \`@@ADJUST 0@@\`, then \`@@TAGS zh=...|en=...@@\`, then \`@@ROAST zh=...|en=...@@\`; ② the title's final score must use scoring.final_score, to two decimals; ③ use sub_scores directly for each dimension's score; ④ the roast goes only in the @@ROAST@@ control line — do not repeat a one-liner in the report body. The tier word stays as given (GOD / ELITE / SOLID / NPC / TRASH). Output only these three control lines plus the report itself — do not explain your reasoning.`; @@ -518,7 +520,10 @@ function buildPayload(scan: ScanResult, lang: Lang) { ? { impact_prs_outside_quality_sample: outsideQualitySample } : {}), }; - const topRepos = (scan.top_repos ?? []).map((repo) => + // GitHub REST's historical `open_issues` aggregate includes pull requests. + // Do not leak it into the writer payload; only the GraphQL-enriched + // `open_issue_count` is fit for factual Issue wording. + const topRepos = (scan.top_repos ?? []).map(({ open_issues: _openIssuesAndPrs, ...repo }) => repo.readme?.features.prompt_summary ? { ...repo, readme_excerpt: undefined } : repo, ); const verifiedImpactSampleCount = scan.verified_impact_prs?.length ?? 0; diff --git a/src/lib/types.ts b/src/lib/types.ts index 9dd3cb6..2aa601a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -38,7 +38,16 @@ export interface TopRepo { name_with_owner?: string; stars: number; forks: number; + /** + * GitHub REST's `open_issues_count`, which includes open pull requests. + * Retained for snapshot compatibility only; never use it as Issue evidence. + */ open_issues: number; + /** + * Exact number of currently open GitHub Issues, excluding pull requests. + * Undefined means the bounded GraphQL enrichment was unavailable, not zero. + */ + open_issue_count?: number; size: number; language: string | null; description: string | null;