Skip to content

Commit d602454

Browse files
authored
fix(ci): carry PR-target/issue-quality harden onto dig2-go (#631)
Carry of #631 / 14dde56 onto dig2-go. Go port: none — CI/workflows and issue-quality scripts only; no Go counterpart. CI note: ubuntu/windows prepare-release-assets + npm-global windows failures match pre-existing dig2-go tip / #676; not introduced by this carry.
1 parent 2f2c83c commit d602454

6 files changed

Lines changed: 585 additions & 168 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use strict";
2+
3+
const fs = require("node:fs");
4+
const path = require("node:path");
5+
const { describe, it } = require("node:test");
6+
const assert = require("node:assert/strict");
7+
8+
describe("enforce-pr-target workflow", () => {
9+
const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml");
10+
const workflow = fs.readFileSync(workflowPath, "utf8");
11+
12+
it("uses pull_request_target without checking out PR head code", () => {
13+
assert.match(workflow, /pull_request_target:/);
14+
assert.doesNotMatch(
15+
workflow,
16+
/actions\/checkout@/,
17+
"wrong-branch enforcer must not check out untrusted PR code",
18+
);
19+
});
20+
21+
it("grants contents:write so draft GraphQL mutations work with GITHUB_TOKEN", () => {
22+
// convertPullRequestToDraft / markPullRequestReadyForReview fail with
23+
// "Resource not accessible by integration" when contents stays unset/read
24+
// (seen on #626). Assert the real permissions block, not comment text
25+
// that also mentions these scopes.
26+
const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m);
27+
assert.ok(permissionsBlock, "workflow must declare a top-level permissions block");
28+
const lines = permissionsBlock[1]
29+
.split("\n")
30+
.map((line) => line.trim())
31+
.filter(Boolean)
32+
.sort();
33+
assert.deepEqual(lines, ["contents: write", "pull-requests: write"]);
34+
});
35+
36+
it("fails the required check on a wrong base even if draft conversion fails", () => {
37+
assert.match(workflow, /core\.setFailed\(/);
38+
assert.match(workflow, /draftConversionFailed/);
39+
assert.match(workflow, /Could not convert pull request to draft/);
40+
});
41+
42+
it("soft-fails ready-for-review restoration the same way", () => {
43+
assert.match(workflow, /readyConversionFailed/);
44+
assert.match(workflow, /Could not mark pull request ready for review/);
45+
});
46+
});

.github/scripts/issue-quality.cjs

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,29 @@ function unwrapSingleEnclosingFence(text) {
2525
return match[2];
2626
}
2727

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

33-
// A lone fenced block whose entire body is a placeholder is still placeholder
34-
// text (e.g. ```text\nN/A\n```), not a real example.
37+
// A lone fenced block whose entire body is a stand-in is still a stand-in
38+
// (e.g. ```text\nN/A\n```), not a real example.
3539
const unwrapped = unwrapSingleEnclosingFence(value);
3640
if (unwrapped !== null) {
3741
value = unwrapped.trim();
38-
if (!value) return false;
42+
if (!value) return null;
3943
}
4044

45+
return value;
46+
}
47+
48+
function isPlaceholderOnlyValue(raw) {
49+
const value = normalizeRawSectionValue(raw);
50+
if (value === null) return false;
4151
return PLACEHOLDER_ONLY_RE.test(value);
4252
}
4353

@@ -398,6 +408,20 @@ function isPlaceholder(text) {
398408
return isPlaceholderOnlyValue(text);
399409
}
400410

