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
46 changes: 46 additions & 0 deletions .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");

describe("enforce-pr-target workflow", () => {
const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml");
const workflow = fs.readFileSync(workflowPath, "utf8");

it("uses pull_request_target without checking out PR head code", () => {
assert.match(workflow, /pull_request_target:/);
assert.doesNotMatch(
workflow,
/actions\/checkout@/,
"wrong-branch enforcer must not check out untrusted PR code",
);
});

it("grants contents:write so draft GraphQL mutations work with GITHUB_TOKEN", () => {
// convertPullRequestToDraft / markPullRequestReadyForReview fail with
// "Resource not accessible by integration" when contents stays unset/read
// (seen on #626). Assert the real permissions block, not comment text
// that also mentions these scopes.
const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m);
assert.ok(permissionsBlock, "workflow must declare a top-level permissions block");
const lines = permissionsBlock[1]
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.sort();
assert.deepEqual(lines, ["contents: write", "pull-requests: write"]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("fails the required check on a wrong base even if draft conversion fails", () => {
assert.match(workflow, /core\.setFailed\(/);
assert.match(workflow, /draftConversionFailed/);
assert.match(workflow, /Could not convert pull request to draft/);
});

it("soft-fails ready-for-review restoration the same way", () => {
assert.match(workflow, /readyConversionFailed/);
assert.match(workflow, /Could not mark pull request ready for review/);
});
});
104 changes: 90 additions & 14 deletions .github/scripts/issue-quality.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,29 @@ function unwrapSingleEnclosingFence(text) {
return match[2];
}

function isPlaceholderOnlyValue(raw) {
if (typeof raw !== "string") return false;
/**
* Shared strip/trim/unwrap used by placeholder and unusable-stand-in matchers.
* Returns null when the value is absent after normalisation.
*/
function normalizeRawSectionValue(raw) {
if (typeof raw !== "string") return null;
let value = raw.replace(/<!--[\s\S]*?-->/g, "").trim();
if (!value) return false;
if (!value) return null;

// A lone fenced block whose entire body is a placeholder is still placeholder
// text (e.g. ```text\nN/A\n```), not a real example.
// A lone fenced block whose entire body is a stand-in is still a stand-in
// (e.g. ```text\nN/A\n```), not a real example.
const unwrapped = unwrapSingleEnclosingFence(value);
if (unwrapped !== null) {
value = unwrapped.trim();
if (!value) return false;
if (!value) return null;
}

return value;
}

function isPlaceholderOnlyValue(raw) {
const value = normalizeRawSectionValue(raw);
if (value === null) return false;
return PLACEHOLDER_ONLY_RE.test(value);
}

Expand Down Expand Up @@ -398,6 +408,20 @@ function isPlaceholder(text) {
return isPlaceholderOnlyValue(text);
}

/**
* True when Version is an "I don't know" stand-in rather than an install id.
* Kept separate from PLACEHOLDER_ONLY_RE so legacy N/A / No response soft-pass
* behaviour is unchanged.
*/
const UNUSABLE_VERSION_RE =
/^[\s_*~`]*(?:unknown|unkown|uknown|don'?t\s+know|do\s+not\s+know|idk|dunno|not\s+sure|unsure|\?+|모름|잘\s*모름|모르겠(?:습니다|음)?|不明|わからない|分からない|不知道|不清楚|keine\s+ahnung|wei[sß]{1,2}\s+nicht)[\s_*~`]*[.!?]*$/i;

function isUnusableVersion(raw) {
const value = normalizeRawSectionValue(raw);
if (value === null) return false;
return UNUSABLE_VERSION_RE.test(value);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const CJK_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;

Expand Down Expand Up @@ -433,6 +457,16 @@ function isTooTerseFeatureSection(text) {
return true;
}

/**
* Bug Reproduction needs steps or concrete signals. A title-like phrase with
* no commands, paths, digits, or product keywords is not actionable.
*/
function isTooTerseBugReproduction(text) {
if (isEmpty(text) || isPlaceholder(text)) return false;
if (hasConcreteDetail(text)) return false;
return countWords(text) < 12;
}

/**
* Check if raw section text is a placeholder-only variant without relying on
* clean() first. Used to distinguish intentionally blank optional fields
Expand Down Expand Up @@ -628,6 +662,8 @@ function validateIssue(issue) {
const repro = extractSection(body, "Reproduction");
const version = extractSection(body, "Version");
const os = extractSection(body, "Operating system") ?? extractSection(body, "OS");
// New Bug report template always includes Client or integration.
const isNewBugForm = extractSection(body, "Client or integration") !== null;

if (isEmpty(summary) && isEmpty(repro)) {
// Soft-pass substantial non-English / freeform structured reports once
Expand Down Expand Up @@ -655,17 +691,56 @@ function validateIssue(issue) {
if (isEmpty(repro)) {
reasons.push("Reproduction is empty.");
guidance.push("List the exact steps to reproduce the problem.");
} else if (!softPass && isTooTerseBugReproduction(repro)) {
reasons.push("Reproduction is too vague to act on.");
guidance.push("List exact steps, commands, and the observed failure — not only a short phrase.");
}
}

// Required environment fields removed after submission.
// Only fire when the headings exist in the body (new form). Legacy bug
// reports never had Version or OS fields, so null means absent, not removed.
// Skip when the raw value is a "No response" placeholder -- the old form had
// both fields as optional, so legacy issues legitimately contain those headings
// with the GitHub placeholder. Only close when the field was actively cleared.
if (!softPass && version !== null && os !== null && isEmpty(version) && isEmpty(os) &&
!isRawPlaceholder(version) && !isRawPlaceholder(os)) {
// Version "Unknown" / "모름" / "idk" is never actionable, on any form.
if (!softPass && version !== null && isUnusableVersion(version)) {
reasons.push("Version is missing or unknown.");
guidance.push("Report the installed `@bitkyc08/opencodex` version (for example `2.7.42`) or a commit SHA from `ocx --version`.");
} else if (
!softPass &&
isNewBugForm &&
version !== null &&
(isEmpty(version) || isRawPlaceholder(version))
) {
// New form requires Version. Legacy N/A / No response soft-pass stays
// only for bodies without Client or integration.
reasons.push("Version is missing.");
guidance.push("Add your OpenCodex version so we can reproduce the environment.");
}

if (!softPass && isNewBugForm && os !== null && isUnusableVersion(os)) {
reasons.push("Operating system is missing or unknown.");
guidance.push("Add your OS name and version (for example Windows 11 24H2).");
} else if (
!softPass &&
isNewBugForm &&
os !== null &&
(isEmpty(os) || isRawPlaceholder(os))
) {
reasons.push("Operating system is missing.");
guidance.push("Add your OS name and version (for example Windows 11 24H2).");
}

// Required environment fields removed after submission on bodies that are
// not the new form (no Client or integration). Legacy reports never had
// Version or OS fields, so null means absent, not removed. Skip when the
// raw value is a "No response" placeholder — the old form had both fields
// as optional. Only close when the field was actively cleared.
if (
!softPass &&
!isNewBugForm &&
version !== null &&
os !== null &&
isEmpty(version) &&
isEmpty(os) &&
!isRawPlaceholder(version) &&
!isRawPlaceholder(os)
) {
reasons.push("Version and Operating system are both missing.");
guidance.push("Add your OpenCodex version and OS so we can reproduce the environment.");
}
Expand Down Expand Up @@ -873,6 +948,7 @@ module.exports = {
isPlaceholderOnlyValue,
isPlaceholder,
isRawPlaceholder,
isUnusableVersion,
countWords,
hasConcreteDetail,
labelForKind,
Expand Down
167 changes: 167 additions & 0 deletions .github/scripts/issue-quality.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
isPlaceholderOnlyValue,
isPlaceholder,
isRawPlaceholder,
isUnusableVersion,
countWords,
hasConcreteDetail,
rejectsWorkflowDispatchPullRequest,
Expand Down Expand Up @@ -693,6 +694,162 @@ describe("validateIssue - bug", () => {
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => r.includes("Version")));
});

it("rejects unknown / don't-know Version values (#624)", () => {
const versions = [
"Unknown",
"Uknown",
"unkown",
"Don't know",
"dont know",
"idk",
"모름",
"잘 모름",
"?",
"???",
];
for (const version of versions) {
const body = [
"### Client or integration",
"Codex CLI",
"### Area",
"CLI",
"### Summary",
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
"### Reproduction",
"1. ocx start --port 10100",
"2. Send any Codex CLI request through the proxy",
"3. Observe the connection drop",
"### Version",
version,
"### Operating system",
"Windows 11",
].join("\n");
const result = validateIssue({
title: "Unexpected interruption continues to occur",
body,
labels: ["bug"],
});
assert.equal(result.kind, "bug");
assert.equal(
result.valid,
false,
`Expected unusable Version "${version}" to be invalid, got: ${result.reasons.join("; ")}`,
);
assert.ok(
result.reasons.some((r) => /Version/i.test(r) && /unknown|missing/i.test(r)),
`Expected Version unknown/missing reason for "${version}", got: ${result.reasons.join("; ")}`,
);
}
});

it("rejects issue #624-style low-effort new-form bug", () => {
const body = [
"### Client or integration",
"Codex CLI",
"### Area",
"CLI",
"### Summary",
"CLI로 확인해봤는데 오픈코덱스 프록시가 중간에 자꾸 연결이 끊어져서 그런거라고 합니다.",
"",
"수정 바랍니다.",
"### Reproduction",
"예기치않게중단됨",
"### Version",
"모름",
"### Operating system",
"윈11",
"### Provider and model",
"_No response_",
"### Logs or error output",
"```shell",
"",
"```",
].join("\n");
const result = validateIssue({
title: "Unexpected interruption continues to occur",
body,
labels: ["bug"],
});
assert.equal(result.kind, "bug");
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => /Version/i.test(r)));
assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague|empty/i.test(r)));
});

it("rejects a new-form bug with a usable Version but placeholder OS", () => {
const body = [
"### Client or integration",
"Codex CLI",
"### Area",
"CLI",
"### Summary",
"Proxy returns 502 when streaming is enabled on Windows.",
"### Reproduction",
"1. ocx start",
"2. Send a streaming /v1/responses request",
"### Version",
"2.7.42",
"### Operating system",
"No response",
].join("\n");
const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] });
assert.equal(result.kind, "bug");
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => /Operating system/i.test(r)));
});

it("rejects a new-form bug whose Reproduction is only a vague phrase", () => {
const body = [
"### Client or integration",
"Codex CLI",
"### Area",
"CLI",
"### Summary",
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
"### Reproduction",
"Unexpected interruption",
"### Version",
"2.7.42",
"### Operating system",
"Windows 11",
].join("\n");
const result = validateIssue({
title: "Unexpected interruption continues to occur",
body,
labels: ["bug"],
});
assert.equal(result.kind, "bug");
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague/i.test(r)));
});

it("rejects unknown Operating system stand-ins on the new bug form", () => {
const body = [
"### Client or integration",
"Codex CLI",
"### Area",
"CLI",
"### Summary",
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
"### Reproduction",
"1. ocx start --port 10100",
"2. Send any Codex CLI request through the proxy",
"3. Observe the connection drop",
"### Version",
"2.7.42",
"### Operating system",
"Unknown",
].join("\n");
const result = validateIssue({
title: "Unexpected interruption continues to occur",
body,
labels: ["bug"],
});
assert.equal(result.kind, "bug");
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => /Operating system/i.test(r)));
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -839,6 +996,16 @@ describe("normalisation", () => {
assert.equal(clean("Not available!"), "");
});

it("detects unusable Version stand-ins without treating them as generic placeholders", () => {
for (const value of ["Unknown", "Uknown", "모름", "idk", "don't know"]) {
assert.equal(isUnusableVersion(value), true, value);
assert.equal(isPlaceholderOnlyValue(value), false, value);
}
for (const value of ["2.7.42", "N/A", "No response", "main@abc1234"]) {
assert.equal(isUnusableVersion(value), false, value);
}
});

it("does not treat sentences containing placeholder phrases as empty", () => {
assert.equal(clean("This is N/A for voice mode today."), "This is N/A for voice mode today.");
assert.equal(clean("Not applicable to Claude Code."), "Not applicable to Claude Code.");
Expand Down
Loading
Loading