Skip to content

Commit 33836f1

Browse files
authored
Merge pull request #2449 from chrismoroney/cmoroney-github-workflow-updates
scripts to automate PR movement
2 parents 8cacc14 + 76174ca commit 33836f1

File tree

2 files changed

+337
-0
lines changed

2 files changed

+337
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
name: Last Reviewed Date Backfill (One Time)
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
dry_run:
7+
description: "Log actions only (no writes)"
8+
type: boolean
9+
default: true
10+
11+
permissions:
12+
contents: read
13+
pull-requests: read
14+
repository-projects: write
15+
16+
jobs:
17+
backfill:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Backfill Last Reviewed Date
21+
uses: actions/github-script@v6
22+
with:
23+
github-token: ${{ secrets.PROJECT_TOKEN }}
24+
script: |
25+
const dryRun = core.getInput('dry_run') === 'true';
26+
27+
const orgLogin = 'ArmDeveloperEcosystem';
28+
const projectNumber = 4;
29+
30+
const ISO_CUTOFF = '2024-12-31';
31+
const toDate = (iso) => new Date(iso + 'T00:00:00.000Z');
32+
33+
// 1) project
34+
const proj = await github.graphql(
35+
`query($org:String!,$num:Int!){ organization(login:$org){ projectV2(number:$num){ id } } }`,
36+
{ org: orgLogin, num: projectNumber }
37+
);
38+
const projectId = proj.organization?.projectV2?.id;
39+
if (!projectId) throw new Error('Project not found');
40+
41+
// 2) fields
42+
const fields = (await github.graphql(
43+
`query($id:ID!){ node(id:$id){ ... on ProjectV2 {
44+
fields(first:50){ nodes{
45+
__typename
46+
... on ProjectV2Field{ id name dataType }
47+
... on ProjectV2SingleSelectField{ id name options{ id name } }
48+
} } } } }`, { id: projectId }
49+
)).node.fields.nodes;
50+
51+
const dateFieldId = (n)=>fields.find(f=>f.__typename==='ProjectV2Field'&&f.name===n&&f.dataType==='DATE')?.id||null;
52+
const statusField = fields.find(f=>f.__typename==='ProjectV2SingleSelectField' && f.name==='Status');
53+
const statusFieldId = statusField?.id;
54+
const publishId = dateFieldId('Publish Date');
55+
const lrdId = dateFieldId('Last Reviewed Date');
56+
57+
if (!statusFieldId || !lrdId) throw new Error('Missing Status or Last Reviewed Date field');
58+
59+
// writers
60+
const setDate = async (itemId, fieldId, iso) => {
61+
if (dryRun) return console.log(`[DRY RUN] setDate item=${itemId} -> ${iso}`);
62+
const m = `mutation($p:ID!,$i:ID!,$f:ID!,$d:String!){
63+
updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f,value:{date:$d}}){
64+
projectV2Item{ id }
65+
}}`;
66+
await github.graphql(m, { p: projectId, i: itemId, f: fieldId, d: iso });
67+
};
68+
69+
// helpers
70+
const getDate = (item,id)=>item.fieldValues.nodes.find(n=>n.__typename==='ProjectV2ItemFieldDateValue'&&n.field?.id===id)?.date||null;
71+
const getStatus = (item)=>{ const n=item.fieldValues.nodes.find(n=>n.__typename==='ProjectV2ItemFieldSingleSelectValue'&&n.field?.id===statusFieldId); return n?.name||null; };
72+
73+
// iterate
74+
async function* items(){ let cursor=null; for(;;){
75+
const r=await github.graphql(
76+
`query($org:String!,$num:Int!,$after:String){
77+
organization(login:$org){ projectV2(number:$num){
78+
items(first:100, after:$after){
79+
nodes{
80+
id
81+
content{ __typename ... on PullRequest{ number repository{ name } } }
82+
fieldValues(first:50){ nodes{
83+
__typename
84+
... on ProjectV2ItemFieldDateValue{ field{ ... on ProjectV2Field{ id name } } date }
85+
... on ProjectV2ItemFieldSingleSelectValue{ field{ ... on ProjectV2SingleSelectField{ id name } } name optionId }
86+
} }
87+
}
88+
pageInfo{ hasNextPage endCursor }
89+
}
90+
} } }`,
91+
{ org: orgLogin, num: projectNumber, after: cursor }
92+
);
93+
const page=r.organization.projectV2.items;
94+
for(const n of page.nodes) yield n;
95+
if(!page.pageInfo.hasNextPage) break;
96+
cursor=page.pageInfo.endCursor;
97+
} }
98+
99+
let updates=0;
100+
for await (const item of items()){
101+
if (item.content?.__typename !== 'PullRequest') continue;
102+
103+
const status = getStatus(item);
104+
if (status !== 'Done' && status !== 'Maintenance') continue;
105+
106+
const lrd = getDate(item, lrdId);
107+
if (lrd) continue; // already has a value
108+
109+
if (status === 'Done') {
110+
const publish = publishId ? getDate(item, publishId) : null;
111+
if (publish) {
112+
await setDate(item.id, lrdId, publish);
113+
updates++; console.log(`[Backfill][Done] Set LRD=${publish}`);
114+
} else {
115+
console.log(`[Skip][Done] No Publish Date; not setting LRD`);
116+
}
117+
}
118+
119+
if (status === 'Maintenance') {
120+
await setDate(item.id, lrdId, ISO_CUTOFF);
121+
updates++; console.log(`[Backfill][Maintenance] Set LRD=${ISO_CUTOFF}`);
122+
}
123+
}
124+
125+
console.log(`Backfill complete. Items updated: ${updates}. Dry run: ${dryRun}`);
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
name: Last Reviewed Cron
2+
3+
on:
4+
schedule:
5+
- cron: "0 9 * * *" # daily at 09:00 UTC
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: "Log actions only (no writes)"
10+
type: boolean
11+
default: false
12+
13+
permissions:
14+
contents: read
15+
pull-requests: read
16+
repository-projects: write
17+
18+
jobs:
19+
sweep:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Move items based on Last Reviewed Date
23+
uses: actions/github-script@v6
24+
with:
25+
github-token: ${{ secrets.PROJECT_TOKEN }}
26+
script: |
27+
// Inputs
28+
const dryRun = core.getInput('dry_run') === 'true';
29+
30+
// ---- Config (edit if needed) ----
31+
const orgLogin = 'ArmDeveloperEcosystem';
32+
const projectNumber = 4;
33+
const STATUS_FIELD_NAME = 'Status';
34+
const STATUS_DONE = 'Done';
35+
const STATUS_MAINT = 'Maintenance';
36+
const LRD_FIELD_NAME = 'Last Reviewed Date';
37+
// ----------------------------------
38+
39+
// Dates
40+
const TODAY = new Date();
41+
const sixMonthsAgoISO = (() => {
42+
const d = new Date(TODAY);
43+
d.setUTCMonth(d.getUTCMonth() - 6);
44+
return d.toISOString().split('T')[0];
45+
})();
46+
const toDate = (iso) => new Date(iso + 'T00:00:00.000Z');
47+
48+
// Project
49+
const proj = await github.graphql(
50+
`query($org:String!,$num:Int!){
51+
organization(login:$org){
52+
projectV2(number:$num){ id }
53+
}
54+
}`,
55+
{ org: orgLogin, num: projectNumber }
56+
);
57+
const projectId = proj.organization?.projectV2?.id;
58+
if (!projectId) throw new Error('Project not found');
59+
60+
// Fields
61+
const fields = (await github.graphql(
62+
`query($id:ID!){
63+
node(id:$id){
64+
... on ProjectV2 {
65+
fields(first:50){
66+
nodes{
67+
__typename
68+
... on ProjectV2Field { id name dataType }
69+
... on ProjectV2SingleSelectField { id name options { id name } }
70+
}
71+
}
72+
}
73+
}
74+
}`, { id: projectId }
75+
)).node.fields.nodes;
76+
77+
const findDateFieldId = (name) =>
78+
fields.find(f => f.__typename === 'ProjectV2Field' && f.name === name && f.dataType === 'DATE')?.id || null;
79+
80+
const statusField = fields.find(f => f.__typename === 'ProjectV2SingleSelectField' && f.name === STATUS_FIELD_NAME);
81+
const statusFieldId = statusField?.id || null;
82+
const doneId = statusField?.options?.find(o => o.name === STATUS_DONE)?.id || null;
83+
const maintId = statusField?.options?.find(o => o.name === STATUS_MAINT)?.id || null;
84+
85+
const lrdId = findDateFieldId(LRD_FIELD_NAME);
86+
87+
if (!statusFieldId || !doneId || !maintId || !lrdId) {
88+
throw new Error('Missing required project fields/options: Status/Done/Maintenance or Last Reviewed Date.');
89+
}
90+
91+
// Helpers
92+
const getDate = (item, fieldId) =>
93+
item.fieldValues.nodes.find(n =>
94+
n.__typename === 'ProjectV2ItemFieldDateValue' && n.field?.id === fieldId
95+
)?.date || null;
96+
97+
const getStatusName = (item) => {
98+
const n = item.fieldValues.nodes.find(n =>
99+
n.__typename === 'ProjectV2ItemFieldSingleSelectValue' && n.field?.id === statusFieldId
100+
);
101+
return n?.name || null;
102+
};
103+
104+
const setStatus = async (itemId, fieldId, optionId) => {
105+
if (dryRun) {
106+
console.log(`[DRY RUN] setStatus item=${itemId} -> option=${optionId}`);
107+
return;
108+
}
109+
const m = `
110+
mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){
111+
updateProjectV2ItemFieldValue(input:{
112+
projectId:$p, itemId:$i, fieldId:$f, value:{ singleSelectOptionId:$o }
113+
}){
114+
projectV2Item { id }
115+
}
116+
}`;
117+
await github.graphql(m, { p: projectId, i: itemId, f: fieldId, o: optionId });
118+
};
119+
120+
async function* iterItems() {
121+
let cursor = null;
122+
for (;;) {
123+
const r = await github.graphql(
124+
`query($org:String!,$num:Int!,$after:String){
125+
organization(login:$org){
126+
projectV2(number:$num){
127+
items(first:100, after:$after){
128+
nodes{
129+
id
130+
content{
131+
__typename
132+
... on PullRequest { number repository{ name } }
133+
}
134+
fieldValues(first:50){
135+
nodes{
136+
__typename
137+
... on ProjectV2ItemFieldDateValue {
138+
field { ... on ProjectV2Field { id name } }
139+
date
140+
}
141+
... on ProjectV2ItemFieldSingleSelectValue {
142+
field { ... on ProjectV2SingleSelectField { id name } }
143+
name
144+
optionId
145+
}
146+
}
147+
}
148+
}
149+
pageInfo{ hasNextPage endCursor }
150+
}
151+
}
152+
}
153+
}`,
154+
{ org: orgLogin, num: projectNumber, after: cursor }
155+
);
156+
const page = r.organization.projectV2.items;
157+
for (const n of page.nodes) yield n;
158+
if (!page.pageInfo.hasNextPage) break;
159+
cursor = page.pageInfo.endCursor;
160+
}
161+
}
162+
163+
// Movement counters & log
164+
let movedDoneToMaint = 0;
165+
let movedMaintToDone = 0;
166+
const moveLog = [];
167+
168+
// Sweep
169+
for await (const item of iterItems()) {
170+
if (item.content?.__typename !== 'PullRequest') continue; // PRs only
171+
172+
const itemId = item.id;
173+
const status = getStatusName(item);
174+
const lrd = getDate(item, lrdId);
175+
if (!status || !lrd) continue; // only move when LRD exists
176+
177+
// Done -> Maintenance: LRD older/equal than 6 months ago
178+
if (status === STATUS_DONE && toDate(lrd) <= toDate(sixMonthsAgoISO)) {
179+
await setStatus(itemId, statusFieldId, maintId);
180+
movedDoneToMaint++;
181+
const line = `[Cron] Move Done → Maintenance (LRD ${lrd} ≤ ${sixMonthsAgoISO})`;
182+
console.log(line);
183+
moveLog.push(line);
184+
continue; // skip second rule for same item
185+
}
186+
187+
// Maintenance -> Done: LRD within last 6 months (strictly newer than threshold)
188+
if (status === STATUS_MAINT && toDate(lrd) > toDate(sixMonthsAgoISO)) {
189+
await setStatus(itemId, statusFieldId, doneId);
190+
movedMaintToDone++;
191+
const line = `[Cron] Move Maintenance → Done (LRD ${lrd} > ${sixMonthsAgoISO})`;
192+
console.log(line);
193+
moveLog.push(line);
194+
}
195+
}
196+
197+
// Summary
198+
const totalMoves = movedDoneToMaint + movedMaintToDone;
199+
console.log(`Cron complete. Moved Done→Maintenance: ${movedDoneToMaint}, Maintenance→Done: ${movedMaintToDone}, Total: ${totalMoves}. Dry run: ${dryRun}`);
200+
201+
// Nice Job Summary in the Actions UI
202+
await core.summary
203+
.addHeading('Last Reviewed Cron Summary')
204+
.addTable([
205+
[{ data: 'Direction', header: true }, { data: 'Count', header: true }],
206+
['Done → Maintenance', String(movedDoneToMaint)],
207+
['Maintenance → Done', String(movedMaintToDone)],
208+
['Total moves', String(totalMoves)],
209+
])
210+
.addHeading('Details', 2)
211+
.addCodeBlock(moveLog.join('\n') || 'No moves', 'text')
212+
.write();

0 commit comments

Comments
 (0)