From 11a7fd675cd716179b8bc106decc4b96d22dc5e5 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 1 Jul 2026 18:11:33 -0700 Subject: [PATCH 1/5] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 050e4d53b..593464be8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Please follow: ## Issues -File issues against the [A2UI project](https://github.com/a2ui-project/a2ui/issues). +File issues against the [A2UI project](https://github.com/a2ui-project/a2ui/issues), with label [component: genui](https://github.com/a2ui-project/a2ui/labels?q=genui). See explanation of triage process in [A2UI CONTRIBUTING.md](https://github.com/a2ui-project/a2ui/blob/main/CONTRIBUTING.md). From 61d81972795ac418a1249432f53151a5a4cd2b96 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 1 Jul 2026 18:34:40 -0700 Subject: [PATCH 2/5] - --- .github/workflows/triage.yaml | 52 +++++++++ scripts/triage.mjs | 213 ++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 .github/workflows/triage.yaml create mode 100644 scripts/triage.mjs diff --git a/.github/workflows/triage.yaml b/.github/workflows/triage.yaml new file mode 100644 index 000000000..dd3229b0e --- /dev/null +++ b/.github/workflows/triage.yaml @@ -0,0 +1,52 @@ +# Copyright 2025 The Flutter Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# This automation adds/removes the 'status: needs-triage' label to pull requests +# so that PRs needing attention are easy to find. The label is fully owned by +# this automation and reconciled on every run. +# +# The flagging rules live in ./scripts/triage.mjs — see the header comment there +# for the full specification. + +name: Flag/Unflag pull requests + +on: + # Periodic reconciliation: catches time-based rules (staleness) and removes the + # label once a PR no longer matches. + schedule: + - cron: "0 15 * * *" # daily at 15:00 UTC + # React promptly to the events that can change a PR's flag state. + pull_request_target: + types: [opened, edited, reopened, ready_for_review, synchronize] + issue_comment: + types: [created] + # Allow manual runs from the Actions tab. + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + triage: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Reconcile triage flag labels + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const path = require('path'); + const { pathToFileURL } = require('url'); + + const filePath = path.resolve('./scripts/triage.mjs'); + const fileUrl = pathToFileURL(filePath).href; + + const importedModule = await import(fileUrl); + await importedModule.default({ github, context }); diff --git a/scripts/triage.mjs b/scripts/triage.mjs new file mode 100644 index 000000000..694416b48 --- /dev/null +++ b/scripts/triage.mjs @@ -0,0 +1,213 @@ +/* + * Copyright 2025 The Flutter Authors. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. + */ + +// Reconciles the 'status: needs-triage' label across all open pull requests. The +// label is fully owned by this automation: it is added to every PR that matches +// the rule below and removed from every PR that does not, on each run. +// +// A PR is flagged when it is a stale PR opened by an external contributor (PRs +// from maintainers are managed by their authors). +// +// "Stale" is measured from the last human contribution (a comment, or the +// opening post if there are no human comments) rather than `updated_at`, so the +// bot's own label edits never reset the clock. A PR is "stale" when no internal +// member has commented after the external author's last comment for more than a +// day. +// +// The job prints to console what PRs are flagged/unflagged and why. To see the +// history of runs see the Actions tab for the triage workflow. + +export const FLAG_LABEL = 'status: needs-triage'; + +export const PR_STALE_DAYS = 1; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// Author associations that count as an internal maintainer response. +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + +export const isBot = user => !user || user.type === 'Bot' || /\[bot\]$/.test(user.login || ''); + +const labelNames = item => + (item.labels || []).map(label => (typeof label === 'string' ? label : label.name)); + +const ageInDays = (isoTimestamp, now) => (now - new Date(isoTimestamp).getTime()) / DAY_MS; + +/** + * Returns the most recent human contribution to a PR: either its newest non-bot + * comment, or — if there are none — the opening post itself. Used both to + * measure staleness and to decide whether an external author is still awaiting a + * maintainer response. + */ +export function lastHumanContribution(item, comments) { + let latest = { + createdAt: item.created_at, + association: item.author_association, + user: item.user, + }; + + for (const comment of comments) { + if (isBot(comment.user)) continue; + if (new Date(comment.created_at) >= new Date(latest.createdAt)) { + latest = { + createdAt: comment.created_at, + association: comment.author_association, + user: comment.user, + }; + } + } + + return latest; +} + +/** + * Returns a human-readable reason why a single open PR should carry the flag + * label, or null if it should not. The reason is logged for visibility. + * + * Only external contributors' PRs are watched; maintainers manage their own, so + * an internally-authored PR is never flagged. A PR is "stale" when no internal + * member has commented after the external author's last comment for more than a + * day. + */ +export function flagReason(item, comments, now) { + if (MAINTAINER_ASSOCIATIONS.has(item.author_association)) { + return null; + } + + const latest = lastHumanContribution(item, comments); + const staleDays = ageInDays(latest.createdAt, now); + + // True when the most recent human contribution is from outside the team — no + // internal member has commented after the external author's last word. + const awaitingMember = !MAINTAINER_ASSOCIATIONS.has(latest.association) && !isBot(latest.user); + + return awaitingMember && staleDays > PR_STALE_DAYS + ? `no maintainer has responded to the author for more than ${PR_STALE_DAYS} day.` + : null; +} + +// Max concurrent API calls per phase. Keeps us fast without tripping GitHub's +// secondary (abuse) rate limits, which a single huge Promise.all can hit. +const BATCH_SIZE = 10; + +/** + * Maps `items` through async `fn` in concurrent batches of `BATCH_SIZE` rather + * than all at once, bounding the number of in-flight requests. + */ +async function mapInBatches(items, fn) { + const results = []; + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + results.push(...(await Promise.all(batch.map(fn)))); + } + return results; +} + +/** + * Fetches the comments needed to evaluate a PR. We only need the most recent + * human contribution, so we skip the API call entirely when the PR has no + * comments and otherwise fetch a single page of the newest comments (sorted + * descending) rather than paginating through the whole history. A failure for + * one PR must not abort the whole run, so errors fall back to no comments. + */ +async function fetchComments({github, owner, repo}, item) { + if (!item.comments) { + return []; + } + try { + const {data} = await github.rest.issues.listComments({ + owner, + repo, + issue_number: item.number, + sort: 'created', + direction: 'desc', + per_page: 100, + }); + return data; + } catch (error) { + console.error(`Failed to fetch comments for #${item.number}:`, error); + return []; + } +} + +export default async function prTriage({github, context}) { + console.log('GenUI PR triage-flag reconciliation started'); + + const {owner, repo} = context.repo; + const now = Date.now(); + + // `listForRepo` returns both issues and PRs; PRs carry a `pull_request` key. + // We only reconcile PRs here. + const openItems = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + per_page: 100, + }); + const openPRs = openItems.filter(item => Boolean(item.pull_request)); + + // Fetch comments in bounded concurrent batches to avoid a slow serial loop + // without flooding the API. + const itemsWithComments = await mapInBatches(openPRs, async item => ({ + item, + comments: await fetchComments({github, owner, repo}, item), + })); + + // Decide each PR's desired state from the snapshot, and keep only those whose + // label needs to change. The snapshot from `listForRepo` can be stale if + // another run (the daily schedule overlapping a PR event) already changed the + // label, so the actual mutation re-checks the live state below. + const itemsToUpdate = itemsWithComments + .map(({item, comments}) => ({item, reason: flagReason(item, comments, now)})) + .filter(({item, reason}) => Boolean(reason) !== labelNames(item).includes(FLAG_LABEL)); + + let added = 0; + let removed = 0; + + await mapInBatches(itemsToUpdate, async ({item, reason}) => { + const wantsFlag = Boolean(reason); + try { + // Re-read the live labels so a concurrent run cannot make us add the + // label twice. + const {data: fresh} = await github.rest.issues.get({ + owner, + repo, + issue_number: item.number, + }); + const hasFlag = labelNames(fresh).includes(FLAG_LABEL); + if (wantsFlag === hasFlag) { + return; // Another run already reconciled this PR. + } + + if (wantsFlag) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: item.number, + labels: [FLAG_LABEL], + }); + added += 1; + console.log(`Flagged ${item.html_url} — ${reason}`); + } else { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: item.number, + name: FLAG_LABEL, + }); + removed += 1; + console.log(`Unflagged ${item.html_url} — no longer matches the triage rule.`); + } + } catch (error) { + console.error(`Failed to update #${item.number}:`, error); + } + }); + + console.log( + `GenUI PR triage-flag reconciliation completed: ` + + `${openPRs.length} PRs, +${added} / -${removed} label changes`, + ); +} From db7c98109d2160cda3b46f727468af07f30004b5 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 1 Jul 2026 18:37:22 -0700 Subject: [PATCH 3/5] - --- .github/workflows/triage.yaml | 4 ++-- {scripts => tool}/triage.mjs | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename {scripts => tool}/triage.mjs (100%) diff --git a/.github/workflows/triage.yaml b/.github/workflows/triage.yaml index dd3229b0e..5f0d98444 100644 --- a/.github/workflows/triage.yaml +++ b/.github/workflows/triage.yaml @@ -6,7 +6,7 @@ # so that PRs needing attention are easy to find. The label is fully owned by # this automation and reconciled on every run. # -# The flagging rules live in ./scripts/triage.mjs — see the header comment there +# The flagging rules live in ./tool/triage.mjs — see the header comment there # for the full specification. name: Flag/Unflag pull requests @@ -45,7 +45,7 @@ jobs: const path = require('path'); const { pathToFileURL } = require('url'); - const filePath = path.resolve('./scripts/triage.mjs'); + const filePath = path.resolve('./tool/triage.mjs'); const fileUrl = pathToFileURL(filePath).href; const importedModule = await import(fileUrl); diff --git a/scripts/triage.mjs b/tool/triage.mjs similarity index 100% rename from scripts/triage.mjs rename to tool/triage.mjs From 6bc171e1a24a86d95b171e852917a89efbd66320 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 1 Jul 2026 18:40:31 -0700 Subject: [PATCH 4/5] - --- .github/workflows/triage.yaml | 7 ++++++- tool/triage.mjs | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/triage.yaml b/.github/workflows/triage.yaml index 5f0d98444..ef95dc7a2 100644 --- a/.github/workflows/triage.yaml +++ b/.github/workflows/triage.yaml @@ -7,7 +7,12 @@ # this automation and reconciled on every run. # # The flagging rules live in ./tool/triage.mjs — see the header comment there -# for the full specification. +# for the full specification: +# https://github.com/flutter/genui/blob/main/tool/triage.mjs +# +# For the history of runs, see: +# https://github.com/flutter/genui/actions/workflows/triage.yaml + name: Flag/Unflag pull requests diff --git a/tool/triage.mjs b/tool/triage.mjs index 694416b48..b84006d33 100644 --- a/tool/triage.mjs +++ b/tool/triage.mjs @@ -17,8 +17,12 @@ // member has commented after the external author's last comment for more than a // day. // +// Flagged PRs: +// https://github.com/flutter/genui/pulls?q=state%3Aopen%20label%3A%22status%3A%20needs-triage%22 +// // The job prints to console what PRs are flagged/unflagged and why. To see the -// history of runs see the Actions tab for the triage workflow. +// history of runs see: +// https://github.com/flutter/genui/actions/workflows/triage.yaml export const FLAG_LABEL = 'status: needs-triage'; From 7c4fd0d05e5ed52516d26dbe7ea7db9b3240baff Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 1 Jul 2026 19:10:31 -0700 Subject: [PATCH 5/5] - --- .github/workflows/triage.yaml | 4 ++ tool/triage.mjs | 121 ++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/.github/workflows/triage.yaml b/.github/workflows/triage.yaml index ef95dc7a2..155922da0 100644 --- a/.github/workflows/triage.yaml +++ b/.github/workflows/triage.yaml @@ -26,6 +26,10 @@ on: types: [opened, edited, reopened, ready_for_review, synchronize] issue_comment: types: [created] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] # Allow manual runs from the Actions tab. workflow_dispatch: diff --git a/tool/triage.mjs b/tool/triage.mjs index b84006d33..e1a24ed51 100644 --- a/tool/triage.mjs +++ b/tool/triage.mjs @@ -11,11 +11,11 @@ // A PR is flagged when it is a stale PR opened by an external contributor (PRs // from maintainers are managed by their authors). // -// "Stale" is measured from the last human contribution (a comment, or the -// opening post if there are no human comments) rather than `updated_at`, so the -// bot's own label edits never reset the clock. A PR is "stale" when no internal -// member has commented after the external author's last comment for more than a -// day. +// "Stale" is measured from the last human contribution (a comment, review, or +// inline review comment, or the opening post if there are none) rather than +// `updated_at`, so the bot's own label edits never reset the clock. A PR is +// "stale" when no internal member has responded after the external author's last +// contribution for more than a day. // // Flagged PRs: // https://github.com/flutter/genui/pulls?q=state%3Aopen%20label%3A%22status%3A%20needs-triage%22 @@ -33,7 +33,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; // Author associations that count as an internal maintainer response. const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); -export const isBot = user => !user || user.type === 'Bot' || /\[bot\]$/.test(user.login || ''); +// A deleted account surfaces as a null `user`; treat that as a human so their +// past contributions still count, rather than silently classifying them as a bot. +export const isBot = user => + Boolean(user) && (user.type === 'Bot' || /\[bot\]$/.test(user.login || '')); const labelNames = item => (item.labels || []).map(label => (typeof label === 'string' ? label : label.name)); @@ -42,25 +45,25 @@ const ageInDays = (isoTimestamp, now) => (now - new Date(isoTimestamp).getTime() /** * Returns the most recent human contribution to a PR: either its newest non-bot - * comment, or — if there are none — the opening post itself. Used both to - * measure staleness and to decide whether an external author is still awaiting a - * maintainer response. + * contribution (a comment, review, or inline review comment), or — if there are + * none — the opening post itself. Used both to measure staleness and to decide + * whether an external author is still awaiting a maintainer response. + * + * `contributions` is the merged, normalized event list from `fetchContributions`. + * It comes from three different endpoints so it is not sorted; the scan picks the + * latest regardless of order. */ -export function lastHumanContribution(item, comments) { +export function lastHumanContribution(item, contributions) { let latest = { createdAt: item.created_at, association: item.author_association, user: item.user, }; - for (const comment of comments) { - if (isBot(comment.user)) continue; - if (new Date(comment.created_at) >= new Date(latest.createdAt)) { - latest = { - createdAt: comment.created_at, - association: comment.author_association, - user: comment.user, - }; + for (const event of contributions) { + if (isBot(event.user)) continue; + if (new Date(event.createdAt) >= new Date(latest.createdAt)) { + latest = event; } } @@ -73,15 +76,15 @@ export function lastHumanContribution(item, comments) { * * Only external contributors' PRs are watched; maintainers manage their own, so * an internally-authored PR is never flagged. A PR is "stale" when no internal - * member has commented after the external author's last comment for more than a - * day. + * member has responded (via a comment, review, or inline review comment) after + * the external author's last contribution for more than a day. */ -export function flagReason(item, comments, now) { +export function flagReason(item, contributions, now) { if (MAINTAINER_ASSOCIATIONS.has(item.author_association)) { return null; } - const latest = lastHumanContribution(item, comments); + const latest = lastHumanContribution(item, contributions); const staleDays = ageInDays(latest.createdAt, now); // True when the most recent human contribution is from outside the team — no @@ -110,31 +113,47 @@ async function mapInBatches(items, fn) { return results; } +// Normalizes the different GitHub contribution shapes into a common +// `{createdAt, association, user}` event. Reviews stamp their submission time in +// `submitted_at`; issue and inline review comments use `created_at`. +const toEvent = contribution => ({ + createdAt: contribution.created_at || contribution.submitted_at, + association: contribution.author_association, + user: contribution.user, +}); + /** - * Fetches the comments needed to evaluate a PR. We only need the most recent - * human contribution, so we skip the API call entirely when the PR has no - * comments and otherwise fetch a single page of the newest comments (sorted - * descending) rather than paginating through the whole history. A failure for - * one PR must not abort the whole run, so errors fall back to no comments. + * Gathers every human-visible contribution to a PR — top-level issue comments, + * formal reviews, and inline review comments — as a flat list of normalized + * events. All three matter: a maintainer often responds by submitting a review + * or leaving inline comments without posting a separate top-level comment, so + * considering only issue comments would wrongly treat the PR as unanswered. A + * failure for one source must not abort the whole run, so errors fall back to an + * empty list for that source. */ -async function fetchComments({github, owner, repo}, item) { - if (!item.comments) { - return []; - } - try { - const {data} = await github.rest.issues.listComments({ - owner, - repo, - issue_number: item.number, - sort: 'created', - direction: 'desc', - per_page: 100, - }); - return data; - } catch (error) { - console.error(`Failed to fetch comments for #${item.number}:`, error); - return []; - } +async function fetchContributions({github, owner, repo}, item) { + const number = item.number; + + const fetchAll = async (label, endpoint, params) => { + try { + return await github.paginate(endpoint, {owner, repo, per_page: 100, ...params}); + } catch (error) { + console.error(`Failed to fetch ${label} for #${number}:`, error); + return []; + } + }; + + const [issueComments, reviews, reviewComments] = await Promise.all([ + // The issue-comment count is on the list item, so skip the call when it is + // zero; reviews and review comments have no such hint and are always fetched. + item.comments + ? fetchAll('comments', github.rest.issues.listComments, {issue_number: number}) + : [], + fetchAll('reviews', github.rest.pulls.listReviews, {pull_number: number}), + fetchAll('review comments', github.rest.pulls.listReviewComments, {pull_number: number}), + ]); + + return [...issueComments, ...reviews, ...reviewComments].map(toEvent); } export default async function prTriage({github, context}) { @@ -153,19 +172,19 @@ export default async function prTriage({github, context}) { }); const openPRs = openItems.filter(item => Boolean(item.pull_request)); - // Fetch comments in bounded concurrent batches to avoid a slow serial loop - // without flooding the API. - const itemsWithComments = await mapInBatches(openPRs, async item => ({ + // Fetch each PR's contributions in bounded concurrent batches to avoid a slow + // serial loop without flooding the API. + const itemsWithContributions = await mapInBatches(openPRs, async item => ({ item, - comments: await fetchComments({github, owner, repo}, item), + contributions: await fetchContributions({github, owner, repo}, item), })); // Decide each PR's desired state from the snapshot, and keep only those whose // label needs to change. The snapshot from `listForRepo` can be stale if // another run (the daily schedule overlapping a PR event) already changed the // label, so the actual mutation re-checks the live state below. - const itemsToUpdate = itemsWithComments - .map(({item, comments}) => ({item, reason: flagReason(item, comments, now)})) + const itemsToUpdate = itemsWithContributions + .map(({item, contributions}) => ({item, reason: flagReason(item, contributions, now)})) .filter(({item, reason}) => Boolean(reason) !== labelNames(item).includes(FLAG_LABEL)); let added = 0;