diff --git a/.github/workflows/design-done-label.yml b/.github/workflows/design-done-label.yml index 204a4466a..59d42f8b7 100644 --- a/.github/workflows/design-done-label.yml +++ b/.github/workflows/design-done-label.yml @@ -1,146 +1,117 @@ -name: Add "Design Done" Label +name: Sync Checklist Labels -# Trigger whenever an issue body is edited so we can detect checklist changes. +# Trigger whenever an issue body is edited so checklist changes are reflected +# as issue labels. on: issues: - types: [edited] + types: [opened, edited, reopened] jobs: - add-design-done-label: - # Only run for issues in this repository (not forks or other events). + sync-checklist-labels: if: github.event.issue != null runs-on: ubuntu-latest permissions: - issues: write # Required to add/remove labels. + issues: write steps: - - name: Check checklist item and apply label + - name: Sync checklist items to labels uses: actions/github-script@v7 with: script: | - // The exact checklist item text we watch for. - const CHECKLIST_ITEM = - 'Design Document \u2014 A detailed design document has been created and reviewed, covering architecture, data flow, and edge cases.'; - const LABEL_NAME = 'Design Done'; - const body = context.payload.issue.body || ''; - - // A checked GFM checkbox looks like: "- [x] " or "* [x] " - // We escape the checklist item for use in a regular expression. - const escapedItem = CHECKLIST_ITEM.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const checkedPattern = /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Design Document\*{0,2}.*$/im; - - if (checkedPattern.test(body)) { - console.log('Checklist item is checked. Ensuring label exists...'); - - // Ensure the "Design Done" label exists in this repository. - // If it already exists this call will throw a 422; we catch and ignore that. + const issueNumber = context.payload.issue.number; + + const checklistLabels = [ + { + label: 'Design Done', + color: '0075ca', + description: 'Design document has been created and reviewed.', + pattern: /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Design Document\*{0,2}.*$/im, + }, + { + label: 'Mail Sent', + color: 'e4e669', + description: 'Design mail has been sent.', + pattern: /^[\s]*[-*]\s+\[x\]\s+.*Design Mail.*$/im, + }, + { + label: 'Code Review Done', + color: '0e8a16', + description: "All code changes have been peer-reviewed and approved according to the project's review standards.", + pattern: /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Code Review\*{0,2}.*$/im, + }, + { + label: 'Testing Done', + color: '5319e7', + description: 'Adequate unit, integration, and/or end-to-end tests have been written and are passing.', + pattern: /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Testing Complete\*{0,2}.*$/im, + }, + { + label: 'Doc Review Done', + color: '1d76db', + description: 'User-facing and/or developer documentation has been updated to reflect the new feature and reviewed.', + pattern: /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Documentation Review\*{0,2}.*$/im, + }, + { + label: 'Feature Complete', + color: '0052cc', + description: 'The feature is fully implemented, all checklist items above are done, and it is ready for release.', + pattern: /^[\s]*[-*]\s+\[x\]\s+.*\*{0,2}Feature Complete\*{0,2}.*$/im, + }, + ]; + + async function ensureLabel({ label, color, description }) { try { await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, - name: LABEL_NAME, - color: '0075ca', // Blue — feel free to change. - description: 'Design document has been created and reviewed.', + name: label, + color, + description, }); - console.log(`Label "${LABEL_NAME}" created.`); + console.log(`Label "${label}" created.`); } catch (err) { if (err.status === 422) { - // Label already exists — that is fine. - console.log(`Label "${LABEL_NAME}" already exists.`); + console.log(`Label "${label}" already exists.`); } else { throw err; } } + } - // Add the label to the issue. GitHub ignores duplicates, so this is - // idempotent — applying it again will not cause an error. + async function addLabel(label) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.issue.number, - labels: [LABEL_NAME], + issue_number: issueNumber, + labels: [label], }); + console.log(`Label "${label}" added to issue #${issueNumber}.`); + } - console.log(`Label "${LABEL_NAME}" added to issue #${context.payload.issue.number}.`); - } else { - console.log('Checklist item is not checked. Removing label if present...'); - - // Remove the label from the issue if it exists. - // GitHub returns 404 if the label is not on the issue; we catch and ignore that. + async function removeLabel(label) { try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.issue.number, - name: LABEL_NAME, + issue_number: issueNumber, + name: label, }); - console.log(`Label "${LABEL_NAME}" removed from issue #${context.payload.issue.number}.`); + console.log(`Label "${label}" removed from issue #${issueNumber}.`); } catch (err) { if (err.status === 404) { - // Label was not on the issue — nothing to remove. - console.log(`Label "${LABEL_NAME}" was not present on issue #${context.payload.issue.number}.`); + console.log(`Label "${label}" was not present on issue #${issueNumber}.`); } else { throw err; } } } - - name: Check "Design Mail" checklist item and apply "Mail Sent" label - uses: actions/github-script@v7 - with: - script: | - const LABEL_NAME = 'Mail Sent'; - - const body = context.payload.issue.body || ''; - - // Matches a checked GFM checkbox line containing "Design Mail" - const checkedPattern = /^[\s]*[-*]\s+\[x\]\s+.*Design Mail.*$/im; - - if (checkedPattern.test(body)) { - console.log('"Design Mail" checklist item is checked. Ensuring label exists...'); - - // Ensure the "Mail Sent" label exists in this repository. - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: LABEL_NAME, - color: 'e4e669', // Yellow — feel free to change. - description: 'Design mail has been sent.', - }); - console.log(`Label "${LABEL_NAME}" created.`); - } catch (err) { - if (err.status === 422) { - console.log(`Label "${LABEL_NAME}" already exists.`); - } else { - throw err; - } - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - labels: [LABEL_NAME], - }); - - console.log(`Label "${LABEL_NAME}" added to issue #${context.payload.issue.number}.`); - } else { - console.log('"Design Mail" checklist item is not checked. Removing label if present...'); - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - name: LABEL_NAME, - }); - console.log(`Label "${LABEL_NAME}" removed from issue #${context.payload.issue.number}.`); - } catch (err) { - if (err.status === 404) { - console.log(`Label "${LABEL_NAME}" was not present on issue #${context.payload.issue.number}.`); - } else { - throw err; - } + for (const config of checklistLabels) { + if (config.pattern.test(body)) { + await ensureLabel(config); + await addLabel(config.label); + } else { + await removeLabel(config.label); } } diff --git a/.github/workflows/enforce-design-gate.yml b/.github/workflows/enforce-design-gate.yml index 5384c839e..75e320107 100644 --- a/.github/workflows/enforce-design-gate.yml +++ b/.github/workflows/enforce-design-gate.yml @@ -1,12 +1,13 @@ name: Enforce Design Gate -# GitHub Projects v2 status changes do not trigger GitHub Actions directly. -# This workflow polls https://github.com/orgs/wso2/projects/144 once per day and moves invalid -# feature issues from "Development" back to "Design". -# -# Prerequisite: -# - PROJECT_TOKEN secret with access to read/write the org Project v2 and comment on issues. +# Token-free org Project v2 access is not available from GitHub Actions. +# This workflow uses issue labels as the gate signal instead: +# - "Development" means the issue is in/entering development. +# - "feature" means the gate applies. +# - Missing "Design Done" or "Mail Sent" adds "Design Gate Blocked" and a comment. on: + issues: + types: [opened, edited, labeled, unlabeled, reopened] schedule: - cron: '0 2 * * *' workflow_dispatch: @@ -16,209 +17,144 @@ jobs: runs-on: ubuntu-latest permissions: issues: write - repository-projects: write steps: - - name: Revert invalid Projects v2 items to Design + - name: Mark development issues that fail the design gate uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const PROJECT_OWNER = 'wso2'; - const PROJECT_NUMBER = 144; - const STATUS_FIELD = 'Status'; - const DESTINATION_STATUS = 'Development'; - const REVERT_STATUS = 'Design'; + const DEVELOPMENT_LABEL = 'Development'; + const BLOCKED_LABEL = 'Design Gate Blocked'; const REQUIRED_LABELS = ['Design Done', 'Mail Sent']; const GATE_TRIGGER_TYPE = 'feature'; - - const projectFragment = ` - id - number - title - fields(first: 100) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { - id - name - } - } + const MARKER = ''; + + async function ensureLabel(name, color, description) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color, + description, + }); + } catch (err) { + if (err.status !== 422) { + throw err; } } - `; - - async function getProject() { - const query = ` - query($login: String!, $number: Int!) { - organization(login: $login) { - projectV2(number: $number) { - ${projectFragment} - } - } - } - `; - - const result = await github.graphql(query, { - login: PROJECT_OWNER, - number: PROJECT_NUMBER, - }); - - return result.organization?.projectV2; } - const project = await getProject(); - if (!project) { - core.setFailed(`Could not find Projects v2 project ${PROJECT_OWNER}/${PROJECT_NUMBER}.`); - return; - } - - const statusField = project.fields.nodes.find(field => field?.name === STATUS_FIELD); - const developmentOption = statusField?.options.find(option => option.name === DESTINATION_STATUS); - const designOption = statusField?.options.find(option => option.name === REVERT_STATUS); - if (!statusField || !developmentOption || !designOption) { - core.setFailed(`Project "${project.title}" must have a "${STATUS_FIELD}" field with "${DESTINATION_STATUS}" and "${REVERT_STATUS}" options.`); - return; - } - - const items = []; - let cursor = null; - let hasNextPage = true; - let revertedCount = 0; - - while (hasNextPage) { - const query = ` - query($login: String!, $number: Int!, $cursor: String) { - organization(login: $login) { - projectV2(number: $number) { - items(first: 100, after: $cursor) { - nodes { - id - content { - ... on Issue { - number - state - title - repository { - name - owner { - login - } - } - labels(first: 100) { - nodes { - name - } - } - } - } - fieldValues(first: 100) { - nodes { - ... on ProjectV2ItemFieldSingleSelectValue { - field { - ... on ProjectV2SingleSelectField { - name - } - } - optionId - name - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } + async function removeLabelIfPresent(issueNumber, label) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label, + }); + } catch (err) { + if (err.status !== 404) { + throw err; } - `; + } + } - const result = await github.graphql(query, { - login: PROJECT_OWNER, - number: PROJECT_NUMBER, - cursor, + async function hasDesignGateComment(issueNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, }); - const page = result.organization.projectV2.items; - items.push(...page.nodes); - hasNextPage = page.pageInfo.hasNextPage; - cursor = page.pageInfo.endCursor; + return comments.some(comment => comment.body?.includes(MARKER)); } - console.log(`Checking ${items.length} items in project "${project.title}".`); + async function enforceIssue(issue) { + const issueNumber = issue.number; + const labels = issue.labels.map(label => typeof label === 'string' ? label : label.name); - for (const item of items) { - const issue = item.content; - if (!issue?.number || !issue?.repository) { - continue; + if (issue.state !== 'open') { + console.log(`#${issueNumber} is not open. Skipping.`); + return; } - if (issue.state !== 'OPEN') { - console.log(`#${issue.number} is not open. Skipping.`); - continue; + if (!labels.includes(GATE_TRIGGER_TYPE)) { + console.log(`#${issueNumber} is not a feature issue. Skipping.`); + await removeLabelIfPresent(issueNumber, BLOCKED_LABEL); + return; } - const status = item.fieldValues.nodes.find(value => value?.field?.name === STATUS_FIELD); - if (status?.name !== DESTINATION_STATUS) { - continue; + if (!labels.includes(DEVELOPMENT_LABEL)) { + console.log(`#${issueNumber} is not marked as ${DEVELOPMENT_LABEL}. Skipping.`); + await removeLabelIfPresent(issueNumber, BLOCKED_LABEL); + return; } - const issueLabels = issue.labels.nodes.map(label => label.name); - if (!issueLabels.includes(GATE_TRIGGER_TYPE)) { - console.log(`#${issue.number} is not a feature issue. Skipping.`); - continue; - } - - const missingLabels = REQUIRED_LABELS.filter(label => !issueLabels.includes(label)); + const missingLabels = REQUIRED_LABELS.filter(label => !labels.includes(label)); if (missingLabels.length === 0) { - console.log(`#${issue.number} passed the design gate.`); - continue; + console.log(`#${issueNumber} passed the design gate.`); + await removeLabelIfPresent(issueNumber, BLOCKED_LABEL); + return; } - console.log(`#${issue.number} is missing ${missingLabels.join(', ')}. Moving back to "${REVERT_STATUS}".`); - - await github.graphql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { - singleSelectOptionId: $optionId - } - }) { - projectV2Item { - id - } - } - } - `, { - projectId: project.id, - itemId: item.id, - fieldId: statusField.id, - optionId: designOption.id, + console.log(`#${issueNumber} is missing ${missingLabels.join(', ')}. Marking as blocked.`); + + await ensureLabel( + BLOCKED_LABEL, + 'd73a4a', + 'Issue is in development but missing required design labels.', + ); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [BLOCKED_LABEL], }); + if (await hasDesignGateComment(issueNumber)) { + console.log(`#${issueNumber} already has a design gate comment.`); + return; + } + await github.rest.issues.createComment({ - owner: issue.repository.owner.login, - repo: issue.repository.name, - issue_number: issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, body: [ + MARKER, '> [!CAUTION]', - `> **Design gate enforced.** This feature issue was in **${DESTINATION_STATUS}** but is missing required design labels, so it has been moved back to **${REVERT_STATUS}**.`, + `> **Design gate warning.** This feature issue is marked as **${DEVELOPMENT_LABEL}** but is missing required design labels.`, '>', - '> Required labels before Development:', + '> Required labels before development:', '>', - ...REQUIRED_LABELS.map(label => `> - ${issueLabels.includes(label) ? 'Present' : 'Missing'}: \`${label}\``), + ...REQUIRED_LABELS.map(label => `> - ${labels.includes(label) ? 'Present' : 'Missing'}: \`${label}\``), ].join('\n'), }); + } + + if (context.payload.issue) { + await enforceIssue(context.payload.issue); + return; + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: `${GATE_TRIGGER_TYPE},${DEVELOPMENT_LABEL}`, + per_page: 100, + }); + + for (const issue of issues) { + if (issue.pull_request) { + continue; + } - revertedCount += 1; + await enforceIssue(issue); } - console.log(`Checked ${items.length} project items. Reverted ${revertedCount}.`); + console.log(`Checked ${issues.length} open ${GATE_TRIGGER_TYPE} issues marked ${DEVELOPMENT_LABEL}.`);