|
| 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 | + const PUBLISHED_URL_FIELD_NAME = 'Published URL'; |
| 38 | + // ---------------------------------- |
| 39 | +
|
| 40 | + // Dates |
| 41 | + const TODAY = new Date(); |
| 42 | + const sixMonthsAgoISO = (() => { |
| 43 | + const d = new Date(TODAY); |
| 44 | + d.setUTCMonth(d.getUTCMonth() - 6); |
| 45 | + return d.toISOString().split('T')[0]; |
| 46 | + })(); |
| 47 | + const toDate = (iso) => new Date(iso + 'T00:00:00.000Z'); |
| 48 | +
|
| 49 | + // Project |
| 50 | + const proj = await github.graphql( |
| 51 | + `query($org:String!,$num:Int!){ |
| 52 | + organization(login:$org){ |
| 53 | + projectV2(number:$num){ id } |
| 54 | + } |
| 55 | + }`, |
| 56 | + { org: orgLogin, num: projectNumber } |
| 57 | + ); |
| 58 | + const projectId = proj.organization?.projectV2?.id; |
| 59 | + if (!projectId) throw new Error('Project not found'); |
| 60 | +
|
| 61 | + // Fields |
| 62 | + const fields = (await github.graphql( |
| 63 | + `query($id:ID!){ |
| 64 | + node(id:$id){ |
| 65 | + ... on ProjectV2 { |
| 66 | + fields(first:50){ |
| 67 | + nodes{ |
| 68 | + __typename |
| 69 | + ... on ProjectV2Field { id name dataType } |
| 70 | + ... on ProjectV2SingleSelectField { id name options { id name } } |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + }`, { id: projectId } |
| 76 | + )).node.fields.nodes; |
| 77 | +
|
| 78 | + const findDateFieldId = (name) => |
| 79 | + fields.find(f => f.__typename === 'ProjectV2Field' && f.name === name && f.dataType === 'DATE')?.id || null; |
| 80 | +
|
| 81 | + const findTextFieldId = (name) => { |
| 82 | + const exact = fields.find(f => f.__typename === 'ProjectV2Field' && f.name === name && f.dataType === 'TEXT'); |
| 83 | + if (exact) return exact.id; |
| 84 | + const ci = fields.find(f => f.__typename === 'ProjectV2Field' && (f.name?.toLowerCase?.() === name.toLowerCase()) && f.dataType === 'TEXT'); |
| 85 | + return ci?.id || null; |
| 86 | + }; |
| 87 | + |
| 88 | + const statusField = fields.find(f => f.__typename === 'ProjectV2SingleSelectField' && f.name === STATUS_FIELD_NAME); |
| 89 | + const statusFieldId = statusField?.id || null; |
| 90 | + const doneId = statusField?.options?.find(o => o.name === STATUS_DONE)?.id || null; |
| 91 | + const maintId = statusField?.options?.find(o => o.name === STATUS_MAINT)?.id || null; |
| 92 | +
|
| 93 | + const lrdId = findDateFieldId(LRD_FIELD_NAME); |
| 94 | + const publishedUrlFieldId = findTextFieldId(PUBLISHED_URL_FIELD_NAME); |
| 95 | +
|
| 96 | + if (!statusFieldId || !doneId || !maintId || !lrdId) { |
| 97 | + throw new Error('Missing required project fields/options: Status/Done/Maintenance or Last Reviewed Date.'); |
| 98 | + } |
| 99 | +
|
| 100 | + // Helpers |
| 101 | + const getDate = (item, fieldId) => |
| 102 | + item.fieldValues.nodes.find(n => |
| 103 | + n.__typename === 'ProjectV2ItemFieldDateValue' && n.field?.id === fieldId |
| 104 | + )?.date || null; |
| 105 | +
|
| 106 | + const getText = (item, fieldId) => |
| 107 | + item.fieldValues.nodes.find(n => |
| 108 | + n.__typename === 'ProjectV2ItemFieldTextValue' && n.field?.id === fieldId |
| 109 | + )?.text || null; |
| 110 | + |
| 111 | + const getStatusName = (item) => { |
| 112 | + const n = item.fieldValues.nodes.find(n => |
| 113 | + n.__typename === 'ProjectV2ItemFieldSingleSelectValue' && n.field?.id === statusFieldId |
| 114 | + ); |
| 115 | + return n?.name || null; |
| 116 | + }; |
| 117 | +
|
| 118 | + const setStatus = async (itemId, fieldId, optionId) => { |
| 119 | + if (dryRun) { |
| 120 | + console.log(`[DRY RUN] setStatus item=${itemId} -> option=${optionId}`); |
| 121 | + return; |
| 122 | + } |
| 123 | + const m = ` |
| 124 | + mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){ |
| 125 | + updateProjectV2ItemFieldValue(input:{ |
| 126 | + projectId:$p, itemId:$i, fieldId:$f, value:{ singleSelectOptionId:$o } |
| 127 | + }){ |
| 128 | + projectV2Item { id } |
| 129 | + } |
| 130 | + }`; |
| 131 | + await github.graphql(m, { p: projectId, i: itemId, f: fieldId, o: optionId }); |
| 132 | + }; |
| 133 | +
|
| 134 | + async function* iterItems() { |
| 135 | + let cursor = null; |
| 136 | + for (;;) { |
| 137 | + const r = await github.graphql( |
| 138 | + `query($org:String!,$num:Int!,$after:String){ |
| 139 | + organization(login:$org){ |
| 140 | + projectV2(number:$num){ |
| 141 | + items(first:100, after:$after){ |
| 142 | + nodes{ |
| 143 | + id |
| 144 | + content{ |
| 145 | + __typename |
| 146 | + ... on PullRequest { |
| 147 | + number |
| 148 | + repository { name } |
| 149 | + } |
| 150 | + } |
| 151 | + fieldValues(first:100){ |
| 152 | + nodes{ |
| 153 | + __typename |
| 154 | + ... on ProjectV2ItemFieldDateValue { |
| 155 | + field { ... on ProjectV2Field { id name } } |
| 156 | + date |
| 157 | + } |
| 158 | + ... on ProjectV2ItemFieldTextValue { |
| 159 | + field { ... on ProjectV2Field { id name } } |
| 160 | + text |
| 161 | + } |
| 162 | + ... on ProjectV2ItemFieldSingleSelectValue { |
| 163 | + field { ... on ProjectV2SingleSelectField { id name } } |
| 164 | + name |
| 165 | + optionId |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + pageInfo { |
| 171 | + hasNextPage |
| 172 | + endCursor |
| 173 | + } |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + }`, |
| 178 | + { org: orgLogin, num: projectNumber, after: cursor } |
| 179 | + ); |
| 180 | + |
| 181 | + const page = r.organization.projectV2.items; |
| 182 | + for (const n of page.nodes) yield n; |
| 183 | + if (!page.pageInfo.hasNextPage) break; |
| 184 | + cursor = page.pageInfo.endCursor; |
| 185 | + } |
| 186 | + } |
| 187 | +
|
| 188 | + const lastTwoFromUrl = (url) => { |
| 189 | + if (!url) return ''; |
| 190 | + try { |
| 191 | + const u = new URL(url); |
| 192 | + const segs = u.pathname.split('/').filter(Boolean); |
| 193 | + if (segs.length >= 2) return `${segs[segs.length - 2]}/${segs[segs.length - 1]}/`; |
| 194 | + if (segs.length === 1) return `${segs[0]}/`; |
| 195 | + return ''; |
| 196 | + } catch { return ''; } |
| 197 | + }; |
| 198 | +
|
| 199 | + // Movement counters & log |
| 200 | + let movedDoneToMaint = 0; |
| 201 | + let movedMaintToDone = 0; |
| 202 | + const moveLog = []; |
| 203 | +
|
| 204 | + // Sweep |
| 205 | + for await (const item of iterItems()) { |
| 206 | + if (item.content?.__typename !== 'PullRequest') continue; // PRs only |
| 207 | +
|
| 208 | + const itemId = item.id; |
| 209 | + const status = getStatusName(item); |
| 210 | + const lrd = getDate(item, lrdId); |
| 211 | + if (!status || !lrd) continue; // only move when LRD exists |
| 212 | + |
| 213 | + const prNumber = item.content.number; |
| 214 | + const repoName = item.content.repository.name; |
| 215 | +
|
| 216 | + const publishedUrl = publishedUrlFieldId ? getText(item, publishedUrlFieldId) : null; |
| 217 | + const lastTwoSegments = lastTwoFromUrl(publishedUrl) || '(no-published-url)'; |
| 218 | +
|
| 219 | + // Done -> Maintenance: LRD older/equal than 6 months ago |
| 220 | + if (status === STATUS_DONE && toDate(lrd) <= toDate(sixMonthsAgoISO)) { |
| 221 | + await setStatus(itemId, statusFieldId, maintId); |
| 222 | + movedDoneToMaint++; |
| 223 | + const line = `[Cron] Moved ${lastTwoSegments} → Maintenance (LRD ${lrd} ≤ ${sixMonthsAgoISO})`; |
| 224 | + console.log(line); |
| 225 | + moveLog.push(line); |
| 226 | + continue; // skip second rule for same item |
| 227 | + } |
| 228 | +
|
| 229 | + // Maintenance -> Done: LRD within last 6 months (strictly newer than threshold) |
| 230 | + if (status === STATUS_MAINT && toDate(lrd) > toDate(sixMonthsAgoISO)) { |
| 231 | + await setStatus(itemId, statusFieldId, doneId); |
| 232 | + movedMaintToDone++; |
| 233 | + const line = `[Cron] Moved ${lastTwoSegments} → Done (LRD ${lrd} > ${sixMonthsAgoISO})`; |
| 234 | + console.log(line); |
| 235 | + moveLog.push(line); |
| 236 | + } |
| 237 | + } |
| 238 | +
|
| 239 | + // Summary |
| 240 | + const totalMoves = movedDoneToMaint + movedMaintToDone; |
| 241 | + console.log(`Cron complete. Moved Done→Maintenance: ${movedDoneToMaint}, Maintenance→Done: ${movedMaintToDone}, Total: ${totalMoves}. Dry run: ${dryRun}`); |
| 242 | +
|
| 243 | + // Nice Job Summary in the Actions UI |
| 244 | + await core.summary |
| 245 | + .addHeading('Last Reviewed Cron Summary') |
| 246 | + .addTable([ |
| 247 | + [{ data: 'Direction', header: true }, { data: 'Count', header: true }], |
| 248 | + ['Done → Maintenance', String(movedDoneToMaint)], |
| 249 | + ['Maintenance → Done', String(movedMaintToDone)], |
| 250 | + ['Total moves', String(totalMoves)], |
| 251 | + ]) |
| 252 | + .addHeading('Details', 2) |
| 253 | + .addCodeBlock(moveLog.join('\n') || 'No moves', 'text') |
| 254 | + .write(); |
0 commit comments