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