[FIX] Inject watchdog keepalive during anti-wrinkle silence #120
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate Pull Request | |
| on: | |
| pull_request_target: | |
| types: [opened, edited, reopened, synchronize] | |
| # Default to read-only; the jobs below opt into the write scopes they need so | |
| # the elevated pull_request_target token is not granted to every step. | |
| permissions: | |
| contents: read | |
| # pull_request_target re-fires on every edit/synchronize. Bots (e.g. CodeRabbit | |
| # editing the PR body) can trigger several events in quick succession, which | |
| # previously produced overlapping runs that each posted a duplicate comment. | |
| # Serialize per PR and cancel superseded runs so only the latest state is acted on. | |
| concurrency: | |
| group: validate-pr-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| check-completeness: | |
| name: Check PR Description | |
| runs-on: ubuntu-latest | |
| # Comments on and labels the PR (both via the issues API). | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: Validate PR template completeness | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prNumber = pr.number; | |
| const prAuthor = pr.user.login; | |
| const prBody = pr.body || ''; | |
| const GITLOCALIZE_BOT = 'gitlocalize-app[bot]'; | |
| if (prAuthor === owner || prAuthor === GITLOCALIZE_BOT) { | |
| console.log(`Author is repo owner or GitLocalize bot (${prAuthor}), skipping completeness check.`); | |
| return; | |
| } | |
| if (pr.state === 'closed') { | |
| console.log('PR is closed — skipping completeness check.'); | |
| return; | |
| } | |
| const BOT_MARKER = '<!-- pr-validator-completeness-bot -->'; | |
| const LABEL = 'needs description'; | |
| // Check 1: description section has real content (not just HTML comments / placeholders) | |
| const descSection = (prBody.match(/## Description\s*([\s\S]*?)(?=\n##|$)/i) || [])[1] || ''; | |
| const descClean = descSection | |
| .replace(/<!--[\s\S]*?-->/g, '') | |
| .replace(/\bCloses?\s+#\s*<!--.*?-->/gi, '') | |
| .trim(); | |
| const descriptionOk = descClean.length > 10; | |
| // Check 2: at least one checkbox checked in Type of Change | |
| const typeSection = (prBody.match(/## Type of Change\s*([\s\S]*?)(?=\n##|$)/i) || [])[1] || ''; | |
| const typeOk = /- \[x\]/i.test(typeSection); | |
| const isComplete = descriptionOk && typeOk; | |
| // Find existing bot comment (paginate so the marker is not missed on busy PRs) | |
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 }); | |
| const existing = comments.find(c => c.body && c.body.includes(BOT_MARKER)); | |
| if (isComplete) { | |
| // Remove warning label if present | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: LABEL }); | |
| } catch (_) {} | |
| // Remove warning comment if present | |
| if (existing) { | |
| await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); | |
| } | |
| console.log('PR description is complete.'); | |
| return; | |
| } | |
| const missing = []; | |
| if (!descriptionOk) missing.push('**Description** — add a meaningful summary of your changes (remove or replace the template placeholder)'); | |
| if (!typeOk) missing.push('**Type of Change** — check at least one box that describes this PR'); | |
| const body = [ | |
| BOT_MARKER, | |
| `Hi @${prAuthor}! This PR's description appears incomplete. Please fill in the following before it can be reviewed:`, | |
| '', | |
| missing.map(m => `- ${m}`).join('\n'), | |
| '', | |
| '> This PR will be **automatically closed in 5 days** if these sections are not completed.', | |
| ].join('\n'); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); | |
| try { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [LABEL] }); | |
| } catch (e) { | |
| console.warn(`Could not add label "${LABEL}": ${e.message}. Make sure the label exists in the repo.`); | |
| } | |
| } | |
| check-issue-link: | |
| name: Check Linked Accepted Issue | |
| runs-on: ubuntu-latest | |
| # Reads PR files/issues and may comment on and close the PR. | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Verify PR is linked to an accepted issue | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prNumber = pr.number; | |
| const prAuthor = pr.user.login; | |
| const prBody = pr.body || ''; | |
| const GITLOCALIZE_BOT = 'gitlocalize-app[bot]'; | |
| if (prAuthor === owner || prAuthor === GITLOCALIZE_BOT) { | |
| console.log(`Author is repo owner or GitLocalize bot (${prAuthor}), skipping issue link check.`); | |
| return; | |
| } | |
| if (pr.state === 'closed') { | |
| console.log('PR already closed — skipping issue link check.'); | |
| return; | |
| } | |
| const BOT_MARKER = '<!-- pr-validator-issue-link-bot -->'; | |
| // Determine if this PR only touches localization assets. Translation work is | |
| // exempt from the accepted-issue requirement. A UI-text change necessarily | |
| // edits strings.json (the source of truth) AND regenerates every | |
| // translations/*.json file, so strings.json counts as a localization asset | |
| // too — otherwise a one-line UI string fix would be wrongly treated as a code | |
| // PR just because of the retranslation churn it drags along. | |
| // | |
| // Paginate the file list: listFiles returns only the first page (30) by | |
| // default, so a PR with many files could be misclassified if a | |
| // non-localization file fell beyond the first page. | |
| const TRANSLATIONS_DIR = 'custom_components/ha_washdata/translations/'; | |
| const STRINGS_SOURCE = 'custom_components/ha_washdata/strings.json'; | |
| const isLocalizationFile = (f) => f.startsWith(TRANSLATIONS_DIR) || f === STRINGS_SOURCE; | |
| let isLocalizationPR = false; | |
| try { | |
| const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber, per_page: 100 }); | |
| const filenames = files.map(f => f.filename); | |
| isLocalizationPR = filenames.length > 0 && filenames.every(isLocalizationFile); | |
| } catch (e) { | |
| console.warn(`Could not fetch changed files: ${e.message}`); | |
| } | |
| if (isLocalizationPR) { | |
| console.log('Localization-only PR (translations/strings.json) — skipping issue link check.'); | |
| return; | |
| } | |
| // Extract all issue number references from the PR body | |
| const issueNums = new Set(); | |
| const patterns = [ | |
| /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)/gi, | |
| new RegExp(`github\\.com/${owner}/${repo}/issues/(\\d+)`, 'gi'), | |
| /#(\d+)/g, | |
| ]; | |
| for (const pat of patterns) { | |
| let m; | |
| while ((m = pat.exec(prBody)) !== null) { | |
| issueNums.add(Number(m[1])); | |
| } | |
| } | |
| // Resolve referenced issues: keep those that exist and are real issues | |
| // (not PRs). Track whether any carries the 'accepted' label. | |
| const referencedIssues = []; | |
| let hasAccepted = false; | |
| for (const num of issueNums) { | |
| try { | |
| const issue = await github.rest.issues.get({ owner, repo, issue_number: num }); | |
| if (issue.data.pull_request) continue; // skip PR references | |
| referencedIssues.push(issue.data); | |
| if (issue.data.labels.some(l => l.name === 'accepted')) { | |
| hasAccepted = true; | |
| } | |
| } catch (_) {} | |
| } | |
| // Labels: NEEDS_ISSUE drives the scheduled 3-day auto-close; AWAITING parks | |
| // the PR while the maintainer reviews a linked-but-unapproved issue (the | |
| // auto-closer ignores AWAITING, so these PRs are not closed automatically). | |
| const NEEDS_ISSUE_LABEL = 'needs accepted issue'; | |
| const AWAITING_LABEL = 'awaiting maintainer'; | |
| const removeLabelSafe = async (name) => { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name }); | |
| } catch (_) {} | |
| }; | |
| const addLabelSafe = async (name) => { | |
| try { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] }); | |
| } catch (e) { | |
| console.warn(`Could not add label "${name}": ${e.message}. Make sure the label exists in the repo.`); | |
| } | |
| }; | |
| // Existing bot notice on the PR (paginate so it is not missed on busy PRs). | |
| const prComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 }); | |
| const existingNotice = prComments.find(c => c.body && c.body.includes(BOT_MARKER)); | |
| const upsertNotice = async (body) => { | |
| if (existingNotice) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existingNotice.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); | |
| } | |
| }; | |
| // Case 1 — linked to an accepted issue: pass and clear any prior warning. | |
| if (hasAccepted) { | |
| console.log('PR is linked to an accepted issue. ✓'); | |
| await removeLabelSafe(NEEDS_ISSUE_LABEL); | |
| await removeLabelSafe(AWAITING_LABEL); | |
| if (existingNotice) { | |
| await github.rest.issues.deleteComment({ owner, repo, comment_id: existingNotice.id }); | |
| } | |
| return; | |
| } | |
| // Case 2 — an issue is linked but not yet accepted: wait on the maintainer. | |
| // Do NOT close; ping the maintainer once per (issue, PR) pair. | |
| if (referencedIssues.length > 0) { | |
| await removeLabelSafe(NEEDS_ISSUE_LABEL); // not on the auto-close track | |
| await addLabelSafe(AWAITING_LABEL); | |
| const issueList = referencedIssues.map(i => `#${i.number}`).join(', '); | |
| const plural = referencedIssues.length > 1; | |
| await upsertNotice([ | |
| BOT_MARKER, | |
| `Hi @${prAuthor}! This PR references ${issueList}, which ${plural ? 'have' : 'has'} not yet received the \`accepted\` label.`, | |
| '', | |
| `@${owner} has been notified and will review the linked ${plural ? 'issues' : 'issue'}. Once the \`accepted\` label is added there, this PR will pass the check automatically.`, | |
| '', | |
| 'Nothing more is needed from you for now — thanks for your patience!', | |
| ].join('\n')); | |
| for (const issue of referencedIssues) { | |
| const PING_MARKER = `<!-- washdata-pending-approval-ping:pr-${prNumber} -->`; | |
| try { | |
| const issueComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 }); | |
| if (issueComments.some(c => c.body && c.body.includes(PING_MARKER))) continue; // pinged already | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: [ | |
| PING_MARKER, | |
| `@${owner} — PR #${prNumber} by @${prAuthor} is linked to this issue and is **waiting for your approval**.`, | |
| '', | |
| 'If you approve this work, add the `accepted` label to this issue and the PR check will pass automatically.', | |
| ].join('\n'), | |
| }); | |
| console.log(`Pinged @${owner} on issue #${issue.number} for PR #${prNumber}.`); | |
| } catch (e) { | |
| console.warn(`Could not ping on issue #${issue.number}: ${e.message}`); | |
| } | |
| } | |
| return; | |
| } | |
| // Case 3 — no valid issue linked at all: warn and start the 3-day grace. | |
| await removeLabelSafe(AWAITING_LABEL); | |
| await addLabelSafe(NEEDS_ISSUE_LABEL); | |
| await upsertNotice([ | |
| BOT_MARKER, | |
| `Hi @${prAuthor}, thank you for your interest in contributing to WashData!`, | |
| '', | |
| 'This PR is **not yet linked to an issue with the `accepted` label**. To have your contribution considered, please follow this process:', | |
| '', | |
| `1. **Open an issue** — create a [Bug Report](https://github.com/${owner}/${repo}/issues/new?template=bug_report.yml) or [Feature Request](https://github.com/${owner}/${repo}/issues/new?template=feature_request.yml) describing what you want to fix or build.`, | |
| '2. **Indicate your intent** — check the box at the bottom of the issue form confirming you plan to submit a PR.', | |
| '3. **Reference it here** — edit this PR description to include `Closes #NNN`. The maintainer will then be pinged to review and approve.', | |
| '', | |
| '> **Localization PRs** (files under `translations/` and `strings.json`) are exempt and can be opened directly. Prefer submitting translations via [GitLocalize](https://gitlocalize.com/repo/10819), which opens PRs automatically.', | |
| '', | |
| '> ⏳ This PR will be **automatically closed in 3 days** if no issue is linked. Linking an issue stops the timer.', | |
| ].join('\n')); | |
| console.log(`PR #${prNumber} flagged — no accepted issue linked (3-day grace started).`); |