diff --git a/.claude/triage-config.json b/.claude/triage-config.json index f574d73dab2..fd7674ad314 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,7 +23,8 @@ ] }, "Team : Scout": { - "members": ["nollymar", "fabrizzio-dotCMS", "msfreeman982", "zJaaal", "rjvelazco", "KevinDavilaDotCMS", "dario-daza"], + "slack_channel": "CQNF9PCFQ", + "members": ["nollymar", "fabrizzio-dotCMS", "ihoffmann-dot", "zJaaal", "rjvelazco", "KevinDavilaDotCMS", "dario-daza"], "areas": [ "core-web/apps/dotcms-block-editor", "core-web/libs/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..c0406bbbb06 --- /dev/null +++ b/.github/scripts/qa-stuck-check/find-stuck-issues.js @@ -0,0 +1,298 @@ +// Finds project items in QA/Done that have not seen any project-field change +// in STUCK_DAYS days on the dotCMS - Product Planning project. +// +// 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. +// +// 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, stuck_days, 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'; + +// 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; + +// 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'), + ); + const teamChannels = buildTeamChannelIndex(triageConfig); + + const items = await fetchAllProjectItems(github, core); + core.info(`Fetched ${items.length} project items from #${PROJECT_NUMBER}`); + + assertStatusFieldVisible(items, core); + + const now = new Date(); + const stuckByTeam = new Map(); + + 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; + + // 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.`, + ); + 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) continue; + const daysStuck = countWeekdaysBetween(itemUpdatedAt, now); + if (daysStuck < STUCK_DAYS) continue; + + const matchedTeams = resolveTeamsFromLabels(labels, teamChannels); + if (matchedTeams.length === 0) continue; + 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 = 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 ${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 }; + } + } + return idx; +} + +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 || seen.has(hit.team)) continue; + seen.add(hit.team); + matches.push(hit); + } + 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, + ); + return node ? node.name : null; +} + +function assertStatusFieldVisible(items, core) { + // 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}/${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 < issueItems.length) { + core.warning( + `${issueItems.length - withStatus} Issue project item(s) had no readable "${STATUS_FIELD_NAME}" field value and were ignored.`, + ); + } +} + +function buildSummary(groups, stuckDays) { + if (groups.length === 0) { + 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}+ business days`, + '', + `_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) { + lines.push(`## ${g.team} → ${g.slack_channel} (${g.issues.length})`); + for (const e of g.issues) { + const assignees = e.assignees.length + ? ` · assignees: ${e.assignees.join(', ')}` + : ''; + lines.push( + `- [#${e.number}](${e.url}) — ${e.title} · status: ${e.status} · last project update ${e.daysStuck} business day${e.daysStuck === 1 ? '' : 's'} ago${assignees}`, + ); + } + lines.push(''); + } + return lines.join('\n'); +} + +async function fetchAllProjectItems(github, core) { + const all = []; + let cursor = null; + do { + const data = await github.graphql(PROJECT_ITEMS_QUERY, { + org: PROJECT_OWNER, + number: PROJECT_NUMBER, + cursor, + }); + 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); + 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: 50) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + content { + __typename + ... on Issue { + number + title + url + state + assignees(first: 20) { nodes { login } } + labels(first: 100) { + pageInfo { hasNextPage } + 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..f1773a01a74 --- /dev/null +++ b/.github/scripts/qa-stuck-check/test-find-stuck-issues.js @@ -0,0 +1,393 @@ +// 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 assert = require('assert'); +const path = require('path'); + +const SCRIPT_PATH = path.resolve( + __dirname, + './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(); + +function mkItem({ id, updatedAt, status, number, title, labels, assignees, omitStatusField, labelsHasNextPage, state }) { + const fieldValues = omitStatusField + ? { nodes: [] } + : { + nodes: [ + { + __typename: 'ProjectV2ItemFieldSingleSelectValue', + name: status, + field: { name: 'Status' }, + }, + ], + }; + return { + id, + updatedAt, + fieldValues, + content: { + __typename: 'Issue', + number, + title, + url: `https://github.com/dotCMS/core/issues/${number}`, + state: state || (status === 'Done' ? 'CLOSED' : 'OPEN'), + assignees: { nodes: (assignees || []).map((login) => ({ login })) }, + labels: { + pageInfo: { hasNextPage: !!labelsHasNextPage }, + nodes: (labels || []).map((name) => ({ name })), + }, + }, + }; +} + +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(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'] }), + ]; + 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(captured.has_results, 'true'); + assert.ok(byTeam['Team : Scout'], 'Scout group present'); + assert.strictEqual(byTeam['Team : Scout'].issues.length, 2); + assert.strictEqual(byTeam['Team : Scout'].slack_channel, 'CQNF9PCFQ'); + 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.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'); +} + +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; + + 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'); + + fs.unlinkSync(tmp); + process.env.TRIAGE_CONFIG_PATH = '.claude/triage-config.json'; + 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 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 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'; + 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'; + 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(); + await testMultipleTeamLabels(); + await testMissingStatusFailsLoudly(); + await testSomeMissingStatusWarnsButContinues(); + await testNullProjectFailsLoudly(); + await testTeamWithoutSlackChannelIgnored(); + await testInvalidStuckDaysFailsLoudly(); + await testNonIssueContentDoesNotTripRatio(); + await testWeekdayCounting(); + await testClosedQaIssueIsSkipped(); + await testLabelsOverflowSkipsIssue(); + console.log('\nAll tests passed.'); +})().catch((e) => { + console.error('\nTest failure:', e); + process.exit(1); +}); \ No newline at end of file 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 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..41d9591d085 --- /dev/null +++ b/.github/workflows/cicd_scheduled_qa-stuck-check.yml @@ -0,0 +1,163 @@ +name: 'QA Stuck Check' + +on: + schedule: + # 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: + 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 (positive integer, default 3)' + type: number + required: false + default: 3 + +permissions: + contents: read + issues: read + +concurrency: + group: qa-stuck-check + cancel-in-progress: false + +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 : ' + # 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: + 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) }} + 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: *${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(', ')}` + : ''; + 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, 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); + 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