Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions config/release-versions.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -19,10 +19,10 @@
"roastReplay": [
{
"score": "v9",
"roast": "v9"
"roast": "v10"
}
],
"collectionReadOrder": ["v4", "v3"]
"collectionReadOrder": ["v4", "v4"]
},
"legacyReadFallback": {
"score": "v5",
Expand All @@ -34,5 +34,5 @@
"approvedMultiComponentIssue": null
},
"aliases": [],
"releasePlan": "docs/releases/v9-v9-v4-rollout.md"
"releasePlan": "docs/releases/v9-v10-v4-rollout.md"
}
22 changes: 22 additions & 0 deletions docs/releases/v9-v10-v4-rollout.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion src/lib/__tests__/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: [] } } } });
}
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions src/lib/__tests__/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
18 changes: 9 additions & 9 deletions src/lib/__tests__/release-versions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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"]);
});
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/cache-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
50 changes: 50 additions & 0 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string, string> = {};
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<Record<string, { issues?: { totalCount?: number } | null } | null>>(
`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(
Expand Down Expand Up @@ -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([
Expand Down
11 changes: 8 additions & 3 deletions src/lib/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down Expand Up @@ -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@@ 控制行里,报告正文不要再写一句话点评。只输出这三行控制指令加报告本身,不要解释你的思考过程。`;
Expand All @@ -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.
Expand Down Expand Up @@ -498,7 +500,7 @@ The Markdown report after the three control lines must be written in **English o
<if payload.risk_notes is non-empty, list those risks with sharp user-facing wording; write "None" only when risk_notes and red_flags are both empty>

**Verdict**
<express the factual conclusion reached in this response; sharp wording is fine, changing facts for a joke is not, and internal field names are forbidden>
<express the factual conclusion reached in this response; sharp wording is fine, changing facts for a joke is not, and internal field names are forbidden. Every action item must be traceable to the payload; only a positive open_issue_count justifies Issue-cleanup advice, and open pull requests are not Issues>
\`\`\`

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.`;
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading