356 lines
12 KiB
YAML
356 lines
12 KiB
YAML
name: '📋 Project Automation'
|
|
|
|
on:
|
|
workflow_dispatch: # disabled: requires GitHub App credentials (APP_ID + APP_PRIVATE_KEY)
|
|
|
|
# Minimal permissions at workflow level, jobs use PROJECT_TOKEN secret
|
|
permissions:
|
|
contents: 'read'
|
|
|
|
env:
|
|
PROJECT_NUMBER: 5
|
|
ORG_NAME: iOfficeAI
|
|
PROJECT_ID: PVT_kwDOCKhK-M4BN1Ah
|
|
STATUS_FIELD_ID: PVTSSF_lADOCKhK-M4BN1Ahzg8tcak
|
|
# Status option IDs
|
|
STATUS_TO_TRIAGE: 8e8f2d59
|
|
STATUS_BLOCKED: cd8368e3
|
|
STATUS_READY_FOR_WORK: 864da50e
|
|
STATUS_IN_PROGRESS: a5c1c67b
|
|
STATUS_CLOSED: 91437030
|
|
STATUS_DONE: 1aa2425f
|
|
|
|
jobs:
|
|
# ============================================
|
|
# Issue Events: Add to Project & manage status
|
|
# ============================================
|
|
|
|
add-issue-to-project:
|
|
name: Add Issue to Project
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'issues' && github.event.action == 'opened'
|
|
permissions:
|
|
contents: 'read'
|
|
outputs:
|
|
item_id: ${{ steps.add.outputs.itemId }}
|
|
steps:
|
|
- name: Mint identity token
|
|
id: mint_token
|
|
uses: actions/create-github-app-token@v2
|
|
with:
|
|
app-id: ${{ vars.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
|
|
- name: Add issue to project
|
|
id: add
|
|
uses: actions/add-to-project@v1.0.2
|
|
with:
|
|
project-url: https://github.com/orgs/iOfficeAI/projects/5
|
|
github-token: ${{ steps.mint_token.outputs.token }}
|
|
|
|
set-issue-initial-status:
|
|
name: Set Issue Initial Status
|
|
runs-on: ubuntu-latest
|
|
needs: add-issue-to-project
|
|
if: github.event_name == 'issues' && github.event.action == 'opened' && needs.add-issue-to-project.outputs.item_id
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
- name: Mint identity token
|
|
id: mint_token
|
|
uses: actions/create-github-app-token@v2
|
|
with:
|
|
app-id: ${{ vars.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
|
|
- name: Set status to To Triage
|
|
uses: actions/github-script@v7
|
|
with:
|
|
github-token: ${{ steps.mint_token.outputs.token }}
|
|
script: |
|
|
const mutation = `
|
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { singleSelectOptionId: $optionId }
|
|
}) {
|
|
projectV2Item { id }
|
|
}
|
|
}
|
|
`;
|
|
|
|
await github.graphql(mutation, {
|
|
projectId: process.env.PROJECT_ID,
|
|
itemId: '${{ needs.add-issue-to-project.outputs.item_id }}',
|
|
fieldId: process.env.STATUS_FIELD_ID,
|
|
optionId: process.env.STATUS_TO_TRIAGE
|
|
});
|
|
|
|
core.info('Issue added to project with status: To Triage');
|
|
|
|
update-issue-on-close:
|
|
name: Update Issue on Close
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'issues' && github.event.action == 'closed'
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
- name: Mint identity token
|
|
id: mint_token
|
|
uses: actions/create-github-app-token@v2
|
|
with:
|
|
app-id: ${{ vars.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
|
|
- name: Update issue status to Done
|
|
uses: actions/github-script@v7
|
|
with:
|
|
github-token: ${{ steps.mint_token.outputs.token }}
|
|
script: |
|
|
const contentId = context.payload.issue.node_id;
|
|
|
|
// Find the item in project
|
|
const query = `
|
|
query($org: String!, $number: Int!) {
|
|
organization(login: $org) {
|
|
projectV2(number: $number) {
|
|
items(first: 100) {
|
|
nodes {
|
|
id
|
|
content { ... on Issue { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
|
|
const item = result.organization.projectV2.items.nodes.find(i => i.content?.id === contentId);
|
|
|
|
if (!item) {
|
|
core.info('Issue not found in project, skipping');
|
|
return;
|
|
}
|
|
|
|
const mutation = `
|
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { singleSelectOptionId: $optionId }
|
|
}) {
|
|
projectV2Item { id }
|
|
}
|
|
}
|
|
`;
|
|
|
|
await github.graphql(mutation, {
|
|
projectId: process.env.PROJECT_ID,
|
|
itemId: item.id,
|
|
fieldId: process.env.STATUS_FIELD_ID,
|
|
optionId: process.env.STATUS_DONE
|
|
});
|
|
|
|
core.info('Issue closed, status updated to Done');
|
|
|
|
update-issue-on-reopen:
|
|
name: Update Issue on Reopen
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'issues' && github.event.action == 'reopened'
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
- name: Mint identity token
|
|
id: mint_token
|
|
uses: actions/create-github-app-token@v2
|
|
with:
|
|
app-id: ${{ vars.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
|
|
- name: Update issue status to To Triage
|
|
uses: actions/github-script@v7
|
|
with:
|
|
github-token: ${{ steps.mint_token.outputs.token }}
|
|
script: |
|
|
const contentId = context.payload.issue.node_id;
|
|
|
|
const query = `
|
|
query($org: String!, $number: Int!) {
|
|
organization(login: $org) {
|
|
projectV2(number: $number) {
|
|
items(first: 100) {
|
|
nodes {
|
|
id
|
|
content { ... on Issue { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
|
|
const item = result.organization.projectV2.items.nodes.find(i => i.content?.id === contentId);
|
|
|
|
if (!item) {
|
|
core.info('Issue not found in project, skipping');
|
|
return;
|
|
}
|
|
|
|
const mutation = `
|
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { singleSelectOptionId: $optionId }
|
|
}) {
|
|
projectV2Item { id }
|
|
}
|
|
}
|
|
`;
|
|
|
|
await github.graphql(mutation, {
|
|
projectId: process.env.PROJECT_ID,
|
|
itemId: item.id,
|
|
fieldId: process.env.STATUS_FIELD_ID,
|
|
optionId: process.env.STATUS_TO_TRIAGE
|
|
});
|
|
|
|
core.info('Issue reopened, status updated to To Triage');
|
|
|
|
# ============================================
|
|
# PR Events: Update linked Issue status
|
|
# ============================================
|
|
|
|
pr-update-linked-issue:
|
|
name: Update Linked Issue Status
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'pull_request'
|
|
permissions:
|
|
contents: 'read'
|
|
pull-requests: 'read'
|
|
steps:
|
|
- name: Mint identity token
|
|
id: mint_token
|
|
uses: actions/create-github-app-token@v2
|
|
with:
|
|
app-id: ${{ vars.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
|
|
- name: Update linked issue status based on PR event
|
|
uses: actions/github-script@v7
|
|
with:
|
|
github-token: ${{ steps.mint_token.outputs.token }}
|
|
script: |
|
|
const prBody = context.payload.pull_request.body || '';
|
|
const prNumber = context.payload.pull_request.number;
|
|
const action = context.payload.action;
|
|
const isMerged = context.payload.pull_request.merged === true;
|
|
|
|
// Extract linked issue numbers from PR body
|
|
const issuePatterns = [
|
|
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi,
|
|
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/gi
|
|
];
|
|
|
|
const linkedIssues = new Set();
|
|
for (const pattern of issuePatterns) {
|
|
let match;
|
|
while ((match = pattern.exec(prBody)) !== null) {
|
|
linkedIssues.add(parseInt(match[1], 10));
|
|
}
|
|
}
|
|
|
|
if (linkedIssues.size === 0) {
|
|
core.info('No linked issues found in PR body, skipping');
|
|
return;
|
|
}
|
|
|
|
core.info(`Found linked issues: ${[...linkedIssues].join(', ')}`);
|
|
|
|
// Determine target status based on PR action
|
|
let targetStatusId;
|
|
let statusName;
|
|
|
|
if (action === 'opened' || action === 'reopened') {
|
|
// PR opened/reopened -> Issue is In Progress
|
|
targetStatusId = process.env.STATUS_IN_PROGRESS;
|
|
statusName = 'In Progress';
|
|
} else if (action === 'closed') {
|
|
if (isMerged) {
|
|
// PR merged -> Issue is Done
|
|
targetStatusId = process.env.STATUS_DONE;
|
|
statusName = 'Done';
|
|
} else {
|
|
// PR closed without merge -> Issue back to Ready for Work
|
|
targetStatusId = process.env.STATUS_READY_FOR_WORK;
|
|
statusName = 'Ready for Work';
|
|
}
|
|
}
|
|
|
|
if (!targetStatusId) {
|
|
core.info(`No status change needed for action: ${action}`);
|
|
return;
|
|
}
|
|
|
|
// Query to find issues in project
|
|
const query = `
|
|
query($org: String!, $number: Int!) {
|
|
organization(login: $org) {
|
|
projectV2(number: $number) {
|
|
items(first: 100) {
|
|
nodes {
|
|
id
|
|
content {
|
|
... on Issue {
|
|
id
|
|
number
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const result = await github.graphql(query, { org: 'iOfficeAI', number: 5 });
|
|
const projectItems = result.organization.projectV2.items.nodes;
|
|
|
|
const mutation = `
|
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { singleSelectOptionId: $optionId }
|
|
}) {
|
|
projectV2Item { id }
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Update each linked issue
|
|
for (const issueNumber of linkedIssues) {
|
|
const item = projectItems.find(i => i.content?.number === issueNumber);
|
|
|
|
if (!item) {
|
|
core.info(`Issue #${issueNumber} not found in project, skipping`);
|
|
continue;
|
|
}
|
|
|
|
await github.graphql(mutation, {
|
|
projectId: process.env.PROJECT_ID,
|
|
itemId: item.id,
|
|
fieldId: process.env.STATUS_FIELD_ID,
|
|
optionId: targetStatusId
|
|
});
|
|
|
|
core.info(`Issue #${issueNumber} status updated to ${statusName} (PR #${prNumber} ${action}${isMerged ? ' merged' : ''})`);
|
|
}
|