From 6e5138b5a4afc032c0747ecebda5cbaab421b270 Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 21 May 2026 17:51:22 -0500 Subject: [PATCH 1/7] feat(ci): scheduled QA-stuck issue check with Slack notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a workflow that runs every 3 days at 13:00 UTC, queries the dotCMS - Product Planning project (#7) for issues in QA/Done status that lack a QA : Passed / QA : Not Needed label and have not changed in 3+ days, and pings the responsible team's Slack channel. Team is resolved from the issue's "Team : " label; team→channel mapping lives in .claude/triage-config.json. Manual dispatch supports dry-run (default) so the rendered messages can be reviewed before any real Slack post. If no issues qualify, the notify job is skipped and no message is sent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/triage-config.json | 4 + .../qa-stuck-check/find-stuck-issues.js | 200 ++++++++++++++++++ .../qa-stuck-check/test-find-stuck-issues.js | 187 ++++++++++++++++ .../cicd_scheduled_qa-stuck-check.yml | 141 ++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 .github/scripts/qa-stuck-check/find-stuck-issues.js create mode 100644 .github/scripts/qa-stuck-check/test-find-stuck-issues.js create mode 100644 .github/workflows/cicd_scheduled_qa-stuck-check.yml diff --git a/.claude/triage-config.json b/.claude/triage-config.json index f574d73dab2..16d919f0843 100644 --- a/.claude/triage-config.json +++ b/.claude/triage-config.json @@ -1,6 +1,7 @@ { "teams": { "Team : Falcon": { + "slack_channel": "CQMHM7PNJ", "members": ["oidacra", "erickgonzalez", "nicobytes", "jcastro-dotcms", "freddyDOTCMS", "adrianjm-dotCMS"], "areas": [ "core-web/apps/dotcms-ui", @@ -22,6 +23,7 @@ ] }, "Team : Scout": { + "slack_channel": "CQNF9PCFQ", "members": ["nollymar", "fabrizzio-dotCMS", "msfreeman982", "zJaaal", "rjvelazco", "KevinDavilaDotCMS", "dario-daza"], "areas": [ "core-web/apps/dotcms-block-editor", @@ -46,10 +48,12 @@ ] }, "Team : Maintenance": { + "slack_channel": "C04UKBYG7SA", "members": ["dsilvam", "dsolistorres", "gortiz-dotcms"], "areas": [] }, "Team : Modernization": { + "slack_channel": "C09LGH4HNR5", "members": ["hmoreras", "hassandotcms"], "areas": [] } diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js new file mode 100644 index 00000000000..43b25ab8ed4 --- /dev/null +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -0,0 +1,200 @@ +// Finds issues stuck in QA/Done on the dotCMS - Product Planning project. +// An issue is "stuck" when: +// - Its project Status is one of TARGET_STATUSES (QA, Done) +// - It does NOT carry any SKIP_LABELS (QA : Passed, QA : Not Needed) +// - The ProjectV2Item has not changed in at least STUCK_DAYS days +// - It carries a "Team : " label that maps to a team configured with +// a slack_channel in .claude/triage-config.json +// +// Groups results by team for downstream Slack posting. +// +// Invoked from actions/github-script; gets { github, core, ... }. +// Outputs (core.setOutput): +// groups_json JSON array of { team, slack_channel, issues: [...] } +// has_results 'true' | 'false' +// summary markdown summary for the job log + +const fs = require('fs'); +const path = require('path'); + +const PROJECT_OWNER = process.env.PROJECT_OWNER || 'dotCMS'; +const PROJECT_NUMBER = parseInt(process.env.PROJECT_NUMBER || '7', 10); +const STATUS_FIELD_NAME = process.env.STATUS_FIELD_NAME || 'Status'; +const TARGET_STATUSES = (process.env.TARGET_STATUSES || 'QA,Done') + .split(',') + .map((s) => s.trim().toLowerCase()); +const SKIP_LABELS = (process.env.SKIP_LABELS || 'QA : Passed,QA : Not Needed') + .split(',') + .map((s) => s.trim().toLowerCase()); +const TEAM_LABEL_PREFIX = (process.env.TEAM_LABEL_PREFIX || 'Team : ') + .toLowerCase(); +const STUCK_DAYS = parseInt(process.env.STUCK_DAYS || '3', 10); +const TRIAGE_CONFIG_PATH = + process.env.TRIAGE_CONFIG_PATH || '.claude/triage-config.json'; + +module.exports = async ({ github, core }) => { + const triageConfig = JSON.parse( + fs.readFileSync(path.resolve(TRIAGE_CONFIG_PATH), 'utf8'), + ); + const teamChannels = buildTeamChannelIndex(triageConfig); + + const items = await fetchAllProjectItems(github); + core.info(`Fetched ${items.length} project items from #${PROJECT_NUMBER}`); + + const cutoff = new Date(Date.now() - STUCK_DAYS * 24 * 60 * 60 * 1000); + const stuck = []; + + for (const item of items) { + const issue = item.content; + if (!issue || issue.__typename !== 'Issue') continue; + + const status = readStatus(item); + if (!status || !TARGET_STATUSES.includes(status.toLowerCase())) continue; + + const labels = (issue.labels?.nodes || []).map((l) => l.name); + const labelsLower = labels.map((l) => l.toLowerCase()); + if (labelsLower.some((l) => SKIP_LABELS.includes(l))) continue; + + const itemUpdatedAt = item.updatedAt ? new Date(item.updatedAt) : null; + if (!itemUpdatedAt || itemUpdatedAt > cutoff) continue; + + const team = resolveTeamFromLabels(labels, teamChannels); + if (!team) continue; + + const daysStuck = Math.floor( + (Date.now() - itemUpdatedAt.getTime()) / (24 * 60 * 60 * 1000), + ); + + stuck.push({ + team, + issue: { + number: issue.number, + title: issue.title, + url: issue.url, + status, + labels, + assignees: (issue.assignees?.nodes || []).map((a) => a.login), + itemUpdatedAt: itemUpdatedAt.toISOString(), + daysStuck, + }, + }); + } + + const grouped = groupByTeam(stuck, teamChannels); + core.setOutput('groups_json', JSON.stringify(grouped)); + core.setOutput('has_results', grouped.length > 0 ? 'true' : 'false'); + core.setOutput('summary', buildSummary(grouped, STUCK_DAYS)); + core.info( + `Found ${stuck.length} stuck issue(s) across ${grouped.length} team(s)`, + ); +}; + +function buildTeamChannelIndex(triageConfig) { + const idx = {}; + for (const [team, cfg] of Object.entries(triageConfig.teams || {})) { + if (cfg.slack_channel) idx[team.toLowerCase()] = { team, channel: cfg.slack_channel }; + } + return idx; +} + +function resolveTeamFromLabels(labels, teamChannels) { + for (const label of labels) { + const lower = label.toLowerCase(); + if (!lower.startsWith(TEAM_LABEL_PREFIX)) continue; + const hit = teamChannels[lower]; + if (hit) return hit.team; + } + return null; +} + +function readStatus(item) { + const node = (item.fieldValues?.nodes || []).find( + (n) => n.field && n.field.name === STATUS_FIELD_NAME, + ); + return node ? node.name : null; +} + +function groupByTeam(stuck, teamChannels) { + const byTeam = {}; + for (const entry of stuck) { + if (!byTeam[entry.team]) byTeam[entry.team] = []; + byTeam[entry.team].push(entry.issue); + } + return Object.entries(byTeam).map(([team, issues]) => ({ + team, + slack_channel: teamChannels[team.toLowerCase()].channel, + issues: issues.sort((a, b) => b.daysStuck - a.daysStuck), + })); +} + +function buildSummary(groups, stuckDays) { + if (groups.length === 0) { + return `No issues stuck in QA for ${stuckDays}+ days.`; + } + const lines = [`# QA-stuck issues (${stuckDays}+ days since last project update)`, '']; + for (const g of groups) { + lines.push(`## ${g.team} → ${g.slack_channel} (${g.issues.length})`); + for (const e of g.issues) { + const assignees = e.assignees.length + ? ` · assignees: ${e.assignees.map((a) => '@' + a).join(', ')}` + : ''; + lines.push( + `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · ${e.daysStuck}d stuck${assignees}`, + ); + } + lines.push(''); + } + return lines.join('\n'); +} + +async function fetchAllProjectItems(github) { + const all = []; + let cursor = null; + do { + const data = await github.graphql(PROJECT_ITEMS_QUERY, { + org: PROJECT_OWNER, + number: PROJECT_NUMBER, + cursor, + }); + const items = data.organization.projectV2.items; + all.push(...items.nodes); + cursor = items.pageInfo.hasNextPage ? items.pageInfo.endCursor : null; + } while (cursor); + return all; +} + +const PROJECT_ITEMS_QUERY = ` + query($org: String!, $number: Int!, $cursor: String) { + organization(login: $org) { + projectV2(number: $number) { + items(first: 50, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + updatedAt + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + content { + __typename + ... on Issue { + number + title + url + state + assignees(first: 10) { nodes { login } } + labels(first: 30) { nodes { name } } + } + } + } + } + } + } + } +`; \ No newline at end of file diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js new file mode 100644 index 00000000000..267eb3ac00f --- /dev/null +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -0,0 +1,187 @@ +// Smoke test for find-stuck-issues.js. Runs the script with a stubbed +// `github.graphql` and asserts which issues are flagged. +// Usage: node .github/scripts/qa-stuck-check/test-find-stuck-issues.js + +const path = require('path'); +const fs = require('fs'); +const assert = require('assert'); + +process.env.PROJECT_OWNER = 'dotCMS'; +process.env.PROJECT_NUMBER = '7'; +process.env.STUCK_DAYS = '3'; +process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; + +const run = require('./find-stuck-issues.js'); + +const now = Date.now(); +const daysAgo = (n) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString(); + +const project = { + organization: { + projectV2: { + items: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + // KEEP: QA, no QA labels, 5d stale, Team:Scout + mkItem({ + id: 'a', + updatedAt: daysAgo(5), + status: 'QA', + number: 100, + title: 'Stuck in QA - Scout', + labels: ['Team : Scout', 'priority:high'], + assignees: ['nollymar'], + }), + // SKIP: has QA : Passed + mkItem({ + id: 'b', + updatedAt: daysAgo(10), + status: 'Done', + number: 101, + title: 'Already passed QA', + labels: ['Team : Falcon', 'QA : Passed'], + assignees: [], + }), + // SKIP: status is In Progress + mkItem({ + id: 'c', + updatedAt: daysAgo(7), + status: 'In Progress', + number: 102, + title: 'Not in QA yet', + labels: ['Team : Falcon'], + assignees: [], + }), + // SKIP: only 1 day stale + mkItem({ + id: 'd', + updatedAt: daysAgo(1), + status: 'QA', + number: 103, + title: 'Too fresh', + labels: ['Team : Maintenance'], + assignees: [], + }), + // SKIP: no Team label that matches config + mkItem({ + id: 'e', + updatedAt: daysAgo(20), + status: 'Done', + number: 104, + title: 'No team label', + labels: [], + assignees: [], + }), + // KEEP: Done, 4d stale, Team:Modernization + mkItem({ + id: 'f', + updatedAt: daysAgo(4), + status: 'Done', + number: 105, + title: 'Done but no QA verification - Modernization', + labels: ['Team : Modernization'], + assignees: ['hmoreras'], + }), + // SKIP: QA : Not Needed + mkItem({ + id: 'g', + updatedAt: daysAgo(8), + status: 'Done', + number: 106, + title: 'No QA needed', + labels: ['Team : Scout', 'QA : Not Needed'], + assignees: [], + }), + // KEEP: QA, 6d stale, Team:Scout (so Scout gets 2 issues) + mkItem({ + id: 'h', + updatedAt: daysAgo(6), + status: 'QA', + number: 107, + title: 'Older Scout QA', + labels: ['Team : Scout'], + assignees: ['rjvelazco'], + }), + ], + }, + }, + }, +}; + +function mkItem({ id, updatedAt, status, number, title, labels, assignees }) { + return { + id, + updatedAt, + fieldValues: { + nodes: [ + { + __typename: 'ProjectV2ItemFieldSingleSelectValue', + name: status, + field: { name: 'Status' }, + }, + ], + }, + content: { + __typename: 'Issue', + number, + title, + url: `https://github.com/dotCMS/core/issues/${number}`, + state: status === 'Done' ? 'CLOSED' : 'OPEN', + assignees: { nodes: assignees.map((login) => ({ login })) }, + labels: { nodes: labels.map((name) => ({ name })) }, + }, + }; +} + +const captured = {}; +const fakeCore = { + info: (m) => console.log('[info]', m), + setOutput: (k, v) => { + captured[k] = v; + }, +}; +const fakeGithub = { + graphql: async () => project, +}; + +(async () => { + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + console.log('\n--- groups ---'); + console.log(JSON.stringify(groups, null, 2)); + console.log('\n--- summary ---'); + console.log(captured.summary); + + assert.strictEqual(captured.has_results, 'true', 'expected results'); + const byTeam = Object.fromEntries(groups.map((g) => [g.team, g])); + + assert.ok(byTeam['Team : Scout'], 'Scout group present'); + assert.strictEqual( + byTeam['Team : Scout'].issues.length, + 2, + 'Scout has 2 issues', + ); + assert.strictEqual(byTeam['Team : Scout'].slack_channel, 'CQNF9PCFQ'); + + assert.ok(byTeam['Team : Modernization'], 'Modernization group present'); + assert.strictEqual(byTeam['Team : Modernization'].issues.length, 1); + assert.strictEqual( + byTeam['Team : Modernization'].slack_channel, + 'C09LGH4HNR5', + ); + + assert.ok(!byTeam['Team : Falcon'], 'Falcon excluded (skipped + not stuck)'); + assert.ok( + !byTeam['Team : Maintenance'], + 'Maintenance excluded (too fresh)', + ); + + // Scout's most-stuck issue should be #107 (6d) sorted before #100 (5d) + assert.strictEqual(byTeam['Team : Scout'].issues[0].number, 107); + + console.log('\nAll assertions passed.'); +})().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/.github/workflows/cicd_scheduled_qa-stuck-check.yml b/.github/workflows/cicd_scheduled_qa-stuck-check.yml new file mode 100644 index 00000000000..35e1f508d38 --- /dev/null +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -0,0 +1,141 @@ +name: 'QA Stuck Check' + +on: + schedule: + # Every 3 days at 13:00 UTC (~9am ET during EDT, 8am ET during EST). + - cron: '0 13 */3 * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Print the messages to the job summary instead of posting to Slack' + type: boolean + required: false + default: true + stuck_days: + description: 'Override days-stuck threshold (default 3)' + type: string + required: false + default: '3' + +permissions: + contents: read + issues: read + +env: + PROJECT_OWNER: dotCMS + PROJECT_NUMBER: '7' + STATUS_FIELD_NAME: 'Status' + TARGET_STATUSES: 'QA,Done' + SKIP_LABELS: 'QA : Passed,QA : Not Needed' + TEAM_LABEL_PREFIX: 'Team : ' + STUCK_DAYS: ${{ inputs.stuck_days || '3' }} + +jobs: + find-stuck-issues: + name: Find stuck QA issues + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + outputs: + groups_json: ${{ steps.find.outputs.groups_json }} + has_results: ${{ steps.find.outputs.has_results }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/scripts/qa-stuck-check + .claude/triage-config.json + sparse-checkout-cone-mode: false + + - name: Find stuck issues + id: find + uses: actions/github-script@v7 + env: + PROJECT_OWNER: ${{ env.PROJECT_OWNER }} + PROJECT_NUMBER: ${{ env.PROJECT_NUMBER }} + STATUS_FIELD_NAME: ${{ env.STATUS_FIELD_NAME }} + TARGET_STATUSES: ${{ env.TARGET_STATUSES }} + SKIP_LABELS: ${{ env.SKIP_LABELS }} + TEAM_LABEL_PREFIX: ${{ env.TEAM_LABEL_PREFIX }} + STUCK_DAYS: ${{ env.STUCK_DAYS }} + with: + # CI_MACHINE_TOKEN is the org-scoped PAT used elsewhere (e.g. the + # slack-channel-resolver workflow). It needs read:project to read + # org-owned ProjectV2 boards; the default GITHUB_TOKEN cannot. + github-token: ${{ secrets.CI_MACHINE_TOKEN }} + retries: 3 + script: | + const run = require('./.github/scripts/qa-stuck-check/find-stuck-issues.js'); + await run({ github, core }); + + - name: Write summary + if: always() && steps.find.outputs.summary + env: + SUMMARY: ${{ steps.find.outputs.summary }} + run: | + echo "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" + + notify-teams: + name: Notify team ${{ matrix.group.team }} + needs: find-stuck-issues + if: needs.find-stuck-issues.outputs.has_results == 'true' + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + strategy: + fail-fast: false + matrix: + group: ${{ fromJSON(needs.find-stuck-issues.outputs.groups_json) }} + steps: + - name: Build Slack message + id: build + uses: actions/github-script@v7 + env: + GROUP_JSON: ${{ toJSON(matrix.group) }} + STUCK_DAYS: ${{ env.STUCK_DAYS }} + with: + script: | + const group = JSON.parse(process.env.GROUP_JSON); + const stuckDays = process.env.STUCK_DAYS; + const count = group.issues.length; + const header = `:hourglass_flowing_sand: *${group.team}* — *${count}* issue${count === 1 ? '' : 's'} stuck in QA for ${stuckDays}+ days`; + const lines = group.issues.map((i) => { + const assignees = i.assignees && i.assignees.length + ? ` (cc ${i.assignees.map((a) => '@' + a).join(', ')})` + : ''; + return `• <${i.url}|#${i.number}> ${i.title} — _${i.status}_, ${i.daysStuck}d${assignees}`; + }); + const text = [header, '', ...lines, '', 'Please prioritize QA review or apply *QA : Passed* / *QA : Not Needed* to clear.'].join('\n'); + core.setOutput('text', text); + core.setOutput('channel', group.slack_channel); + + - name: Dry-run (print only) + if: inputs.dry_run == true + env: + CHANNEL: ${{ steps.build.outputs.channel }} + TEXT: ${{ steps.build.outputs.text }} + run: | + { + echo "### Would post to channel \`$CHANNEL\`" + echo '' + echo '```' + echo "$TEXT" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Post to Slack + if: inputs.dry_run != true + env: + CHANNEL: ${{ steps.build.outputs.channel }} + TEXT: ${{ steps.build.outputs.text }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: | + payload=$(jq -nc --arg channel "$CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text, unfurl_links: false, unfurl_media: false}') + response=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H 'Content-Type: application/json; charset=utf-8' \ + -d "$payload") + ok=$(jq -r '.ok' <<<"$response") + if [[ "$ok" != "true" ]]; then + echo "Slack post failed: $response" + exit 1 + fi + echo "Posted to $CHANNEL" \ No newline at end of file From 93afc3ff9dae9e7b68c48ff73842e591356abf4c Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 21 May 2026 18:07:36 -0500 Subject: [PATCH 2/7] fix(ci): address review on qa-stuck-check - Cron now '0 13 * * 1,4' (Mon/Thu) instead of '*/3' day-of-month, which produced uneven cadence across month boundaries. - Bumped GraphQL caps: fieldValues 20->50, labels 30->50, assignees 10->20, so the Status / Team labels can't silently fall off the page. - Added a Status-field presence assertion: fails the run if fewer than half of items expose a readable Status (surfaces schema mismatches in dry-run instead of returning empty results). - Slack message wording now says "no project activity for Nd" / "last project update Nd ago" rather than "stuck for Nd", reflecting that ProjectV2Item.updatedAt is a proxy and any project-field edit resets the clock. Same caveat in the job summary. - Dropped "(cc @user)" framing: Slack rendered it as plain text, not an actual mention. Assignees are still listed for context. - Multiple "Team : X" labels on one issue now report the issue under each matched team (previously only the first label-order match got notified). - Added a guard for projectV2 == null (renamed project / token without read:project scope) so dry-runs fail with a clear error rather than a "Cannot read properties of null" stack. - stuck_days is now embedded on each group; matrix builder no longer needs to re-resolve the env var. - Test suite expanded to cover: empty project, multi-team labels, missing Status field (fail loudly), partial-missing Status (warn, continue), null projectV2, and a team configured without a slack channel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../qa-stuck-check/find-stuck-issues.js | 155 +++++--- .../qa-stuck-check/test-find-stuck-issues.js | 370 +++++++++++------- .../cicd_scheduled_qa-stuck-check.yml | 24 +- 3 files changed, 341 insertions(+), 208 deletions(-) diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js index 43b25ab8ed4..f8355fe9e18 100644 --- a/.github/scripts/qa-stuck-check/find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -1,16 +1,24 @@ -// Finds issues stuck in QA/Done on the dotCMS - Product Planning project. -// An issue is "stuck" when: -// - Its project Status is one of TARGET_STATUSES (QA, Done) -// - It does NOT carry any SKIP_LABELS (QA : Passed, QA : Not Needed) -// - The ProjectV2Item has not changed in at least STUCK_DAYS days -// - It carries a "Team : " label that maps to a team configured with -// a slack_channel in .claude/triage-config.json +// Finds project items in QA/Done that have not seen any project-field change +// in STUCK_DAYS days on the dotCMS - Product Planning project. // -// Groups results by team for downstream Slack posting. +// IMPORTANT: the metric is "ProjectV2Item.updatedAt is N+ days ago". The GitHub +// Projects v2 GraphQL API does not expose status-change history, so this is a +// proxy for "no QA progress". Any project-field edit (Priority, Iteration, an +// automation move, etc.) resets the clock. This intentionally undercounts +// rather than overcounts — the Slack message wording reflects that. // -// Invoked from actions/github-script; gets { github, core, ... }. +// Filters applied per project item: +// - content is an Issue +// - project Status is one of TARGET_STATUSES (case-insensitive) +// - none of SKIP_LABELS are present +// - item.updatedAt is at least STUCK_DAYS ago +// - the issue has one or more "Team : " labels that map to a team +// with a slack_channel in .claude/triage-config.json. If the issue has +// multiple matching team labels, it is reported under EACH team. +// +// Invoked from actions/github-script; gets { github, core }. // Outputs (core.setOutput): -// groups_json JSON array of { team, slack_channel, issues: [...] } +// groups_json JSON array of { team, slack_channel, stuck_days, issues: [...] } // has_results 'true' | 'false' // summary markdown summary for the job log @@ -32,17 +40,23 @@ const STUCK_DAYS = parseInt(process.env.STUCK_DAYS || '3', 10); const TRIAGE_CONFIG_PATH = process.env.TRIAGE_CONFIG_PATH || '.claude/triage-config.json'; +// If fewer than this fraction of project items have a Status field value, we +// assume the GraphQL `fieldValues(first:N)` cap is hiding it and fail loudly. +const STATUS_PRESENCE_MIN_RATIO = 0.5; + module.exports = async ({ github, core }) => { const triageConfig = JSON.parse( fs.readFileSync(path.resolve(TRIAGE_CONFIG_PATH), 'utf8'), ); const teamChannels = buildTeamChannelIndex(triageConfig); - const items = await fetchAllProjectItems(github); + const items = await fetchAllProjectItems(github, core); core.info(`Fetched ${items.length} project items from #${PROJECT_NUMBER}`); + assertStatusFieldVisible(items, core); + const cutoff = new Date(Date.now() - STUCK_DAYS * 24 * 60 * 60 * 1000); - const stuck = []; + const stuckByTeam = new Map(); for (const item of items) { const issue = item.content; @@ -58,53 +72,68 @@ module.exports = async ({ github, core }) => { const itemUpdatedAt = item.updatedAt ? new Date(item.updatedAt) : null; if (!itemUpdatedAt || itemUpdatedAt > cutoff) continue; - const team = resolveTeamFromLabels(labels, teamChannels); - if (!team) continue; + const matchedTeams = resolveTeamsFromLabels(labels, teamChannels); + if (matchedTeams.length === 0) continue; const daysStuck = Math.floor( (Date.now() - itemUpdatedAt.getTime()) / (24 * 60 * 60 * 1000), ); - - stuck.push({ - team, - issue: { - number: issue.number, - title: issue.title, - url: issue.url, - status, - labels, - assignees: (issue.assignees?.nodes || []).map((a) => a.login), - itemUpdatedAt: itemUpdatedAt.toISOString(), - daysStuck, - }, - }); + const issueRecord = { + number: issue.number, + title: issue.title, + url: issue.url, + status, + labels, + assignees: (issue.assignees?.nodes || []).map((a) => a.login), + itemUpdatedAt: itemUpdatedAt.toISOString(), + daysStuck, + }; + + for (const match of matchedTeams) { + if (!stuckByTeam.has(match.team)) { + stuckByTeam.set(match.team, { channel: match.channel, issues: [] }); + } + stuckByTeam.get(match.team).issues.push(issueRecord); + } } - const grouped = groupByTeam(stuck, teamChannels); + const grouped = Array.from(stuckByTeam.entries()).map(([team, v]) => ({ + team, + slack_channel: v.channel, + stuck_days: STUCK_DAYS, + issues: v.issues.sort((a, b) => b.daysStuck - a.daysStuck), + })); + core.setOutput('groups_json', JSON.stringify(grouped)); core.setOutput('has_results', grouped.length > 0 ? 'true' : 'false'); core.setOutput('summary', buildSummary(grouped, STUCK_DAYS)); core.info( - `Found ${stuck.length} stuck issue(s) across ${grouped.length} team(s)`, + `Found ${grouped.reduce((n, g) => n + g.issues.length, 0)} issue-team pairing(s) across ${grouped.length} team(s)`, ); }; function buildTeamChannelIndex(triageConfig) { const idx = {}; for (const [team, cfg] of Object.entries(triageConfig.teams || {})) { - if (cfg.slack_channel) idx[team.toLowerCase()] = { team, channel: cfg.slack_channel }; + if (cfg.slack_channel) { + idx[team.toLowerCase()] = { team, channel: cfg.slack_channel }; + } } return idx; } -function resolveTeamFromLabels(labels, teamChannels) { +function resolveTeamsFromLabels(labels, teamChannels) { + const matches = []; + const seen = new Set(); for (const label of labels) { const lower = label.toLowerCase(); if (!lower.startsWith(TEAM_LABEL_PREFIX)) continue; const hit = teamChannels[lower]; - if (hit) return hit.team; + if (!hit || seen.has(hit.team)) continue; + seen.add(hit.team); + matches.push(hit); } - return null; + return matches; } function readStatus(item) { @@ -114,32 +143,44 @@ function readStatus(item) { return node ? node.name : null; } -function groupByTeam(stuck, teamChannels) { - const byTeam = {}; - for (const entry of stuck) { - if (!byTeam[entry.team]) byTeam[entry.team] = []; - byTeam[entry.team].push(entry.issue); +function assertStatusFieldVisible(items, core) { + if (items.length === 0) return; + const withStatus = items.filter((i) => readStatus(i) !== null).length; + const ratio = withStatus / items.length; + if (ratio < STATUS_PRESENCE_MIN_RATIO) { + const msg = + `Only ${withStatus}/${items.length} project items expose a "${STATUS_FIELD_NAME}" field value. ` + + 'Either the field is named differently or it falls outside the GraphQL fieldValues(first:N) page. ' + + 'Bump the cap in the query or update STATUS_FIELD_NAME.'; + core.setFailed(msg); + throw new Error(msg); + } + if (withStatus < items.length) { + core.warning( + `${items.length - withStatus} project item(s) had no readable "${STATUS_FIELD_NAME}" field value and were ignored.`, + ); } - return Object.entries(byTeam).map(([team, issues]) => ({ - team, - slack_channel: teamChannels[team.toLowerCase()].channel, - issues: issues.sort((a, b) => b.daysStuck - a.daysStuck), - })); } function buildSummary(groups, stuckDays) { if (groups.length === 0) { - return `No issues stuck in QA for ${stuckDays}+ days.`; + return `No QA/Done issues with ${stuckDays}+ days of project inactivity.`; } - const lines = [`# QA-stuck issues (${stuckDays}+ days since last project update)`, '']; + const total = groups.reduce((n, g) => n + g.issues.length, 0); + const lines = [ + `# QA/Done issues with no project activity for ${stuckDays}+ days`, + '', + `_Total: ${total} issue-team pairing(s) across ${groups.length} team(s). Metric: ProjectV2Item.updatedAt — any project-field edit resets the clock._`, + '', + ]; for (const g of groups) { lines.push(`## ${g.team} → ${g.slack_channel} (${g.issues.length})`); for (const e of g.issues) { const assignees = e.assignees.length - ? ` · assignees: ${e.assignees.map((a) => '@' + a).join(', ')}` + ? ` · assignees: ${e.assignees.join(', ')}` : ''; lines.push( - `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · ${e.daysStuck}d stuck${assignees}`, + `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · last project update ${e.daysStuck}d ago${assignees}`, ); } lines.push(''); @@ -147,7 +188,7 @@ function buildSummary(groups, stuckDays) { return lines.join('\n'); } -async function fetchAllProjectItems(github) { +async function fetchAllProjectItems(github, core) { const all = []; let cursor = null; do { @@ -156,7 +197,15 @@ async function fetchAllProjectItems(github) { number: PROJECT_NUMBER, cursor, }); - const items = data.organization.projectV2.items; + const project = data?.organization?.projectV2; + if (!project) { + const msg = + `Could not read projectV2 #${PROJECT_NUMBER} for org "${PROJECT_OWNER}". ` + + 'Either the project does not exist or the token lacks the read:project scope.'; + core.setFailed(msg); + throw new Error(msg); + } + const items = project.items; all.push(...items.nodes); cursor = items.pageInfo.hasNextPage ? items.pageInfo.endCursor : null; } while (cursor); @@ -172,7 +221,7 @@ const PROJECT_ITEMS_QUERY = ` nodes { id updatedAt - fieldValues(first: 20) { + fieldValues(first: 50) { nodes { __typename ... on ProjectV2ItemFieldSingleSelectValue { @@ -188,8 +237,8 @@ const PROJECT_ITEMS_QUERY = ` title url state - assignees(first: 10) { nodes { login } } - labels(first: 30) { nodes { name } } + assignees(first: 20) { nodes { login } } + labels(first: 50) { nodes { name } } } } } diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js index 267eb3ac00f..e88332edd7f 100644 --- a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -1,187 +1,263 @@ -// Smoke test for find-stuck-issues.js. Runs the script with a stubbed -// `github.graphql` and asserts which issues are flagged. +// Smoke tests for find-stuck-issues.js. Stubs `github.graphql` and asserts +// what gets flagged / grouped under each scenario. +// // Usage: node .github/scripts/qa-stuck-check/test-find-stuck-issues.js -const path = require('path'); -const fs = require('fs'); const assert = require('assert'); +const path = require('path'); -process.env.PROJECT_OWNER = 'dotCMS'; -process.env.PROJECT_NUMBER = '7'; -process.env.STUCK_DAYS = '3'; -process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; - -const run = require('./find-stuck-issues.js'); +const SCRIPT_PATH = path.resolve( + __dirname, + './find-stuck-issues.js', +); const now = Date.now(); const daysAgo = (n) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString(); -const project = { - organization: { - projectV2: { - items: { - pageInfo: { hasNextPage: false, endCursor: null }, +function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField }) { + const fieldValues = omitStatusField + ? { nodes: [] } + : { nodes: [ - // KEEP: QA, no QA labels, 5d stale, Team:Scout - mkItem({ - id: 'a', - updatedAt: daysAgo(5), - status: 'QA', - number: 100, - title: 'Stuck in QA - Scout', - labels: ['Team : Scout', 'priority:high'], - assignees: ['nollymar'], - }), - // SKIP: has QA : Passed - mkItem({ - id: 'b', - updatedAt: daysAgo(10), - status: 'Done', - number: 101, - title: 'Already passed QA', - labels: ['Team : Falcon', 'QA : Passed'], - assignees: [], - }), - // SKIP: status is In Progress - mkItem({ - id: 'c', - updatedAt: daysAgo(7), - status: 'In Progress', - number: 102, - title: 'Not in QA yet', - labels: ['Team : Falcon'], - assignees: [], - }), - // SKIP: only 1 day stale - mkItem({ - id: 'd', - updatedAt: daysAgo(1), - status: 'QA', - number: 103, - title: 'Too fresh', - labels: ['Team : Maintenance'], - assignees: [], - }), - // SKIP: no Team label that matches config - mkItem({ - id: 'e', - updatedAt: daysAgo(20), - status: 'Done', - number: 104, - title: 'No team label', - labels: [], - assignees: [], - }), - // KEEP: Done, 4d stale, Team:Modernization - mkItem({ - id: 'f', - updatedAt: daysAgo(4), - status: 'Done', - number: 105, - title: 'Done but no QA verification - Modernization', - labels: ['Team : Modernization'], - assignees: ['hmoreras'], - }), - // SKIP: QA : Not Needed - mkItem({ - id: 'g', - updatedAt: daysAgo(8), - status: 'Done', - number: 106, - title: 'No QA needed', - labels: ['Team : Scout', 'QA : Not Needed'], - assignees: [], - }), - // KEEP: QA, 6d stale, Team:Scout (so Scout gets 2 issues) - mkItem({ - id: 'h', - updatedAt: daysAgo(6), - status: 'QA', - number: 107, - title: 'Older Scout QA', - labels: ['Team : Scout'], - assignees: ['rjvelazco'], - }), + { + __typename: 'ProjectV2ItemFieldSingleSelectValue', + name: status, + field: { name: 'Status' }, + }, ], - }, - }, - }, -}; - -function mkItem({ id, updatedAt, status, number, title, labels, assignees }) { + }; return { id, updatedAt, - fieldValues: { - nodes: [ - { - __typename: 'ProjectV2ItemFieldSingleSelectValue', - name: status, - field: { name: 'Status' }, - }, - ], - }, + fieldValues, content: { __typename: 'Issue', number, title, url: `https://github.com/dotCMS/core/issues/${number}`, state: status === 'Done' ? 'CLOSED' : 'OPEN', - assignees: { nodes: assignees.map((login) => ({ login })) }, - labels: { nodes: labels.map((name) => ({ name })) }, + assignees: { nodes: (assignees || []).map((login) => ({ login })) }, + labels: { nodes: (labels || []).map((name) => ({ name })) }, }, }; } -const captured = {}; -const fakeCore = { - info: (m) => console.log('[info]', m), - setOutput: (k, v) => { - captured[k] = v; - }, -}; -const fakeGithub = { - graphql: async () => project, -}; +function freshModule() { + // find-stuck-issues.js reads env vars at module load, so re-require it after + // resetting envs in each test. + delete require.cache[SCRIPT_PATH]; + return require(SCRIPT_PATH); +} + +function makeFakes(items) { + const captured = {}; + let failed = null; + const warnings = []; + const fakeCore = { + info: (m) => console.log('[info]', m), + warning: (m) => { + warnings.push(m); + console.log('[warn]', m); + }, + setFailed: (m) => { + failed = m; + console.log('[failed]', m); + }, + setOutput: (k, v) => { + captured[k] = v; + }, + }; + const fakeGithub = { + graphql: async () => ({ + organization: { + projectV2: { + items: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: items, + }, + }, + }, + }), + }; + return { fakeCore, fakeGithub, captured, warnings, get failed() { return failed; } }; +} + +async function testMainScenario() { + console.log('\n=== testMainScenario ==='); + process.env.PROJECT_OWNER = 'dotCMS'; + process.env.PROJECT_NUMBER = '7'; + process.env.STUCK_DAYS = '3'; + process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; + + const run = freshModule(); + const items = [ + mkItem({ id: 'a', updatedAt: daysAgo(5), status: 'QA', number: 100, title: 'Stuck in QA - Scout', labels: ['Team : Scout', 'priority:high'], assignees: ['nollymar'] }), + mkItem({ id: 'b', updatedAt: daysAgo(10), status: 'Done', number: 101, title: 'Already passed QA', labels: ['Team : Falcon', 'QA : Passed'] }), + mkItem({ id: 'c', updatedAt: daysAgo(7), status: 'In Progress', number: 102, title: 'Not in QA yet', labels: ['Team : Falcon'] }), + mkItem({ id: 'd', updatedAt: daysAgo(1), status: 'QA', number: 103, title: 'Too fresh', labels: ['Team : Maintenance'] }), + mkItem({ id: 'e', updatedAt: daysAgo(20), status: 'Done', number: 104, title: 'No team label', labels: [] }), + mkItem({ id: 'f', updatedAt: daysAgo(4), status: 'Done', number: 105, title: 'Done but no QA - Modernization', labels: ['Team : Modernization'], assignees: ['hmoreras'] }), + mkItem({ id: 'g', updatedAt: daysAgo(8), status: 'Done', number: 106, title: 'No QA needed', labels: ['Team : Scout', 'QA : Not Needed'] }), + mkItem({ id: 'h', updatedAt: daysAgo(6), status: 'QA', number: 107, title: 'Older Scout QA', labels: ['Team : Scout'], assignees: ['rjvelazco'] }), + ]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); -(async () => { await run({ github: fakeGithub, core: fakeCore }); const groups = JSON.parse(captured.groups_json); - console.log('\n--- groups ---'); - console.log(JSON.stringify(groups, null, 2)); - console.log('\n--- summary ---'); - console.log(captured.summary); - - assert.strictEqual(captured.has_results, 'true', 'expected results'); const byTeam = Object.fromEntries(groups.map((g) => [g.team, g])); + assert.strictEqual(captured.has_results, 'true'); assert.ok(byTeam['Team : Scout'], 'Scout group present'); - assert.strictEqual( - byTeam['Team : Scout'].issues.length, - 2, - 'Scout has 2 issues', - ); + assert.strictEqual(byTeam['Team : Scout'].issues.length, 2); assert.strictEqual(byTeam['Team : Scout'].slack_channel, 'CQNF9PCFQ'); - - assert.ok(byTeam['Team : Modernization'], 'Modernization group present'); + assert.strictEqual(byTeam['Team : Scout'].stuck_days, 3); assert.strictEqual(byTeam['Team : Modernization'].issues.length, 1); - assert.strictEqual( - byTeam['Team : Modernization'].slack_channel, - 'C09LGH4HNR5', + assert.strictEqual(byTeam['Team : Modernization'].slack_channel, 'C09LGH4HNR5'); + assert.ok(!byTeam['Team : Falcon']); + assert.ok(!byTeam['Team : Maintenance']); + assert.strictEqual(byTeam['Team : Scout'].issues[0].number, 107); + + console.log('OK'); +} + +async function testEmptyProject() { + console.log('\n=== testEmptyProject ==='); + const run = freshModule(); + const { fakeCore, fakeGithub, captured } = makeFakes([]); + await run({ github: fakeGithub, core: fakeCore }); + + assert.strictEqual(captured.has_results, 'false'); + assert.strictEqual(JSON.parse(captured.groups_json).length, 0); + assert.match(captured.summary, /No QA\/Done issues/); + console.log('OK'); +} + +async function testMultipleTeamLabels() { + console.log('\n=== testMultipleTeamLabels ==='); + const run = freshModule(); + const items = [ + mkItem({ id: 'm', updatedAt: daysAgo(7), status: 'QA', number: 200, title: 'Cross-team issue', labels: ['Team : Scout', 'Team : Falcon'], assignees: [] }), + ]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + const byTeam = Object.fromEntries(groups.map((g) => [g.team, g])); + + assert.strictEqual(groups.length, 2, 'reported to both teams'); + assert.strictEqual(byTeam['Team : Scout'].issues[0].number, 200); + assert.strictEqual(byTeam['Team : Falcon'].issues[0].number, 200); + console.log('OK'); +} + +async function testMissingStatusFailsLoudly() { + console.log('\n=== testMissingStatusFailsLoudly ==='); + const run = freshModule(); + // 4 of 5 items have no Status -> ratio < 0.5 -> setFailed + throw + const items = [ + mkItem({ id: '1', updatedAt: daysAgo(5), omitStatusField: true, number: 300, title: 'no status', labels: ['Team : Scout'] }), + mkItem({ id: '2', updatedAt: daysAgo(5), omitStatusField: true, number: 301, title: 'no status', labels: ['Team : Scout'] }), + mkItem({ id: '3', updatedAt: daysAgo(5), omitStatusField: true, number: 302, title: 'no status', labels: ['Team : Scout'] }), + mkItem({ id: '4', updatedAt: daysAgo(5), omitStatusField: true, number: 303, title: 'no status', labels: ['Team : Scout'] }), + mkItem({ id: '5', updatedAt: daysAgo(5), status: 'QA', number: 304, title: 'has status', labels: ['Team : Scout'] }), + ]; + const fakes = makeFakes(items); + + await assert.rejects( + () => run({ github: fakes.fakeGithub, core: fakes.fakeCore }), + /falls outside the GraphQL fieldValues/, + ); + assert.match(fakes.failed, /Only 1\/5/); + console.log('OK'); +} + +async function testSomeMissingStatusWarnsButContinues() { + console.log('\n=== testSomeMissingStatusWarnsButContinues ==='); + const run = freshModule(); + // 1 of 3 missing -> ratio 2/3 > 0.5 -> warn but continue + const items = [ + mkItem({ id: '1', updatedAt: daysAgo(5), omitStatusField: true, number: 400, title: 'no status', labels: ['Team : Scout'] }), + mkItem({ id: '2', updatedAt: daysAgo(5), status: 'QA', number: 401, title: 'qa', labels: ['Team : Scout'] }), + mkItem({ id: '3', updatedAt: daysAgo(5), status: 'Done', number: 402, title: 'done', labels: ['Team : Modernization'] }), + ]; + const { fakeCore, fakeGithub, captured, warnings } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + assert.strictEqual(JSON.parse(captured.groups_json).length, 2); + assert.ok(warnings.some((w) => /had no readable/.test(w))); + console.log('OK'); +} + +async function testNullProjectFailsLoudly() { + console.log('\n=== testNullProjectFailsLoudly ==='); + const run = freshModule(); + let failed = null; + const fakeCore = { + info: () => {}, + warning: () => {}, + setFailed: (m) => { + failed = m; + }, + setOutput: () => {}, + }; + const fakeGithub = { + graphql: async () => ({ organization: { projectV2: null } }), + }; + + await assert.rejects( + () => run({ github: fakeGithub, core: fakeCore }), + /Could not read projectV2/, ); + assert.match(failed, /Could not read projectV2/); + console.log('OK'); +} - assert.ok(!byTeam['Team : Falcon'], 'Falcon excluded (skipped + not stuck)'); - assert.ok( - !byTeam['Team : Maintenance'], - 'Maintenance excluded (too fresh)', +async function testTeamWithoutSlackChannelIgnored() { + console.log('\n=== testTeamWithoutSlackChannelIgnored ==='); + // Use a temp triage config without slack_channel for a team to confirm we + // silently ignore issues that only carry that team's label. + const fs = require('fs'); + const os = require('os'); + const tmp = path.join(os.tmpdir(), `triage-${Date.now()}.json`); + fs.writeFileSync( + tmp, + JSON.stringify({ + teams: { + 'Team : Scout': { slack_channel: 'CQNF9PCFQ', members: [], areas: [] }, + 'Team : Ghost': { members: [], areas: [] }, // no slack_channel + }, + }), ); + process.env.TRIAGE_CONFIG_PATH = tmp; - // Scout's most-stuck issue should be #107 (6d) sorted before #100 (5d) - assert.strictEqual(byTeam['Team : Scout'].issues[0].number, 107); + const run = freshModule(); + const items = [ + mkItem({ id: 'g', updatedAt: daysAgo(7), status: 'QA', number: 500, title: 'ghost-only', labels: ['Team : Ghost'] }), + mkItem({ id: 's', updatedAt: daysAgo(7), status: 'QA', number: 501, title: 'scout', labels: ['Team : Scout'] }), + ]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + assert.strictEqual(groups.length, 1); + assert.strictEqual(groups[0].team, 'Team : Scout'); - console.log('\nAll assertions passed.'); + fs.unlinkSync(tmp); + process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; + console.log('OK'); +} + +(async () => { + await testMainScenario(); + await testEmptyProject(); + await testMultipleTeamLabels(); + await testMissingStatusFailsLoudly(); + await testSomeMissingStatusWarnsButContinues(); + await testNullProjectFailsLoudly(); + await testTeamWithoutSlackChannelIgnored(); + console.log('\nAll tests passed.'); })().catch((e) => { - console.error(e); + console.error('\nTest failure:', e); process.exit(1); }); \ No newline at end of file diff --git a/.github/workflows/cicd_scheduled_qa-stuck-check.yml b/.github/workflows/cicd_scheduled_qa-stuck-check.yml index 35e1f508d38..71319d5bb45 100644 --- a/.github/workflows/cicd_scheduled_qa-stuck-check.yml +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -2,8 +2,10 @@ name: 'QA Stuck Check' on: schedule: - # Every 3 days at 13:00 UTC (~9am ET during EDT, 8am ET during EST). - - cron: '0 13 */3 * *' + # Mondays and Thursdays at 13:00 UTC (~9am ET during EDT, 8am ET during EST). + # Approximates "every 3 days" without the month-boundary glitches of '*/3' + # in day-of-month. + - cron: '0 13 * * 1,4' workflow_dispatch: inputs: dry_run: @@ -89,20 +91,26 @@ jobs: uses: actions/github-script@v7 env: GROUP_JSON: ${{ toJSON(matrix.group) }} - STUCK_DAYS: ${{ env.STUCK_DAYS }} with: script: | const group = JSON.parse(process.env.GROUP_JSON); - const stuckDays = process.env.STUCK_DAYS; + const stuckDays = group.stuck_days; const count = group.issues.length; - const header = `:hourglass_flowing_sand: *${group.team}* — *${count}* issue${count === 1 ? '' : 's'} stuck in QA for ${stuckDays}+ days`; + const header = `:hourglass_flowing_sand: *${group.team}* — *${count}* QA/Done issue${count === 1 ? '' : 's'} with no project activity for ${stuckDays}+ days`; const lines = group.issues.map((i) => { const assignees = i.assignees && i.assignees.length - ? ` (cc ${i.assignees.map((a) => '@' + a).join(', ')})` + ? ` · assignees: ${i.assignees.join(', ')}` : ''; - return `• <${i.url}|#${i.number}> ${i.title} — _${i.status}_, ${i.daysStuck}d${assignees}`; + return `• <${i.url}|#${i.number}> ${i.title} — _${i.status}_, last project update ${i.daysStuck}d ago${assignees}`; }); - const text = [header, '', ...lines, '', 'Please prioritize QA review or apply *QA : Passed* / *QA : Not Needed* to clear.'].join('\n'); + const text = [ + header, + '', + ...lines, + '', + '_Metric: time since the project item last changed (any field). May undercount if other fields were edited recently._', + 'Please prioritize QA review or apply *QA : Passed* / *QA : Not Needed* to clear.', + ].join('\n'); core.setOutput('text', text); core.setOutput('channel', group.slack_channel); From 18197c76b07abd52ba99b01ed634fbe95149e226 Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 21 May 2026 18:43:54 -0500 Subject: [PATCH 3/7] fix(ci): harden qa-stuck-check input + pagination + concurrency Second pass on review feedback. - Validate STUCK_DAYS up front: must be a finite integer >= 1 or the run fails. Without this, parseInt('abc') -> NaN -> cutoff invalid -> every QA/Done item passes the staleness gate (false positive storm). Same risk for 0 / -1, which slid cutoff to now/future. - workflow_dispatch.inputs.stuck_days is now type: number so GH rejects non-numeric input at dispatch time. - Labels page-size: bumped to 100 and the query now returns pageInfo. Issues whose labels overflow page 1 are skipped with a warning rather than evaluated against an incomplete label set (the QA : Passed gate could otherwise produce false positives). - Concurrency group "qa-stuck-check" with cancel-in-progress: false so a manual dry-run can't overlap a scheduled live post. - Escape Slack mrkdwn-sensitive chars (&, <, >) in team / status / title / assignee strings so a title like "Fix & " doesn't break the link syntax. jq --arg still handles JSON escaping; this is the orthogonal mrkdwn layer. - Tests: added invalid-STUCK_DAYS path and labels-overflow path. Suite is now 9 cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../qa-stuck-check/find-stuck-issues.js | 26 ++++++++- .../qa-stuck-check/test-find-stuck-issues.js | 53 ++++++++++++++++++- .../cicd_scheduled_qa-stuck-check.yml | 22 +++++--- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js index f8355fe9e18..adb965d0a5e 100644 --- a/.github/scripts/qa-stuck-check/find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -44,7 +44,22 @@ const TRIAGE_CONFIG_PATH = // assume the GraphQL `fieldValues(first:N)` cap is hiding it and fail loudly. const STATUS_PRESENCE_MIN_RATIO = 0.5; +// Hard caps on the nested GraphQL connections. Items that exceed these are +// skipped with a warning rather than partially evaluated, to avoid silently +// flagging an issue whose QA-resolution / Team label fell past page 1. +const LABELS_PAGE_SIZE = 100; + +function validateConfig(core) { + if (!Number.isFinite(STUCK_DAYS) || STUCK_DAYS < 1) { + const msg = `STUCK_DAYS must be a finite integer >= 1, got "${process.env.STUCK_DAYS}"`; + core.setFailed(msg); + throw new Error(msg); + } +} + module.exports = async ({ github, core }) => { + validateConfig(core); + const triageConfig = JSON.parse( fs.readFileSync(path.resolve(TRIAGE_CONFIG_PATH), 'utf8'), ); @@ -65,6 +80,12 @@ module.exports = async ({ github, core }) => { const status = readStatus(item); if (!status || !TARGET_STATUSES.includes(status.toLowerCase())) continue; + if (issue.labels?.pageInfo?.hasNextPage) { + core.warning( + `Issue #${issue.number} has more labels than fit in one page (>${LABELS_PAGE_SIZE}); skipping to avoid a false positive.`, + ); + continue; + } const labels = (issue.labels?.nodes || []).map((l) => l.name); const labelsLower = labels.map((l) => l.toLowerCase()); if (labelsLower.some((l) => SKIP_LABELS.includes(l))) continue; @@ -238,7 +259,10 @@ const PROJECT_ITEMS_QUERY = ` url state assignees(first: 20) { nodes { login } } - labels(first: 50) { nodes { name } } + labels(first: 100) { + pageInfo { hasNextPage } + nodes { name } + } } } } diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js index e88332edd7f..7d789744500 100644 --- a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -14,7 +14,7 @@ const SCRIPT_PATH = path.resolve( const now = Date.now(); const daysAgo = (n) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString(); -function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField }) { +function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField, labelsHasNextPage }) { const fieldValues = omitStatusField ? { nodes: [] } : { @@ -37,7 +37,10 @@ function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitS url: `https://github.com/dotCMS/core/issues/${number}`, state: status === 'Done' ? 'CLOSED' : 'OPEN', assignees: { nodes: (assignees || []).map((login) => ({ login })) }, - labels: { nodes: (labels || []).map((name) => ({ name })) }, + labels: { + pageInfo: { hasNextPage: !!labelsHasNextPage }, + nodes: (labels || []).map((name) => ({ name })), + }, }, }; } @@ -248,6 +251,50 @@ async function testTeamWithoutSlackChannelIgnored() { console.log('OK'); } +async function testInvalidStuckDaysFailsLoudly() { + console.log('\n=== testInvalidStuckDaysFailsLoudly ==='); + // Note: '' falls back to the JS-level default '3' via `process.env.STUCK_DAYS || '3'`, + // which mirrors the schedule trigger where inputs.stuck_days is unset. + for (const bad of ['abc', '0', '-1']) { + process.env.STUCK_DAYS = bad; + process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; + const run = freshModule(); + const { fakeCore, fakeGithub } = makeFakes([]); + await assert.rejects( + () => run({ github: fakeGithub, core: fakeCore }), + /STUCK_DAYS must be a finite integer/, + `expected throw for STUCK_DAYS="${bad}"`, + ); + } + process.env.STUCK_DAYS = '3'; + console.log('OK'); +} + +async function testLabelsOverflowSkipsIssue() { + console.log('\n=== testLabelsOverflowSkipsIssue ==='); + process.env.STUCK_DAYS = '3'; + const run = freshModule(); + // Two stale, team-labeled issues. The first reports labelsHasNextPage=true, + // so it must be skipped (we can't trust its skip-label gate). The second + // must come through. + const items = [ + mkItem({ id: 'overflow', updatedAt: daysAgo(7), status: 'QA', number: 600, title: 'Too many labels', labels: ['Team : Scout'], assignees: [], labelsHasNextPage: true }), + mkItem({ id: 'ok', updatedAt: daysAgo(7), status: 'QA', number: 601, title: 'Normal', labels: ['Team : Scout'], assignees: [] }), + ]; + const { fakeCore, fakeGithub, captured, warnings } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + assert.strictEqual(groups.length, 1); + assert.strictEqual(groups[0].issues.length, 1); + assert.strictEqual(groups[0].issues[0].number, 601); + assert.ok( + warnings.some((w) => /Issue #600 has more labels/.test(w)), + 'expected warning about issue #600', + ); + console.log('OK'); +} + (async () => { await testMainScenario(); await testEmptyProject(); @@ -256,6 +303,8 @@ async function testTeamWithoutSlackChannelIgnored() { await testSomeMissingStatusWarnsButContinues(); await testNullProjectFailsLoudly(); await testTeamWithoutSlackChannelIgnored(); + await testInvalidStuckDaysFailsLoudly(); + await testLabelsOverflowSkipsIssue(); console.log('\nAll tests passed.'); })().catch((e) => { console.error('\nTest failure:', e); diff --git a/.github/workflows/cicd_scheduled_qa-stuck-check.yml b/.github/workflows/cicd_scheduled_qa-stuck-check.yml index 71319d5bb45..0894a19a25c 100644 --- a/.github/workflows/cicd_scheduled_qa-stuck-check.yml +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -14,15 +14,19 @@ on: required: false default: true stuck_days: - description: 'Override days-stuck threshold (default 3)' - type: string + description: 'Override days-stuck threshold (positive integer, default 3)' + type: number required: false - default: '3' + default: 3 permissions: contents: read issues: read +concurrency: + group: qa-stuck-check + cancel-in-progress: false + env: PROJECT_OWNER: dotCMS PROJECT_NUMBER: '7' @@ -93,15 +97,21 @@ jobs: GROUP_JSON: ${{ toJSON(matrix.group) }} with: script: | + // Slack mrkdwn requires &, <, > to be escaped in user-supplied text. + // https://api.slack.com/reference/surfaces/formatting#escaping + const esc = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>'); const group = JSON.parse(process.env.GROUP_JSON); const stuckDays = group.stuck_days; const count = group.issues.length; - const header = `:hourglass_flowing_sand: *${group.team}* — *${count}* QA/Done issue${count === 1 ? '' : 's'} with no project activity for ${stuckDays}+ days`; + const header = `:hourglass_flowing_sand: *${esc(group.team)}* — *${count}* QA/Done issue${count === 1 ? '' : 's'} with no project activity for ${stuckDays}+ days`; const lines = group.issues.map((i) => { const assignees = i.assignees && i.assignees.length - ? ` · assignees: ${i.assignees.join(', ')}` + ? ` · assignees: ${i.assignees.map(esc).join(', ')}` : ''; - return `• <${i.url}|#${i.number}> ${i.title} — _${i.status}_, last project update ${i.daysStuck}d ago${assignees}`; + return `• <${i.url}|#${i.number}> ${esc(i.title)} — _${esc(i.status)}_, last project update ${i.daysStuck}d ago${assignees}`; }); const text = [ header, From b0f674cb07c302db406948c484be54f1c5bef95f Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 21 May 2026 18:58:11 -0500 Subject: [PATCH 4/7] fix(ci): drop CLOSED non-Done issues + wire tests into PR CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two items the second review flagged as fix-before-merge. - find-stuck-issues now drops items where state == 'CLOSED' and the project Status is anything other than Done. A closed issue parked in Status=QA is board-cleanup (closed-as-completed without moving the card, or closed-as-not-planned without a QA : Not Needed label) — pinging the team about it is noise. Added a test case asserting CLOSED+QA is dropped while OPEN+QA and CLOSED+Done are kept. - New workflow cicd_pr_qa-stuck-check-validate.yml runs `node .github/scripts/qa-stuck-check/test-find-stuck-issues.js` on PRs that touch the script, scheduled workflow, this validator workflow, or the triage config. Sub-second job (zero npm deps). Without this, regressions would only surface on the next Mon/Thu 13:00 UTC scheduled run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../qa-stuck-check/find-stuck-issues.js | 6 +++ .../qa-stuck-check/test-find-stuck-issues.js | 27 +++++++++++- .../cicd_pr_qa-stuck-check-validate.yml | 44 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/cicd_pr_qa-stuck-check-validate.yml diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js index adb965d0a5e..24ccc653ef9 100644 --- a/.github/scripts/qa-stuck-check/find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -80,6 +80,12 @@ module.exports = async ({ github, core }) => { const status = readStatus(item); if (!status || !TARGET_STATUSES.includes(status.toLowerCase())) continue; + // Drop CLOSED issues unless they're in the Done column. A closed issue + // sitting in Status=QA was either closed-as-completed (board cleanup + // needed) or closed-as-not-planned (should carry QA : Not Needed) — + // either way there is no pending QA work to ping the team about. + if (issue.state === 'CLOSED' && status.toLowerCase() !== 'done') continue; + if (issue.labels?.pageInfo?.hasNextPage) { core.warning( `Issue #${issue.number} has more labels than fit in one page (>${LABELS_PAGE_SIZE}); skipping to avoid a false positive.`, diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js index 7d789744500..79ecf5ad2be 100644 --- a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -14,7 +14,7 @@ const SCRIPT_PATH = path.resolve( const now = Date.now(); const daysAgo = (n) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString(); -function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField, labelsHasNextPage }) { +function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField, labelsHasNextPage, state }) { const fieldValues = omitStatusField ? { nodes: [] } : { @@ -35,7 +35,7 @@ function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitS number, title, url: `https://github.com/dotCMS/core/issues/${number}`, - state: status === 'Done' ? 'CLOSED' : 'OPEN', + state: state || (status === 'Done' ? 'CLOSED' : 'OPEN'), assignees: { nodes: (assignees || []).map((login) => ({ login })) }, labels: { pageInfo: { hasNextPage: !!labelsHasNextPage }, @@ -270,6 +270,28 @@ async function testInvalidStuckDaysFailsLoudly() { console.log('OK'); } +async function testClosedQaIssueIsSkipped() { + console.log('\n=== testClosedQaIssueIsSkipped ==='); + process.env.STUCK_DAYS = '3'; + const run = freshModule(); + const items = [ + // SKIP: CLOSED but still parked in Status=QA (board-cleanup case) + mkItem({ id: 'cqa', updatedAt: daysAgo(7), status: 'QA', state: 'CLOSED', number: 700, title: 'Closed but in QA column', labels: ['Team : Scout'] }), + // KEEP: CLOSED + Done is the expected combo + mkItem({ id: 'cd', updatedAt: daysAgo(7), status: 'Done', state: 'CLOSED', number: 701, title: 'Closed and Done', labels: ['Team : Scout'] }), + // KEEP: OPEN + QA is the normal case + mkItem({ id: 'oq', updatedAt: daysAgo(7), status: 'QA', state: 'OPEN', number: 702, title: 'Open QA', labels: ['Team : Scout'] }), + ]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + assert.strictEqual(groups.length, 1); + const numbers = groups[0].issues.map((i) => i.number).sort(); + assert.deepStrictEqual(numbers, [701, 702], 'closed-QA dropped, others kept'); + console.log('OK'); +} + async function testLabelsOverflowSkipsIssue() { console.log('\n=== testLabelsOverflowSkipsIssue ==='); process.env.STUCK_DAYS = '3'; @@ -304,6 +326,7 @@ async function testLabelsOverflowSkipsIssue() { await testNullProjectFailsLoudly(); await testTeamWithoutSlackChannelIgnored(); await testInvalidStuckDaysFailsLoudly(); + await testClosedQaIssueIsSkipped(); await testLabelsOverflowSkipsIssue(); console.log('\nAll tests passed.'); })().catch((e) => { diff --git a/.github/workflows/cicd_pr_qa-stuck-check-validate.yml b/.github/workflows/cicd_pr_qa-stuck-check-validate.yml new file mode 100644 index 00000000000..d7b85171260 --- /dev/null +++ b/.github/workflows/cicd_pr_qa-stuck-check-validate.yml @@ -0,0 +1,44 @@ +name: 'QA Stuck Check — PR validate' + +# Runs the smoke-test suite for the qa-stuck-check script on PRs that touch +# it. Sub-second job, no install needed (the script and test have zero npm +# deps). Prevents silent regressions from surfacing only on the Mon/Thu +# scheduled run. + +on: + pull_request: + branches: [main] + paths: + - '.github/scripts/qa-stuck-check/**' + - '.github/workflows/cicd_scheduled_qa-stuck-check.yml' + - '.github/workflows/cicd_pr_qa-stuck-check-validate.yml' + - '.claude/triage-config.json' + +permissions: + contents: read + +concurrency: + group: qa-stuck-check-validate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Run smoke tests + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/scripts/qa-stuck-check + .claude/triage-config.json + .nvmrc + sparse-checkout-cone-mode: false + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Run tests + run: node .github/scripts/qa-stuck-check/test-find-stuck-issues.js \ No newline at end of file From 6a91742913e5f9f902a23bfe76bad3754775e06f Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Fri, 22 May 2026 15:24:58 -0500 Subject: [PATCH 5/7] fix(ci): scope status-presence check to Issue items; stop coercing stuck_days=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two highest-impact findings from the latest review. - assertStatusFieldVisible now filters to content.__typename === 'Issue' before computing the Status-presence ratio. The previous version included PullRequest / DraftIssue items in the denominator, which can legitimately lack a Status value on this board — a project with even a handful of PRs added to it would have failed the ratio guard with a misleading "field falls outside fieldValues page" error. Test added asserting that a single Issue+Status alongside several DraftIssues passes cleanly. - STUCK_DAYS env in the workflow now uses an explicit empty-string check instead of `||`. GH Actions evaluates `0 || '3'` to `'3'`, so a manual dispatch with stuck_days=0 was silently coerced to the default 3 instead of failing validateConfig with a clear error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../qa-stuck-check/find-stuck-issues.js | 16 +++++++----- .../qa-stuck-check/test-find-stuck-issues.js | 25 +++++++++++++++++++ .../cicd_scheduled_qa-stuck-check.yml | 5 +++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js index 24ccc653ef9..b48bfe349dc 100644 --- a/.github/scripts/qa-stuck-check/find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -171,20 +171,24 @@ function readStatus(item) { } function assertStatusFieldVisible(items, core) { - if (items.length === 0) return; - const withStatus = items.filter((i) => readStatus(i) !== null).length; - const ratio = withStatus / items.length; + // Restrict to Issue-typed items: PullRequest / DraftIssue content can + // legitimately lack a Status value on this board, and including them in + // the denominator made the ratio fail for the wrong reason. + const issueItems = items.filter((i) => i.content?.__typename === 'Issue'); + if (issueItems.length === 0) return; + const withStatus = issueItems.filter((i) => readStatus(i) !== null).length; + const ratio = withStatus / issueItems.length; if (ratio < STATUS_PRESENCE_MIN_RATIO) { const msg = - `Only ${withStatus}/${items.length} project items expose a "${STATUS_FIELD_NAME}" field value. ` + + `Only ${withStatus}/${issueItems.length} Issue-typed project items expose a "${STATUS_FIELD_NAME}" field value. ` + 'Either the field is named differently or it falls outside the GraphQL fieldValues(first:N) page. ' + 'Bump the cap in the query or update STATUS_FIELD_NAME.'; core.setFailed(msg); throw new Error(msg); } - if (withStatus < items.length) { + if (withStatus < issueItems.length) { core.warning( - `${items.length - withStatus} project item(s) had no readable "${STATUS_FIELD_NAME}" field value and were ignored.`, + `${issueItems.length - withStatus} Issue project item(s) had no readable "${STATUS_FIELD_NAME}" field value and were ignored.`, ); } } diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js index 79ecf5ad2be..d995d0429cc 100644 --- a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -270,6 +270,30 @@ async function testInvalidStuckDaysFailsLoudly() { console.log('OK'); } +async function testNonIssueContentDoesNotTripRatio() { + console.log('\n=== testNonIssueContentDoesNotTripRatio ==='); + process.env.STUCK_DAYS = '3'; + const run = freshModule(); + // 1 Issue with Status + 4 non-Issue items missing Status. Before the fix + // this tripped the ratio guard (1/5 < 0.5); after the fix the ratio is + // computed only over Issue-typed items (1/1 = 1.0). + const issueItem = mkItem({ id: 'ok', updatedAt: daysAgo(7), status: 'QA', number: 800, title: 'real issue', labels: ['Team : Scout'] }); + const draftLike = (id, n) => ({ + id, + updatedAt: daysAgo(7), + fieldValues: { nodes: [] }, + content: { __typename: 'DraftIssue', title: `draft ${n}` }, + }); + const items = [issueItem, draftLike('d1', 1), draftLike('d2', 2), draftLike('d3', 3), draftLike('d4', 4)]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + assert.strictEqual(groups.length, 1, 'Issue passes; non-Issue items ignored'); + assert.strictEqual(groups[0].issues[0].number, 800); + console.log('OK'); +} + async function testClosedQaIssueIsSkipped() { console.log('\n=== testClosedQaIssueIsSkipped ==='); process.env.STUCK_DAYS = '3'; @@ -326,6 +350,7 @@ async function testLabelsOverflowSkipsIssue() { await testNullProjectFailsLoudly(); await testTeamWithoutSlackChannelIgnored(); await testInvalidStuckDaysFailsLoudly(); + await testNonIssueContentDoesNotTripRatio(); await testClosedQaIssueIsSkipped(); await testLabelsOverflowSkipsIssue(); console.log('\nAll tests passed.'); diff --git a/.github/workflows/cicd_scheduled_qa-stuck-check.yml b/.github/workflows/cicd_scheduled_qa-stuck-check.yml index 0894a19a25c..5cb383e4a2e 100644 --- a/.github/workflows/cicd_scheduled_qa-stuck-check.yml +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -34,7 +34,10 @@ env: TARGET_STATUSES: 'QA,Done' SKIP_LABELS: 'QA : Passed,QA : Not Needed' TEAM_LABEL_PREFIX: 'Team : ' - STUCK_DAYS: ${{ inputs.stuck_days || '3' }} + # Empty-string check (not `||`) so an explicit `stuck_days: 0` propagates + # to the script and fails validateConfig with a clear error, instead of + # being coerced to the default 3 by `0 || '3'`. + STUCK_DAYS: ${{ inputs.stuck_days != '' && inputs.stuck_days || '3' }} jobs: find-stuck-issues: From affb01f438f47f8c42998f06d3f91156899a8d9a Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Mon, 25 May 2026 15:38:56 -0500 Subject: [PATCH 6/7] fix(ci): count staleness in business days only (Mon-Fri) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the script flagged an issue when calendar-day diff exceeded STUCK_DAYS, so an issue last touched on Friday would already trip the 3-day threshold by Monday despite only one business day passing. The schedule itself fires Mon/Thu — no weekend triggers — but the clock needed to follow the same Mon-Fri convention. - Replaced the calendar cutoff with countWeekdaysBetween(updatedAt, now), which iterates UTC dates from updatedAt+1d through now and counts only Mon-Fri. Matches the convention already used by cicd_scheduled_notify-seated-prs.yml. - Reporting now reads "N business days" / "Mon-Fri" in both the Slack message and the GitHub job summary. - Tests pin Date.now to a fixed UTC Monday (2026-05-25) so weekday math is deterministic regardless of when the suite runs. New testWeekdayCounting covers Fri/Thu/Wed/Sun -> Mon boundary cases. - Adjusted one offset (daysAgo 4 -> 8) in testMainScenario so a Done Modernization issue still passes the threshold under the new metric. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../qa-stuck-check/find-stuck-issues.js | 35 +++++++++++++------ .../qa-stuck-check/test-find-stuck-issues.js | 35 ++++++++++++++++++- .../cicd_scheduled_qa-stuck-check.yml | 7 ++-- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/.github/scripts/qa-stuck-check/find-stuck-issues.js b/.github/scripts/qa-stuck-check/find-stuck-issues.js index b48bfe349dc..c0406bbbb06 100644 --- a/.github/scripts/qa-stuck-check/find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -70,7 +70,7 @@ module.exports = async ({ github, core }) => { assertStatusFieldVisible(items, core); - const cutoff = new Date(Date.now() - STUCK_DAYS * 24 * 60 * 60 * 1000); + const now = new Date(); const stuckByTeam = new Map(); for (const item of items) { @@ -97,14 +97,12 @@ module.exports = async ({ github, core }) => { if (labelsLower.some((l) => SKIP_LABELS.includes(l))) continue; const itemUpdatedAt = item.updatedAt ? new Date(item.updatedAt) : null; - if (!itemUpdatedAt || itemUpdatedAt > cutoff) continue; + if (!itemUpdatedAt) continue; + const daysStuck = countWeekdaysBetween(itemUpdatedAt, now); + if (daysStuck < STUCK_DAYS) continue; const matchedTeams = resolveTeamsFromLabels(labels, teamChannels); if (matchedTeams.length === 0) continue; - - const daysStuck = Math.floor( - (Date.now() - itemUpdatedAt.getTime()) / (24 * 60 * 60 * 1000), - ); const issueRecord = { number: issue.number, title: issue.title, @@ -163,6 +161,23 @@ function resolveTeamsFromLabels(labels, teamChannels) { return matches; } +// Counts full UTC weekdays elapsed between two timestamps, exclusive of `from` +// and inclusive of `to`. Sat (6) and Sun (0) are skipped. Matches the +// convention used by cicd_scheduled_notify-seated-prs.yml so a Friday→Monday +// gap counts as 1 weekday, not 3. +function countWeekdaysBetween(from, to) { + if (!(from instanceof Date) || !(to instanceof Date) || to <= from) return 0; + const MS_PER_DAY = 24 * 60 * 60 * 1000; + const startUTC = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()); + const endUTC = Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate()); + let count = 0; + for (let d = startUTC + MS_PER_DAY; d <= endUTC; d += MS_PER_DAY) { + const dow = new Date(d).getUTCDay(); + if (dow !== 0 && dow !== 6) count++; + } + return count; +} + function readStatus(item) { const node = (item.fieldValues?.nodes || []).find( (n) => n.field && n.field.name === STATUS_FIELD_NAME, @@ -195,13 +210,13 @@ function assertStatusFieldVisible(items, core) { function buildSummary(groups, stuckDays) { if (groups.length === 0) { - return `No QA/Done issues with ${stuckDays}+ days of project inactivity.`; + return `No QA/Done issues with ${stuckDays}+ business days of project inactivity.`; } const total = groups.reduce((n, g) => n + g.issues.length, 0); const lines = [ - `# QA/Done issues with no project activity for ${stuckDays}+ days`, + `# QA/Done issues with no project activity for ${stuckDays}+ business days`, '', - `_Total: ${total} issue-team pairing(s) across ${groups.length} team(s). Metric: ProjectV2Item.updatedAt — any project-field edit resets the clock._`, + `_Total: ${total} issue-team pairing(s) across ${groups.length} team(s). Metric: ProjectV2Item.updatedAt, weekday-counted (Mon–Fri) — any project-field edit resets the clock._`, '', ]; for (const g of groups) { @@ -211,7 +226,7 @@ function buildSummary(groups, stuckDays) { ? ` · assignees: ${e.assignees.join(', ')}` : ''; lines.push( - `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · last project update ${e.daysStuck}d ago${assignees}`, + `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · last project update ${e.daysStuck} business day${e.daysStuck === 1 ? '' : 's'} ago${assignees}`, ); } lines.push(''); diff --git a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js index d995d0429cc..f1773a01a74 100644 --- a/.github/scripts/qa-stuck-check/test-find-stuck-issues.js +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -11,6 +11,10 @@ const SCRIPT_PATH = path.resolve( './find-stuck-issues.js', ); +// Pin "now" to a fixed UTC Monday so weekday counting is deterministic +// regardless of when the test is run. Monday 2026-05-25 13:00 UTC. +const FIXED_NOW = Date.UTC(2026, 4, 25, 13, 0, 0); +Date.now = () => FIXED_NOW; const now = Date.now(); const daysAgo = (n) => new Date(now - n * 24 * 60 * 60 * 1000).toISOString(); @@ -99,7 +103,7 @@ async function testMainScenario() { mkItem({ id: 'c', updatedAt: daysAgo(7), status: 'In Progress', number: 102, title: 'Not in QA yet', labels: ['Team : Falcon'] }), mkItem({ id: 'd', updatedAt: daysAgo(1), status: 'QA', number: 103, title: 'Too fresh', labels: ['Team : Maintenance'] }), mkItem({ id: 'e', updatedAt: daysAgo(20), status: 'Done', number: 104, title: 'No team label', labels: [] }), - mkItem({ id: 'f', updatedAt: daysAgo(4), status: 'Done', number: 105, title: 'Done but no QA - Modernization', labels: ['Team : Modernization'], assignees: ['hmoreras'] }), + mkItem({ id: 'f', updatedAt: daysAgo(8), status: 'Done', number: 105, title: 'Done but no QA - Modernization', labels: ['Team : Modernization'], assignees: ['hmoreras'] }), mkItem({ id: 'g', updatedAt: daysAgo(8), status: 'Done', number: 106, title: 'No QA needed', labels: ['Team : Scout', 'QA : Not Needed'] }), mkItem({ id: 'h', updatedAt: daysAgo(6), status: 'QA', number: 107, title: 'Older Scout QA', labels: ['Team : Scout'], assignees: ['rjvelazco'] }), ]; @@ -294,6 +298,34 @@ async function testNonIssueContentDoesNotTripRatio() { console.log('OK'); } +async function testWeekdayCounting() { + console.log('\n=== testWeekdayCounting ==='); + // FIXED_NOW = Mon 2026-05-25. Threshold = 3 business days. + // Fri 2026-05-22 -> weekdays elapsed: Mon only = 1 -> SKIP + // Thu 2026-05-21 -> Fri, Mon = 2 -> SKIP + // Wed 2026-05-20 -> Thu, Fri, Mon = 3 -> KEEP + // Sun 2026-05-17 -> Mon..Fri (5/18-5/22), then Mon (5/25) = 6 -> KEEP + process.env.STUCK_DAYS = '3'; + const run = freshModule(); + const items = [ + mkItem({ id: 'fri', updatedAt: '2026-05-22T13:00:00Z', status: 'QA', number: 900, title: 'Last touched Friday', labels: ['Team : Scout'] }), + mkItem({ id: 'thu', updatedAt: '2026-05-21T13:00:00Z', status: 'QA', number: 901, title: 'Last touched Thursday', labels: ['Team : Scout'] }), + mkItem({ id: 'wed', updatedAt: '2026-05-20T13:00:00Z', status: 'QA', number: 902, title: 'Last touched Wednesday', labels: ['Team : Scout'] }), + mkItem({ id: 'sun', updatedAt: '2026-05-17T13:00:00Z', status: 'QA', number: 903, title: 'Last touched Sunday (weekend in)', labels: ['Team : Scout'] }), + ]; + const { fakeCore, fakeGithub, captured } = makeFakes(items); + await run({ github: fakeGithub, core: fakeCore }); + + const groups = JSON.parse(captured.groups_json); + assert.strictEqual(groups.length, 1); + const byNumber = Object.fromEntries(groups[0].issues.map((i) => [i.number, i.daysStuck])); + assert.ok(!byNumber[900], '#900 (Fri, 1 weekday) excluded'); + assert.ok(!byNumber[901], '#901 (Thu, 2 weekdays) excluded'); + assert.strictEqual(byNumber[902], 3, '#902 (Wed) is 3 weekdays'); + assert.strictEqual(byNumber[903], 6, '#903 (Sun) is 6 weekdays'); + console.log('OK'); +} + async function testClosedQaIssueIsSkipped() { console.log('\n=== testClosedQaIssueIsSkipped ==='); process.env.STUCK_DAYS = '3'; @@ -351,6 +383,7 @@ async function testLabelsOverflowSkipsIssue() { await testTeamWithoutSlackChannelIgnored(); await testInvalidStuckDaysFailsLoudly(); await testNonIssueContentDoesNotTripRatio(); + await testWeekdayCounting(); await testClosedQaIssueIsSkipped(); await testLabelsOverflowSkipsIssue(); console.log('\nAll tests passed.'); diff --git a/.github/workflows/cicd_scheduled_qa-stuck-check.yml b/.github/workflows/cicd_scheduled_qa-stuck-check.yml index 5cb383e4a2e..41d9591d085 100644 --- a/.github/workflows/cicd_scheduled_qa-stuck-check.yml +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -109,19 +109,20 @@ jobs: const group = JSON.parse(process.env.GROUP_JSON); const stuckDays = group.stuck_days; const count = group.issues.length; - const header = `:hourglass_flowing_sand: *${esc(group.team)}* — *${count}* QA/Done issue${count === 1 ? '' : 's'} with no project activity for ${stuckDays}+ days`; + const header = `:hourglass_flowing_sand: *${esc(group.team)}* — *${count}* QA/Done issue${count === 1 ? '' : 's'} with no project activity for ${stuckDays}+ business days`; const lines = group.issues.map((i) => { const assignees = i.assignees && i.assignees.length ? ` · assignees: ${i.assignees.map(esc).join(', ')}` : ''; - return `• <${i.url}|#${i.number}> ${esc(i.title)} — _${esc(i.status)}_, last project update ${i.daysStuck}d ago${assignees}`; + const bd = `${i.daysStuck} business day${i.daysStuck === 1 ? '' : 's'}`; + return `• <${i.url}|#${i.number}> ${esc(i.title)} — _${esc(i.status)}_, last project update ${bd} ago${assignees}`; }); const text = [ header, '', ...lines, '', - '_Metric: time since the project item last changed (any field). May undercount if other fields were edited recently._', + '_Metric: time since the project item last changed, counted in business days (Mon–Fri). Any project-field edit resets the clock._', 'Please prioritize QA review or apply *QA : Passed* / *QA : Not Needed* to clear.', ].join('\n'); core.setOutput('text', text); From 1eb18dfaf65376e13ea425dfa9f33ab038bdc571 Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Mon, 25 May 2026 16:09:03 -0500 Subject: [PATCH 7/7] chore(triage): replace msfreeman982 with ihoffmann-dot on Team : Scout Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/triage-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/triage-config.json b/.claude/triage-config.json index 16d919f0843..fd7674ad314 100644 --- a/.claude/triage-config.json +++ b/.claude/triage-config.json @@ -24,7 +24,7 @@ }, "Team : Scout": { "slack_channel": "CQNF9PCFQ", - "members": ["nollymar", "fabrizzio-dotCMS", "msfreeman982", "zJaaal", "rjvelazco", "KevinDavilaDotCMS", "dario-daza"], + "members": ["nollymar", "fabrizzio-dotCMS", "ihoffmann-dot", "zJaaal", "rjvelazco", "KevinDavilaDotCMS", "dario-daza"], "areas": [ "core-web/apps/dotcms-block-editor", "core-web/libs/block-editor",