Enforce Design Gate #83
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: 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". | |
| # | |
| # Required secret: | |
| # - PROJECT_TOKEN: token with access to read/write the wso2 org Project v2 | |
| # or API_PLATFORM_PROJECT_TOKEN if using the repository secret name below. | |
| on: | |
| schedule: | |
| - cron: '0 2 * * *' | |
| workflow_dispatch: | |
| jobs: | |
| enforce-design-gate: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Revert invalid Projects v2 items to Design | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.API_PLATFORM_PROJECT_TOKEN }} | |
| script: | | |
| const PROJECT_OWNER = 'wso2'; | |
| const PROJECT_NUMBER = 144; | |
| const STATUS_FIELD = 'Status'; | |
| const DESTINATION_STATUS = 'Development'; | |
| const REVERT_STATUS = 'Design'; | |
| 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 | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| 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; | |
| } | |
| let project; | |
| try { | |
| project = await getProject(); | |
| } catch (err) { | |
| core.setFailed([ | |
| `Could not access https://github.com/orgs/${PROJECT_OWNER}/projects/${PROJECT_NUMBER}.`, | |
| 'Check that API_PLATFORM_PROJECT_TOKEN has access to the wso2 org project.', | |
| `Original error: ${err.message}`, | |
| ].join('\n')); | |
| return; | |
| } | |
| if (!project) { | |
| core.setFailed(`Could not resolve https://github.com/orgs/${PROJECT_OWNER}/projects/${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 | |
| issueType { | |
| name | |
| } | |
| repository { | |
| name | |
| owner { | |
| login | |
| } | |
| } | |
| labels(first: 100) { | |
| nodes { | |
| name | |
| } | |
| } | |
| } | |
| } | |
| fieldValues(first: 100) { | |
| nodes { | |
| ... on ProjectV2ItemFieldSingleSelectValue { | |
| field { | |
| ... on ProjectV2SingleSelectField { | |
| name | |
| } | |
| } | |
| name | |
| } | |
| } | |
| } | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const result = await github.graphql(query, { | |
| login: PROJECT_OWNER, | |
| number: PROJECT_NUMBER, | |
| cursor, | |
| }); | |
| const page = result.organization.projectV2.items; | |
| items.push(...page.nodes); | |
| hasNextPage = page.pageInfo.hasNextPage; | |
| cursor = page.pageInfo.endCursor; | |
| } | |
| console.log(`Checking ${items.length} items in project "${project.title}".`); | |
| for (const item of items) { | |
| const issue = item.content; | |
| if (!issue?.number || !issue?.repository) { | |
| continue; | |
| } | |
| if (issue.state !== 'OPEN') { | |
| console.log(`#${issue.number} is not open. Skipping.`); | |
| continue; | |
| } | |
| const status = item.fieldValues.nodes.find(value => value?.field?.name === STATUS_FIELD); | |
| if (status?.name !== DESTINATION_STATUS) { | |
| continue; | |
| } | |
| const labels = issue.labels.nodes.map(label => label.name); | |
| const issueType = issue.issueType?.name || ''; | |
| const isFeatureIssue = labels.some(label => label.toLowerCase() === GATE_TRIGGER_TYPE) | |
| || issueType.toLowerCase() === GATE_TRIGGER_TYPE; | |
| if (!isFeatureIssue) { | |
| console.log(`#${issue.number} is not a feature issue. Labels: ${labels.join(', ') || 'none'}. Type: ${issueType || 'none'}. Skipping.`); | |
| continue; | |
| } | |
| const missingLabels = REQUIRED_LABELS.filter(label => !labels.includes(label)); | |
| if (missingLabels.length === 0) { | |
| console.log(`#${issue.number} passed the design gate.`); | |
| continue; | |
| } | |
| 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, | |
| }); | |
| revertedCount += 1; | |
| } | |
| console.log(`Checked ${items.length} project items. Reverted ${revertedCount}.`); |