Skip to content

Enforce Design Gate #83

Enforce Design Gate

Enforce Design Gate #83

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}.`);