From 8979fbef64882d2da21f77654e0fed017ec0e26a Mon Sep 17 00:00:00 2001 From: anshajanth Date: Tue, 21 Jul 2026 23:48:27 +0530 Subject: [PATCH 1/2] Add public announcement workflow. --- .../workflows/public-announcement-label.yml | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .github/workflows/public-announcement-label.yml diff --git a/.github/workflows/public-announcement-label.yml b/.github/workflows/public-announcement-label.yml new file mode 100644 index 00000000..1766da2f --- /dev/null +++ b/.github/workflows/public-announcement-label.yml @@ -0,0 +1,170 @@ +name: Public Announcement label (30d after Batched) + +# FLOW 1 of 2: scheduled marker-stamp + label-add (merged into one cron). +# Runs daily. For every item in "Batched for Public Announcement": +# - if it has no clock marker yet -> stamp MARKER_LABEL (starts the clock); +# - once the marker is >= WAIT_DAYS old -> add "PublicPR/Pending from " label(s). +# +# Why a marker (Actions can't sleep, and Projects v2 has no "status changed at" +# timestamp): the first cron run that sees the item Batched stamps a hidden +# MARKER_LABEL; the PR/issue timeline records when it was added, and that +# timestamp is the 30-day clock. Granularity is ~1 day, fine for a 30-day timer. +# +# REQUIRED SECRET: PROJECT_TOKEN +# A classic PAT (scopes: repo, project) or a GitHub App token with +# read access to the project and write access to issues in the target repo. +# The default GITHUB_TOKEN CANNOT read user/org Projects v2, so this is mandatory. + +on: + schedule: + - cron: '0 6 * * *' # daily at 06:00 UTC + workflow_dispatch: # allow manual runs for testing + +permissions: + contents: read + +jobs: + label-batched: + runs-on: ubuntu-latest + env: + PROJECT_OWNER: ${{ github.repository_owner }} # org that owns the repo (and the project) + OWNER_TYPE: organization # this repo lives in an org + PROJECT_NUMBER: '1' # project number from the board URL + STATUS_FIELD: Status # single-select field name on the board + STATUS_NAME: Batched for Public Announcement # status value that starts the clock + MARKER_LABEL: batched-tracked # hidden marker that records the clock start + WAIT_DAYS: '30' + # After WAIT_DAYS, each rule fires when ALL of its "sources" labels are + # present, adding any of its "targets" not already on the item. Multiple + # rules can fire for one item. To extend: add rules, add more entries to + # "sources" (all required) or "targets" (all added). This block is the + # only place you need to edit. + LABEL_RULES: | + [ + { "sources": ["Patch/IAM Completed"], "targets": ["PublicPR/Pending from IAM"] }, + { "sources": ["Patch/APIM Completed"], "targets": ["PublicPR/Pending from APIM"] }, + { "sources": ["Patch/SOLUTIONS Completed"], "targets": ["PublicPR/Pending from SOLUTIONS"] }, + { "sources": ["Patch/INTEGRATION Completed"], "targets": ["PublicPR/Pending from INTEGRATION"] }, + { "sources": ["Patch/AI Completed"], "targets": ["PublicPR/Pending from AI"] } + ] + steps: + - name: Apply label 30 days after Batched + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { + PROJECT_OWNER, OWNER_TYPE, PROJECT_NUMBER, + STATUS_FIELD, STATUS_NAME, + MARKER_LABEL, WAIT_DAYS, LABEL_RULES, + } = process.env; + + // Rules: add a rule's targets when all of its sources are present. + const rules = JSON.parse(LABEL_RULES); + const waitMs = Number(WAIT_DAYS) * 24 * 60 * 60 * 1000; + const now = Date.now(); + const ownerField = OWNER_TYPE === 'organization' ? 'organization' : 'user'; + + // 1) Page through every project item, reading its Status and the + // underlying issue/PR (number + repo). + const query = ` + query($login:String!, $number:Int!, $cursor:String) { + ${ownerField}(login:$login) { + projectV2(number:$number) { + items(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + fieldValueByName(name:"${STATUS_FIELD}") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + content { + __typename + ... on Issue { number repository { owner { login } name } } + ... on PullRequest { number repository { owner { login } name } } + } + } + } + } + } + }`; + + const batched = []; + let cursor = null; + do { + const data = await github.graphql(query, { + login: PROJECT_OWNER, number: Number(PROJECT_NUMBER), cursor, + }); + const project = data[ownerField].projectV2; + for (const node of project.items.nodes) { + const status = node.fieldValueByName?.name; + const content = node.content; + if (status !== STATUS_NAME) continue; + if (!content || !content.number || !content.repository) continue; // draft item + batched.push({ + owner: content.repository.owner.login, + repo: content.repository.name, + number: content.number, + type: content.__typename, + }); + } + cursor = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor : null; + } while (cursor); + + core.info(`Found ${batched.length} item(s) in status "${STATUS_NAME}".`); + + for (const item of batched) { + const { owner, repo, number } = item; + const ref = `${owner}/${repo}#${number}`; + + // Current labels on the underlying issue/PR. + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }); + const labels = issue.labels.map(l => (typeof l === 'string' ? l : l.name)); + + // First sighting in Batched status -> start the clock with the marker. + if (!labels.includes(MARKER_LABEL)) { + await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [MARKER_LABEL] }); + core.info(`${ref}: marked with "${MARKER_LABEL}" (clock started).`); + continue; + } + + // Marker present -> find when it was added (earliest "labeled" event). + const events = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, repo, issue_number: number, per_page: 100, + }); + const markedAt = events + .filter(e => e.event === 'labeled' && e.label && e.label.name === MARKER_LABEL) + .map(e => new Date(e.created_at).getTime()) + .sort((a, b) => a - b)[0]; + + if (markedAt == null) { + core.warning(`${ref}: has "${MARKER_LABEL}" but no labeled event found; skipping.`); + continue; + } + + const ageDays = Math.floor((now - markedAt) / 86400000); + if (now - markedAt < waitMs) { + core.info(`${ref}: ${ageDays}d in Batched (< ${WAIT_DAYS}d), waiting.`); + continue; + } + + // 30 days elapsed: fire every rule whose sources are all present, + // collecting target labels not already on the item. + const labelSet = new Set(labels); + const toAdd = new Set(); + for (const rule of rules) { + const sources = rule.sources || []; + const targets = rule.targets || []; + if (sources.length && sources.every(s => labelSet.has(s))) { + for (const t of targets) if (!labelSet.has(t)) toAdd.add(t); + } + } + const additions = [...toAdd]; + + if (additions.length === 0) { + core.info(`${ref}: ${ageDays}d elapsed but no rule produced a new label; nothing to add.`); + continue; + } + + await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: additions }); + core.info(`${ref}: ${ageDays}d in Batched >= ${WAIT_DAYS}d -> added ${additions.map(l => `"${l}"`).join(', ')}.`); + } From 8968b0620a713beb7b1524c4bd0e0f362e69b671 Mon Sep 17 00:00:00 2001 From: Anshajanth Yoganathan <30573192+anjai94@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:05:08 +0530 Subject: [PATCH 2/2] Delete .github/workflows directory --- .../workflows/public-announcement-label.yml | 170 ------------------ 1 file changed, 170 deletions(-) delete mode 100644 .github/workflows/public-announcement-label.yml diff --git a/.github/workflows/public-announcement-label.yml b/.github/workflows/public-announcement-label.yml deleted file mode 100644 index 1766da2f..00000000 --- a/.github/workflows/public-announcement-label.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: Public Announcement label (30d after Batched) - -# FLOW 1 of 2: scheduled marker-stamp + label-add (merged into one cron). -# Runs daily. For every item in "Batched for Public Announcement": -# - if it has no clock marker yet -> stamp MARKER_LABEL (starts the clock); -# - once the marker is >= WAIT_DAYS old -> add "PublicPR/Pending from " label(s). -# -# Why a marker (Actions can't sleep, and Projects v2 has no "status changed at" -# timestamp): the first cron run that sees the item Batched stamps a hidden -# MARKER_LABEL; the PR/issue timeline records when it was added, and that -# timestamp is the 30-day clock. Granularity is ~1 day, fine for a 30-day timer. -# -# REQUIRED SECRET: PROJECT_TOKEN -# A classic PAT (scopes: repo, project) or a GitHub App token with -# read access to the project and write access to issues in the target repo. -# The default GITHUB_TOKEN CANNOT read user/org Projects v2, so this is mandatory. - -on: - schedule: - - cron: '0 6 * * *' # daily at 06:00 UTC - workflow_dispatch: # allow manual runs for testing - -permissions: - contents: read - -jobs: - label-batched: - runs-on: ubuntu-latest - env: - PROJECT_OWNER: ${{ github.repository_owner }} # org that owns the repo (and the project) - OWNER_TYPE: organization # this repo lives in an org - PROJECT_NUMBER: '1' # project number from the board URL - STATUS_FIELD: Status # single-select field name on the board - STATUS_NAME: Batched for Public Announcement # status value that starts the clock - MARKER_LABEL: batched-tracked # hidden marker that records the clock start - WAIT_DAYS: '30' - # After WAIT_DAYS, each rule fires when ALL of its "sources" labels are - # present, adding any of its "targets" not already on the item. Multiple - # rules can fire for one item. To extend: add rules, add more entries to - # "sources" (all required) or "targets" (all added). This block is the - # only place you need to edit. - LABEL_RULES: | - [ - { "sources": ["Patch/IAM Completed"], "targets": ["PublicPR/Pending from IAM"] }, - { "sources": ["Patch/APIM Completed"], "targets": ["PublicPR/Pending from APIM"] }, - { "sources": ["Patch/SOLUTIONS Completed"], "targets": ["PublicPR/Pending from SOLUTIONS"] }, - { "sources": ["Patch/INTEGRATION Completed"], "targets": ["PublicPR/Pending from INTEGRATION"] }, - { "sources": ["Patch/AI Completed"], "targets": ["PublicPR/Pending from AI"] } - ] - steps: - - name: Apply label 30 days after Batched - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.PROJECT_TOKEN }} - script: | - const { - PROJECT_OWNER, OWNER_TYPE, PROJECT_NUMBER, - STATUS_FIELD, STATUS_NAME, - MARKER_LABEL, WAIT_DAYS, LABEL_RULES, - } = process.env; - - // Rules: add a rule's targets when all of its sources are present. - const rules = JSON.parse(LABEL_RULES); - const waitMs = Number(WAIT_DAYS) * 24 * 60 * 60 * 1000; - const now = Date.now(); - const ownerField = OWNER_TYPE === 'organization' ? 'organization' : 'user'; - - // 1) Page through every project item, reading its Status and the - // underlying issue/PR (number + repo). - const query = ` - query($login:String!, $number:Int!, $cursor:String) { - ${ownerField}(login:$login) { - projectV2(number:$number) { - items(first:100, after:$cursor) { - pageInfo { hasNextPage endCursor } - nodes { - fieldValueByName(name:"${STATUS_FIELD}") { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - content { - __typename - ... on Issue { number repository { owner { login } name } } - ... on PullRequest { number repository { owner { login } name } } - } - } - } - } - } - }`; - - const batched = []; - let cursor = null; - do { - const data = await github.graphql(query, { - login: PROJECT_OWNER, number: Number(PROJECT_NUMBER), cursor, - }); - const project = data[ownerField].projectV2; - for (const node of project.items.nodes) { - const status = node.fieldValueByName?.name; - const content = node.content; - if (status !== STATUS_NAME) continue; - if (!content || !content.number || !content.repository) continue; // draft item - batched.push({ - owner: content.repository.owner.login, - repo: content.repository.name, - number: content.number, - type: content.__typename, - }); - } - cursor = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor : null; - } while (cursor); - - core.info(`Found ${batched.length} item(s) in status "${STATUS_NAME}".`); - - for (const item of batched) { - const { owner, repo, number } = item; - const ref = `${owner}/${repo}#${number}`; - - // Current labels on the underlying issue/PR. - const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }); - const labels = issue.labels.map(l => (typeof l === 'string' ? l : l.name)); - - // First sighting in Batched status -> start the clock with the marker. - if (!labels.includes(MARKER_LABEL)) { - await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [MARKER_LABEL] }); - core.info(`${ref}: marked with "${MARKER_LABEL}" (clock started).`); - continue; - } - - // Marker present -> find when it was added (earliest "labeled" event). - const events = await github.paginate(github.rest.issues.listEventsForTimeline, { - owner, repo, issue_number: number, per_page: 100, - }); - const markedAt = events - .filter(e => e.event === 'labeled' && e.label && e.label.name === MARKER_LABEL) - .map(e => new Date(e.created_at).getTime()) - .sort((a, b) => a - b)[0]; - - if (markedAt == null) { - core.warning(`${ref}: has "${MARKER_LABEL}" but no labeled event found; skipping.`); - continue; - } - - const ageDays = Math.floor((now - markedAt) / 86400000); - if (now - markedAt < waitMs) { - core.info(`${ref}: ${ageDays}d in Batched (< ${WAIT_DAYS}d), waiting.`); - continue; - } - - // 30 days elapsed: fire every rule whose sources are all present, - // collecting target labels not already on the item. - const labelSet = new Set(labels); - const toAdd = new Set(); - for (const rule of rules) { - const sources = rule.sources || []; - const targets = rule.targets || []; - if (sources.length && sources.every(s => labelSet.has(s))) { - for (const t of targets) if (!labelSet.has(t)) toAdd.add(t); - } - } - const additions = [...toAdd]; - - if (additions.length === 0) { - core.info(`${ref}: ${ageDays}d elapsed but no rule produced a new label; nothing to add.`); - continue; - } - - await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: additions }); - core.info(`${ref}: ${ageDays}d in Batched >= ${WAIT_DAYS}d -> added ${additions.map(l => `"${l}"`).join(', ')}.`); - }