Skip to content

Commit 5a7c71f

Browse files
committed
feat: add project sync scaffolding
1 parent 05719bd commit 5a7c71f

16 files changed

Lines changed: 1410 additions & 0 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Sync PR Project Fields - Apply
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Sync PR Project Fields - Compute"]
6+
types: [completed]
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
apply:
17+
if: github.event.workflow_run.conclusion == 'success'
18+
runs-on: ubuntu-latest
19+
permissions:
20+
# projects: write is required for GitHub Projects V2 GraphQL mutations
21+
# and is not available at the workflow level — it must be scoped to the job
22+
contents: read
23+
actions: read
24+
# Note: 'projects: write' requires the GITHUB_TOKEN to be granted this
25+
# permission in the repository or organisation settings, or a PAT/App
26+
# token with the project scope must be passed via secrets.PROJECT_TOKEN.
27+
28+
steps:
29+
- name: Harden Runner
30+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
31+
with:
32+
egress-policy: audit
33+
34+
- name: Checkout scripts
35+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
36+
with:
37+
sparse-checkout: |
38+
.github/scripts
39+
sparse-checkout-cone-mode: false
40+
41+
- name: Download Artifact
42+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
43+
continue-on-error: true
44+
with:
45+
name: project-sync-payload
46+
run-id: ${{ github.event.workflow_run.id }}
47+
github-token: ${{ secrets.GITHUB_TOKEN }}
48+
49+
- name: Apply Project Field Values
50+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
51+
env:
52+
PROJECT_NODE_ID: ${{ secrets.PROJECT_NODE_ID }}
53+
with:
54+
# Use a PAT or GitHub App token with the 'project' OAuth scope for
55+
# Projects V2 mutations. GITHUB_TOKEN only has project scope when
56+
# explicitly granted in repo/org settings.
57+
github-token: ${{ secrets.PROJECT_TOKEN || secrets.GITHUB_TOKEN }}
58+
script: |
59+
const fs = require('fs');
60+
61+
if (!fs.existsSync('project-sync-payload.json')) {
62+
console.log('No payload file found. Compute phase had nothing to sync.');
63+
return;
64+
}
65+
66+
const payload = JSON.parse(fs.readFileSync('project-sync-payload.json', 'utf8'));
67+
const { prNodeId, priority, type } = payload;
68+
69+
if (!prNodeId) {
70+
console.log('Payload is missing prNodeId. Skipping.');
71+
return;
72+
}
73+
74+
console.log(`Applying project fields for PR node: ${prNodeId}`);
75+
console.log(` priority="${priority || 'none'}" type="${type || 'none'}"`);
76+
77+
const setupSync = require('./.github/scripts/project-sync/index.js');
78+
const syncPR = await setupSync({ github, context, core });
79+
80+
if (typeof syncPR === 'function') {
81+
await syncPR({ prNodeId, priority, type });
82+
console.log('Project fields synced successfully.');
83+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Sync PR Project Fields - Compute
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, reopened, synchronize]
6+
7+
permissions:
8+
issues: read
9+
contents: read
10+
pull-requests: read
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
compute:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Harden Runner
21+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
22+
with:
23+
egress-policy: audit
24+
25+
- name: Checkout scripts
26+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
27+
with:
28+
sparse-checkout: |
29+
.github/scripts
30+
sparse-checkout-cone-mode: false
31+
32+
- name: Compute Project Field Values
33+
id: compute
34+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
35+
with:
36+
script: |
37+
const prNumber = context.payload.pull_request.number;
38+
const prNodeId = context.payload.pull_request.node_id;
39+
console.log(`--- Processing PR #${prNumber} (node: ${prNodeId}) ---`);
40+
41+
const { data: prData } = await github.rest.pulls.get({
42+
owner: context.repo.owner,
43+
repo: context.repo.repo,
44+
pull_number: prNumber,
45+
});
46+
47+
// Skip bot-authored PRs — they are not linked to labelled issues
48+
const prAuthor = prData.user.login;
49+
if (/\[bot\]$/i.test(prAuthor) || /dependabot/i.test(prAuthor)) {
50+
console.log(`Skipping bot-authored PR (author: ${prAuthor}).`);
51+
return;
52+
}
53+
54+
// Inline helpers — kept here to avoid a checkout of the scripts dir
55+
// at the low-privilege compute phase.
56+
const { getLinkedIssueLabels } = require('./.github/scripts/project-sync/helpers/linked-issues');
57+
const { deriveFieldValues } = require('./.github/scripts/project-sync/helpers/mapping');
58+
59+
const issueLabels = await getLinkedIssueLabels(
60+
prData.body,
61+
github,
62+
context.repo.owner,
63+
context.repo.repo
64+
);
65+
66+
if (issueLabels.length === 0) {
67+
console.log('No priority or type labels found on linked issues. Nothing to sync.');
68+
return;
69+
}
70+
71+
const { priority, type } = deriveFieldValues(issueLabels);
72+
73+
if (!priority && !type) {
74+
console.log('Labels found but none map to a project field. Nothing to sync.');
75+
return;
76+
}
77+
78+
const fs = require('fs');
79+
const payload = { prNodeId, priority, type };
80+
fs.writeFileSync('project-sync-payload.json', JSON.stringify(payload));
81+
console.log(`Payload written: ${JSON.stringify(payload)}`);
82+
83+
- name: Check for Payload File
84+
id: check_file
85+
run: |
86+
if [ -f project-sync-payload.json ]; then
87+
echo "exists=true" >> $GITHUB_OUTPUT
88+
else
89+
echo "exists=false" >> $GITHUB_OUTPUT
90+
fi
91+
92+
- name: Upload Artifact
93+
if: steps.check_file.outputs.exists == 'true'
94+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
95+
with:
96+
name: project-sync-payload
97+
path: project-sync-payload.json
98+
retention-days: 1
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Test Project Sync
2+
3+
on:
4+
push:
5+
paths:
6+
- '.github/scripts/project-sync/**'
7+
- '.github/workflows/test-project-sync.yml'
8+
pull_request:
9+
paths:
10+
- '.github/scripts/project-sync/**'
11+
- '.github/workflows/test-project-sync.yml'
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
test:
18+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
19+
runs-on: hl-sdk-py-lin-md
20+
permissions:
21+
contents: read
22+
steps:
23+
- name: Harden the runner
24+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
25+
with:
26+
egress-policy: audit
27+
28+
- name: Checkout repository
29+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
30+
31+
- name: Setup Node.js
32+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
33+
with:
34+
node-version: '20'
35+
36+
- name: Run Tests
37+
working-directory: .github/scripts/project-sync
38+
run: npm test
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
2+
/**
3+
* Safety floor for the GitHub API rate limit.
4+
* The workflow will skip processing if remaining calls fall below this.
5+
*/
6+
const RATE_LIMIT_FLOOR = 100;
7+
8+
/**
9+
* Node ID of the GitHub Projects V2 board.
10+
* Override via PROJECT_NODE_ID environment variable.
11+
*
12+
* Starts with "PVT_" — retrieve via GraphQL as described above.
13+
*/
14+
const PROJECT_NODE_ID = process.env.PROJECT_NODE_ID?.trim() || '';
15+
16+
/**
17+
* Node ID of the "Priority" Single Select field on the project board.
18+
* Override via PRIORITY_FIELD_ID environment variable.
19+
*/
20+
const PRIORITY_FIELD_ID = process.env.PRIORITY_FIELD_ID?.trim() || '';
21+
22+
/**
23+
* Node ID of the "Type" Single Select field on the project board.
24+
* Override via TYPE_FIELD_ID environment variable.
25+
*/
26+
const TYPE_FIELD_ID = process.env.TYPE_FIELD_ID?.trim() || '';
27+
28+
/**
29+
* Priority label names — must be applied manually by maintainers to issues.
30+
* Ordered from most urgent to least urgent (index 0 wins when multiple match).
31+
*/
32+
const PRIORITY_LABELS = [
33+
{
34+
name: process.env.PRIORITY_URGENT_LABEL?.trim() || 'priority: urgent',
35+
color: 'b60205',
36+
description: 'Drop everything — fix this now',
37+
optionName: 'Urgent',
38+
},
39+
{
40+
name: process.env.PRIORITY_HIGH_LABEL?.trim() || 'priority: high',
41+
color: 'e11d48',
42+
description: 'Should be reviewed in the current cycle',
43+
optionName: 'High',
44+
},
45+
{
46+
name: process.env.PRIORITY_MEDIUM_LABEL?.trim() || 'priority: medium',
47+
color: 'f97316',
48+
description: 'Normal priority — schedule when convenient',
49+
optionName: 'Medium',
50+
},
51+
{
52+
name: process.env.PRIORITY_LOW_LABEL?.trim() || 'priority: low',
53+
color: 'fde68a',
54+
description: 'Nice to have, no urgency',
55+
optionName: 'Low',
56+
},
57+
];
58+
59+
/**
60+
* Type label names — must be applied manually by maintainers to issues.
61+
* Ordered by precedence (index 0 wins when multiple match).
62+
*/
63+
const TYPE_LABELS = [
64+
{
65+
name: process.env.TYPE_BUG_LABEL?.trim() || 'bug',
66+
color: 'd73a4a',
67+
description: 'Something is not working',
68+
optionName: 'Bug',
69+
},
70+
{
71+
name: process.env.TYPE_FEATURE_LABEL?.trim() || 'feature',
72+
color: 'a2eeef',
73+
description: 'New feature or enhancement request',
74+
optionName: 'Feature',
75+
},
76+
];
77+
78+
/** All managed label objects in one flat array (for ensureLabels). */
79+
const ALL_MANAGED_LABELS = [...PRIORITY_LABELS, ...TYPE_LABELS];
80+
81+
/** All priority label names as a Set for fast membership tests. */
82+
const PRIORITY_LABEL_NAMES = new Set(PRIORITY_LABELS.map((l) => l.name.toLowerCase()));
83+
84+
/** All type label names as a Set for fast membership tests. */
85+
const TYPE_LABEL_NAMES = new Set(TYPE_LABELS.map((l) => l.name.toLowerCase()));
86+
87+
module.exports = {
88+
RATE_LIMIT_FLOOR,
89+
PROJECT_NODE_ID,
90+
PRIORITY_FIELD_ID,
91+
TYPE_FIELD_ID,
92+
PRIORITY_LABELS,
93+
TYPE_LABELS,
94+
ALL_MANAGED_LABELS,
95+
PRIORITY_LABEL_NAMES,
96+
TYPE_LABEL_NAMES,
97+
};

0 commit comments

Comments
 (0)