Files
2026-07-13 13:20:22 +08:00

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