From fe1bc880b269d7b1d2257d65ebab3406fd214552 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:33:12 +0530 Subject: [PATCH] fix: harden issue triage automation --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/close-stale-issues.yml | 27 ++++++++++--- .github/workflows/close-unlabeled-issues.yml | 15 +++++++ .github/workflows/stale-needs-info.yml | 17 ++++++++ .github/workflows/triage-needs-info.yml | 41 ++++++++++++++++---- 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ef13405bc..e1f3880e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: Please replace the default title with a short summary of the actual problem. Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed after 1 day if the missing details are not added. - Bug reports left open for more than 30 days may be closed as stale. + Bug reports with no activity for more than 30 days may be closed as stale. If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml index c0723d01e..c1611ac8f 100644 --- a/.github/workflows/close-stale-issues.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,4 +1,4 @@ -name: Close stale bug issues +name: Close inactive bug issues on: schedule: @@ -18,7 +18,7 @@ jobs: close_stale: runs-on: ubuntu-latest steps: - - name: Close bug issues open longer than 30 days + - name: Close bug issues inactive longer than 30 days uses: actions/github-script@v7 with: script: | @@ -35,15 +35,32 @@ jobs: .slice(0, 10); const items = await github.paginate(github.rest.search.issuesAndPullRequests, { - q: `repo:${owner}/${repo} is:issue is:open label:bug created:<${cutoff}`, + // Staleness is inactivity, not issue age. Do not close an old issue + // that has had recent discussion or edits. + q: `repo:${owner}/${repo} is:issue is:open label:bug updated:<${cutoff}`, per_page: 100, }); - core.info(`Found ${items.length} open bug issues older than ${CLOSE_AFTER_DAYS} days.`); + core.info(`Found ${items.length} bug issues inactive for ${CLOSE_AFTER_DAYS} days.`); + + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } for (const item of items) { const issue_number = item.number; + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issue_number}: would comment and close.`); continue; @@ -63,7 +80,7 @@ jobs: if (!alreadyCommented) { const body = `${closeMarker}\n` + - `Closing this bug report because it has been open for more than 30 days.\n\n` + + `Closing this bug report because it has had no activity for more than 30 days.\n\n` + `If this is still relevant, please open a fresh issue with the latest details.`; await github.rest.issues.createComment({ diff --git a/.github/workflows/close-unlabeled-issues.yml b/.github/workflows/close-unlabeled-issues.yml index 90e31b653..77b27a2d7 100644 --- a/.github/workflows/close-unlabeled-issues.yml +++ b/.github/workflows/close-unlabeled-issues.yml @@ -36,9 +36,24 @@ jobs: core.info(`Found ${items.length} open unlabeled issues.`); + async function hasBeenReopened(issueNumber) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + for (const item of items) { const issueNumber = item.number; + if (await hasBeenReopened(issueNumber)) { + core.info(`#${issueNumber}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issueNumber}: would comment and close.`); continue; diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml index 97fce1207..5ff74736e 100644 --- a/.github/workflows/stale-needs-info.yml +++ b/.github/workflows/stale-needs-info.yml @@ -32,11 +32,28 @@ jobs: return github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 }); } + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + const items = await listOpenNeedsInfoIssues(); for (const item of items) { const issue_number = item.number; const updatedAtMs = new Date(item.updated_at).getTime(); + // Reopening is an explicit human decision that overrides automated + // closure. Never close a currently open issue that was reopened. + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index bf699cdf5..3bd29f38f 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -2,7 +2,9 @@ name: Triage (needs-info) on: issues: - types: [opened, edited, reopened] + # A reopen is an explicit human triage decision. Do not run automatic + # closure/needs-info rules again after a maintainer reopens an issue. + types: [opened, edited] permissions: issues: write @@ -24,6 +26,19 @@ jobs: const body = issue.body || ""; const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase()); + // An edit can happen after a human has reopened an issue. Preserve + // that explicit triage decision instead of closing it again. + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + if (events.some(event => event.event === "reopened")) { + core.info(`#${issue_number}: skipping triage because it was manually reopened.`); + return; + } + const NEEDS_INFO = "needs-info"; const NEEDS_INFO_COLOR = "d4c5f9"; const NEEDS_INFO_DESC = "More details needed to reproduce / triage."; @@ -72,12 +87,14 @@ jobs: function isEmptyish(value) { const text = normalizedMeaningfulText(value); - return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); + return !text || + !/[\p{L}\p{N}]/u.test(text) || + /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); } function isVague(value) { const text = normalizedMeaningfulText(value); - return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); + return /^(same|same issue|same as above|see above|me too|not working|doesn't work|doesnt work|does not work|nothing happens|broken|bug|issue|problem|error|failed|failure|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); } function hasUsefulText(value, minimumLength) { @@ -119,16 +136,26 @@ jobs: const isCrashIssue = crashPattern.test(crashContext); const normalizedLogs = normalizedMeaningfulText(logs); const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs); + const hasDetailedDescription = hasUsefulText(description, 80); if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) { problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)"); } - if (!hasUsefulText(description, 50)) { + if (!hasUsefulText(description, 20)) { problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")"); } - if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)"); - if (!hasUsefulText(expected, 10)) problems.push("Expected behavior"); - if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)"); + // Some valid bugs only need one short action (for example, "Login" or + // "Open Continue Watching"). Judge the report as a whole instead of + // imposing arbitrary per-field character counts. + if (!hasUsefulText(steps, 5) && !hasDetailedDescription) { + problems.push("Steps to reproduce (please list exact steps)"); + } + if (!hasUsefulText(expected, 3) && !hasDetailedDescription) { + problems.push("Expected behavior"); + } + if (!hasUsefulText(actual, 3) && !hasDetailedDescription) { + problems.push("Actual behavior (include any on-screen error text)"); + } async function ensureLabel(name, color, description) { try {