411+
/**
412+
* True when Version is an "I don't know" stand-in rather than an install id.
413+
* Kept separate from PLACEHOLDER_ONLY_RE so legacy N/A / No response soft-pass
414+
* behaviour is unchanged.
415+
*/
416+
const UNUSABLE_VERSION_RE =
417+
/^[\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;
418+
419+
function isUnusableVersion(raw) {
420+
const value = normalizeRawSectionValue(raw);
421+
if (value === null) return false;
422+
return UNUSABLE_VERSION_RE.test(value);
423+
}
424+
401425
const CJK_RE =
402426
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
403427

@@ -433,6 +457,16 @@ function isTooTerseFeatureSection(text) {
433457
return true;
434458
}
435459

460+
/**
461+
* Bug Reproduction needs steps or concrete signals. A title-like phrase with
462+
* no commands, paths, digits, or product keywords is not actionable.
463+
*/
464+
function isTooTerseBugReproduction(text) {
465+
if (isEmpty(text) || isPlaceholder(text)) return false;
466+
if (hasConcreteDetail(text)) return false;
467+
return countWords(text) < 12;
468+
}
469+
436470
/**
437471
* Check if raw section text is a placeholder-only variant without relying on
438472
* clean() first. Used to distinguish intentionally blank optional fields
@@ -628,6 +662,8 @@ function validateIssue(issue) {
628662
const repro = extractSection(body, "Reproduction");
629663
const version = extractSection(body, "Version");
630664
const os = extractSection(body, "Operating system") ?? extractSection(body, "OS");
665+
// New Bug report template always includes Client or integration.
666+
const isNewBugForm = extractSection(body, "Client or integration") !== null;
631667

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

661-
// Required environment fields removed after submission.
662-
// Only fire when the headings exist in the body (new form). Legacy bug
663-
// reports never had Version or OS fields, so null means absent, not removed.
664-
// Skip when the raw value is a "No response" placeholder -- the old form had
665-
// both fields as optional, so legacy issues legitimately contain those headings
666-
// with the GitHub placeholder. Only close when the field was actively cleared.
667-
if (!softPass && version !== null && os !== null && isEmpty(version) && isEmpty(os) &&
668-
!isRawPlaceholder(version) && !isRawPlaceholder(os)) {
700+
// Version "Unknown" / "모름" / "idk" is never actionable, on any form.
701+
if (!softPass && version !== null && isUnusableVersion(version)) {
702+
reasons.push("Version is missing or unknown.");
703+
guidance.push("Report the installed `@bitkyc08/opencodex` version (for example `2.7.42`) or a commit SHA from `ocx --version`.");
704+
} else if (
705+
!softPass &&
706+
isNewBugForm &&
707+
version !== null &&
708+
(isEmpty(version) || isRawPlaceholder(version))
709+
) {
710+
// New form requires Version. Legacy N/A / No response soft-pass stays
711+
// only for bodies without Client or integration.
712+
reasons.push("Version is missing.");
713+
guidance.push("Add your OpenCodex version so we can reproduce the environment.");
714+
}
715+
716+
if (!softPass && isNewBugForm && os !== null && isUnusableVersion(os)) {
717+
reasons.push("Operating system is missing or unknown.");
718+
guidance.push("Add your OS name and version (for example Windows 11 24H2).");
719+
} else if (
720+
!softPass &&
721+
isNewBugForm &&
722+
os !== null &&
723+
(isEmpty(os) || isRawPlaceholder(os))
724+
) {
725+
reasons.push("Operating system is missing.");
726+
guidance.push("Add your OS name and version (for example Windows 11 24H2).");
727+
}
728+
729+
// Required environment fields removed after submission on bodies that are
730+
// not the new form (no Client or integration). Legacy reports never had
731+
// Version or OS fields, so null means absent, not removed. Skip when the
732+
// raw value is a "No response" placeholder — the old form had both fields
733+
// as optional. Only close when the field was actively cleared.
734+
if (
735+
!softPass &&
736+
!isNewBugForm &&
737+
version !== null &&
738+
os !== null &&
739+
isEmpty(version) &&
740+
isEmpty(os) &&
741+
!isRawPlaceholder(version) &&
742+
!isRawPlaceholder(os)
743+
) {
669744
reasons.push("Version and Operating system are both missing.");
670745
guidance.push("Add your OpenCodex version and OS so we can reproduce the environment.");
671746
}
@@ -873,6 +948,7 @@ module.exports = {
873948
isPlaceholderOnlyValue,
874949
isPlaceholder,
875950
isRawPlaceholder,
951+
isUnusableVersion,
876952
countWords,
877953
hasConcreteDetail,
878954
labelForKind,

.github/scripts/issue-quality.test.cjs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
isPlaceholderOnlyValue,
1717
isPlaceholder,
1818
isRawPlaceholder,
19+
isUnusableVersion,
1920
countWords,
2021
hasConcreteDetail,
2122
rejectsWorkflowDispatchPullRequest,
@@ -693,6 +694,162 @@ describe("validateIssue - bug", () => {
693694
assert.equal(result.valid, false);
694695
assert.ok(result.reasons.some((r) => r.includes("Version")));
695696
});
697+
698+
it("rejects unknown / don't-know Version values (#624)", () => {
699+
const versions = [
700+
"Unknown",
701+
"Uknown",
702+
"unkown",
703+
"Don't know",
704+
"dont know",
705+
"idk",
706+
"모름",
707+
"잘 모름",
708+
"?",
709+
"???",
710+
];
711+
for (const version of versions) {
712+
const body = [
713+
"### Client or integration",
714+
"Codex CLI",
715+
"### Area",
716+
"CLI",
717+
"### Summary",
718+
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
719+
"### Reproduction",
720+
"1. ocx start --port 10100",
721+
"2. Send any Codex CLI request through the proxy",
722+
"3. Observe the connection drop",
723+
"### Version",
724+
version,
725+
"### Operating system",
726+
"Windows 11",
727+
].join("\n");
728+
const result = validateIssue({
729+
title: "Unexpected interruption continues to occur",
730+
body,
731+
labels: ["bug"],
732+
});
733+
assert.equal(result.kind, "bug");
734+
assert.equal(
735+
result.valid,
736+
false,
737+
`Expected unusable Version "${version}" to be invalid, got: ${result.reasons.join("; ")}`,
738+
);
739+
assert.ok(
740+
result.reasons.some((r) => /Version/i.test(r) && /unknown|missing/i.test(r)),
741+
`Expected Version unknown/missing reason for "${version}", got: ${result.reasons.join("; ")}`,
742+
);
743+
}
744+
});
745+
746+
it("rejects issue #624-style low-effort new-form bug", () => {
747+
const body = [
748+
"### Client or integration",
749+
"Codex CLI",
750+
"### Area",
751+
"CLI",
752+
"### Summary",
753+
"CLI로 확인해봤는데 오픈코덱스 프록시가 중간에 자꾸 연결이 끊어져서 그런거라고 합니다.",
754+
"",
755+
"수정 바랍니다.",
756+
"### Reproduction",
757+
"예기치않게중단됨",
758+
"### Version",
759+
"모름",
760+
"### Operating system",
761+
"윈11",
762+
"### Provider and model",
763+
"_No response_",
764+
"### Logs or error output",
765+
"```shell",
766+
"",
767+
"```",
768+
].join("\n");
769+
const result = validateIssue({
770+
title: "Unexpected interruption continues to occur",
771+
body,
772+
labels: ["bug"],
773+
});
774+
assert.equal(result.kind, "bug");
775+
assert.equal(result.valid, false);
776+
assert.ok(result.reasons.some((r) => /Version/i.test(r)));
777+
assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague|empty/i.test(r)));
778+
});
779+
780+
it("rejects a new-form bug with a usable Version but placeholder OS", () => {
781+
const body = [
782+
"### Client or integration",
783+
"Codex CLI",
784+
"### Area",
785+
"CLI",
786+
"### Summary",
787+
"Proxy returns 502 when streaming is enabled on Windows.",
788+
"### Reproduction",
789+
"1. ocx start",
790+
"2. Send a streaming /v1/responses request",
791+
"### Version",
792+
"2.7.42",
793+
"### Operating system",
794+
"No response",
795+
].join("\n");
796+
const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] });
797+
assert.equal(result.kind, "bug");
798+
assert.equal(result.valid, false);
799+
assert.ok(result.reasons.some((r) => /Operating system/i.test(r)));
800+
});
801+
802+
it("rejects a new-form bug whose Reproduction is only a vague phrase", () => {
803+
const body = [
804+
"### Client or integration",
805+
"Codex CLI",
806+
"### Area",
807+
"CLI",
808+
"### Summary",
809+
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
810+
"### Reproduction",
811+
"Unexpected interruption",
812+
"### Version",
813+
"2.7.42",
814+
"### Operating system",
815+
"Windows 11",
816+
].join("\n");
817+
const result = validateIssue({
818+
title: "Unexpected interruption continues to occur",
819+
body,
820+
labels: ["bug"],
821+
});
822+
assert.equal(result.kind, "bug");
823+
assert.equal(result.valid, false);
824+
assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague/i.test(r)));
825+
});
826+
827+
it("rejects unknown Operating system stand-ins on the new bug form", () => {
828+
const body = [
829+
"### Client or integration",
830+
"Codex CLI",
831+
"### Area",
832+
"CLI",
833+
"### Summary",
834+
"The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.",
835+
"### Reproduction",
836+
"1. ocx start --port 10100",
837+
"2. Send any Codex CLI request through the proxy",
838+
"3. Observe the connection drop",
839+
"### Version",
840+
"2.7.42",
841+
"### Operating system",
842+
"Unknown",
843+
].join("\n");
844+
const result = validateIssue({
845+
title: "Unexpected interruption continues to occur",
846+
body,
847+
labels: ["bug"],
848+
});
849+
assert.equal(result.kind, "bug");
850+
assert.equal(result.valid, false);
851+
assert.ok(result.reasons.some((r) => /Operating system/i.test(r)));
852+
});
696853
});
697854

698855
// ---------------------------------------------------------------------------
@@ -839,6 +996,16 @@ describe("normalisation", () => {
839996
assert.equal(clean("Not available!"), "");
840997
});
841998

999+
it("detects unusable Version stand-ins without treating them as generic placeholders", () => {
1000+
for (const value of ["Unknown", "Uknown", "모름", "idk", "don't know"]) {
1001+
assert.equal(isUnusableVersion(value), true, value);
1002+
assert.equal(isPlaceholderOnlyValue(value), false, value);
1003+
}
1004+
for (const value of ["2.7.42", "N/A", "No response", "main@abc1234"]) {
1005+
assert.equal(isUnusableVersion(value), false, value);
1006+
}
1007+
});
1008+
8421009
it("does not treat sentences containing placeholder phrases as empty", () => {
8431010
assert.equal(clean("This is N/A for voice mode today."), "This is N/A for voice mode today.");
8441011
assert.equal(clean("Not applicable to Claude Code."), "Not applicable to Claude Code.");

0 commit comments

Comments
 (0)