From 36f58c61d6a56827d07504bd13bfb642fa5a47ae Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 05:58:42 +0200
Subject: [PATCH 1/9] test(issue-quality): reproduce #1162 false close
---
...e-quality-equivalent-bug-evidence.test.cjs | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 .github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
diff --git a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
new file mode 100644
index 0000000000..f9f980a54b
--- /dev/null
+++ b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
@@ -0,0 +1,38 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { validateIssue } = require("./issue-quality.cjs");
+
+test("accepts #1162-shaped bug evidence without literal Reproduction/Version/OS headings", () => {
+ const result = validateIssue({
+ title: "[Bug]: Cursor Claude-family models fail after stream start while non-Claude models pass",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+Cursor Claude-family models deterministically fail through the Claude Code path after the stream starts, while non-Claude Cursor models complete normally using the same provider and installation.
+
+### Environment
+- OpenCodex: 2.10.2
+- OS: Linux (Ubuntu x64)
+- Claude Code: 1.0.88
+
+### What fails / what passes
+- \`cursor/claude-sonnet-4.5\` -> FAIL with \`resource_exhausted\` after stream start.
+- \`cursor/grok-4.5\` -> PASS using the same request path.
+
+### Debug evidence (ocx debug provider)
+Run \`ocx debug provider cursor\` and send the same request through the Claude Code integration.
+
+\`\`\`text
+Provider error: resource_exhausted after stream start
+request failed after the first streamed frame
+\`\`\`
+`,
+ });
+
+ assert.equal(result.valid, true, result.reasons.join("\n"));
+});
From 01ab60db798d7900424c333c47a704ba561c33db Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:00:23 +0200
Subject: [PATCH 2/9] test(ci): run split issue-quality regressions
---
.github/workflows/issue-quality-tests.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml
index e5f95e6c7d..81d1cd4ab0 100644
--- a/.github/workflows/issue-quality-tests.yml
+++ b/.github/workflows/issue-quality-tests.yml
@@ -5,7 +5,7 @@ on:
paths:
- ".github/ISSUE_TEMPLATE/**"
- ".github/scripts/issue-quality.cjs"
- - ".github/scripts/issue-quality.test.cjs"
+ - ".github/scripts/issue-quality*.test.cjs"
- ".github/scripts/pr-quality.cjs"
- ".github/scripts/pr-quality.test.cjs"
- ".github/scripts/pr-quality-messages.cjs"
@@ -34,7 +34,7 @@ on:
paths:
- ".github/ISSUE_TEMPLATE/**"
- ".github/scripts/issue-quality.cjs"
- - ".github/scripts/issue-quality.test.cjs"
+ - ".github/scripts/issue-quality*.test.cjs"
- ".github/scripts/pr-quality.cjs"
- ".github/scripts/pr-quality.test.cjs"
- ".github/scripts/pr-quality-messages.cjs"
@@ -75,7 +75,7 @@ jobs:
- name: Run validator tests
run: |
- node --test .github/scripts/issue-quality.test.cjs
+ node --test .github/scripts/issue-quality*.test.cjs
node --test .github/scripts/pr-quality.test.cjs
node --test .github/scripts/pr-labeler.test.cjs
node --test .github/scripts/enforce-pr-target.test.cjs
From 27abfcdc15b6146e4b68259d97b2cd283da6707a Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:02:58 +0200
Subject: [PATCH 3/9] fix(issue-quality): accept equivalent structured bug
evidence
---
.github/scripts/issue-quality-core.cjs | 1649 +++++++++++++++++++++++
.github/scripts/issue-quality.cjs | 1668 +-----------------------
2 files changed, 1706 insertions(+), 1611 deletions(-)
create mode 100644 .github/scripts/issue-quality-core.cjs
diff --git a/.github/scripts/issue-quality-core.cjs b/.github/scripts/issue-quality-core.cjs
new file mode 100644
index 0000000000..1c0a6430a7
--- /dev/null
+++ b/.github/scripts/issue-quality-core.cjs
@@ -0,0 +1,1649 @@
+"use strict";
+
+// ---------------------------------------------------------------------------
+// Pure issue-quality validation for OpenCodex.
+// CommonJS, zero runtime dependencies. No GitHub API calls.
+// ---------------------------------------------------------------------------
+
+/**
+ * True when the entire meaningful value is a placeholder-only token.
+ * Supports harmless Markdown emphasis/code markers and trailing punctuation.
+ * Sentences that merely contain a placeholder phrase are not matches.
+ */
+const PLACEHOLDER_ONLY_RE =
+ /^[\s_*~`]*(?:no\s+response|n\/?a|not\s+applicable|not\s+available|none|todo|tbd)[\s_*~`]*[.!?]*$/i;
+
+/**
+ * If `text` is exactly one enclosing fenced code block (``` or ~~~), return the
+ * inner body; otherwise null. Real multi-statement fences are left alone by
+ * the placeholder matcher after unwrap.
+ */
+function unwrapSingleEnclosingFence(text) {
+ const trimmed = text.trim();
+ const match = trimmed.match(/^(```|~~~)[^\n]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/);
+ if (!match) return null;
+ return match[2];
+}
+
+/**
+ * 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(//g, "").trim();
+ if (!value) return null;
+
+ // 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 null;
+ }
+
+ return value;
+}
+
+function isPlaceholderOnlyValue(raw) {
+ const value = normalizeRawSectionValue(raw);
+ if (value === null) return false;
+ return PLACEHOLDER_ONLY_RE.test(value);
+}
+
+/**
+ * Strip image/media-only content from a markdown or HTML fragment so that a
+ * section whose only content is a screenshot or media embed is treated as
+ * empty by the validators.
+ *
+ * Handles:
+ * - Markdown images: ``, ``
+ * - HTML
and ... blocks
+ * - Common media embeds (video/audio) when they are the only content
+ *
+ * Text mixed with media (for example a caption or repro steps around an
+ * image) is preserved; only the media tokens themselves are removed.
+ */
+function stripMediaTokens(text) {
+ if (typeof text !== "string") return "";
+ // Indented code lines render as literal code in GitHub Markdown. Protect
+ // them first so neither the HTML nor the Markdown media stripper can
+ // remove example syntax; restore the lines afterwards.
+ const protectedText = protectIndentedCodeLines(text);
+ const markdownStripped = stripMarkdownImages(stripHtmlMedia(protectedText.text));
+ const referenceStripped = stripReferenceImages(markdownStripped);
+ return restoreIndentedCodeLines(referenceStripped, protectedText.lines);
+}
+
+/**
+ * Replace every indented code line (4+ leading spaces or a tab) with a
+ * placeholder of equal length so media stripping cannot touch it. Returns the
+ * masked text plus the original lines for restoration.
+ */
+function protectIndentedCodeLines(text) {
+ const lines = [];
+ const masked = text.split("\n").map((line) => {
+ if (/^(?: {4,}|\t)/.test(line)) {
+ lines.push(line);
+ return "\u0000" + line.replace(/[^\n]/g, " ").slice(1);
+ }
+ lines.push(null);
+ return line;
+ });
+ return { text: masked.join("\n"), lines };
+}
+
+/**
+ * Restore masked indented-code lines from their original content. Placeholder
+ * lines are identified by the leading \u0000 marker and matched positionally.
+ */
+function restoreIndentedCodeLines(text, lines) {
+ const out = text.split("\n").map((line, i) => {
+ if (lines[i] !== null && line.startsWith("\u0000")) {
+ return lines[i];
+ }
+ return line;
+ });
+ return out.join("\n");
+}
+
+/**
+ * Strip HTML media blocks whose entire inner content is media markup (no
+ * substantive text). A block that contains fallback/caption prose — for
+ * example ``
+ * — is left untouched so the prose survives the empty-section check.
+ *
+ * Handles
, ..., , and
+ * .
+ */
+function stripHtmlMedia(text) {
+ if (typeof text !== "string") return "";
+ let s = text
+ .replace(/
]*>/gi, " ")
+ .replace(//g, " ");
+
+ // Whole media blocks: replace only when the inner content is not
+ // substantive text (no word characters outside tags).
+ s = s.replace(
+ /<(picture|video|audio)\b[^>]*>([\s\S]*?)<\/\1>/gi,
+ (match, tag, inner) => {
+ const innerStripped = inner
+ .replace(/<[^>]+>/g, " ")
+ .replace(/[\s_*~`]+/g, " ")
+ .trim();
+ return innerStripped.length === 0 ? " " : match;
+ },
+ );
+ return s;
+}
+
+/**
+ * Remove Markdown image tokens `` using a small
+ * balanced scanner instead of a regex, because destinations may contain
+ * balanced parentheses (for example `image_(final).png`) and alt text may
+ * contain balanced brackets (`![Image [screenshot]](url)`).
+ *
+ * A token is matched only when:
+ * - it starts with `![` (not escaped);
+ * - the alt text is balanced with respect to `[` / `]`;
+ * - the destination is balanced with respect to `(`, `)` and `"` (an
+ * optional title may follow); and
+ * - the token closes with a `)`.
+ *
+ * Malformed tokens (unbalanced destination, e.g. `a)b.png)`) are left in
+ * place — they are not valid Markdown images and must not be silently
+ * dropped.
+ */
+function stripMarkdownImages(text) {
+ if (typeof text !== "string") return "";
+ const out = [];
+ let i = 0;
+ while (i < text.length) {
+ // Inside an indented code block (4+ leading spaces or a tab), image
+ // syntax is literal code, not a rendered image. Leave it untouched so a
+ // section that documents example syntax is not emptied.
+ if (isInsideIndentedCode(text, i)) {
+ out.push(text[i]);
+ i += 1;
+ continue;
+ }
+ // A backslash-escaped or code-fenced `![` is not an image token. We only
+ // guard the common `\!` escape here; fenced blocks are handled by the
+ // section extractor upstream, which does not include them in sections.
+ if (text[i] === "!" && text[i + 1] === "[") {
+ const end = scanMarkdownImage(text, i);
+ if (end !== -1) {
+ out.push(" ");
+ i = end;
+ continue;
+ }
+ }
+ out.push(text[i]);
+ i += 1;
+ }
+ return out.join("");
+}
+
+/**
+ * True when `index` sits inside an indented code block, i.e. on a line that
+ * starts with four or more spaces or a tab. Such lines render as literal
+ * code in GitHub Markdown.
+ */
+function isInsideIndentedCode(text, index) {
+ const lineStart = text.lastIndexOf("\n", index - 1) + 1;
+ const prefix = text.slice(lineStart, index);
+ return /^(?: {4,}|\t)/.test(prefix);
+}
+
+/**
+ * Strip reference-style Markdown images: inline references `![alt][ref]`
+ * and the reference definitions `[ref]: https://...` they point at. These
+ * are valid image syntax that a media-only section may use to embed a
+ * screenshot.
+ */
+function stripReferenceImages(text) {
+ if (typeof text !== "string") return "";
+ // Inline reference: ![alt][ref] or ![alt][] (implicit). Alt may contain
+ // balanced brackets, so a balanced scan is used for the label part.
+ let s = stripInlineReferences(text);
+ // Reference definitions: [ref]: url "title" — only when the reference is
+ // actually used by an image in the same text. A definition alone (or one
+ // used by a text link) is not media and must stay.
+ const refs = new Set();
+ for (const ref of collectInlineReferenceLabels(text)) {
+ refs.add(ref.toLowerCase());
+ }
+ if (refs.size > 0) {
+ s = s.replace(
+ /^\s*\[([^\]]+)\]:\s*\S+(?:\s+["'(][^"')]*["')])?\s*$/gm,
+ (line, ref) => (refs.has(ref.toLowerCase()) ? " " : line),
+ );
+ }
+ return s;
+}
+
+/**
+ * Strip inline reference-style image tokens `![alt][ref]` / `![alt][]`
+ * using a balanced scan for the alt text (which may contain nested brackets).
+ */
+function stripInlineReferences(text) {
+ const out = [];
+ let i = 0;
+ while (i < text.length) {
+ if (text[i] === "!" && text[i + 1] === "[") {
+ const end = scanReferenceImage(text, i);
+ if (end !== -1) {
+ out.push(" ");
+ i = end;
+ continue;
+ }
+ }
+ out.push(text[i]);
+ i += 1;
+ }
+ return out.join("");
+}
+
+/**
+ * Scan an inline reference-style image `![alt][ref]` or `![alt][]` starting
+ * at `start`. Returns the index just past the closing `]` on success, or -1.
+ */
+function scanReferenceImage(text, start) {
+ const altEnd = scanBalancedBrackets(text, start + 2);
+ if (altEnd === -1 || text[altEnd] !== "]") return -1;
+ if (text[altEnd + 1] !== "[") return -1;
+ const refEnd = scanBalancedBrackets(text, altEnd + 2);
+ if (refEnd === -1 || text[refEnd] !== "]") return -1;
+ return refEnd + 1;
+}
+
+/**
+ * Scan balanced bracket content starting at `start` (inside the opening `[`).
+ * Returns the index of the matching closing `]`, or -1 when unbalanced.
+ */
+function scanBalancedBrackets(text, start) {
+ let depth = 0;
+ for (let i = start; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === "\\") {
+ i += 1;
+ continue;
+ }
+ if (ch === "[") {
+ depth += 1;
+ } else if (ch === "]") {
+ if (depth === 0) return i;
+ depth -= 1;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Collect the reference labels used by inline reference-style images. For an
+ * explicit `![alt][ref]` the label is `ref`; for an implicit `![alt][]` the
+ * label is the alt text.
+ */
+function collectInlineReferenceLabels(text) {
+ const labels = [];
+ let i = 0;
+ while (i < text.length) {
+ if (text[i] === "!" && text[i + 1] === "[") {
+ const altStart = i + 2;
+ const altEnd = scanBalancedBrackets(text, altStart);
+ if (altEnd !== -1 && text[altEnd] === "]") {
+ const alt = text.slice(altStart, altEnd);
+ if (text[altEnd + 1] === "[") {
+ const refStart = altEnd + 2;
+ const refEnd = scanBalancedBrackets(text, refStart);
+ if (refEnd !== -1 && text[refEnd] === "]") {
+ const ref = text.slice(refStart, refEnd);
+ labels.push(ref ? ref : alt);
+ i = refEnd + 1;
+ continue;
+ }
+ }
+ }
+ }
+ i += 1;
+ }
+ return labels;
+}
+
+/**
+ * Scan a Markdown image token starting at `start` (which points at `!`).
+ * Returns the index just past the closing `)` on success, or -1 when the
+ * token is malformed.
+ */
+function scanMarkdownImage(text, start) {
+ // Alt text: `![` ... `]` with balanced nested brackets.
+ let i = start + 2;
+ let bracketDepth = 0;
+ for (; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === "\\") {
+ i += 1; // skip escaped character
+ continue;
+ }
+ if (ch === "[") {
+ bracketDepth += 1;
+ } else if (ch === "]") {
+ if (bracketDepth === 0) break;
+ bracketDepth -= 1;
+ }
+ }
+ if (i >= text.length || text[i] !== "]") return -1;
+
+ // Destination: `(` ... `)` with balanced parentheses. An optional
+ // whitespace-separated `"title"` may follow the destination.
+ if (text[i + 1] !== "(") return -1;
+ i += 2;
+ let parenDepth = 1;
+ let inQuotes = false;
+ for (; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === "\\") {
+ i += 1; // skip escaped character
+ continue;
+ }
+ if (ch === '"') {
+ inQuotes = !inQuotes;
+ continue;
+ }
+ if (inQuotes) continue;
+ if (ch === "(") {
+ parenDepth += 1;
+ } else if (ch === ")") {
+ parenDepth -= 1;
+ if (parenDepth === 0) return i + 1;
+ }
+ }
+ return -1;
+}
+
+/**
+ * True when a section contains no substantive text after removing media
+ * tokens and whitespace. Used to decide whether a media-only section should
+ * count as empty for quality validation.
+ */
+function isMediaOnly(text) {
+ if (typeof text !== "string") return false;
+ const stripped = stripMediaTokens(text);
+ return stripped.replace(/\s+/g, "").length === 0;
+}
+
+/**
+ * Strip HTML comments, placeholder-only values, and trim whitespace.
+ */
+function clean(raw) {
+ if (typeof raw !== "string") return "";
+ let s = raw.replace(//g, "");
+ // Media-only sections (a lone screenshot or embed) carry no reportable
+ // text. Strip the media tokens so the section participates in emptiness and
+ // duplicate detection like any other blank section. This closes the
+ // image-only-section bypass (see #1098: an `
`-only goal hid repeated
+ // prose in the other sections from duplicate detection).
+ if (isMediaOnly(s)) {
+ s = stripMediaTokens(s).replace(/\s+/g, " ").trim();
+ }
+ // Whole-value placeholders first (including a single enclosing fence), so
+ // line-by-line stripping cannot leave bare fence markers behind.
+ if (isPlaceholderOnlyValue(s)) return "";
+ // Treat placeholder-only lines (GitHub "No response", N/A, etc.) as empty.
+ s = s
+ .split("\n")
+ .map((line) => (isPlaceholderOnlyValue(line) ? "" : line))
+ .join("\n");
+ if (isPlaceholderOnlyValue(s)) return "";
+ return s.trim();
+}
+
+/**
+ * Lowercase, strip punctuation (Unicode-aware), collapse whitespace.
+ */
+function normalise(raw) {
+ return clean(raw)
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s]/gu, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * Canonical form for duplicate detection: normalise + strip common filler
+ * phrases that do not add semantic content.
+ */
+function canonicalise(raw) {
+ let s = normalise(raw);
+ const fillers = [
+ /^i want to\s+/,
+ /^we need to\s+/,
+ /^would like to\s+/,
+ /^i would like to\s+/,
+ /^we would like to\s+/,
+ /^please\s+/,
+ ];
+ for (const re of fillers) s = s.replace(re, "");
+ return s.trim();
+}
+
+/**
+ * Extract the text content of a markdown ### section by heading name.
+ * Returns null when the heading is absent.
+ */
+function extractSection(body, heading) {
+ if (typeof body !== "string") return null;
+ const lines = body.split("\n");
+ const headingLower = heading.toLowerCase().trim();
+ let capturing = false;
+ let sectionDepth = 0;
+ let fence = null;
+ const out = [];
+ for (const line of lines) {
+ if (fence) {
+ if (new RegExp(`^[ \\t]{0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) {
+ fence = null;
+ }
+ if (capturing) out.push(line);
+ continue;
+ }
+
+ const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
+ if (fenceMatch) {
+ fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
+ if (capturing) out.push(line);
+ continue;
+ }
+
+ const m = line.match(/^(#{2,4})\s+(.*)/);
+ if (m) {
+ const depth = m[1].length;
+ if (capturing && depth <= sectionDepth) break;
+ if (!capturing && m[2].toLowerCase().trim() === headingLower) {
+ capturing = true;
+ sectionDepth = depth;
+ continue;
+ }
+ }
+ if (capturing) out.push(line);
+ }
+ if (!capturing) return null;
+ return out.join("\n").trim();
+}
+
+/**
+ * Resolve a logical section from the first matching heading.
+ * Prefers the first non-empty match; if every present heading is empty,
+ * returns that empty string so callers can distinguish "missing" (null)
+ * from "present but blank".
+ */
+function resolveSection(body, headings) {
+ let firstPresent = null;
+ for (const heading of headings) {
+ const section = extractSection(body, heading);
+ if (section === null) continue;
+ if (firstPresent === null) firstPresent = section;
+ if (!isEmpty(section)) return section;
+ }
+ return firstPresent;
+}
+
+/**
+ * True when the body has multiple non-empty h2–h4 sections with enough detail.
+ * Soft-pass only — unstructured length alone is not enough, and a single
+ * arbitrary heading must not bypass the quality gate (Codex on #564).
+ */
+function hasSubstantialStructuredContent(body, minSectionLen = 40, minRichSections = 2) {
+ if (typeof body !== "string") return false;
+ const lines = body.split("\n");
+ let capturing = false;
+ let bucket = [];
+ let richSections = 0;
+ const flush = () => {
+ if (clean(bucket.join("\n")).length >= minSectionLen) richSections += 1;
+ bucket = [];
+ };
+ for (const line of lines) {
+ const m = line.match(/^#{2,4}\s+(.*)/);
+ if (m) {
+ if (capturing) flush();
+ capturing = true;
+ continue;
+ }
+ if (capturing) bucket.push(line);
+ }
+ if (capturing) flush();
+ return richSections >= minRichSections;
+}
+
+// ---------------------------------------------------------------------------
+// Issue kind detection
+// ---------------------------------------------------------------------------
+
+const FEATURE_NEW_HEADINGS = [
+ "What are you trying to accomplish?",
+ "What prevents this today?",
+ "What should OpenCodex do?",
+];
+const FEATURE_LEGACY_HEADINGS = ["Problem to solve", "Proposed solution"];
+const FEATURE_GOAL_HEADINGS = [
+ "What are you trying to accomplish?",
+ "Goal / Problem",
+ "Goal/Problem",
+ "Problem to solve",
+];
+const FEATURE_BLOCKER_HEADINGS = [
+ "What prevents this today?",
+ "Current limitation",
+ "Current workaround",
+];
+const FEATURE_BEHAVIOUR_HEADINGS = [
+ "What should OpenCodex do?",
+ "Expected behaviour",
+ "Expected behavior",
+ "Proposed solution",
+];
+const FEATURE_EXAMPLE_HEADINGS = [
+ "Example usage or interface",
+ "Example usage",
+ "Example",
+];
+const FEATURE_ALIAS_DETECT_HEADINGS = [
+ "Goal / Problem",
+ "Goal/Problem",
+ "Expected behaviour",
+ "Expected behavior",
+ "Current limitation",
+ "Current workaround",
+ "Example usage",
+ // Intentionally omit bare "Example" — too common in freeform/bug reports.
+];
+const BUG_NEW_HEADINGS = ["Client or integration", "Summary", "Reproduction"];
+const BUG_LEGACY_HEADINGS = ["Summary", "Reproduction"];
+const PROVIDER_HEADINGS = [
+ "Provider or upstream service",
+ "Endpoint or capability",
+ "Current behaviour",
+ "Expected behaviour",
+];
+const DOCS_HEADINGS = [
+ "Documentation problem type",
+ "Documentation location",
+ "What is wrong or missing?",
+];
+
+const KIND_TO_LABEL = {
+ bug: "bug",
+ feature: "enhancement",
+ documentation: "documentation",
+ "provider-compatibility": "provider-compatibility",
+};
+
+/**
+ * Orthogonal product-area labels (additive beside kind/process labels).
+ * Colors/descriptions are used when the workflow ensures labels exist.
+ */
+const AREA_LABELS = {
+ provider: {
+ color: "1D76DB",
+ description: "Provider adapters, OpenAI-compat presets, upstream API quirks",
+ },
+ "account-pool": {
+ color: "5319E7",
+ description: "OAuth, credentials, Codex pool, quota, failover, plans",
+ },
+ catalog: {
+ color: "006B75",
+ description: "Model catalog, slugs, visibility, routed entries",
+ },
+ gui: {
+ color: "D93F0B",
+ description: "Dashboard, tray, settings UI",
+ },
+ cli: {
+ color: "FBCA04",
+ description: "CLI, config inject, packaging flags",
+ },
+ proxy: {
+ color: "0E8A16",
+ description: "HTTP proxy, routing, reverse-proxy / management auth",
+ },
+ platform: {
+ color: "BFDADC",
+ description: "OS/service/tray/ACL (Windows-heavy, not Windows-only)",
+ },
+ streaming: {
+ color: "C5DEF5",
+ description: "SSE, WebSocket, terminal stream frames",
+ },
+ tools: {
+ color: "F9D0C4",
+ description: "tool_calls, MCP, web-search / sidecar tools",
+ },
+ install: {
+ color: "EDEDED",
+ description: "Installation or packaging",
+ },
+ service: {
+ color: "EDEDED",
+ description: "Service lifecycle (WinSW/launchd/scheduler)",
+ },
+};
+
+/** Canonical Area dropdown text → area label(s). Keys are lowercased. */
+const AREA_FIELD_TO_LABELS = {
+ cli: ["cli"],
+ "proxy and routing": ["proxy"],
+ dashboard: ["gui"],
+ "provider adapter": ["provider"],
+ "provider adapters": ["provider"],
+ "authentication and account pool": ["account-pool"],
+ "catalog / models": ["catalog"],
+ streaming: ["streaming"],
+ "tools / mcp / web search": ["tools"],
+ "installation or packaging": ["install"],
+ "service lifecycle": ["service"],
+ "service lifecycle (config injection)": ["service"],
+ "platform (windows / macos / linux)": ["platform"],
+ // Do not map to kind label `documentation` — that collides with labelBasedKind
+ // when a feature/bug form picks Area: Documentation. Docs form already seeds
+ // the kind label; Area selection alone does not add an area tag.
+ documentation: [],
+ // No dedicated label; heuristics still run in detectAreaLabels.
+ "multiple areas": [],
+ other: [],
+};
+
+/** Body headings used for area heuristics (excludes Environment / OS metadata). */
+const AREA_HEURISTIC_BODY_HEADINGS = [
+ "Summary",
+ "Reproduction",
+ "What are you trying to accomplish?",
+ "What prevents this today?",
+ "What should OpenCodex do?",
+ "Example usage or interface",
+ "Current behaviour",
+ "Expected behaviour",
+ "Minimal redacted request or reproduction",
+ "What is wrong or missing?",
+ "Documentation problem type",
+ "Documentation location",
+];
+
+/**
+ * Heuristic rules. `scope: "title"` avoids false hits from template Environment /
+ * OS fields in the body; `scope: "full"` is for distinctive technical tokens.
+ */
+const AREA_HEURISTICS = [
+ {
+ label: "account-pool",
+ scope: "full",
+ re: /\b(oauth|reauth|needsreauth|account pool|codex.?auth|auto[- ]?switch|account failover|refresh token|plan_type|chatgpt[- ]account|reset credit)\b/i,
+ },
+ {
+ label: "account-pool",
+ scope: "title",
+ re: /\b(quota|failover|pool account|account switch)\b/i,
+ },
+ {
+ label: "catalog",
+ scope: "full",
+ re: /\b(model catalog|opencodex-catalog|model list|model visibility|virtual model|routed (catalog|entries|slug)|model slug)\b/i,
+ },
+ {
+ label: "catalog",
+ scope: "title",
+ re: /\bcatalog\b/i,
+ },
+ {
+ label: "gui",
+ scope: "title",
+ re: /\b(dashboard|\bgui\b|tray|sidebar|settings (page|tab|ui))\b/i,
+ },
+ {
+ label: "cli",
+ scope: "title",
+ re: /\b(ocx\b|config\.toml|config inject)\b/i,
+ },
+ {
+ label: "proxy",
+ scope: "full",
+ re: /\b(reverse[- ]proxy|management api|admin[- ]token|\/api\/\*|bind(s)? the (old )?port)\b/i,
+ },
+ {
+ label: "proxy",
+ scope: "title",
+ re: /\b(reverse[- ]proxy|management api|admin[- ]token)\b/i,
+ },
+ {
+ label: "platform",
+ scope: "full",
+ re: /\b(winsw|launchd|schtasks|icacls|windows-latest|tray host|scheduler backend)\b/i,
+ },
+ {
+ label: "platform",
+ scope: "title",
+ re: /\b(\[windows\]|\[macos\]|windows|macos|darwin|win32|wsl)\b/i,
+ },
+ {
+ label: "streaming",
+ scope: "full",
+ re: /\b(sse|websocket|\bws\b|stream(ing)?\b.{0,40}\btruncat\w*|stream(ing)?\b.{0,40}\bterminal\b|terminal (sse )?frame|without a terminal)\b/i,
+ },
+ {
+ label: "tools",
+ scope: "full",
+ re: /\b(tool_calls?|tool[- ]calls?|\bmcp\b|web[- ]search|tool[- ]recall)\b/i,
+ },
+ {
+ label: "install",
+ scope: "full",
+ re: /\b(npm (global )?install|packaging|release asset|npx ocx)\b/i,
+ },
+ {
+ label: "service",
+ scope: "full",
+ re: /\b(ocx service|winsw|scheduler backend|launchd service)\b/i,
+ },
+ {
+ label: "provider",
+ scope: "full",
+ re: /\b(provider adapter|openai[- ]compatible|provider[- ]compat|adapter quirk|built[- ]in provider|provider preset)\b/i,
+ },
+ {
+ label: "provider",
+ scope: "title",
+ re: /\b(\[provider\]|provider compat|openai[- ]compatible)\b/i,
+ },
+];
+
+/**
+ * Map a detected issue kind to its triage label. Returns null when unknown.
+ */
+function labelForKind(kind) {
+ if (!kind || typeof kind !== "string") return null;
+ return KIND_TO_LABEL[kind] || null;
+}
+
+/**
+ * Map a template Area dropdown value to orthogonal area label names.
+ * Returns [] for Other / Multiple areas / unknown / empty.
+ *
+ * @param {unknown} areaText
+ * @returns {string[]}
+ */
+function mapAreaFieldToLabels(areaText) {
+ if (typeof areaText !== "string") return [];
+ const key = areaText.replace(/\s+/g, " ").trim().toLowerCase();
+ if (!key) return [];
+ return AREA_FIELD_TO_LABELS[key] ? [...AREA_FIELD_TO_LABELS[key]] : [];
+}
+
+/**
+ * Build heuristic text from title-relevant semantic sections only — never from
+ * Operating system / Version / Checks metadata that every template includes.
+ *
+ * @param {string} body
+ * @returns {string}
+ */
+function bodyForAreaHeuristics(body) {
+ if (typeof body !== "string" || !body.trim()) return "";
+ const parts = [];
+ for (const heading of AREA_HEURISTIC_BODY_HEADINGS) {
+ const section = extractSection(body, heading);
+ if (section) parts.push(section);
+ }
+ return parts.join("\n\n");
+}
+
+/**
+ * Conservative title/body heuristics for orthogonal area labels.
+ *
+ * @param {string} title
+ * @param {string} body semantic body text (already filtered)
+ * @returns {string[]}
+ */
+function heuristicAreaLabels(title, body) {
+ const titleText = title || "";
+ const fullText = `${titleText}\n${body || ""}`;
+ const seen = new Set();
+ const out = [];
+ for (const { label, re, scope } of AREA_HEURISTICS) {
+ const text = scope === "title" ? titleText : fullText;
+ if (!re.test(text) || seen.has(label)) continue;
+ seen.add(label);
+ out.push(label);
+ }
+ return out;
+}
+
+/**
+ * Detect additive product-area labels from Area field, form defaults, and
+ * title/body heuristics. Never invents per-provider labels.
+ *
+ * @param {{
+ * title?: string,
+ * body?: string,
+ * labels?: string[],
+ * heuristicBody?: string,
+ * }} issue
+ * `body` is the source form (for Area / provider headings).
+ * `heuristicBody` may include English translation text for heuristics only.
+ * @returns {string[]}
+ */
+function detectAreaLabels(issue) {
+ const title = typeof issue?.title === "string" ? issue.title : "";
+ const body = typeof issue?.body === "string" ? issue.body : "";
+ const labels = Array.isArray(issue?.labels) ? issue.labels : [];
+ const heuristicSource = typeof issue?.heuristicBody === "string" ? issue.heuristicBody : body;
+
+ const areaSection = extractSection(body, "Area");
+ const fromArea = mapAreaFieldToLabels(areaSection);
+ const fromHeur = heuristicAreaLabels(title, bodyForAreaHeuristics(heuristicSource));
+ const fromForm = [];
+ if (labels.includes("provider-compatibility")) fromForm.push("provider");
+ // Provider-compat form uses this heading instead of Area.
+ if (extractSection(body, "Provider or upstream service") !== null) {
+ fromForm.push("provider");
+ }
+
+ const seen = new Set();
+ const out = [];
+ for (const label of [...fromArea, ...fromForm, ...fromHeur]) {
+ if (!label || seen.has(label)) continue;
+ if (!AREA_LABELS[label]) continue;
+ seen.add(label);
+ out.push(label);
+ }
+ return out;
+}
+
+function countHeadings(body, headings) {
+ let n = 0;
+ for (const h of headings) {
+ if (extractSection(body, h) !== null) n++;
+ }
+ return n;
+}
+
+/**
+ * Detect the issue kind from body headings, title prefix, labels, and
+ * optional stored bot kind.
+ *
+ * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
+ * @returns {"feature"|"bug"|"provider-compatibility"|"documentation"|null}
+ */
+function detectIssueKindFromContent(issue) {
+ const { title = "", body = "", labels = [] } = issue;
+ const titleLower = title.toLowerCase();
+
+ // Provider compatibility: distinct headings.
+ if (countHeadings(body, PROVIDER_HEADINGS) >= 3) return "provider-compatibility";
+
+ // Documentation: distinct headings.
+ if (countHeadings(body, DOCS_HEADINGS) >= 2) return "documentation";
+
+ // New feature form: at least 2 of the 3 core headings.
+ if (countHeadings(body, FEATURE_NEW_HEADINGS) >= 2) return "feature";
+
+ // Translated / alternate feature headings (e.g. after issue-triage).
+ // Require a feature-specific goal heading so common headings like
+ // "Expected behaviour" cannot reclassify bug/freeform reports as features.
+ // ([Feature]: prefix and enhancement labels are handled elsewhere.)
+ if (
+ countHeadings(body, FEATURE_ALIAS_DETECT_HEADINGS) >= 2 &&
+ countHeadings(body, FEATURE_GOAL_HEADINGS) >= 1
+ ) {
+ return "feature";
+ }
+
+ // New bug form: Client or integration + Summary + Reproduction.
+ if (
+ extractSection(body, "Client or integration") !== null &&
+ extractSection(body, "Summary") !== null &&
+ extractSection(body, "Reproduction") !== null
+ ) {
+ return "bug";
+ }
+
+ // Legacy feature form: title prefix or old headings.
+ if (titleLower.startsWith("[feature]:") || countHeadings(body, FEATURE_LEGACY_HEADINGS) >= 2) {
+ return "feature";
+ }
+
+ // Legacy bug form: title prefix or old headings (Summary + Reproduction).
+ if (titleLower.startsWith("[bug]:") || countHeadings(body, BUG_LEGACY_HEADINGS) >= 2) {
+ // Only classify as bug when there is supporting evidence (label or prefix)
+ // to avoid false positives on generic issues that happen to have those words.
+ if (titleLower.startsWith("[bug]:") || labels.includes("bug")) return "bug";
+ }
+
+ return null;
+}
+
+/**
+ * True when body evidence for `kind` is a full structured form, not merely a
+ * title prefix or leftover label. Used to decide whether detected kind may
+ * override a stored bot kind.
+ */
+function hasStrongKindEvidence(kind, issue) {
+ const { body = "" } = issue;
+ switch (kind) {
+ case "provider-compatibility":
+ return countHeadings(body, PROVIDER_HEADINGS) >= 3;
+ case "documentation":
+ return countHeadings(body, DOCS_HEADINGS) >= 2;
+ case "feature":
+ return (
+ countHeadings(body, FEATURE_NEW_HEADINGS) >= 2 ||
+ countHeadings(body, FEATURE_LEGACY_HEADINGS) >= 2 ||
+ (countHeadings(body, FEATURE_ALIAS_DETECT_HEADINGS) >= 2 &&
+ countHeadings(body, FEATURE_GOAL_HEADINGS) >= 1)
+ );
+ case "bug":
+ return (
+ extractSection(body, "Client or integration") !== null &&
+ extractSection(body, "Summary") !== null &&
+ extractSection(body, "Reproduction") !== null
+ );
+ default:
+ return false;
+ }
+}
+
+/**
+ * Detect the issue kind from body headings, title prefix, labels, and
+ * optional stored bot kind.
+ *
+ * Stored kind survives heading removal (bypass protection). A different
+ * detected kind overrides it only when the body has strong form evidence.
+ *
+ * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
+ * @returns {"feature"|"bug"|"provider-compatibility"|"documentation"|null}
+ */
+function detectIssueKind(issue) {
+ const { storedKind } = issue;
+ const detected = detectIssueKindFromContent(issue);
+
+ if (storedKind) {
+ if (
+ detected &&
+ detected !== storedKind &&
+ hasStrongKindEvidence(detected, issue)
+ ) {
+ return detected;
+ }
+ return storedKind;
+ }
+
+ return detected;
+}
+
+// ---------------------------------------------------------------------------
+// Validation
+// ---------------------------------------------------------------------------
+
+function isEmpty(text) {
+ const c = clean(text);
+ if (c.length === 0) return true;
+ // Stand-ins like "...", "…", "---" are not actionable report content.
+ return /^[\p{P}\p{S}\s]+$/u.test(c);
+}
+
+function allSameCanonical(sections) {
+ const cans = sections.map(canonicalise).filter(Boolean);
+ if (cans.length < 2) return false;
+ return cans.every((c) => c === cans[0]);
+}
+
+function allRepeatTitle(sections, title) {
+ const titleCan = canonicalise(title);
+ if (!titleCan) return false;
+ const cans = sections.map(canonicalise).filter(Boolean);
+ if (cans.length === 0) return false;
+ return cans.every((c) => c === titleCan);
+}
+
+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);
+}
+
+const CJK_RE =
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
+
+function countWords(text) {
+ const c = clean(text);
+ if (!c) return 0;
+
+ // Count each CJK character as one unit, and non-CJK scripts as Unicode
+ // word tokens. Mixing one CJK glyph into a Latin/Cyrillic word must not
+ // inflate the count to letter-length.
+ const cjkChars = c.match(CJK_RE) || [];
+ const nonCjkText = c.replace(CJK_RE, " ");
+ const nonCjkTokens = nonCjkText.match(/[\p{L}\p{N}']+/gu) || [];
+
+ return cjkChars.length + nonCjkTokens.length;
+}
+
+function hasConcreteDetail(text) {
+ const c = clean(text);
+ if (!c) return false;
+ return (
+ /\d/.test(c) ||
+ /[`{}\[\]<>/\\]/.test(c) ||
+ /\b(ocx|config|api|cli|dashboard|provider|proxy|route|endpoint|workflow|command)\b/i.test(c)
+ );
+}
+
+function isTooTerseFeatureSection(text) {
+ if (isEmpty(text) || isPlaceholder(text)) return false;
+ const words = countWords(text);
+ if (words >= 8) return false;
+ if (words >= 6 && hasConcreteDetail(text)) return false;
+ return true;
+}
+
+/**
+ * Bug Reproduction needs concrete signals that let a maintainer reproduce the
+ * failure. Product keywords alone (e.g. "choose model deepseek" or "send a
+ * message in the codex plugin") are not actionable: the report must name a
+ * command, an error, a file/config path, or an exact observed output.
+ */
+// Commands and exact technical actions, e.g. "ocx start", "run bun",
+// "send a streaming request", "curl https://...".
+const REPRO_COMMAND_RE = new RegExp([
+ "\\b(?:run|start|stop|restart|install|launch|execute|reproduce|trigger|invoke)\\s+(?:(?:the|an|a)\\s+)?(?:ocx|bun|npm|pnpm|yarn|curl|node|codex|proxy|server|dashboard|plugin)\\b",
+ "\\b(?:ocx|bun|npm|pnpm|yarn|curl|node|codex)\\s+(?:start|run|stop|restart|install|config|--[a-z-]+)\\b",
+ "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+(?:streaming|api|http|json|completion|chat|config|auth|embedding|post|graphql|grpc)\\s+(?:request|call|command|prompt|query)\\b",
+ "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+(?:api|curl|endpoint|url)\\b",
+ "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+[\\w.-]+\\s+request\\s+to\\s+(?:the\\s+)?(?:endpoint|url|api|server|proxy|\\S+/\\S+)\\b",
+ "\\b(?:pip|npm|bun)\\s+install\\b",
+ "\\b(?:curl|wget)\\s+[^\\s]+",
+].join("|"), "i");
+
+// Error, exception, and failure tokens, plus status codes in status context
+// (bare 3-digit numbers can be ports or version numbers).
+const REPRO_FAILURE_RE = new RegExp([
+ "\\b(?:segfault|sigsegv|panic|abort|exception|traceback|stack\\s*trace|timeout|timed\\s*out|refused|reset|denied|failed?|error|crash|hang|hangs?|stuck|spinning|empty\\s*response)\\b",
+ "\\b(?:status\\s*(?:code\\s*)?|code\\s*|http\\s*)(?:is|of|:)?\\s*[1-5]\\d\\d\\b",
+].join("|"), "i");
+
+// File, config, and log paths such as ~/.codex/config.toml or C:\\logs\\ocx.log.
+const REPRO_PATH_RE = new RegExp([
+ "~?/[\\w.@-]+(?:/[\\w.@-]+)+",
+ "[A-Za-z]:\\\\(?:[\\w.@-]+\\\\)+[\\w.@-]+",
+ "~?/[\\w.@-]+/[\\w.@-]+\\.(?:json|yaml|yml|toml|conf|log|env|txt|ts|js|tsx|jsx|sh|ps1|py)",
+ "[\\w.@-]+\\.(?:json|yaml|yml|toml|conf|log|env)\\b",
+].join("|"));
+const ACTIONABLE_REPRO_RE = new RegExp(
+ [REPRO_COMMAND_RE.source, REPRO_FAILURE_RE.source, REPRO_PATH_RE.source].join("|"),
+ "i",
+);
+
+// Sigil-only fences with no body content are never actionable.
+const EMPTY_FENCE_RE = /^[ \t]{0,3}(?:```+|~~~+)\s*\n\s*\n[ \t]{0,3}(?:```+|~~~+)\s*$/;
+
+/**
+ * True when a bug Reproduction names commands, error tokens, file/config
+ * paths, or exact technical actions. Product/model mentions without any of
+ * those signals (e.g. #977) are treated as unactionable. Fenced blocks only
+ * count as actionable when their body contains non-whitespace content.
+ */
+function hasActionableReproductionDetail(text) {
+ const c = clean(text);
+ if (!c) return false;
+ if (ACTIONABLE_REPRO_RE.test(c)) return true;
+ // Fenced blocks: only count when the body has non-whitespace content.
+ if (/```|~~~/.test(c)) {
+ const parts = stripFencedActionableContent(c);
+ if (parts) return true;
+ }
+ return false;
+}
+
+/**
+ * Walk the text looking for a fenced code block whose body contains
+ * non-whitespace content. Returns the non-empty body or null.
+ */
+function stripFencedActionableContent(text) {
+ const fenceRe = /^[ \t]{0,3}(`{3,}|~{3,})/;
+ const lines = text.split("\n");
+ let i = 0;
+ while (i < lines.length) {
+ const m = lines[i].match(fenceRe);
+ if (!m) { i++; continue; }
+ const marker = m[1];
+ const markerLen = marker.length;
+ // Find the closing fence on a later line.
+ let j = i + 1;
+ const endRe = new RegExp(
+ `^[ \\t]{0,3}${marker[0] === "`" ? "`" : "~"}{${markerLen},}[ \\t]*$`,
+ );
+ while (j < lines.length && !endRe.test(lines[j])) j++;
+ if (j > i + 1) {
+ const body = lines.slice(i + 1, j).join("\n");
+ if (body.trim()) return body;
+ }
+ i = j + 1;
+ }
+ return null;
+}
+
+function isTooTerseBugReproduction(text) {
+ if (isEmpty(text) || isPlaceholder(text)) return false;
+ if (hasActionableReproductionDetail(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
+ * (legacy "No response" / N/A) from actively cleared required fields.
+ */
+function isRawPlaceholder(raw) {
+ if (raw === null) return false;
+ return isPlaceholderOnlyValue(raw);
+}
+
+/**
+ * Near-miss freeform headings that often appear in API-opened or copy-pasted
+ * bug reports instead of the Bug report template (e.g. Description / Log entry).
+ */
+const FREEFORM_BUG_NEAR_MISS_HEADINGS = [
+ "Description",
+ "Steps to reproduce",
+ "Steps to Reproduce",
+ "How to reproduce",
+ "Log",
+ "Logs",
+ "Log entry",
+ "Error",
+ "Error output",
+ "Stack trace",
+];
+
+/**
+ * True when an unclassified body looks like a bug report that skipped the
+ * template (near-miss headings and/or repro/error signals).
+ *
+ * @param {{ title?: string, body?: string }} issue
+ * @returns {boolean}
+ */
+function looksLikeUntemplatedBugReport(issue) {
+ const body = typeof issue?.body === "string" ? issue.body : "";
+ if (!body.trim()) return false;
+
+ const nearMissCount = countHeadings(body, FREEFORM_BUG_NEAR_MISS_HEADINGS);
+ const hasReproduction =
+ extractSection(body, "Reproduction") !== null ||
+ extractSection(body, "Steps to reproduce") !== null ||
+ extractSection(body, "Steps to Reproduce") !== null ||
+ extractSection(body, "How to reproduce") !== null;
+ const hasDescription = extractSection(body, "Description") !== null;
+ const hasLogOrError =
+ extractSection(body, "Log") !== null ||
+ extractSection(body, "Logs") !== null ||
+ extractSection(body, "Log entry") !== null ||
+ extractSection(body, "Logs or error output") !== null ||
+ extractSection(body, "Error") !== null ||
+ extractSection(body, "Error output") !== null ||
+ extractSection(body, "Stack trace") !== null;
+
+ if (hasDescription && (hasReproduction || hasLogOrError)) return true;
+ if (hasReproduction && hasLogOrError) return true;
+ if (nearMissCount >= 2) return true;
+
+ // Body-level signals for heading-free freeform dumps.
+ const signalRe =
+ /\b(repro(?:duce|duction| steps)?|stack\s*traces?|traceback|segfault|panic|exception|error\s*output|ECONNREFUSED|SIGSEGV)\b/i;
+ if (signalRe.test(body) && (hasDescription || hasReproduction || hasLogOrError || nearMissCount >= 1)) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Reasons/guidance when no structured issue kind was detected.
+ *
+ * @param {{ title?: string, body?: string }} issue
+ * @returns {{ reasons: string[], guidance: string[] }}
+ */
+function untemplatedIssueFailure(issue) {
+ if (looksLikeUntemplatedBugReport(issue)) {
+ return {
+ reasons: [
+ "This looks like a bug report but it does not use the Bug report template headings (for example Description/Log entry instead of Summary).",
+ ],
+ guidance: [
+ "Use the Bug report template, or edit this issue to include: Client or integration, Summary, Reproduction, Version, and Operating system.",
+ "Retitling with `[Bug]:` and applying the `bug` label alone is not enough without those section headings filled in.",
+ ],
+ };
+ }
+ return {
+ reasons: [
+ "This issue does not use a recognized issue template.",
+ ],
+ guidance: [
+ "Open a new issue with the Bug report, Feature request, Documentation, or Provider compatibility template.",
+ "Or edit this issue so the body uses the template section headings for the kind of report you are filing.",
+ ],
+ };
+}
+
+/**
+ * Validate an issue body for its detected kind.
+ *
+ * Unclassified (freeform / non-template) issues are invalid so API-opened
+ * reports cannot skip the quality gate. Trusted-author exemption is enforced
+ * by the workflow, not here.
+ *
+ * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
+ * @returns {{ kind: string|null, valid: boolean, softPass: boolean, reasons: string[], guidance: string[] }}
+ */
+function validateIssue(issue) {
+ const { title = "", body = "" } = issue;
+ const kind = detectIssueKind(issue);
+ const reasons = [];
+ const guidance = [];
+ let softPass = false;
+
+ if (!kind) {
+ const failure = untemplatedIssueFailure(issue);
+ return {
+ kind: null,
+ valid: false,
+ softPass: false,
+ reasons: failure.reasons,
+ guidance: failure.guidance,
+ };
+ }
+
+ if (kind === "feature") {
+ const goal = resolveSection(body, FEATURE_GOAL_HEADINGS);
+ const blocker = resolveSection(body, FEATURE_BLOCKER_HEADINGS);
+ const behaviour = resolveSection(body, FEATURE_BEHAVIOUR_HEADINGS);
+ const example = resolveSection(body, FEATURE_EXAMPLE_HEADINGS);
+
+ const coreSections = [goal, blocker, behaviour, example];
+ const emptyCore = [];
+ if (isEmpty(goal)) emptyCore.push("goal / problem");
+ // blocker and example are only required when those headings exist.
+ // On the legacy / translated forms these sections may be absent (null).
+ if (blocker !== null && isEmpty(blocker)) emptyCore.push("current limitation");
+ if (isEmpty(behaviour)) emptyCore.push("expected behaviour");
+ if (example !== null && isPlaceholder(example)) {
+ reasons.push("Example usage or interface contains placeholder text instead of a concrete example.");
+ guidance.push("Add a real CLI command, config snippet, API exchange, or before/after workflow example.");
+ } else if (example !== null && isEmpty(example)) {
+ emptyCore.push("example usage");
+ }
+
+ const mappedHeadingPresent =
+ goal !== null || blocker !== null || behaviour !== null || example !== null;
+
+ if (emptyCore.length > 0) {
+ // Soft-pass rich non-template bodies once kind is already feature (title
+ // prefix, enhancement label, or stored kind). Do not require the title to
+ // keep a `[Feature]:` prefix — maintainer retitles must not re-arm closure.
+ const canSoftPass =
+ !mappedHeadingPresent &&
+ hasSubstantialStructuredContent(body);
+ if (canSoftPass) {
+ softPass = true;
+ } else {
+ reasons.push(`Required sections are missing or empty: ${emptyCore.join(", ")}.`);
+ guidance.push("Fill in each required section with specific detail about your workflow.");
+ }
+ }
+
+ if (!softPass) {
+ const nonEmpty = coreSections.filter((s) => !isEmpty(s));
+ if (nonEmpty.length >= 2 && allSameCanonical(nonEmpty)) {
+ reasons.push("All core sections contain the same content.");
+ guidance.push("Each section should describe a different aspect: goal, limitation, expected behaviour, and a concrete example.");
+ }
+
+ if (nonEmpty.length >= 2 && allRepeatTitle(nonEmpty, title)) {
+ reasons.push("All core sections merely repeat the issue title.");
+ guidance.push("Expand each section with details beyond the title.");
+ }
+
+ if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
+ reasons.push("Required sections contain only placeholder text.");
+ guidance.push("Replace placeholder text with your actual proposal.");
+ }
+ }
+
+ const terseSections = [];
+ if (goal !== null && isTooTerseFeatureSection(goal)) terseSections.push("goal / problem");
+ if (blocker !== null && isTooTerseFeatureSection(blocker)) terseSections.push("current limitation");
+ if (behaviour !== null && isTooTerseFeatureSection(behaviour)) terseSections.push("expected behaviour");
+ if (terseSections.length > 0) {
+ reasons.push(`Required sections are too vague to act on: ${terseSections.join(", ")}.`);
+ guidance.push("Describe the workflow, limitation, and expected behaviour with enough detail for someone to implement or evaluate the request.");
+ }
+ }
+
+ if (kind === "bug") {
+ const summary = extractSection(body, "Summary");
+ 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
+ // kind is already bug (label, stored kind, or prior `[Bug]:` detection).
+ // Requiring the title to keep a `[Bug]:` prefix caused #545: a maintainer
+ // retitle of an already detailed report was treated as empty Summary/
+ // Reproduction and auto-closed.
+ const canSoftPass =
+ summary === null &&
+ repro === null &&
+ hasSubstantialStructuredContent(body);
+ if (canSoftPass) {
+ softPass = true;
+ } else {
+ reasons.push("Both Summary and Reproduction are empty.");
+ guidance.push("Describe what happened and how to reproduce it.");
+ }
+ } else {
+ // Each mapped field is required on its own — a filled Summary with an
+ // empty / ellipsis Reproduction (e.g. #598) must not pass.
+ if (isEmpty(summary)) {
+ reasons.push("Summary is empty.");
+ guidance.push("Describe what happened (the symptom or error).");
+ }
+ 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.");
+ }
+ }
+
+ // 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 (including when the heading was removed).
+ // 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.");
+ }
+
+ if (!softPass) {
+ const nonEmpty = [summary, repro].filter((s) => !isEmpty(s));
+ if (nonEmpty.length >= 2 && allSameCanonical(nonEmpty)) {
+ reasons.push("Summary and Reproduction contain the same content.");
+ guidance.push("Summary should describe the symptom; Reproduction should list the exact steps.");
+ }
+
+ if (nonEmpty.length >= 1 && allRepeatTitle(nonEmpty, title)) {
+ reasons.push("Summary and Reproduction merely repeat the title.");
+ guidance.push("Add detail beyond the title: what you observed, what you expected, and the exact steps.");
+ }
+
+ if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
+ reasons.push("Required sections contain only placeholder text.");
+ guidance.push("Replace placeholder text with your actual report.");
+ }
+ }
+ }
+
+ if (kind === "provider-compatibility") {
+ const current = extractSection(body, "Current behaviour");
+ const expected = extractSection(body, "Expected behaviour");
+ const repro = extractSection(body, "Minimal redacted request or reproduction");
+ const response = extractSection(body, "Actual response or error");
+ const docs = extractSection(body, "Upstream documentation");
+
+ const emptyCore = [];
+ if (isEmpty(current)) emptyCore.push("current behaviour");
+ if (isEmpty(expected)) emptyCore.push("expected behaviour");
+ // Metadata fields: provider, version, endpoint are required on the form.
+ const provider = extractSection(body, "Provider or upstream service");
+ const version = extractSection(body, "OpenCodex version");
+ const endpoint = extractSection(body, "Endpoint or capability");
+ if (provider !== null && isEmpty(provider)) emptyCore.push("provider or upstream service");
+ if (version !== null && isRawPlaceholder(version) === false && isEmpty(version)) emptyCore.push("OpenCodex version");
+ if (endpoint !== null && isEmpty(endpoint)) emptyCore.push("endpoint or capability");
+ if (emptyCore.length > 0) {
+ // Same soft-pass as bug/feature: label- or maintainer-scoped provider
+ // reports often use non-English structured headings after a retitle.
+ const mappedHeadingPresent =
+ current !== null || expected !== null || repro !== null || response !== null || docs !== null ||
+ provider !== null || version !== null || endpoint !== null;
+ const canSoftPass =
+ !mappedHeadingPresent &&
+ hasSubstantialStructuredContent(body);
+ if (canSoftPass) {
+ softPass = true;
+ } else {
+ reasons.push(`Required sections are missing or empty: ${emptyCore.join(", ")}.`);
+ guidance.push("Describe both the current and expected behaviour.");
+ }
+ }
+
+ if (!softPass && !isEmpty(current) && !isEmpty(expected) && canonicalise(current) === canonicalise(expected)) {
+ reasons.push("Current and expected behaviour are effectively identical.");
+ guidance.push("Explain the difference between what happens now and what should happen.");
+ }
+
+ const allSections = [current, expected, repro, response].filter((s) => !isEmpty(s));
+ if (!softPass && allSections.length >= 2 && allRepeatTitle(allSections, title)) {
+ reasons.push("All sections merely repeat the issue title.");
+ guidance.push("Add specific detail in each section.");
+ }
+
+ if (!softPass && isEmpty(repro) && isEmpty(response)) {
+ reasons.push("Both the request/reproduction and the actual response/error are absent.");
+ guidance.push("Include at least a minimal redacted request or the actual error output.");
+ }
+
+ if (!softPass && isEmpty(docs)) {
+ reasons.push("Upstream documentation is empty without stating that no public specification exists.");
+ guidance.push("Add a URL to the provider specification, or state that no public spec exists.");
+ }
+ }
+
+ if (kind === "documentation") {
+ const location = extractSection(body, "Documentation location");
+ const problem = extractSection(body, "What is wrong or missing?");
+ const expected = extractSection(body, "What should the documentation explain instead?");
+
+ if (isEmpty(location) && isEmpty(problem)) {
+ reasons.push("Documentation location and problem description are both missing.");
+ guidance.push("Point to the exact documentation page and describe what is wrong.");
+ }
+
+ const nonEmpty = [location, problem, expected].filter((s) => !isEmpty(s));
+ if (nonEmpty.length >= 1 && allRepeatTitle(nonEmpty, title)) {
+ reasons.push("The body merely repeats the title.");
+ guidance.push("Add detail: the exact URL or path, what is wrong, and what it should say.");
+ }
+
+ if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
+ reasons.push("Required sections contain only placeholder text.");
+ guidance.push("Replace placeholder text with the actual documentation problem.");
+ }
+ }
+
+ return {
+ kind,
+ valid: reasons.length === 0 && !softPass,
+ softPass,
+ reasons,
+ guidance,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Closure ownership
+// ---------------------------------------------------------------------------
+
+/**
+ * Decide whether the bot may auto-close an invalid issue.
+ *
+ * After a maintainer reopens and deactivates enforcement, later `edited`
+ * events must not close the issue again.
+ *
+ * @param {{ active?: boolean, maintainerOverride?: boolean }|null|undefined} botState
+ * @returns {boolean}
+ */
+function shouldEnforceClosure(botState) {
+ if (botState && botState.maintainerOverride === true) return false;
+ return true;
+}
+
+/**
+ * Decide whether the bot may reopen a closed issue.
+ *
+ * @param {{ active: boolean, closedAt: string|null, stateReason: string }} botState
+ * @param {{ state: string, closed_at: string|null, state_reason: string|null, closed_by?: string|null }} issue
+ * @param {boolean} maintainerOverride True when a maintainer changed the issue state after the bot.
+ * @returns {boolean}
+ */
+function shouldReopen(botState, issue, maintainerOverride) {
+ if (!botState || !botState.active) return false;
+ if (issue.state !== "closed") return false;
+ if (maintainerOverride) return false;
+ if (issue.closed_at !== botState.closedAt) return false;
+ if (issue.state_reason !== botState.stateReason) return false;
+ // Only reopen if the bot itself was the last actor to close the issue.
+ // A human closing it (even with the same timestamp) means intentional closure.
+ if (issue.closed_by && issue.closed_by !== "github-actions[bot]") return false;
+ return true;
+}
+
+/**
+ * workflow_dispatch accepts a bare issue number, but GitHub reuses the same
+ * number namespace for issues and pull requests. Reject PR targets before any
+ * validation or mutation runs.
+ *
+ * @param {{ pull_request?: unknown }} issue
+ * @param {number|string} issueNumber
+ * @param {string} eventName
+ * @returns {string|null}
+ */
+function rejectsWorkflowDispatchPullRequest(issue, issueNumber, eventName) {
+ if (eventName !== "workflow_dispatch") return null;
+ if (!issue?.pull_request) return null;
+ return `#${issueNumber} is a pull request. This workflow only accepts issue numbers.`;
+}
+
+/**
+ * workflow_dispatch can be started from a selected branch. Reject runs whose
+ * selected ref is not the repository default branch so untrusted branch code
+ * cannot drive issue mutations with issues:write.
+ *
+ * @param {string} eventName
+ * @param {string|null|undefined} ref
+ * @param {string|null|undefined} defaultBranch
+ * @returns {string|null}
+ */
+function rejectsWorkflowDispatchNonDefaultBranch(eventName, ref, defaultBranch) {
+ if (eventName !== "workflow_dispatch") return null;
+ if (!defaultBranch || typeof defaultBranch !== "string") {
+ return "workflow_dispatch requires repository.default_branch to be available.";
+ }
+ const expected = `refs/heads/${defaultBranch}`;
+ if (ref !== expected) {
+ return (
+ `workflow_dispatch must run from the default branch (${defaultBranch}); ` +
+ `selected ref was ${ref || "(empty)"}.`
+ );
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Exports
+// ---------------------------------------------------------------------------
+
+module.exports = {
+ clean,
+ normalise,
+ canonicalise,
+ stripMediaTokens,
+ isMediaOnly,
+ extractSection,
+ resolveSection,
+ detectIssueKind,
+ validateIssue,
+ looksLikeUntemplatedBugReport,
+ shouldReopen,
+ shouldEnforceClosure,
+ isPlaceholderOnlyValue,
+ isPlaceholder,
+ isRawPlaceholder,
+ isUnusableVersion,
+ countWords,
+ hasConcreteDetail,
+ hasActionableReproductionDetail,
+ labelForKind,
+ KIND_TO_LABEL,
+ AREA_LABELS,
+ AREA_FIELD_TO_LABELS,
+ mapAreaFieldToLabels,
+ bodyForAreaHeuristics,
+ heuristicAreaLabels,
+ detectAreaLabels,
+ hasSubstantialStructuredContent,
+ rejectsWorkflowDispatchPullRequest,
+ rejectsWorkflowDispatchNonDefaultBranch,
+};
diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs
index 1c0a6430a7..24b14c3633 100644
--- a/.github/scripts/issue-quality.cjs
+++ b/.github/scripts/issue-quality.cjs
@@ -1,1649 +1,95 @@
"use strict";
-// ---------------------------------------------------------------------------
-// Pure issue-quality validation for OpenCodex.
-// CommonJS, zero runtime dependencies. No GitHub API calls.
-// ---------------------------------------------------------------------------
+// Keep the strict canonical validator intact and normalize equivalent structured
+// bug evidence before delegating to it. This lets detailed reports survive
+// harmless heading changes without weakening the underlying quality checks.
+const core = require("./issue-quality-core.cjs");
-/**
- * True when the entire meaningful value is a placeholder-only token.
- * Supports harmless Markdown emphasis/code markers and trailing punctuation.
- * Sentences that merely contain a placeholder phrase are not matches.
- */
-const PLACEHOLDER_ONLY_RE =
- /^[\s_*~`]*(?:no\s+response|n\/?a|not\s+applicable|not\s+available|none|todo|tbd)[\s_*~`]*[.!?]*$/i;
-
-/**
- * If `text` is exactly one enclosing fenced code block (``` or ~~~), return the
- * inner body; otherwise null. Real multi-statement fences are left alone by
- * the placeholder matcher after unwrap.
- */
-function unwrapSingleEnclosingFence(text) {
- const trimmed = text.trim();
- const match = trimmed.match(/^(```|~~~)[^\n]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/);
- if (!match) return null;
- return match[2];
-}
-
-/**
- * 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(//g, "").trim();
- if (!value) return null;
-
- // 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 null;
- }
-
- return value;
-}
-
-function isPlaceholderOnlyValue(raw) {
- const value = normalizeRawSectionValue(raw);
- if (value === null) return false;
- return PLACEHOLDER_ONLY_RE.test(value);
-}
-
-/**
- * Strip image/media-only content from a markdown or HTML fragment so that a
- * section whose only content is a screenshot or media embed is treated as
- * empty by the validators.
- *
- * Handles:
- * - Markdown images: ``, ``
- * - HTML
and ... blocks
- * - Common media embeds (video/audio) when they are the only content
- *
- * Text mixed with media (for example a caption or repro steps around an
- * image) is preserved; only the media tokens themselves are removed.
- */
-function stripMediaTokens(text) {
- if (typeof text !== "string") return "";
- // Indented code lines render as literal code in GitHub Markdown. Protect
- // them first so neither the HTML nor the Markdown media stripper can
- // remove example syntax; restore the lines afterwards.
- const protectedText = protectIndentedCodeLines(text);
- const markdownStripped = stripMarkdownImages(stripHtmlMedia(protectedText.text));
- const referenceStripped = stripReferenceImages(markdownStripped);
- return restoreIndentedCodeLines(referenceStripped, protectedText.lines);
-}
-
-/**
- * Replace every indented code line (4+ leading spaces or a tab) with a
- * placeholder of equal length so media stripping cannot touch it. Returns the
- * masked text plus the original lines for restoration.
- */
-function protectIndentedCodeLines(text) {
- const lines = [];
- const masked = text.split("\n").map((line) => {
- if (/^(?: {4,}|\t)/.test(line)) {
- lines.push(line);
- return "\u0000" + line.replace(/[^\n]/g, " ").slice(1);
- }
- lines.push(null);
- return line;
- });
- return { text: masked.join("\n"), lines };
-}
-
-/**
- * Restore masked indented-code lines from their original content. Placeholder
- * lines are identified by the leading \u0000 marker and matched positionally.
- */
-function restoreIndentedCodeLines(text, lines) {
- const out = text.split("\n").map((line, i) => {
- if (lines[i] !== null && line.startsWith("\u0000")) {
- return lines[i];
- }
- return line;
- });
- return out.join("\n");
-}
-
-/**
- * Strip HTML media blocks whose entire inner content is media markup (no
- * substantive text). A block that contains fallback/caption prose — for
- * example ``
- * — is left untouched so the prose survives the empty-section check.
- *
- * Handles
, ..., , and
- * .
- */
-function stripHtmlMedia(text) {
- if (typeof text !== "string") return "";
- let s = text
- .replace(/
]*>/gi, " ")
- .replace(//g, " ");
-
- // Whole media blocks: replace only when the inner content is not
- // substantive text (no word characters outside tags).
- s = s.replace(
- /<(picture|video|audio)\b[^>]*>([\s\S]*?)<\/\1>/gi,
- (match, tag, inner) => {
- const innerStripped = inner
- .replace(/<[^>]+>/g, " ")
- .replace(/[\s_*~`]+/g, " ")
- .trim();
- return innerStripped.length === 0 ? " " : match;
- },
- );
- return s;
-}
-
-/**
- * Remove Markdown image tokens `` using a small
- * balanced scanner instead of a regex, because destinations may contain
- * balanced parentheses (for example `image_(final).png`) and alt text may
- * contain balanced brackets (`![Image [screenshot]](url)`).
- *
- * A token is matched only when:
- * - it starts with `![` (not escaped);
- * - the alt text is balanced with respect to `[` / `]`;
- * - the destination is balanced with respect to `(`, `)` and `"` (an
- * optional title may follow); and
- * - the token closes with a `)`.
- *
- * Malformed tokens (unbalanced destination, e.g. `a)b.png)`) are left in
- * place — they are not valid Markdown images and must not be silently
- * dropped.
- */
-function stripMarkdownImages(text) {
- if (typeof text !== "string") return "";
- const out = [];
- let i = 0;
- while (i < text.length) {
- // Inside an indented code block (4+ leading spaces or a tab), image
- // syntax is literal code, not a rendered image. Leave it untouched so a
- // section that documents example syntax is not emptied.
- if (isInsideIndentedCode(text, i)) {
- out.push(text[i]);
- i += 1;
- continue;
- }
- // A backslash-escaped or code-fenced `![` is not an image token. We only
- // guard the common `\!` escape here; fenced blocks are handled by the
- // section extractor upstream, which does not include them in sections.
- if (text[i] === "!" && text[i + 1] === "[") {
- const end = scanMarkdownImage(text, i);
- if (end !== -1) {
- out.push(" ");
- i = end;
- continue;
- }
- }
- out.push(text[i]);
- i += 1;
- }
- return out.join("");
-}
-
-/**
- * True when `index` sits inside an indented code block, i.e. on a line that
- * starts with four or more spaces or a tab. Such lines render as literal
- * code in GitHub Markdown.
- */
-function isInsideIndentedCode(text, index) {
- const lineStart = text.lastIndexOf("\n", index - 1) + 1;
- const prefix = text.slice(lineStart, index);
- return /^(?: {4,}|\t)/.test(prefix);
-}
-
-/**
- * Strip reference-style Markdown images: inline references `![alt][ref]`
- * and the reference definitions `[ref]: https://...` they point at. These
- * are valid image syntax that a media-only section may use to embed a
- * screenshot.
- */
-function stripReferenceImages(text) {
- if (typeof text !== "string") return "";
- // Inline reference: ![alt][ref] or ![alt][] (implicit). Alt may contain
- // balanced brackets, so a balanced scan is used for the label part.
- let s = stripInlineReferences(text);
- // Reference definitions: [ref]: url "title" — only when the reference is
- // actually used by an image in the same text. A definition alone (or one
- // used by a text link) is not media and must stay.
- const refs = new Set();
- for (const ref of collectInlineReferenceLabels(text)) {
- refs.add(ref.toLowerCase());
- }
- if (refs.size > 0) {
- s = s.replace(
- /^\s*\[([^\]]+)\]:\s*\S+(?:\s+["'(][^"')]*["')])?\s*$/gm,
- (line, ref) => (refs.has(ref.toLowerCase()) ? " " : line),
- );
- }
- return s;
-}
-
-/**
- * Strip inline reference-style image tokens `![alt][ref]` / `![alt][]`
- * using a balanced scan for the alt text (which may contain nested brackets).
- */
-function stripInlineReferences(text) {
- const out = [];
- let i = 0;
- while (i < text.length) {
- if (text[i] === "!" && text[i + 1] === "[") {
- const end = scanReferenceImage(text, i);
- if (end !== -1) {
- out.push(" ");
- i = end;
- continue;
- }
- }
- out.push(text[i]);
- i += 1;
- }
- return out.join("");
-}
-
-/**
- * Scan an inline reference-style image `![alt][ref]` or `![alt][]` starting
- * at `start`. Returns the index just past the closing `]` on success, or -1.
- */
-function scanReferenceImage(text, start) {
- const altEnd = scanBalancedBrackets(text, start + 2);
- if (altEnd === -1 || text[altEnd] !== "]") return -1;
- if (text[altEnd + 1] !== "[") return -1;
- const refEnd = scanBalancedBrackets(text, altEnd + 2);
- if (refEnd === -1 || text[refEnd] !== "]") return -1;
- return refEnd + 1;
-}
-
-/**
- * Scan balanced bracket content starting at `start` (inside the opening `[`).
- * Returns the index of the matching closing `]`, or -1 when unbalanced.
- */
-function scanBalancedBrackets(text, start) {
- let depth = 0;
- for (let i = start; i < text.length; i += 1) {
- const ch = text[i];
- if (ch === "\\") {
- i += 1;
- continue;
- }
- if (ch === "[") {
- depth += 1;
- } else if (ch === "]") {
- if (depth === 0) return i;
- depth -= 1;
- }
- }
- return -1;
-}
-
-/**
- * Collect the reference labels used by inline reference-style images. For an
- * explicit `![alt][ref]` the label is `ref`; for an implicit `![alt][]` the
- * label is the alt text.
- */
-function collectInlineReferenceLabels(text) {
- const labels = [];
- let i = 0;
- while (i < text.length) {
- if (text[i] === "!" && text[i + 1] === "[") {
- const altStart = i + 2;
- const altEnd = scanBalancedBrackets(text, altStart);
- if (altEnd !== -1 && text[altEnd] === "]") {
- const alt = text.slice(altStart, altEnd);
- if (text[altEnd + 1] === "[") {
- const refStart = altEnd + 2;
- const refEnd = scanBalancedBrackets(text, refStart);
- if (refEnd !== -1 && text[refEnd] === "]") {
- const ref = text.slice(refStart, refEnd);
- labels.push(ref ? ref : alt);
- i = refEnd + 1;
- continue;
- }
- }
- }
- }
- i += 1;
- }
- return labels;
-}
-
-/**
- * Scan a Markdown image token starting at `start` (which points at `!`).
- * Returns the index just past the closing `)` on success, or -1 when the
- * token is malformed.
- */
-function scanMarkdownImage(text, start) {
- // Alt text: `![` ... `]` with balanced nested brackets.
- let i = start + 2;
- let bracketDepth = 0;
- for (; i < text.length; i += 1) {
- const ch = text[i];
- if (ch === "\\") {
- i += 1; // skip escaped character
- continue;
- }
- if (ch === "[") {
- bracketDepth += 1;
- } else if (ch === "]") {
- if (bracketDepth === 0) break;
- bracketDepth -= 1;
- }
- }
- if (i >= text.length || text[i] !== "]") return -1;
-
- // Destination: `(` ... `)` with balanced parentheses. An optional
- // whitespace-separated `"title"` may follow the destination.
- if (text[i + 1] !== "(") return -1;
- i += 2;
- let parenDepth = 1;
- let inQuotes = false;
- for (; i < text.length; i += 1) {
- const ch = text[i];
- if (ch === "\\") {
- i += 1; // skip escaped character
- continue;
- }
- if (ch === '"') {
- inQuotes = !inQuotes;
- continue;
- }
- if (inQuotes) continue;
- if (ch === "(") {
- parenDepth += 1;
- } else if (ch === ")") {
- parenDepth -= 1;
- if (parenDepth === 0) return i + 1;
- }
- }
- return -1;
-}
-
-/**
- * True when a section contains no substantive text after removing media
- * tokens and whitespace. Used to decide whether a media-only section should
- * count as empty for quality validation.
- */
-function isMediaOnly(text) {
- if (typeof text !== "string") return false;
- const stripped = stripMediaTokens(text);
- return stripped.replace(/\s+/g, "").length === 0;
-}
-
-/**
- * Strip HTML comments, placeholder-only values, and trim whitespace.
- */
-function clean(raw) {
- if (typeof raw !== "string") return "";
- let s = raw.replace(//g, "");
- // Media-only sections (a lone screenshot or embed) carry no reportable
- // text. Strip the media tokens so the section participates in emptiness and
- // duplicate detection like any other blank section. This closes the
- // image-only-section bypass (see #1098: an `
`-only goal hid repeated
- // prose in the other sections from duplicate detection).
- if (isMediaOnly(s)) {
- s = stripMediaTokens(s).replace(/\s+/g, " ").trim();
- }
- // Whole-value placeholders first (including a single enclosing fence), so
- // line-by-line stripping cannot leave bare fence markers behind.
- if (isPlaceholderOnlyValue(s)) return "";
- // Treat placeholder-only lines (GitHub "No response", N/A, etc.) as empty.
- s = s
- .split("\n")
- .map((line) => (isPlaceholderOnlyValue(line) ? "" : line))
- .join("\n");
- if (isPlaceholderOnlyValue(s)) return "";
- return s.trim();
-}
-
-/**
- * Lowercase, strip punctuation (Unicode-aware), collapse whitespace.
- */
-function normalise(raw) {
- return clean(raw)
- .toLowerCase()
- .replace(/[^\p{L}\p{N}\s]/gu, "")
- .replace(/\s+/g, " ")
- .trim();
-}
-
-/**
- * Canonical form for duplicate detection: normalise + strip common filler
- * phrases that do not add semantic content.
- */
-function canonicalise(raw) {
- let s = normalise(raw);
- const fillers = [
- /^i want to\s+/,
- /^we need to\s+/,
- /^would like to\s+/,
- /^i would like to\s+/,
- /^we would like to\s+/,
- /^please\s+/,
- ];
- for (const re of fillers) s = s.replace(re, "");
- return s.trim();
-}
-
-/**
- * Extract the text content of a markdown ### section by heading name.
- * Returns null when the heading is absent.
- */
-function extractSection(body, heading) {
- if (typeof body !== "string") return null;
- const lines = body.split("\n");
- const headingLower = heading.toLowerCase().trim();
- let capturing = false;
- let sectionDepth = 0;
- let fence = null;
- const out = [];
- for (const line of lines) {
- if (fence) {
- if (new RegExp(`^[ \\t]{0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) {
- fence = null;
- }
- if (capturing) out.push(line);
- continue;
- }
-
- const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
- if (fenceMatch) {
- fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
- if (capturing) out.push(line);
- continue;
- }
-
- const m = line.match(/^(#{2,4})\s+(.*)/);
- if (m) {
- const depth = m[1].length;
- if (capturing && depth <= sectionDepth) break;
- if (!capturing && m[2].toLowerCase().trim() === headingLower) {
- capturing = true;
- sectionDepth = depth;
- continue;
- }
- }
- if (capturing) out.push(line);
- }
- if (!capturing) return null;
- return out.join("\n").trim();
-}
-
-/**
- * Resolve a logical section from the first matching heading.
- * Prefers the first non-empty match; if every present heading is empty,
- * returns that empty string so callers can distinguish "missing" (null)
- * from "present but blank".
- */
-function resolveSection(body, headings) {
- let firstPresent = null;
- for (const heading of headings) {
- const section = extractSection(body, heading);
- if (section === null) continue;
- if (firstPresent === null) firstPresent = section;
- if (!isEmpty(section)) return section;
- }
- return firstPresent;
-}
-
-/**
- * True when the body has multiple non-empty h2–h4 sections with enough detail.
- * Soft-pass only — unstructured length alone is not enough, and a single
- * arbitrary heading must not bypass the quality gate (Codex on #564).
- */
-function hasSubstantialStructuredContent(body, minSectionLen = 40, minRichSections = 2) {
- if (typeof body !== "string") return false;
- const lines = body.split("\n");
- let capturing = false;
- let bucket = [];
- let richSections = 0;
- const flush = () => {
- if (clean(bucket.join("\n")).length >= minSectionLen) richSections += 1;
- bucket = [];
- };
- for (const line of lines) {
- const m = line.match(/^#{2,4}\s+(.*)/);
- if (m) {
- if (capturing) flush();
- capturing = true;
- continue;
- }
- if (capturing) bucket.push(line);
- }
- if (capturing) flush();
- return richSections >= minRichSections;
-}
-
-// ---------------------------------------------------------------------------
-// Issue kind detection
-// ---------------------------------------------------------------------------
-
-const FEATURE_NEW_HEADINGS = [
- "What are you trying to accomplish?",
- "What prevents this today?",
- "What should OpenCodex do?",
-];
-const FEATURE_LEGACY_HEADINGS = ["Problem to solve", "Proposed solution"];
-const FEATURE_GOAL_HEADINGS = [
- "What are you trying to accomplish?",
- "Goal / Problem",
- "Goal/Problem",
- "Problem to solve",
-];
-const FEATURE_BLOCKER_HEADINGS = [
- "What prevents this today?",
- "Current limitation",
- "Current workaround",
-];
-const FEATURE_BEHAVIOUR_HEADINGS = [
- "What should OpenCodex do?",
- "Expected behaviour",
- "Expected behavior",
- "Proposed solution",
-];
-const FEATURE_EXAMPLE_HEADINGS = [
- "Example usage or interface",
- "Example usage",
- "Example",
-];
-const FEATURE_ALIAS_DETECT_HEADINGS = [
- "Goal / Problem",
- "Goal/Problem",
- "Expected behaviour",
- "Expected behavior",
- "Current limitation",
- "Current workaround",
- "Example usage",
- // Intentionally omit bare "Example" — too common in freeform/bug reports.
-];
-const BUG_NEW_HEADINGS = ["Client or integration", "Summary", "Reproduction"];
-const BUG_LEGACY_HEADINGS = ["Summary", "Reproduction"];
-const PROVIDER_HEADINGS = [
- "Provider or upstream service",
- "Endpoint or capability",
- "Current behaviour",
- "Expected behaviour",
-];
-const DOCS_HEADINGS = [
- "Documentation problem type",
- "Documentation location",
- "What is wrong or missing?",
-];
-
-const KIND_TO_LABEL = {
- bug: "bug",
- feature: "enhancement",
- documentation: "documentation",
- "provider-compatibility": "provider-compatibility",
-};
-
-/**
- * Orthogonal product-area labels (additive beside kind/process labels).
- * Colors/descriptions are used when the workflow ensures labels exist.
- */
-const AREA_LABELS = {
- provider: {
- color: "1D76DB",
- description: "Provider adapters, OpenAI-compat presets, upstream API quirks",
- },
- "account-pool": {
- color: "5319E7",
- description: "OAuth, credentials, Codex pool, quota, failover, plans",
- },
- catalog: {
- color: "006B75",
- description: "Model catalog, slugs, visibility, routed entries",
- },
- gui: {
- color: "D93F0B",
- description: "Dashboard, tray, settings UI",
- },
- cli: {
- color: "FBCA04",
- description: "CLI, config inject, packaging flags",
- },
- proxy: {
- color: "0E8A16",
- description: "HTTP proxy, routing, reverse-proxy / management auth",
- },
- platform: {
- color: "BFDADC",
- description: "OS/service/tray/ACL (Windows-heavy, not Windows-only)",
- },
- streaming: {
- color: "C5DEF5",
- description: "SSE, WebSocket, terminal stream frames",
- },
- tools: {
- color: "F9D0C4",
- description: "tool_calls, MCP, web-search / sidecar tools",
- },
- install: {
- color: "EDEDED",
- description: "Installation or packaging",
- },
- service: {
- color: "EDEDED",
- description: "Service lifecycle (WinSW/launchd/scheduler)",
- },
-};
-
-/** Canonical Area dropdown text → area label(s). Keys are lowercased. */
-const AREA_FIELD_TO_LABELS = {
- cli: ["cli"],
- "proxy and routing": ["proxy"],
- dashboard: ["gui"],
- "provider adapter": ["provider"],
- "provider adapters": ["provider"],
- "authentication and account pool": ["account-pool"],
- "catalog / models": ["catalog"],
- streaming: ["streaming"],
- "tools / mcp / web search": ["tools"],
- "installation or packaging": ["install"],
- "service lifecycle": ["service"],
- "service lifecycle (config injection)": ["service"],
- "platform (windows / macos / linux)": ["platform"],
- // Do not map to kind label `documentation` — that collides with labelBasedKind
- // when a feature/bug form picks Area: Documentation. Docs form already seeds
- // the kind label; Area selection alone does not add an area tag.
- documentation: [],
- // No dedicated label; heuristics still run in detectAreaLabels.
- "multiple areas": [],
- other: [],
-};
-
-/** Body headings used for area heuristics (excludes Environment / OS metadata). */
-const AREA_HEURISTIC_BODY_HEADINGS = [
- "Summary",
- "Reproduction",
- "What are you trying to accomplish?",
- "What prevents this today?",
- "What should OpenCodex do?",
- "Example usage or interface",
- "Current behaviour",
- "Expected behaviour",
- "Minimal redacted request or reproduction",
- "What is wrong or missing?",
- "Documentation problem type",
- "Documentation location",
-];
-
-/**
- * Heuristic rules. `scope: "title"` avoids false hits from template Environment /
- * OS fields in the body; `scope: "full"` is for distinctive technical tokens.
- */
-const AREA_HEURISTICS = [
- {
- label: "account-pool",
- scope: "full",
- re: /\b(oauth|reauth|needsreauth|account pool|codex.?auth|auto[- ]?switch|account failover|refresh token|plan_type|chatgpt[- ]account|reset credit)\b/i,
- },
- {
- label: "account-pool",
- scope: "title",
- re: /\b(quota|failover|pool account|account switch)\b/i,
- },
- {
- label: "catalog",
- scope: "full",
- re: /\b(model catalog|opencodex-catalog|model list|model visibility|virtual model|routed (catalog|entries|slug)|model slug)\b/i,
- },
- {
- label: "catalog",
- scope: "title",
- re: /\bcatalog\b/i,
- },
- {
- label: "gui",
- scope: "title",
- re: /\b(dashboard|\bgui\b|tray|sidebar|settings (page|tab|ui))\b/i,
- },
- {
- label: "cli",
- scope: "title",
- re: /\b(ocx\b|config\.toml|config inject)\b/i,
- },
- {
- label: "proxy",
- scope: "full",
- re: /\b(reverse[- ]proxy|management api|admin[- ]token|\/api\/\*|bind(s)? the (old )?port)\b/i,
- },
- {
- label: "proxy",
- scope: "title",
- re: /\b(reverse[- ]proxy|management api|admin[- ]token)\b/i,
- },
- {
- label: "platform",
- scope: "full",
- re: /\b(winsw|launchd|schtasks|icacls|windows-latest|tray host|scheduler backend)\b/i,
- },
- {
- label: "platform",
- scope: "title",
- re: /\b(\[windows\]|\[macos\]|windows|macos|darwin|win32|wsl)\b/i,
- },
- {
- label: "streaming",
- scope: "full",
- re: /\b(sse|websocket|\bws\b|stream(ing)?\b.{0,40}\btruncat\w*|stream(ing)?\b.{0,40}\bterminal\b|terminal (sse )?frame|without a terminal)\b/i,
- },
- {
- label: "tools",
- scope: "full",
- re: /\b(tool_calls?|tool[- ]calls?|\bmcp\b|web[- ]search|tool[- ]recall)\b/i,
- },
- {
- label: "install",
- scope: "full",
- re: /\b(npm (global )?install|packaging|release asset|npx ocx)\b/i,
- },
- {
- label: "service",
- scope: "full",
- re: /\b(ocx service|winsw|scheduler backend|launchd service)\b/i,
- },
- {
- label: "provider",
- scope: "full",
- re: /\b(provider adapter|openai[- ]compatible|provider[- ]compat|adapter quirk|built[- ]in provider|provider preset)\b/i,
- },
- {
- label: "provider",
- scope: "title",
- re: /\b(\[provider\]|provider compat|openai[- ]compatible)\b/i,
- },
-];
-
-/**
- * Map a detected issue kind to its triage label. Returns null when unknown.
- */
-function labelForKind(kind) {
- if (!kind || typeof kind !== "string") return null;
- return KIND_TO_LABEL[kind] || null;
-}
-
-/**
- * Map a template Area dropdown value to orthogonal area label names.
- * Returns [] for Other / Multiple areas / unknown / empty.
- *
- * @param {unknown} areaText
- * @returns {string[]}
- */
-function mapAreaFieldToLabels(areaText) {
- if (typeof areaText !== "string") return [];
- const key = areaText.replace(/\s+/g, " ").trim().toLowerCase();
- if (!key) return [];
- return AREA_FIELD_TO_LABELS[key] ? [...AREA_FIELD_TO_LABELS[key]] : [];
-}
-
-/**
- * Build heuristic text from title-relevant semantic sections only — never from
- * Operating system / Version / Checks metadata that every template includes.
- *
- * @param {string} body
- * @returns {string}
- */
-function bodyForAreaHeuristics(body) {
- if (typeof body !== "string" || !body.trim()) return "";
- const parts = [];
- for (const heading of AREA_HEURISTIC_BODY_HEADINGS) {
- const section = extractSection(body, heading);
- if (section) parts.push(section);
- }
- return parts.join("\n\n");
-}
-
-/**
- * Conservative title/body heuristics for orthogonal area labels.
- *
- * @param {string} title
- * @param {string} body semantic body text (already filtered)
- * @returns {string[]}
- */
-function heuristicAreaLabels(title, body) {
- const titleText = title || "";
- const fullText = `${titleText}\n${body || ""}`;
- const seen = new Set();
- const out = [];
- for (const { label, re, scope } of AREA_HEURISTICS) {
- const text = scope === "title" ? titleText : fullText;
- if (!re.test(text) || seen.has(label)) continue;
- seen.add(label);
- out.push(label);
- }
- return out;
-}
-
-/**
- * Detect additive product-area labels from Area field, form defaults, and
- * title/body heuristics. Never invents per-provider labels.
- *
- * @param {{
- * title?: string,
- * body?: string,
- * labels?: string[],
- * heuristicBody?: string,
- * }} issue
- * `body` is the source form (for Area / provider headings).
- * `heuristicBody` may include English translation text for heuristics only.
- * @returns {string[]}
- */
-function detectAreaLabels(issue) {
- const title = typeof issue?.title === "string" ? issue.title : "";
- const body = typeof issue?.body === "string" ? issue.body : "";
- const labels = Array.isArray(issue?.labels) ? issue.labels : [];
- const heuristicSource = typeof issue?.heuristicBody === "string" ? issue.heuristicBody : body;
-
- const areaSection = extractSection(body, "Area");
- const fromArea = mapAreaFieldToLabels(areaSection);
- const fromHeur = heuristicAreaLabels(title, bodyForAreaHeuristics(heuristicSource));
- const fromForm = [];
- if (labels.includes("provider-compatibility")) fromForm.push("provider");
- // Provider-compat form uses this heading instead of Area.
- if (extractSection(body, "Provider or upstream service") !== null) {
- fromForm.push("provider");
- }
-
- const seen = new Set();
- const out = [];
- for (const label of [...fromArea, ...fromForm, ...fromHeur]) {
- if (!label || seen.has(label)) continue;
- if (!AREA_LABELS[label]) continue;
- seen.add(label);
- out.push(label);
- }
- return out;
-}
-
-function countHeadings(body, headings) {
- let n = 0;
- for (const h of headings) {
- if (extractSection(body, h) !== null) n++;
- }
- return n;
-}
-
-/**
- * Detect the issue kind from body headings, title prefix, labels, and
- * optional stored bot kind.
- *
- * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
- * @returns {"feature"|"bug"|"provider-compatibility"|"documentation"|null}
- */
-function detectIssueKindFromContent(issue) {
- const { title = "", body = "", labels = [] } = issue;
- const titleLower = title.toLowerCase();
-
- // Provider compatibility: distinct headings.
- if (countHeadings(body, PROVIDER_HEADINGS) >= 3) return "provider-compatibility";
-
- // Documentation: distinct headings.
- if (countHeadings(body, DOCS_HEADINGS) >= 2) return "documentation";
-
- // New feature form: at least 2 of the 3 core headings.
- if (countHeadings(body, FEATURE_NEW_HEADINGS) >= 2) return "feature";
-
- // Translated / alternate feature headings (e.g. after issue-triage).
- // Require a feature-specific goal heading so common headings like
- // "Expected behaviour" cannot reclassify bug/freeform reports as features.
- // ([Feature]: prefix and enhancement labels are handled elsewhere.)
- if (
- countHeadings(body, FEATURE_ALIAS_DETECT_HEADINGS) >= 2 &&
- countHeadings(body, FEATURE_GOAL_HEADINGS) >= 1
- ) {
- return "feature";
- }
-
- // New bug form: Client or integration + Summary + Reproduction.
- if (
- extractSection(body, "Client or integration") !== null &&
- extractSection(body, "Summary") !== null &&
- extractSection(body, "Reproduction") !== null
- ) {
- return "bug";
- }
-
- // Legacy feature form: title prefix or old headings.
- if (titleLower.startsWith("[feature]:") || countHeadings(body, FEATURE_LEGACY_HEADINGS) >= 2) {
- return "feature";
- }
-
- // Legacy bug form: title prefix or old headings (Summary + Reproduction).
- if (titleLower.startsWith("[bug]:") || countHeadings(body, BUG_LEGACY_HEADINGS) >= 2) {
- // Only classify as bug when there is supporting evidence (label or prefix)
- // to avoid false positives on generic issues that happen to have those words.
- if (titleLower.startsWith("[bug]:") || labels.includes("bug")) return "bug";
- }
-
- return null;
-}
-
-/**
- * True when body evidence for `kind` is a full structured form, not merely a
- * title prefix or leftover label. Used to decide whether detected kind may
- * override a stored bot kind.
- */
-function hasStrongKindEvidence(kind, issue) {
- const { body = "" } = issue;
- switch (kind) {
- case "provider-compatibility":
- return countHeadings(body, PROVIDER_HEADINGS) >= 3;
- case "documentation":
- return countHeadings(body, DOCS_HEADINGS) >= 2;
- case "feature":
- return (
- countHeadings(body, FEATURE_NEW_HEADINGS) >= 2 ||
- countHeadings(body, FEATURE_LEGACY_HEADINGS) >= 2 ||
- (countHeadings(body, FEATURE_ALIAS_DETECT_HEADINGS) >= 2 &&
- countHeadings(body, FEATURE_GOAL_HEADINGS) >= 1)
- );
- case "bug":
- return (
- extractSection(body, "Client or integration") !== null &&
- extractSection(body, "Summary") !== null &&
- extractSection(body, "Reproduction") !== null
- );
- default:
- return false;
- }
-}
-
-/**
- * Detect the issue kind from body headings, title prefix, labels, and
- * optional stored bot kind.
- *
- * Stored kind survives heading removal (bypass protection). A different
- * detected kind overrides it only when the body has strong form evidence.
- *
- * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
- * @returns {"feature"|"bug"|"provider-compatibility"|"documentation"|null}
- */
-function detectIssueKind(issue) {
- const { storedKind } = issue;
- const detected = detectIssueKindFromContent(issue);
-
- if (storedKind) {
- if (
- detected &&
- detected !== storedKind &&
- hasStrongKindEvidence(detected, issue)
- ) {
- return detected;
- }
- return storedKind;
- }
-
- return detected;
-}
-
-// ---------------------------------------------------------------------------
-// Validation
-// ---------------------------------------------------------------------------
-
-function isEmpty(text) {
- const c = clean(text);
- if (c.length === 0) return true;
- // Stand-ins like "...", "…", "---" are not actionable report content.
- return /^[\p{P}\p{S}\s]+$/u.test(c);
-}
-
-function allSameCanonical(sections) {
- const cans = sections.map(canonicalise).filter(Boolean);
- if (cans.length < 2) return false;
- return cans.every((c) => c === cans[0]);
-}
-
-function allRepeatTitle(sections, title) {
- const titleCan = canonicalise(title);
- if (!titleCan) return false;
- const cans = sections.map(canonicalise).filter(Boolean);
- if (cans.length === 0) return false;
- return cans.every((c) => c === titleCan);
-}
-
-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);
-}
-
-const CJK_RE =
- /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
-
-function countWords(text) {
- const c = clean(text);
- if (!c) return 0;
-
- // Count each CJK character as one unit, and non-CJK scripts as Unicode
- // word tokens. Mixing one CJK glyph into a Latin/Cyrillic word must not
- // inflate the count to letter-length.
- const cjkChars = c.match(CJK_RE) || [];
- const nonCjkText = c.replace(CJK_RE, " ");
- const nonCjkTokens = nonCjkText.match(/[\p{L}\p{N}']+/gu) || [];
-
- return cjkChars.length + nonCjkTokens.length;
-}
-
-function hasConcreteDetail(text) {
- const c = clean(text);
- if (!c) return false;
- return (
- /\d/.test(c) ||
- /[`{}\[\]<>/\\]/.test(c) ||
- /\b(ocx|config|api|cli|dashboard|provider|proxy|route|endpoint|workflow|command)\b/i.test(c)
- );
-}
-
-function isTooTerseFeatureSection(text) {
- if (isEmpty(text) || isPlaceholder(text)) return false;
- const words = countWords(text);
- if (words >= 8) return false;
- if (words >= 6 && hasConcreteDetail(text)) return false;
- return true;
-}
-
-/**
- * Bug Reproduction needs concrete signals that let a maintainer reproduce the
- * failure. Product keywords alone (e.g. "choose model deepseek" or "send a
- * message in the codex plugin") are not actionable: the report must name a
- * command, an error, a file/config path, or an exact observed output.
- */
-// Commands and exact technical actions, e.g. "ocx start", "run bun",
-// "send a streaming request", "curl https://...".
-const REPRO_COMMAND_RE = new RegExp([
- "\\b(?:run|start|stop|restart|install|launch|execute|reproduce|trigger|invoke)\\s+(?:(?:the|an|a)\\s+)?(?:ocx|bun|npm|pnpm|yarn|curl|node|codex|proxy|server|dashboard|plugin)\\b",
- "\\b(?:ocx|bun|npm|pnpm|yarn|curl|node|codex)\\s+(?:start|run|stop|restart|install|config|--[a-z-]+)\\b",
- "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+(?:streaming|api|http|json|completion|chat|config|auth|embedding|post|graphql|grpc)\\s+(?:request|call|command|prompt|query)\\b",
- "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+(?:api|curl|endpoint|url)\\b",
- "\\b(?:send|issue|make|post)\\s+(?:a|an|any)\\s+[\\w.-]+\\s+request\\s+to\\s+(?:the\\s+)?(?:endpoint|url|api|server|proxy|\\S+/\\S+)\\b",
- "\\b(?:pip|npm|bun)\\s+install\\b",
- "\\b(?:curl|wget)\\s+[^\\s]+",
-].join("|"), "i");
-
-// Error, exception, and failure tokens, plus status codes in status context
-// (bare 3-digit numbers can be ports or version numbers).
-const REPRO_FAILURE_RE = new RegExp([
- "\\b(?:segfault|sigsegv|panic|abort|exception|traceback|stack\\s*trace|timeout|timed\\s*out|refused|reset|denied|failed?|error|crash|hang|hangs?|stuck|spinning|empty\\s*response)\\b",
- "\\b(?:status\\s*(?:code\\s*)?|code\\s*|http\\s*)(?:is|of|:)?\\s*[1-5]\\d\\d\\b",
-].join("|"), "i");
-
-// File, config, and log paths such as ~/.codex/config.toml or C:\\logs\\ocx.log.
-const REPRO_PATH_RE = new RegExp([
- "~?/[\\w.@-]+(?:/[\\w.@-]+)+",
- "[A-Za-z]:\\\\(?:[\\w.@-]+\\\\)+[\\w.@-]+",
- "~?/[\\w.@-]+/[\\w.@-]+\\.(?:json|yaml|yml|toml|conf|log|env|txt|ts|js|tsx|jsx|sh|ps1|py)",
- "[\\w.@-]+\\.(?:json|yaml|yml|toml|conf|log|env)\\b",
-].join("|"));
-const ACTIONABLE_REPRO_RE = new RegExp(
- [REPRO_COMMAND_RE.source, REPRO_FAILURE_RE.source, REPRO_PATH_RE.source].join("|"),
- "i",
-);
-
-// Sigil-only fences with no body content are never actionable.
-const EMPTY_FENCE_RE = /^[ \t]{0,3}(?:```+|~~~+)\s*\n\s*\n[ \t]{0,3}(?:```+|~~~+)\s*$/;
-
-/**
- * True when a bug Reproduction names commands, error tokens, file/config
- * paths, or exact technical actions. Product/model mentions without any of
- * those signals (e.g. #977) are treated as unactionable. Fenced blocks only
- * count as actionable when their body contains non-whitespace content.
- */
-function hasActionableReproductionDetail(text) {
- const c = clean(text);
- if (!c) return false;
- if (ACTIONABLE_REPRO_RE.test(c)) return true;
- // Fenced blocks: only count when the body has non-whitespace content.
- if (/```|~~~/.test(c)) {
- const parts = stripFencedActionableContent(c);
- if (parts) return true;
- }
- return false;
-}
-
-/**
- * Walk the text looking for a fenced code block whose body contains
- * non-whitespace content. Returns the non-empty body or null.
- */
-function stripFencedActionableContent(text) {
- const fenceRe = /^[ \t]{0,3}(`{3,}|~{3,})/;
- const lines = text.split("\n");
- let i = 0;
- while (i < lines.length) {
- const m = lines[i].match(fenceRe);
- if (!m) { i++; continue; }
- const marker = m[1];
- const markerLen = marker.length;
- // Find the closing fence on a later line.
- let j = i + 1;
- const endRe = new RegExp(
- `^[ \\t]{0,3}${marker[0] === "`" ? "`" : "~"}{${markerLen},}[ \\t]*$`,
- );
- while (j < lines.length && !endRe.test(lines[j])) j++;
- if (j > i + 1) {
- const body = lines.slice(i + 1, j).join("\n");
- if (body.trim()) return body;
- }
- i = j + 1;
- }
- return null;
-}
-
-function isTooTerseBugReproduction(text) {
- if (isEmpty(text) || isPlaceholder(text)) return false;
- if (hasActionableReproductionDetail(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
- * (legacy "No response" / N/A) from actively cleared required fields.
- */
-function isRawPlaceholder(raw) {
- if (raw === null) return false;
- return isPlaceholderOnlyValue(raw);
-}
-
-/**
- * Near-miss freeform headings that often appear in API-opened or copy-pasted
- * bug reports instead of the Bug report template (e.g. Description / Log entry).
- */
-const FREEFORM_BUG_NEAR_MISS_HEADINGS = [
- "Description",
+const REPRODUCTION_ALIASES = [
"Steps to reproduce",
"Steps to Reproduce",
"How to reproduce",
- "Log",
- "Logs",
- "Log entry",
- "Error",
+ "What fails / what passes",
+ "Debug evidence (ocx debug provider)",
+ "Debug evidence",
+ "Logs or error output",
"Error output",
"Stack trace",
];
-/**
- * True when an unclassified body looks like a bug report that skipped the
- * template (near-miss headings and/or repro/error signals).
- *
- * @param {{ title?: string, body?: string }} issue
- * @returns {boolean}
- */
-function looksLikeUntemplatedBugReport(issue) {
- const body = typeof issue?.body === "string" ? issue.body : "";
- if (!body.trim()) return false;
-
- const nearMissCount = countHeadings(body, FREEFORM_BUG_NEAR_MISS_HEADINGS);
- const hasReproduction =
- extractSection(body, "Reproduction") !== null ||
- extractSection(body, "Steps to reproduce") !== null ||
- extractSection(body, "Steps to Reproduce") !== null ||
- extractSection(body, "How to reproduce") !== null;
- const hasDescription = extractSection(body, "Description") !== null;
- const hasLogOrError =
- extractSection(body, "Log") !== null ||
- extractSection(body, "Logs") !== null ||
- extractSection(body, "Log entry") !== null ||
- extractSection(body, "Logs or error output") !== null ||
- extractSection(body, "Error") !== null ||
- extractSection(body, "Error output") !== null ||
- extractSection(body, "Stack trace") !== null;
+function extractEnvironmentField(environment, names) {
+ if (environment == null) return null;
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
- if (hasDescription && (hasReproduction || hasLogOrError)) return true;
- if (hasReproduction && hasLogOrError) return true;
- if (nearMissCount >= 2) return true;
-
- // Body-level signals for heading-free freeform dumps.
- const signalRe =
- /\b(repro(?:duce|duction| steps)?|stack\s*traces?|traceback|segfault|panic|exception|error\s*output|ECONNREFUSED|SIGSEGV)\b/i;
- if (signalRe.test(body) && (hasDescription || hasReproduction || hasLogOrError || nearMissCount >= 1)) {
- return true;
+ for (const rawLine of String(environment).split(/\r?\n/)) {
+ const line = rawLine.replace(/^\s*[-*+]\s+/, "").trim();
+ const match = line.match(/^([^:]+):\s*(.+)$/);
+ if (!match) continue;
+ if (wanted.has(match[1].trim().toLowerCase())) return match[2].trim();
}
- return false;
-}
-/**
- * Reasons/guidance when no structured issue kind was detected.
- *
- * @param {{ title?: string, body?: string }} issue
- * @returns {{ reasons: string[], guidance: string[] }}
- */
-function untemplatedIssueFailure(issue) {
- if (looksLikeUntemplatedBugReport(issue)) {
- return {
- reasons: [
- "This looks like a bug report but it does not use the Bug report template headings (for example Description/Log entry instead of Summary).",
- ],
- guidance: [
- "Use the Bug report template, or edit this issue to include: Client or integration, Summary, Reproduction, Version, and Operating system.",
- "Retitling with `[Bug]:` and applying the `bug` label alone is not enough without those section headings filled in.",
- ],
- };
- }
- return {
- reasons: [
- "This issue does not use a recognized issue template.",
- ],
- guidance: [
- "Open a new issue with the Bug report, Feature request, Documentation, or Provider compatibility template.",
- "Or edit this issue so the body uses the template section headings for the kind of report you are filing.",
- ],
- };
+ return null;
}
-/**
- * Validate an issue body for its detected kind.
- *
- * Unclassified (freeform / non-template) issues are invalid so API-opened
- * reports cannot skip the quality gate. Trusted-author exemption is enforced
- * by the workflow, not here.
- *
- * @param {{ title: string, body: string, labels: string[], storedKind?: string|null }} issue
- * @returns {{ kind: string|null, valid: boolean, softPass: boolean, reasons: string[], guidance: string[] }}
- */
-function validateIssue(issue) {
- const { title = "", body = "" } = issue;
- const kind = detectIssueKind(issue);
- const reasons = [];
- const guidance = [];
- let softPass = false;
-
- if (!kind) {
- const failure = untemplatedIssueFailure(issue);
- return {
- kind: null,
- valid: false,
- softPass: false,
- reasons: failure.reasons,
- guidance: failure.guidance,
- };
- }
-
- if (kind === "feature") {
- const goal = resolveSection(body, FEATURE_GOAL_HEADINGS);
- const blocker = resolveSection(body, FEATURE_BLOCKER_HEADINGS);
- const behaviour = resolveSection(body, FEATURE_BEHAVIOUR_HEADINGS);
- const example = resolveSection(body, FEATURE_EXAMPLE_HEADINGS);
-
- const coreSections = [goal, blocker, behaviour, example];
- const emptyCore = [];
- if (isEmpty(goal)) emptyCore.push("goal / problem");
- // blocker and example are only required when those headings exist.
- // On the legacy / translated forms these sections may be absent (null).
- if (blocker !== null && isEmpty(blocker)) emptyCore.push("current limitation");
- if (isEmpty(behaviour)) emptyCore.push("expected behaviour");
- if (example !== null && isPlaceholder(example)) {
- reasons.push("Example usage or interface contains placeholder text instead of a concrete example.");
- guidance.push("Add a real CLI command, config snippet, API exchange, or before/after workflow example.");
- } else if (example !== null && isEmpty(example)) {
- emptyCore.push("example usage");
- }
-
- const mappedHeadingPresent =
- goal !== null || blocker !== null || behaviour !== null || example !== null;
-
- if (emptyCore.length > 0) {
- // Soft-pass rich non-template bodies once kind is already feature (title
- // prefix, enhancement label, or stored kind). Do not require the title to
- // keep a `[Feature]:` prefix — maintainer retitles must not re-arm closure.
- const canSoftPass =
- !mappedHeadingPresent &&
- hasSubstantialStructuredContent(body);
- if (canSoftPass) {
- softPass = true;
- } else {
- reasons.push(`Required sections are missing or empty: ${emptyCore.join(", ")}.`);
- guidance.push("Fill in each required section with specific detail about your workflow.");
- }
- }
-
- if (!softPass) {
- const nonEmpty = coreSections.filter((s) => !isEmpty(s));
- if (nonEmpty.length >= 2 && allSameCanonical(nonEmpty)) {
- reasons.push("All core sections contain the same content.");
- guidance.push("Each section should describe a different aspect: goal, limitation, expected behaviour, and a concrete example.");
- }
-
- if (nonEmpty.length >= 2 && allRepeatTitle(nonEmpty, title)) {
- reasons.push("All core sections merely repeat the issue title.");
- guidance.push("Expand each section with details beyond the title.");
- }
-
- if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
- reasons.push("Required sections contain only placeholder text.");
- guidance.push("Replace placeholder text with your actual proposal.");
- }
- }
-
- const terseSections = [];
- if (goal !== null && isTooTerseFeatureSection(goal)) terseSections.push("goal / problem");
- if (blocker !== null && isTooTerseFeatureSection(blocker)) terseSections.push("current limitation");
- if (behaviour !== null && isTooTerseFeatureSection(behaviour)) terseSections.push("expected behaviour");
- if (terseSections.length > 0) {
- reasons.push(`Required sections are too vague to act on: ${terseSections.join(", ")}.`);
- guidance.push("Describe the workflow, limitation, and expected behaviour with enough detail for someone to implement or evaluate the request.");
- }
- }
-
- if (kind === "bug") {
- const summary = extractSection(body, "Summary");
- 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
- // kind is already bug (label, stored kind, or prior `[Bug]:` detection).
- // Requiring the title to keep a `[Bug]:` prefix caused #545: a maintainer
- // retitle of an already detailed report was treated as empty Summary/
- // Reproduction and auto-closed.
- const canSoftPass =
- summary === null &&
- repro === null &&
- hasSubstantialStructuredContent(body);
- if (canSoftPass) {
- softPass = true;
- } else {
- reasons.push("Both Summary and Reproduction are empty.");
- guidance.push("Describe what happened and how to reproduce it.");
- }
- } else {
- // Each mapped field is required on its own — a filled Summary with an
- // empty / ellipsis Reproduction (e.g. #598) must not pass.
- if (isEmpty(summary)) {
- reasons.push("Summary is empty.");
- guidance.push("Describe what happened (the symptom or error).");
- }
- 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.");
- }
- }
-
- // 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 (including when the heading was removed).
- // 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.");
- }
+function appendSection(body, heading, value) {
+ if (value == null || String(value).trim() === "") return body;
+ return `${String(body || "").trimEnd()}\n\n### ${heading}\n${String(value).trim()}\n`;
+}
- 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).");
- }
+function normalizeEquivalentBugEvidence(issue) {
+ if (!issue || typeof issue !== "object") return issue;
- // 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.");
- }
+ const body = String(issue.body || "");
+ // Limit alias normalization to the current bug form. Legacy/freeform reports
+ // retain the existing enforcement behavior.
+ if (core.extractSection(body, "Client or integration") === null) return issue;
- if (!softPass) {
- const nonEmpty = [summary, repro].filter((s) => !isEmpty(s));
- if (nonEmpty.length >= 2 && allSameCanonical(nonEmpty)) {
- reasons.push("Summary and Reproduction contain the same content.");
- guidance.push("Summary should describe the symptom; Reproduction should list the exact steps.");
- }
+ let normalized = body;
- if (nonEmpty.length >= 1 && allRepeatTitle(nonEmpty, title)) {
- reasons.push("Summary and Reproduction merely repeat the title.");
- guidance.push("Add detail beyond the title: what you observed, what you expected, and the exact steps.");
- }
+ if (core.extractSection(normalized, "Reproduction") === null) {
+ const evidence = REPRODUCTION_ALIASES
+ .map((heading) => core.extractSection(body, heading))
+ .filter((section) => section != null && String(section).trim() !== "")
+ .join("\n\n");
- if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
- reasons.push("Required sections contain only placeholder text.");
- guidance.push("Replace placeholder text with your actual report.");
- }
+ // Alias headings only count when they contain the same concrete signals the
+ // canonical Reproduction field already requires (commands/errors/paths/etc.).
+ if (evidence && core.hasActionableReproductionDetail(evidence)) {
+ normalized = appendSection(normalized, "Reproduction", evidence);
}
}
- if (kind === "provider-compatibility") {
- const current = extractSection(body, "Current behaviour");
- const expected = extractSection(body, "Expected behaviour");
- const repro = extractSection(body, "Minimal redacted request or reproduction");
- const response = extractSection(body, "Actual response or error");
- const docs = extractSection(body, "Upstream documentation");
-
- const emptyCore = [];
- if (isEmpty(current)) emptyCore.push("current behaviour");
- if (isEmpty(expected)) emptyCore.push("expected behaviour");
- // Metadata fields: provider, version, endpoint are required on the form.
- const provider = extractSection(body, "Provider or upstream service");
- const version = extractSection(body, "OpenCodex version");
- const endpoint = extractSection(body, "Endpoint or capability");
- if (provider !== null && isEmpty(provider)) emptyCore.push("provider or upstream service");
- if (version !== null && isRawPlaceholder(version) === false && isEmpty(version)) emptyCore.push("OpenCodex version");
- if (endpoint !== null && isEmpty(endpoint)) emptyCore.push("endpoint or capability");
- if (emptyCore.length > 0) {
- // Same soft-pass as bug/feature: label- or maintainer-scoped provider
- // reports often use non-English structured headings after a retitle.
- const mappedHeadingPresent =
- current !== null || expected !== null || repro !== null || response !== null || docs !== null ||
- provider !== null || version !== null || endpoint !== null;
- const canSoftPass =
- !mappedHeadingPresent &&
- hasSubstantialStructuredContent(body);
- if (canSoftPass) {
- softPass = true;
- } else {
- reasons.push(`Required sections are missing or empty: ${emptyCore.join(", ")}.`);
- guidance.push("Describe both the current and expected behaviour.");
- }
- }
-
- if (!softPass && !isEmpty(current) && !isEmpty(expected) && canonicalise(current) === canonicalise(expected)) {
- reasons.push("Current and expected behaviour are effectively identical.");
- guidance.push("Explain the difference between what happens now and what should happen.");
- }
-
- const allSections = [current, expected, repro, response].filter((s) => !isEmpty(s));
- if (!softPass && allSections.length >= 2 && allRepeatTitle(allSections, title)) {
- reasons.push("All sections merely repeat the issue title.");
- guidance.push("Add specific detail in each section.");
- }
+ const environment = core.extractSection(body, "Environment");
- if (!softPass && isEmpty(repro) && isEmpty(response)) {
- reasons.push("Both the request/reproduction and the actual response/error are absent.");
- guidance.push("Include at least a minimal redacted request or the actual error output.");
- }
-
- if (!softPass && isEmpty(docs)) {
- reasons.push("Upstream documentation is empty without stating that no public specification exists.");
- guidance.push("Add a URL to the provider specification, or state that no public spec exists.");
- }
+ if (core.extractSection(normalized, "Version") === null) {
+ // Do not accept a generic dependency "Version" from Environment: the gate
+ // specifically needs the OpenCodex install version.
+ const version = extractEnvironmentField(environment, ["OpenCodex", "OpenCodex version"]);
+ if (version) normalized = appendSection(normalized, "Version", version);
}
- if (kind === "documentation") {
- const location = extractSection(body, "Documentation location");
- const problem = extractSection(body, "What is wrong or missing?");
- const expected = extractSection(body, "What should the documentation explain instead?");
-
- if (isEmpty(location) && isEmpty(problem)) {
- reasons.push("Documentation location and problem description are both missing.");
- guidance.push("Point to the exact documentation page and describe what is wrong.");
- }
-
- const nonEmpty = [location, problem, expected].filter((s) => !isEmpty(s));
- if (nonEmpty.length >= 1 && allRepeatTitle(nonEmpty, title)) {
- reasons.push("The body merely repeats the title.");
- guidance.push("Add detail: the exact URL or path, what is wrong, and what it should say.");
- }
-
- if (nonEmpty.length > 0 && nonEmpty.every(isPlaceholder)) {
- reasons.push("Required sections contain only placeholder text.");
- guidance.push("Replace placeholder text with the actual documentation problem.");
- }
+ if (
+ core.extractSection(normalized, "Operating system") === null &&
+ core.extractSection(normalized, "OS") === null
+ ) {
+ const os = extractEnvironmentField(environment, ["OS", "Operating system"]);
+ if (os) normalized = appendSection(normalized, "Operating system", os);
}
- return {
- kind,
- valid: reasons.length === 0 && !softPass,
- softPass,
- reasons,
- guidance,
- };
+ return normalized === body ? issue : { ...issue, body: normalized };
}
-// ---------------------------------------------------------------------------
-// Closure ownership
-// ---------------------------------------------------------------------------
-
-/**
- * Decide whether the bot may auto-close an invalid issue.
- *
- * After a maintainer reopens and deactivates enforcement, later `edited`
- * events must not close the issue again.
- *
- * @param {{ active?: boolean, maintainerOverride?: boolean }|null|undefined} botState
- * @returns {boolean}
- */
-function shouldEnforceClosure(botState) {
- if (botState && botState.maintainerOverride === true) return false;
- return true;
-}
-
-/**
- * Decide whether the bot may reopen a closed issue.
- *
- * @param {{ active: boolean, closedAt: string|null, stateReason: string }} botState
- * @param {{ state: string, closed_at: string|null, state_reason: string|null, closed_by?: string|null }} issue
- * @param {boolean} maintainerOverride True when a maintainer changed the issue state after the bot.
- * @returns {boolean}
- */
-function shouldReopen(botState, issue, maintainerOverride) {
- if (!botState || !botState.active) return false;
- if (issue.state !== "closed") return false;
- if (maintainerOverride) return false;
- if (issue.closed_at !== botState.closedAt) return false;
- if (issue.state_reason !== botState.stateReason) return false;
- // Only reopen if the bot itself was the last actor to close the issue.
- // A human closing it (even with the same timestamp) means intentional closure.
- if (issue.closed_by && issue.closed_by !== "github-actions[bot]") return false;
- return true;
-}
-
-/**
- * workflow_dispatch accepts a bare issue number, but GitHub reuses the same
- * number namespace for issues and pull requests. Reject PR targets before any
- * validation or mutation runs.
- *
- * @param {{ pull_request?: unknown }} issue
- * @param {number|string} issueNumber
- * @param {string} eventName
- * @returns {string|null}
- */
-function rejectsWorkflowDispatchPullRequest(issue, issueNumber, eventName) {
- if (eventName !== "workflow_dispatch") return null;
- if (!issue?.pull_request) return null;
- return `#${issueNumber} is a pull request. This workflow only accepts issue numbers.`;
+function detectIssueKind(issue) {
+ return core.detectIssueKind(normalizeEquivalentBugEvidence(issue));
}
-/**
- * workflow_dispatch can be started from a selected branch. Reject runs whose
- * selected ref is not the repository default branch so untrusted branch code
- * cannot drive issue mutations with issues:write.
- *
- * @param {string} eventName
- * @param {string|null|undefined} ref
- * @param {string|null|undefined} defaultBranch
- * @returns {string|null}
- */
-function rejectsWorkflowDispatchNonDefaultBranch(eventName, ref, defaultBranch) {
- if (eventName !== "workflow_dispatch") return null;
- if (!defaultBranch || typeof defaultBranch !== "string") {
- return "workflow_dispatch requires repository.default_branch to be available.";
- }
- const expected = `refs/heads/${defaultBranch}`;
- if (ref !== expected) {
- return (
- `workflow_dispatch must run from the default branch (${defaultBranch}); ` +
- `selected ref was ${ref || "(empty)"}.`
- );
- }
- return null;
+function validateIssue(issue) {
+ return core.validateIssue(normalizeEquivalentBugEvidence(issue));
}
-// ---------------------------------------------------------------------------
-// Exports
-// ---------------------------------------------------------------------------
-
module.exports = {
- clean,
- normalise,
- canonicalise,
- stripMediaTokens,
- isMediaOnly,
- extractSection,
- resolveSection,
+ ...core,
detectIssueKind,
validateIssue,
- looksLikeUntemplatedBugReport,
- shouldReopen,
- shouldEnforceClosure,
- isPlaceholderOnlyValue,
- isPlaceholder,
- isRawPlaceholder,
- isUnusableVersion,
- countWords,
- hasConcreteDetail,
- hasActionableReproductionDetail,
- labelForKind,
- KIND_TO_LABEL,
- AREA_LABELS,
- AREA_FIELD_TO_LABELS,
- mapAreaFieldToLabels,
- bodyForAreaHeuristics,
- heuristicAreaLabels,
- detectAreaLabels,
- hasSubstantialStructuredContent,
- rejectsWorkflowDispatchPullRequest,
- rejectsWorkflowDispatchNonDefaultBranch,
+ normalizeEquivalentBugEvidence,
};
From 293100867c98bba36e3cb58b06203de8d317a1a5 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:03:33 +0200
Subject: [PATCH 4/9] test(issue-quality): preserve strict alias validation
---
...e-quality-equivalent-bug-evidence.test.cjs | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
diff --git a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
index f9f980a54b..b7f571cc3a 100644
--- a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
+++ b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
@@ -36,3 +36,74 @@ request failed after the first streamed frame
assert.equal(result.valid, true, result.reasons.join("\n"));
});
+
+test("does not treat vague alternative headings as actionable reproduction", () => {
+ const result = validateIssue({
+ title: "[Bug]: Cursor model does not work",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+The selected Cursor model does not complete a request through the Claude Code integration.
+
+### Environment
+- OpenCodex: 2.10.2
+- OS: Linux
+
+### What fails / what passes
+It does not work.
+`,
+ });
+
+ assert.equal(result.valid, false);
+ assert.match(result.reasons.join("\n"), /Reproduction/i);
+});
+
+test("still rejects an unknown OpenCodex version from Environment", () => {
+ const result = validateIssue({
+ title: "[Bug]: Cursor request fails",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+A Cursor request fails after the proxy starts streaming a response through Claude Code.
+
+### Environment
+- OpenCodex: unknown
+- OS: Linux
+
+### Debug evidence
+Run \`ocx debug provider cursor\`; it returns \`resource_exhausted\` after stream start.
+`,
+ });
+
+ assert.equal(result.valid, false);
+ assert.match(result.reasons.join("\n"), /Version/i);
+});
+
+test("still rejects missing OS metadata from Environment", () => {
+ const result = validateIssue({
+ title: "[Bug]: Cursor request fails",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+A Cursor request fails after the proxy starts streaming a response through Claude Code.
+
+### Environment
+- OpenCodex: 2.10.2
+
+### Debug evidence
+Run \`ocx debug provider cursor\`; it returns \`resource_exhausted\` after stream start.
+`,
+ });
+
+ assert.equal(result.valid, false);
+ assert.match(result.reasons.join("\n"), /Operating system/i);
+});
From 27e681b4c8c4c28c693e3d81ce90809f5a852848 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:10:00 +0200
Subject: [PATCH 5/9] test(ci): watch issue-quality core changes
---
.github/workflows/issue-quality-tests.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml
index 81d1cd4ab0..dbc8f5341f 100644
--- a/.github/workflows/issue-quality-tests.yml
+++ b/.github/workflows/issue-quality-tests.yml
@@ -5,6 +5,7 @@ on:
paths:
- ".github/ISSUE_TEMPLATE/**"
- ".github/scripts/issue-quality.cjs"
+ - ".github/scripts/issue-quality-core.cjs"
- ".github/scripts/issue-quality*.test.cjs"
- ".github/scripts/pr-quality.cjs"
- ".github/scripts/pr-quality.test.cjs"
@@ -34,6 +35,7 @@ on:
paths:
- ".github/ISSUE_TEMPLATE/**"
- ".github/scripts/issue-quality.cjs"
+ - ".github/scripts/issue-quality-core.cjs"
- ".github/scripts/issue-quality*.test.cjs"
- ".github/scripts/pr-quality.cjs"
- ".github/scripts/pr-quality.test.cjs"
From 272416f5befd276c15d70e8de4ae91b36f9d6544 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:22:30 +0200
Subject: [PATCH 6/9] fix(issue-quality): normalize equivalent bug evidence
safely
---
.github/scripts/issue-quality.cjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs
index 24b14c3633..9b38b6b06b 100644
--- a/.github/scripts/issue-quality.cjs
+++ b/.github/scripts/issue-quality.cjs
@@ -7,7 +7,6 @@ const core = require("./issue-quality-core.cjs");
const REPRODUCTION_ALIASES = [
"Steps to reproduce",
- "Steps to Reproduce",
"How to reproduce",
"What fails / what passes",
"Debug evidence (ocx debug provider)",
@@ -25,7 +24,8 @@ function extractEnvironmentField(environment, names) {
const line = rawLine.replace(/^\s*[-*+]\s+/, "").trim();
const match = line.match(/^([^:]+):\s*(.+)$/);
if (!match) continue;
- if (wanted.has(match[1].trim().toLowerCase())) return match[2].trim();
+ const key = match[1].replace(/[*_`~]/g, "").trim().toLowerCase();
+ if (wanted.has(key)) return match[2].trim();
}
return null;
From 3a85bdcdf861936673f2217b6ee8493f30a07f65 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:22:46 +0200
Subject: [PATCH 7/9] test(issue-quality): cover CodeRabbit regressions
---
...e-quality-equivalent-bug-evidence.test.cjs | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
diff --git a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
index b7f571cc3a..ac6b8091db 100644
--- a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
+++ b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
@@ -37,6 +37,54 @@ request failed after the first streamed frame
assert.equal(result.valid, true, result.reasons.join("\n"));
});
+test("accepts emphasized Environment keys", () => {
+ const result = validateIssue({
+ title: "[Bug]: Cursor request fails after stream start",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+The Cursor request fails after streaming starts through the Claude Code integration.
+
+### Environment
+- **OpenCodex**: 2.10.2
+- **OS**: Linux (Ubuntu x64)
+
+### Debug evidence
+Run \`ocx debug provider cursor\`; it returns \`resource_exhausted\` after stream start.
+`,
+ });
+
+ assert.equal(result.valid, true, result.reasons.join("\n"));
+});
+
+test("does not duplicate case-insensitive reproduction aliases", () => {
+ const repeated = "Run `ocx start` and observe the proxy error after the request fails.";
+ const result = validateIssue({
+ title: "[Bug]: Proxy request fails",
+ labels: ["bug"],
+ body: `
+### Client or integration
+Claude Code
+
+### Summary
+${repeated}
+
+### Environment
+- OpenCodex: 2.10.2
+- OS: Linux
+
+### Steps to Reproduce
+${repeated}
+`,
+ });
+
+ assert.equal(result.valid, false);
+ assert.match(result.reasons.join("\n"), /same content/i);
+});
+
test("does not treat vague alternative headings as actionable reproduction", () => {
const result = validateIssue({
title: "[Bug]: Cursor model does not work",
From 21d9f526dfc3bd4af02f893300ffde6d83e6ec30 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:25:50 +0200
Subject: [PATCH 8/9] fix(issue-quality): address review findings in core
---
.github/scripts/issue-quality-core.cjs | 28 ++++----------------------
1 file changed, 4 insertions(+), 24 deletions(-)
diff --git a/.github/scripts/issue-quality-core.cjs b/.github/scripts/issue-quality-core.cjs
index 1c0a6430a7..817624f46f 100644
--- a/.github/scripts/issue-quality-core.cjs
+++ b/.github/scripts/issue-quality-core.cjs
@@ -159,14 +159,6 @@ function stripMarkdownImages(text) {
const out = [];
let i = 0;
while (i < text.length) {
- // Inside an indented code block (4+ leading spaces or a tab), image
- // syntax is literal code, not a rendered image. Leave it untouched so a
- // section that documents example syntax is not emptied.
- if (isInsideIndentedCode(text, i)) {
- out.push(text[i]);
- i += 1;
- continue;
- }
// A backslash-escaped or code-fenced `![` is not an image token. We only
// guard the common `\!` escape here; fenced blocks are handled by the
// section extractor upstream, which does not include them in sections.
@@ -184,17 +176,6 @@ function stripMarkdownImages(text) {
return out.join("");
}
-/**
- * True when `index` sits inside an indented code block, i.e. on a line that
- * starts with four or more spaces or a tab. Such lines render as literal
- * code in GitHub Markdown.
- */
-function isInsideIndentedCode(text, index) {
- const lineStart = text.lastIndexOf("\n", index - 1) + 1;
- const prefix = text.slice(lineStart, index);
- return /^(?: {4,}|\t)/.test(prefix);
-}
-
/**
* Strip reference-style Markdown images: inline references `![alt][ref]`
* and the reference definitions `[ref]: https://...` they point at. These
@@ -1166,7 +1147,6 @@ function isRawPlaceholder(raw) {
const FREEFORM_BUG_NEAR_MISS_HEADINGS = [
"Description",
"Steps to reproduce",
- "Steps to Reproduce",
"How to reproduce",
"Log",
"Logs",
@@ -1191,7 +1171,6 @@ function looksLikeUntemplatedBugReport(issue) {
const hasReproduction =
extractSection(body, "Reproduction") !== null ||
extractSection(body, "Steps to reproduce") !== null ||
- extractSection(body, "Steps to Reproduce") !== null ||
extractSection(body, "How to reproduce") !== null;
const hasDescription = extractSection(body, "Description") !== null;
const hasLogOrError =
@@ -1539,10 +1518,11 @@ function validateIssue(issue) {
/**
* Decide whether the bot may auto-close an invalid issue.
*
- * After a maintainer reopens and deactivates enforcement, later `edited`
- * events must not close the issue again.
+ * Only an explicit maintainer override disables future closure enforcement.
+ * A normal `active: false` state is intentionally re-armable if a later edit
+ * makes the issue invalid again.
*
- * @param {{ active?: boolean, maintainerOverride?: boolean }|null|undefined} botState
+ * @param {{ maintainerOverride?: boolean }|null|undefined} botState
* @returns {boolean}
*/
function shouldEnforceClosure(botState) {
From 5da6a3c939f4ad67502494f13c61114069489259 Mon Sep 17 00:00:00 2001
From: Wibias <37517432+Wibias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:27:33 +0200
Subject: [PATCH 9/9] test(issue-quality): make env regression actionable
---
.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
index ac6b8091db..35b5b54898 100644
--- a/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
+++ b/.github/scripts/issue-quality-equivalent-bug-evidence.test.cjs
@@ -53,7 +53,7 @@ The Cursor request fails after streaming starts through the Claude Code integrat
- **OS**: Linux (Ubuntu x64)
### Debug evidence
-Run \`ocx debug provider cursor\`; it returns \`resource_exhausted\` after stream start.
+Run ocx debug provider cursor; the request fails with \`resource_exhausted\` after stream start.
`,
});