diff --git a/.agents/skills/quarterly-ladder-analysis/SKILL.md b/.agents/skills/quarterly-ladder-analysis/SKILL.md new file mode 100644 index 0000000..7312ce7 --- /dev/null +++ b/.agents/skills/quarterly-ladder-analysis/SKILL.md @@ -0,0 +1,128 @@ +--- +name: quarterly-ladder-analysis +description: Instructions and operational workflow for conducting the Flutter Contributor Ladder quarterly audits (Community Audit and Partner Team Verification). +--- + +# Quarterly Contributor Ladder Analysis Skill + +This skill guides you through executing Flutter's quarterly contributor reviews across the 30+ day review cycle. + +## Step 0: Determine Current Phase in the 30+ Day Process + +Before running any mutating commands, **first ask the user (if not already clear from their request) where in the 30+ day quarterly review process we are** (you can also check open `quarterly-review` / `quarterly-review-checklist` issues and open PRs via `gh issue list` and `gh pr list`): + +1. **Phase 1 — Kickoff & Opening the PR / Review Issues (Day 1)**: Starting the quarterly review after the automated checklist issue is filed. +2. **Phase 2 — Mid-Review Check-In (Days 1–30)**: Auditing responses on the open review issue, checking an individual contributor (`tool/check_user.dart`), or tracking partner confirmations during the 30-day window. +3. **Phase 3 — Finalization & Merging (Day 30+)**: Incorporating 30-day responses into the open quarterly review PR, landing the PR, syncing GitHub/Discord permissions, and closing the issues. + +Proceed to the matching phase below. + +--- + +## Phase 1: Kickoff, Ban-History Vetting, Single PR & Review Issues (Day 1) + +### 1.1 Preview Nominated Candidates & Ask User for Ban-History Check + +1. Ensure you are on a fresh branch off the latest `main` (which is updated daily by `.github/workflows/sync_activity.yml`): + + ```bash + git checkout -b YYYY-QX-quarterly-review + ``` + +2. Run `--prepare` in dry-run, non-interactive mode first to see the nominated candidates without writing any files (`tool/quarterly_audit.dart` automatically queries live `flutter` GitHub organization and team membership so every current org member is evaluated): + + ```bash + dart run tool/quarterly_audit.dart --prepare --dry-run --no-prompt + ``` + +3. **Present the nominated usernames** (promotions to Committer, returning Dasher Emeriti, and prospective external new joiners) to the user and **ask them to check those usernames against the internal Code of Conduct / ban history list**. + - Contributors who were previously banned for Code of Conduct violations remain welcome to contribute as external Contributors after a temporary ban expires, but are not eligible for ladder membership. + - Wait for the user to confirm whether any usernames should be excluded before generating files on disk so no one is singled out in reports or commits. + +### 1.2 Generate Reports & Update Roster for the Single PR + +Once the user confirms any exclusions (or confirms none are needed), run `--prepare`: + +```bash +dart run tool/quarterly_audit.dart --prepare --no-prompt --exclude user1,user2 +``` + +*(Omit `--exclude` if no candidates are excluded.)* + +This single command: + +- Writes `reports/YYYY-QX-community-ladder-audit.md` and `reports/YYYY-QX-partner-verification-audit.md` (with excluded users completely omitted). +- Updates `data/contributors.yaml`, `ROSTER.md`, and `EMERITUS.md`. + +### 1.3 Open the Single Quarterly Review PR + +Commit the generated reports and proposed roster updates into **1 PR**: + +```bash +git add reports/ data/contributors.yaml ROSTER.md EMERITUS.md +git commit -m "chore: QX YYYY quarterly contributor ladder review" +``` + +This PR stays open throughout the 30-day community review period and does not land until Phase 3 (Day 30+). + +### 1.4 Draft & Open the Public Review Issues + +Use the tool to preview or open the two public tracking issues from the vetted reports to start the 30-day review period: + +```bash +# Preview drafted issues and gh CLI commands: +dart run tool/quarterly_audit.dart --draft-issues --pr + +# Or open both issues directly on GitHub via gh CLI: +dart run tool/quarterly_audit.dart --open-issues --pr +``` + +--- + +## Phase 2: Manage the 30-Day Grace Period (Days 1–30) + +- Inspect responses on the Community Audit issue (`gh issue view --comments`): + - Collect Discord usernames from newly invited contributors (`user=discord_handle`). + - Summarize reported qualitative contributions that cannot be quantified automatically (issue triage comments, reproduction verification, [RFC](https://github.com/flutter/rfc) reviews, mentoring) for the user to review. + - To inspect a specific contributor's 12-month metrics and current roster status: + + ```bash + dart run tool/check_user.dart + ``` + +- Remind the user to reach out to partner teams to confirm active status for `@flutter/partners` members. +- *(During the 30-day window, `.github/workflows/check_grace_period.yml` runs once a week to post a weekly countdown update on the open review issues, and pings the assignee once 30 days have elapsed.)* + +--- + +## Phase 3: Update the Open PR, Land It & Provision Access (Day 30+) + +1. Gather 30-day adjustments: + - Read all comments on the open Community Audit issue (`gh issue view --comments`) and confirm with the user: + - `--retain`: Contributors in the 30-day grace period who shared verified qualitative contributions. + - `--exclude`: Any previously excluded usernames plus any invited contributors who declined. + - `--discord`: `github_user=discord_handle` mappings shared by accepted new joiners, returning Emeriti, and promoted members. +2. Update the open PR branch: + On the open `YYYY-QX-quarterly-review` PR branch, run `--finalize`: + + ```bash + dart run tool/quarterly_audit.dart --finalize \ + --retain user1,user2 \ + --exclude excluded_or_declined_user \ + --discord user=discord_handle + ``` + +3. Validate schema and roster sync: + + ```bash + dart run tool/validate_roster.dart + dart run tool/generate_roster.dart --check + ``` + +4. Commit, push & land the PR: + Commit the updated `data/contributors.yaml`, `ROSTER.md`, and `EMERITUS.md` to the PR branch and merge the PR. +5. Post-merge provisioning checklist: + - Update GitHub organization teams (`@flutter/reviewers`, `@flutter/committers`, and `@flutter/maintainers` as child teams of `@flutter/flutter-hackers`, along with `@flutter/partners` child teams) to match `ROSTER.md` (`@flutter/collaborators` is automatically managed by internal tooling and should not be modified manually). + - Remove members who transitioned to Dasher Emeritus from the `flutter` GitHub organization. + - Grant the Discord team member role to newly added ladder members using their collected Discord handles. + - Close the public review issues and the quarterly checklist issue. diff --git a/.agents/skills/quarterly-ladder-analysis/references/criteria.md b/.agents/skills/quarterly-ladder-analysis/references/criteria.md new file mode 100644 index 0000000..35c1181 --- /dev/null +++ b/.agents/skills/quarterly-ladder-analysis/references/criteria.md @@ -0,0 +1,19 @@ +# Contributor Ladder Metric Criteria Reference + +## 12-Month Rolling Criteria Rubric + +| Role | GitHub Role | Criteria (Rolling 12 Months) | +| :--- | :--- | :--- | +| **Contributor** | `Read` (External) | Any material contribution (code, docs, issue triage, QA). | +| **Reviewer** | `Triage` (Org Member) | $\ge 10$ qualifying contributions (merged PRs, valid issues, reviews on merged PRs, reproducible issue triage). | +| **Committer** | `Write` (Org Member) | $\ge 30$ qualifying contributions and $\ge 10$ merged PRs, demonstrating depth, code quality, and technical judgment. | +| **Maintainer** | `Write` + Org Mod (Org Member) | All members of `@flutter/googlers` and `@flutter/partners` (there are no Maintainers outside of Googlers and Partners). | +| **Dasher Emeritus** | `Read` (External Alumni) | Inactive community role-holder ($< 10$ contributions in past 12 months after 30-day grace period). Removed from the GitHub org and listed in `EMERITUS.md`. | + +## Special Policies + +Contributors who have previously been banned for Code of Conduct violations remain welcome to contribute as external Contributors after a temporary ban expires, but are not eligible for ladder membership (`Reviewer`, `Committer`, `Maintainer`). When running `tool/quarterly_audit.dart --prepare` locally, the person running the audit checks the nominated usernames against the internal Code of Conduct / ban history list and excludes any ineligible candidates before any report or roster files are generated, ensuring no one is singled out. + +Community members who joined `flutter-hackers` within the 12 months prior to ladder launch receive a one-time 12-month Reviewer transition grace period (`grace_until` in `data/contributors.yaml`). Once that initial year expires, no further recent-joiner carve-outs apply. + +Googlers and Partners are automatically classified as Maintainers (while their 12-month contribution history is still recorded in `data/activity_summary.json`), and contributions that cannot be quantified automatically—such as issue triage, reproduction verifications, [RFC](https://github.com/flutter/rfc) feedback, design docs, and mentoring—count toward Reviewer and Committer qualifications when shared during the 30-day review period. diff --git a/.agents/skills/quarterly-ladder-analysis/references/known_maintainers.txt b/.agents/skills/quarterly-ladder-analysis/references/known_maintainers.txt new file mode 100644 index 0000000..ab06217 --- /dev/null +++ b/.agents/skills/quarterly-ladder-analysis/references/known_maintainers.txt @@ -0,0 +1,8 @@ +# These are known maintainers, listed here by exception due to a sync issue with internal tooling and GitHub. +CarlosMendonca +LouiseHsu +abdallahshaban557 +andywolff +gaaclarke +gmackall +jesswrd diff --git a/.github/workflows/check_grace_period.yml b/.github/workflows/check_grace_period.yml new file mode 100644 index 0000000..271d970 --- /dev/null +++ b/.github/workflows/check_grace_period.yml @@ -0,0 +1,45 @@ +name: Weekly Grace Period Check + +on: + schedule: + # 03:23 UTC every Monday (Weekly check for open quarterly review issues, offset from top of hour) + - cron: '23 3 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (do not post comments or add labels)' + type: boolean + default: false + +permissions: {} + +concurrency: + group: check-grace-period + cancel-in-progress: false + +jobs: + grace_period_check: + name: Check Open Quarterly Review Issues + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Dart SDK + uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c # v1.7.1 + + - name: Install dependencies + run: dart pub get + + - name: Check Open Quarterly Review Issues + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run || false }} + run: dart run tool/check_grace_period.dart diff --git a/.github/workflows/quarterly_review.yml b/.github/workflows/quarterly_review.yml new file mode 100644 index 0000000..f350545 --- /dev/null +++ b/.github/workflows/quarterly_review.yml @@ -0,0 +1,128 @@ +name: Quarterly Ladder Review + +on: + schedule: + # 03:17 UTC on the 1st of January, April, July, and October (Quarterly Kickoff, offset from top of hour) + - cron: '17 3 1 1,4,7,10 *' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (do not file issues)' + type: boolean + default: false + +permissions: {} + +concurrency: + group: quarterly-review + cancel-in-progress: false + +jobs: + file_checklist: + name: File Quarterly Review Checklist Issue + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: File Quarterly Review Checklist Issue for @Piinks + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run || false }} + run: | + QUARTER="Q$(( ($(date +%-m) - 1) / 3 + 1 )) $(date +%Y)" + QUARTER_SLUG="$(date +%Y)-Q$(( ($(date +%-m) - 1) / 3 + 1 ))" + + cat < checklist_body.md + # Quarterly Contributor Ladder Review Checklist (${QUARTER}) + + This issue tracks the facilitator tasks for the **${QUARTER}** Flutter Contributor Ladder review. + See [docs/QUARTERLY_REVIEW.md](docs/QUARTERLY_REVIEW.md) for full details. + + ## Phase 1: Kickoff & Opening the Review PR (Day 1) + + - [ ] **1. Run the audit locally & vet against the internal ban history list** + Pull the latest \`main\` branch (with daily synced activity data), create a branch for the quarterly review PR, and run: + + \`\`\`bash + git checkout -b ${QUARTER_SLUG}-quarterly-review + dart run tool/quarterly_audit.dart --prepare + \`\`\` + + - The tool lists all nominated promotions, returning Emeriti, and prospective external new joiners so you can manually check those usernames against the internal Code of Conduct / ban history list. + - Enter any ineligible usernames when prompted (or pass \`--exclude user1,user2\`) so they are excluded before any files are written and no one is singled out. + - This generates \`reports/${QUARTER_SLUG}-community-ladder-audit.md\` and \`reports/${QUARTER_SLUG}-partner-verification-audit.md\` and updates \`data/contributors.yaml\`, \`ROSTER.md\`, and \`EMERITUS.md\`. + - [ ] **2. Open the single Quarterly Review PR (do not merge until after 30-day grace period)** + Commit the generated reports and roster updates into a single PR: + + \`\`\`bash + git add reports/ data/contributors.yaml ROSTER.md EMERITUS.md + git commit -m "chore: ${QUARTER} quarterly contributor ladder review" + \`\`\` + + *(Hold this PR open throughout the 30-day review period.)* + - [ ] **3. Open the public Community Review & Partner Verification issues** + Use the tool to draft or open the two public tracking issues from your vetted reports to start the 30-day review period: + + \`\`\`bash + # Preview drafted issues and gh commands: + dart run tool/quarterly_audit.dart --draft-issues --pr + + # Or open both issues directly via gh CLI: + dart run tool/quarterly_audit.dart --open-issues --pr + \`\`\` + + ## Phase 2: 30-Day Review Period (Days 1–30) + + - [ ] **4. Monitor community responses & confirm partner members** + - Collect Discord usernames from newly invited contributors on the Community Audit issue. + - Review qualitative contributions that cannot be quantified automatically (triage, reproductions, [RFCs](https://github.com/flutter/rfc), mentoring). + - Reach out to partner teams to confirm active status for \`@flutter/partners\` members. + - *(The weekly \`.github/workflows/check_grace_period.yml\` workflow automatically posts a weekly countdown update on the open review issues during the 30-day window and pings the assignee once 30 days have elapsed.)* + + ## Phase 3: Finalization & Provisioning (Day 30+) + + - [ ] **5. Update the open PR with 30-day adjustments & land it** + On your \`${QUARTER_SLUG}-quarterly-review\` PR branch, run the tool with any adjustments from the 30-day window: + + \`\`\`bash + dart run tool/quarterly_audit.dart --finalize \\ + --retain user1,user2 \\ + --exclude declined_or_excluded_user \\ + --discord new_user=discord_handle + \`\`\` + + Commit, push to the PR branch, and merge the PR. + - [ ] **6. Synchronize GitHub teams, Discord access & close issues** + - Update \`@flutter/reviewers\`, \`@flutter/committers\`, and \`@flutter/maintainers\` (child teams of \`@flutter/flutter-hackers\`, where \`@flutter/maintainers\` contains all members of \`@flutter/googlers\` and \`@flutter/partners\`) and \`@flutter/partners\` child teams to match \`ROSTER.md\` (\`@flutter/collaborators\` is automatically managed by internal tooling and is not updated manually). + - Remove members who transitioned to Dasher Emeritus from the \`flutter\` GitHub organization. + - Grant the Discord team member role to newly added ladder members using their collected Discord handles. + - Close the public review issues and this checklist issue. + EOF + + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run enabled. Would file checklist issue:" + cat checklist_body.md + exit 0 + fi + + gh label create "quarterly-review-checklist" --description "Quarterly Review Facilitator Checklist" --color "0052CC" --force || true + + EXISTING_CHECKLIST=$(gh issue list --state all --label "quarterly-review-checklist" --search "${QUARTER}" --json number --jq '.[0].number') + if [ -z "$EXISTING_CHECKLIST" ]; then + CHECKLIST_URL=$(gh issue create \ + --title "[Quarterly Review Checklist]: ${QUARTER} Contributor Ladder Audit" \ + --body-file checklist_body.md \ + --label "quarterly-review-checklist" \ + --assignee "Piinks") + echo "Created Quarterly Review Checklist Issue: $CHECKLIST_URL" + else + echo "Quarterly Review Checklist Issue already exists (#$EXISTING_CHECKLIST)." + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e01b250..6716ecf 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -39,6 +39,12 @@ jobs: - name: Analyze Dart code run: dart analyze --fatal-infos + - name: Validate contributors.yaml + run: dart run tool/validate_roster.dart + + - name: Verify ROSTER.md and EMERITUS.md are in sync + run: dart run tool/generate_roster.dart --check + - name: Run unit test suite run: dart test diff --git a/EMERITUS.md b/EMERITUS.md new file mode 100644 index 0000000..9df292b --- /dev/null +++ b/EMERITUS.md @@ -0,0 +1,68 @@ +# Dasher Emeritus + +> [!IMPORTANT] +> **Pending Q4 Audit:** No contributor roles or organization memberships have been changed yet. The Emeritus transitions listed below are pending the Q4 audit, which will be subject to a 30-day community review period before being finalized. + +This directory honors **Dasher Emeritus** alumni—former Flutter Reviewers, Committers, and Maintainers who previously held roles on the contributor ladder and have stepped back from active contribution (< 10 contributions over the preceding 12 months). + +To maintain repository security, Dasher Emeriti do not remain members of the `flutter` GitHub organization. Instead, their contributions are recognized here, and they retain access to Flutter contributor channels on Discord (including `#hidden-chat`) and the announcement-only `flutter-contributors` Google Group (for project announcements and calendar invites to our recurring Flutter Contributor Roundtable and the Dash Forum, where contributors can present and get feedback on their [RFCs](https://github.com/flutter/rfc); the registration form is pinned in `#hidden-chat` on Discord) so they can stay in touch and more easily ramp back up onto the project if and when they choose to return. + +Returning Emeriti who resume contributing are automatically identified during quarterly reviews (where they can reply directly on the public tracking issue). For off-cycle reinstatement, because creating new issues and pull requests in `flutter/contributors` is restricted to organization members (`collaborators`), returning Emeriti can reach out in `#hidden-chat` on Discord (or to an active organization member) to have an active org member sponsor and open a [Dasher Emeritus Reinstatement request](.github/ISSUE_TEMPLATE/2_emeritus_reinstatement.yml) on their behalf (where they can also share an updated Discord username if needed). See [docs/LADDER.md](docs/LADDER.md) for details. + +> [!NOTE] +> This file is automatically generated from `data/contributors.yaml`. +> To update, edit `data/contributors.yaml` and run `dart run tool/generate_roster.dart`. + +## Honored Alumni + +[@0xZOne](https://github.com/0xZOne) • [@324mao](https://github.com/324mao) • [@a14n](https://github.com/a14n) • [@aaron-sf](https://github.com/aaron-sf) +[@adarshprabhu](https://github.com/adarshprabhu) • [@adicahyono](https://github.com/adicahyono) • [@akrwb](https://github.com/akrwb) • [@albertodev01](https://github.com/albertodev01) +[@alestiago](https://github.com/alestiago) • [@allisonryan0002](https://github.com/allisonryan0002) • [@almasryalyoum](https://github.com/almasryalyoum) • [@alxp1982](https://github.com/alxp1982) +[@anderdobo](https://github.com/anderdobo) • [@AnnaPS](https://github.com/AnnaPS) • [@antonellof](https://github.com/antonellof) • [@ANTONINEUTRON](https://github.com/ANTONINEUTRON) +[@AntonyKiragu](https://github.com/AntonyKiragu) • [@atrope](https://github.com/atrope) • [@AustinGTI](https://github.com/AustinGTI) • [@AyushBherwani1998](https://github.com/AyushBherwani1998) +[@azchohfi](https://github.com/azchohfi) • [@bartekpacia](https://github.com/bartekpacia) • [@BerkSpar](https://github.com/BerkSpar) • [@bernaferrari](https://github.com/bernaferrari) +[@brianegan](https://github.com/brianegan) • [@bruno-garcia](https://github.com/bruno-garcia) • [@bselwe](https://github.com/bselwe) • [@btobin](https://github.com/btobin) +[@budikhafiKLY](https://github.com/budikhafiKLY) • [@burakkasikci](https://github.com/burakkasikci) • [@CareF](https://github.com/CareF) • [@chenglu](https://github.com/chenglu) +[@chinmoy12c](https://github.com/chinmoy12c) • [@chriscraws](https://github.com/chriscraws) • [@chrisraygill](https://github.com/chrisraygill) • [@cityxsuperapp](https://github.com/cityxsuperapp) +[@CoffeeDevUK](https://github.com/CoffeeDevUK) • [@ColdPaleLight](https://github.com/ColdPaleLight) • [@collinjackson](https://github.com/collinjackson) • [@dam-ease](https://github.com/dam-ease) +[@darshankawar](https://github.com/darshankawar) • [@DBowen33](https://github.com/DBowen33) • [@dclark-disney](https://github.com/dclark-disney) • [@diarmaidlindsay](https://github.com/diarmaidlindsay) +[@DiegoGW](https://github.com/DiegoGW) • [@diegoveloper](https://github.com/diegoveloper) • [@djol](https://github.com/djol) • [@dominikIncibit](https://github.com/dominikIncibit) +[@eda-nagao](https://github.com/eda-nagao) • [@eggfly](https://github.com/eggfly) • [@egoe](https://github.com/egoe) • [@Ehesp](https://github.com/Ehesp) +[@eieio](https://github.com/eieio) • [@emma-roudabush-disney](https://github.com/emma-roudabush-disney) • [@emrebuyukozkan](https://github.com/emrebuyukozkan) • [@ened](https://github.com/ened) +[@enzoftware](https://github.com/enzoftware) • [@erickzanardo](https://github.com/erickzanardo) • [@eseidel](https://github.com/eseidel) • [@exaby73](https://github.com/exaby73) +[@farshad1489](https://github.com/farshad1489) • [@felangel](https://github.com/felangel) • [@filiph](https://github.com/filiph) • [@FirentisTFW](https://github.com/FirentisTFW) +[@FlutterIssues](https://github.com/FlutterIssues) • [@fma-goog](https://github.com/fma-goog) • [@FXschwartz](https://github.com/FXschwartz) • [@gastonliz](https://github.com/gastonliz) +[@gianfrancopapa](https://github.com/gianfrancopapa) • [@gnprice](https://github.com/gnprice) • [@guoguo338](https://github.com/guoguo338) • [@h9jiang](https://github.com/h9jiang) +[@hamdikahloun](https://github.com/hamdikahloun) • [@harri35](https://github.com/harri35) • [@hbatagelo](https://github.com/hbatagelo) • [@helinanever](https://github.com/helinanever) +[@hgraceb](https://github.com/hgraceb) • [@HidenoriMatsubayashi](https://github.com/HidenoriMatsubayashi) • [@hillelcoren](https://github.com/hillelcoren) • [@Hiroaki-Kanai](https://github.com/Hiroaki-Kanai) +[@horiaciu](https://github.com/horiaciu) • [@IanSimpson](https://github.com/IanSimpson) • [@ihorkozar](https://github.com/ihorkozar) • [@irmasantoso](https://github.com/irmasantoso) +[@itsjen](https://github.com/itsjen) • [@ja-dfir](https://github.com/ja-dfir) • [@Jan-Stepien](https://github.com/Jan-Stepien) • [@jcollins-disney](https://github.com/jcollins-disney) +[@JErazo7](https://github.com/JErazo7) • [@jeroen-meijer](https://github.com/jeroen-meijer) • [@jesskuras](https://github.com/jesskuras) • [@jingshao-code](https://github.com/jingshao-code) +[@jnschulze](https://github.com/jnschulze) • [@jomblocoder](https://github.com/jomblocoder) • [@jorgecoca](https://github.com/jorgecoca) • [@josemontespg](https://github.com/josemontespg) +[@jpnurmi](https://github.com/jpnurmi) • [@JsouLiang](https://github.com/JsouLiang) • [@Justian-SopraSteria](https://github.com/Justian-SopraSteria) • [@jwinarske](https://github.com/jwinarske) +[@kaiceyd](https://github.com/kaiceyd) • [@kenvandine](https://github.com/kenvandine) • [@kevinburns426](https://github.com/kevinburns426) • [@kevya-google](https://github.com/kevya-google) +[@kinla004](https://github.com/kinla004) • [@Kirpal](https://github.com/Kirpal) • [@kkboateng](https://github.com/kkboateng) • [@kmillikin](https://github.com/kmillikin) +[@kokoro-team](https://github.com/kokoro-team) • [@kondoakio](https://github.com/kondoakio) • [@KristinBi](https://github.com/KristinBi) • [@kwg4](https://github.com/kwg4) +[@laramartin](https://github.com/laramartin) • [@leoafarias](https://github.com/leoafarias) • [@linxuebin1990](https://github.com/linxuebin1990) • [@LinXunFeng](https://github.com/LinXunFeng) +[@lucavenir](https://github.com/lucavenir) • [@luckysmg](https://github.com/luckysmg) • [@MacarenaSantos](https://github.com/MacarenaSantos) • [@magicianA](https://github.com/magicianA) +[@maheshj01](https://github.com/maheshj01) • [@Markzipan](https://github.com/Markzipan) • [@martinduva](https://github.com/martinduva) • [@MaruQuarky](https://github.com/MaruQuarky) +[@marwfair](https://github.com/marwfair) • [@matiasleyba](https://github.com/matiasleyba) • [@matsuda-shota](https://github.com/matsuda-shota) • [@matthew-carroll](https://github.com/matthew-carroll) +[@matthewshahi](https://github.com/matthewshahi) • [@mattmartelnz](https://github.com/mattmartelnz) • [@MauriPastorini](https://github.com/MauriPastorini) • [@maxp89](https://github.com/maxp89) +[@mchudy](https://github.com/mchudy) • [@Menshoz](https://github.com/Menshoz) • [@mhadaily](https://github.com/mhadaily) • [@michaelkedar](https://github.com/michaelkedar) +[@milezabaleta](https://github.com/milezabaleta) • [@miquelbeltran](https://github.com/miquelbeltran) • [@moffatman](https://github.com/moffatman) • [@MrUnfunny](https://github.com/MrUnfunny) +[@msangalli](https://github.com/msangalli) • [@MuthuGCodes](https://github.com/MuthuGCodes) • [@mvanbeusekom](https://github.com/mvanbeusekom) • [@nikosportolos](https://github.com/nikosportolos) +[@nirunirosha401-personal](https://github.com/nirunirosha401-personal) • [@noboru-i](https://github.com/noboru-i) • [@nt4f04uNd](https://github.com/nt4f04uNd) • [@okursoftware](https://github.com/okursoftware) +[@omartinma](https://github.com/omartinma) • [@omatt](https://github.com/omatt) • [@pedromassango](https://github.com/pedromassango) • [@pingbird](https://github.com/pingbird) +[@PoojaB26](https://github.com/PoojaB26) • [@QMAshfaqAli](https://github.com/QMAshfaqAli) • [@rahulan](https://github.com/rahulan) • [@rajayogan](https://github.com/rajayogan) +[@renefloor](https://github.com/renefloor) • [@Rexios80](https://github.com/Rexios80) • [@rzambroni](https://github.com/rzambroni) • [@Salakar](https://github.com/Salakar) +[@scarletteliza](https://github.com/scarletteliza) • [@scutlight](https://github.com/scutlight) • [@SenseiMinimal](https://github.com/SenseiMinimal) • [@shirbayat](https://github.com/shirbayat) +[@simpson-peter](https://github.com/simpson-peter) • [@slightfoot](https://github.com/slightfoot) • [@snikiche](https://github.com/snikiche) • [@SofiaRey](https://github.com/SofiaRey) +[@spideythewebhead](https://github.com/spideythewebhead) • [@spjass](https://github.com/spjass) • [@spratt-disney](https://github.com/spratt-disney) • [@ssmitienko](https://github.com/ssmitienko) +[@sushanthmshetty](https://github.com/sushanthmshetty) • [@swavkulinski](https://github.com/swavkulinski) • [@Swiftaxe](https://github.com/Swiftaxe) • [@tanercc](https://github.com/tanercc) +[@tcannon-disney](https://github.com/tcannon-disney) • [@tfabacher-dp](https://github.com/tfabacher-dp) • [@tgucio](https://github.com/tgucio) • [@thekorn](https://github.com/thekorn) +[@thkim1011](https://github.com/thkim1011) • [@TimoPieti](https://github.com/TimoPieti) • [@tomgilder](https://github.com/tomgilder) • [@tpiaggio](https://github.com/tpiaggio) +[@trevorplant](https://github.com/trevorplant) • [@tsurumotokdi](https://github.com/tsurumotokdi) • [@udayon](https://github.com/udayon) • [@vaind](https://github.com/vaind) +[@valentinallavayol](https://github.com/valentinallavayol) • [@Vanethos](https://github.com/Vanethos) • [@wakita181009](https://github.com/wakita181009) • [@wangying3426](https://github.com/wangying3426) +[@werainkhatri](https://github.com/werainkhatri) • [@whesse](https://github.com/whesse) • [@wolfenrain](https://github.com/wolfenrain) • [@workerbee22](https://github.com/workerbee22) +[@wprater](https://github.com/wprater) • [@xm0128](https://github.com/xm0128) • [@xu-baolin](https://github.com/xu-baolin) • [@YeungKC](https://github.com/YeungKC) +[@yiiim](https://github.com/yiiim) • [@ymgn-dev](https://github.com/ymgn-dev) • [@zhongwuzw](https://github.com/zhongwuzw) • [@Zudah228](https://github.com/Zudah228) diff --git a/ROSTER.md b/ROSTER.md new file mode 100644 index 0000000..0689b98 --- /dev/null +++ b/ROSTER.md @@ -0,0 +1,104 @@ +# Contributor Roster + +> [!IMPORTANT] +> **Pending Q4 Audit:** No contributor roles or permissions have been changed yet. The placements listed below are initial baseline assignments pending the Q4 audit, which will be subject to a 30-day community review period before being finalized. + +This directory lists active Flutter Contributor Ladder members. +Honorary alumni who have stepped back from active contribution and exited the GitHub organization are listed in [EMERITUS.md](EMERITUS.md). +Governance details and tier qualifications are specified in [docs/LADDER.md](docs/LADDER.md). + +> [!NOTE] +> This file is automatically generated from `data/contributors.yaml`. +> To update, edit `data/contributors.yaml` and run `dart run tool/generate_roster.dart`. + +## Maintainers (`@flutter/maintainers`) + +Maintainers hold GitHub Write permissions and organization moderation privileges, and belong to `@flutter/maintainers` (a child team of `@flutter/flutter-hackers`). + +[@aam](https://github.com/aam) • [@abdallahshaban557](https://github.com/abdallahshaban557) • [@alexmarkov](https://github.com/alexmarkov) • [@amcasari](https://github.com/amcasari) +[@ananci](https://github.com/ananci) • [@andrewkolos](https://github.com/andrewkolos) • [@andywolff](https://github.com/andywolff) • [@another-rex](https://github.com/another-rex) +[@antfitch](https://github.com/antfitch) • [@apwilson](https://github.com/apwilson) • [@arbreng](https://github.com/arbreng) • [@athomas](https://github.com/athomas) +[@b-luk](https://github.com/b-luk) • [@bdero](https://github.com/bdero) • [@biggs0125](https://github.com/biggs0125) • [@bkonyi](https://github.com/bkonyi) +[@bparrishMines](https://github.com/bparrishMines) • [@brianquinlan](https://github.com/brianquinlan) • [@bwilkerson](https://github.com/bwilkerson) • [@camsim99](https://github.com/camsim99) +[@CarlosMendonca](https://github.com/CarlosMendonca) • [@caroqliu](https://github.com/caroqliu) • [@catharp](https://github.com/catharp) • [@cbracken](https://github.com/cbracken) +[@chandarrengoog](https://github.com/chandarrengoog) • [@chaselatta](https://github.com/chaselatta) • [@chingjun](https://github.com/chingjun) • [@chloestefantsova](https://github.com/chloestefantsova) +[@ChristianEdwardPadilla](https://github.com/ChristianEdwardPadilla) • [@chunhtai](https://github.com/chunhtai) • [@cj-radcliff](https://github.com/cj-radcliff) • [@cligh](https://github.com/cligh) +[@conooi](https://github.com/conooi) • [@cpsloal](https://github.com/cpsloal) • [@craiglabenz](https://github.com/craiglabenz) • [@DanTup](https://github.com/DanTup) +[@danxorzum](https://github.com/danxorzum) • [@dcharkes](https://github.com/dcharkes) • [@di](https://github.com/di) • [@ditman](https://github.com/ditman) +[@dkbharti-glitch](https://github.com/dkbharti-glitch) • [@dkwingsmt](https://github.com/dkwingsmt) • [@domesticmouse](https://github.com/domesticmouse) • [@dsmcl](https://github.com/dsmcl) +[@dustingreen](https://github.com/dustingreen) • [@elliette](https://github.com/elliette) • [@emckean](https://github.com/emckean) • [@ericwindmill](https://github.com/ericwindmill) +[@essjay05](https://github.com/essjay05) • [@everyplace](https://github.com/everyplace) • [@eyebrowsoffire](https://github.com/eyebrowsoffire) • [@flar](https://github.com/flar) +[@flutter-zl](https://github.com/flutter-zl) • [@gaaclarke](https://github.com/gaaclarke) • [@GemmaJAy](https://github.com/GemmaJAy) • [@gitrey](https://github.com/gitrey) +[@gmackall](https://github.com/gmackall) • [@goderbauer](https://github.com/goderbauer) • [@grouma](https://github.com/grouma) • [@gspencergoog](https://github.com/gspencergoog) +[@guidezpl](https://github.com/guidezpl) • [@hannah-hyj](https://github.com/hannah-hyj) • [@harryterkelsen](https://github.com/harryterkelsen) • [@helin24](https://github.com/helin24) +[@hellohuanlin](https://github.com/hellohuanlin) • [@Hixie](https://github.com/Hixie) • [@ievdokdm](https://github.com/ievdokdm) • [@iinozemtsev](https://github.com/iinozemtsev) +[@inferno-chromium](https://github.com/inferno-chromium) • [@InMatrix](https://github.com/InMatrix) • [@itsjustkevin](https://github.com/itsjustkevin) • [@jacob314](https://github.com/jacob314) +[@jacobsimionato](https://github.com/jacobsimionato) • [@jaeheon](https://github.com/jaeheon) • [@jakemac53](https://github.com/jakemac53) • [@jalseth](https://github.com/jalseth) +[@jason-simmons](https://github.com/jason-simmons) • [@jensjoha](https://github.com/jensjoha) • [@jesswrd](https://github.com/jesswrd) • [@jfschmakeit](https://github.com/jfschmakeit) +[@jiahaog](https://github.com/jiahaog) • [@jmagman](https://github.com/jmagman) • [@joefspiro](https://github.com/joefspiro) • [@johnmccutchan](https://github.com/johnmccutchan) +[@johnniwinther](https://github.com/johnniwinther) • [@johnpryan](https://github.com/johnpryan) • [@johnsonmh](https://github.com/johnsonmh) • [@jokerttu](https://github.com/jokerttu) +[@jonasfj](https://github.com/jonasfj) • [@jonathanmetzman](https://github.com/jonathanmetzman) • [@joshualitt](https://github.com/joshualitt) • [@jq129398](https://github.com/jq129398) +[@jrwang](https://github.com/jrwang) • [@jtmcdole](https://github.com/jtmcdole) • [@juan-mora-google](https://github.com/juan-mora-google) • [@justinmc](https://github.com/justinmc) +[@jwill](https://github.com/jwill) • [@jwren](https://github.com/jwren) • [@jyameo](https://github.com/jyameo) • [@kallentu](https://github.com/kallentu) +[@keertip](https://github.com/keertip) • [@kenzieschmoll](https://github.com/kenzieschmoll) • [@kevmoo](https://github.com/kevmoo) • [@khanhnwin](https://github.com/khanhnwin) +[@kjlubick](https://github.com/kjlubick) • [@knopp](https://github.com/knopp) • [@lamek](https://github.com/lamek) • [@leafpetersen](https://github.com/leafpetersen) +[@liamappelbe](https://github.com/liamappelbe) • [@loic-sharma](https://github.com/loic-sharma) • [@LongCatIsLooong](https://github.com/LongCatIsLooong) • [@LouiseHsu](https://github.com/LouiseHsu) +[@mariamhas](https://github.com/mariamhas) • [@MaryaBelanger](https://github.com/MaryaBelanger) • [@maryx](https://github.com/maryx) • [@mattkae](https://github.com/mattkae) +[@mbcorona](https://github.com/mbcorona) • [@mboetger](https://github.com/mboetger) • [@mbrase](https://github.com/mbrase) • [@mdebbar](https://github.com/mdebbar) +[@miguelfrde](https://github.com/miguelfrde) • [@mikaelkomu](https://github.com/mikaelkomu) • [@mit-mit](https://github.com/mit-mit) • [@mkustermann](https://github.com/mkustermann) +[@mosuem](https://github.com/mosuem) • [@mraleph](https://github.com/mraleph) • [@munificent](https://github.com/munificent) • [@muralikrishmt](https://github.com/muralikrishmt) +[@nan-yu](https://github.com/nan-yu) • [@natebosch](https://github.com/natebosch) • [@nickyringland](https://github.com/nickyringland) • [@nshahan](https://github.com/nshahan) +[@nuritzi](https://github.com/nuritzi) • [@okorohelijah](https://github.com/okorohelijah) • [@oliverchang](https://github.com/oliverchang) • [@omerlevran46](https://github.com/omerlevran46) +[@parlough](https://github.com/parlough) • [@paul-geoghegan](https://github.com/paul-geoghegan) • [@peixinli](https://github.com/peixinli) • [@pennzht](https://github.com/pennzht) +[@Piinks](https://github.com/Piinks) • [@polina-c](https://github.com/polina-c) • [@pq](https://github.com/pq) • [@precision](https://github.com/precision) +[@preethi0228-git](https://github.com/preethi0228-git) • [@puneetkukreja98](https://github.com/puneetkukreja98) • [@QuncCccccc](https://github.com/QuncCccccc) • [@ranbeuer](https://github.com/ranbeuer) +[@redbrogdon](https://github.com/redbrogdon) • [@reidbaker](https://github.com/reidbaker) • [@renaudd](https://github.com/renaudd) • [@renyou](https://github.com/renyou) +[@Renzo-Olivares](https://github.com/Renzo-Olivares) • [@rmacnak-google](https://github.com/rmacnak-google) • [@robert-ancell](https://github.com/robert-ancell) • [@rodydavis](https://github.com/rodydavis) +[@rspier](https://github.com/rspier) • [@Rusino](https://github.com/Rusino) • [@samuli-codemate](https://github.com/samuli-codemate) • [@saurabh-mirajkar](https://github.com/saurabh-mirajkar) +[@Saviq](https://github.com/Saviq) • [@scheglov](https://github.com/scheglov) • [@schultek](https://github.com/schultek) • [@sethladd](https://github.com/sethladd) +[@sfshaza2](https://github.com/sfshaza2) • [@sgnagnarella](https://github.com/sgnagnarella) • [@shanecglass](https://github.com/shanecglass) • [@sigmundch](https://github.com/sigmundch) +[@sigurdm](https://github.com/sigurdm) • [@sokoloff06](https://github.com/sokoloff06) • [@srawlins](https://github.com/srawlins) • [@srujzs](https://github.com/srujzs) +[@sstrickl](https://github.com/sstrickl) • [@stef-luo](https://github.com/stef-luo) • [@stereotype441](https://github.com/stereotype441) • [@stevemessick](https://github.com/stevemessick) +[@stuartmorgan-g](https://github.com/stuartmorgan-g) • [@studgeek](https://github.com/studgeek) • [@suertreus](https://github.com/suertreus) • [@suryaKommana2662](https://github.com/suryaKommana2662) +[@szakarias](https://github.com/szakarias) • [@tarobins](https://github.com/tarobins) • [@tarrinneal](https://github.com/tarrinneal) • [@tasnim-khan](https://github.com/tasnim-khan) +[@thagomizer](https://github.com/thagomizer) • [@timothyhoang-google](https://github.com/timothyhoang-google) • [@twerske](https://github.com/twerske) • [@utopicnarwhal](https://github.com/utopicnarwhal) +[@uysalere](https://github.com/uysalere) • [@vashworth](https://github.com/vashworth) • [@victorsanni](https://github.com/victorsanni) • [@vivahome2](https://github.com/vivahome2) +[@vsmenon](https://github.com/vsmenon) • [@walley892](https://github.com/walley892) • [@xster](https://github.com/xster) • [@yjbanov](https://github.com/yjbanov) +[@zanderso](https://github.com/zanderso) • [@zhenqiu1101](https://github.com/zhenqiu1101) • [@zijiehe-google-com](https://github.com/zijiehe-google-com) • [@zoeyfan](https://github.com/zoeyfan) + +## Committers (`@flutter/committers`) + +Committers hold GitHub Write permissions (merge rights) and provide authoritative code reviews. + +| Contributor | GitHub profile | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- | +| @AbdeMohlbi | [github.com/AbdeMohlbi](https://github.com/AbdeMohlbi) | 143 | 241 | +| @ValentinVignal | [github.com/ValentinVignal](https://github.com/ValentinVignal) | 30 | 47 | +| @bleroux | [github.com/bleroux](https://github.com/bleroux) | 20 | 31 | +| @davidhicks980 | [github.com/davidhicks980](https://github.com/davidhicks980) | 12 | 25 | +| @huycozy | [github.com/huycozy](https://github.com/huycozy) | 11 | 33 | +| @ksokolovskyi | [github.com/ksokolovskyi](https://github.com/ksokolovskyi) | 23 | 36 | +| @navaronbracke | [github.com/navaronbracke](https://github.com/navaronbracke) | 43 | 333 | +| @rkishan516 | [github.com/rkishan516](https://github.com/rkishan516) | 51 | 77 | +| @vickyduran | [github.com/vickyduran](https://github.com/vickyduran) | - | - | + +## Reviewers (`@flutter/reviewers`) + +Reviewers hold GitHub Triage permissions, triage issues, and conduct first-pass code reviews. + +| Contributor | GitHub profile | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- | +| @AlexV525 | [github.com/AlexV525](https://github.com/AlexV525) | 3 | 6 | +| @Hari-07 | [github.com/Hari-07](https://github.com/Hari-07) | 9 | 30 | +| @Jhon-Aires | [github.com/Jhon-Aires](https://github.com/Jhon-Aires) | - | - | +| @RobertOdrowaz | [github.com/RobertOdrowaz](https://github.com/RobertOdrowaz) | 7 | 12 | +| @csells | [github.com/csells](https://github.com/csells) | 11 | 22 | +| @danagbemava-nc | [github.com/danagbemava-nc](https://github.com/danagbemava-nc) | 0 | 8 | +| @htrolle-epam | [github.com/htrolle-epam](https://github.com/htrolle-epam) | 15 | 15 | +| @kentnguyen99 | [github.com/kentnguyen99](https://github.com/kentnguyen99) | 0 | 542 | +| @piedcipher | [github.com/piedcipher](https://github.com/piedcipher) | 7 | 16 | +| @tirth-patel-nc | [github.com/tirth-patel-nc](https://github.com/tirth-patel-nc) | 0 | 10 | + +## Dasher Emeritus + +Former ladder members who have stepped back from active contribution do not remain in the GitHub organization and are honored in [EMERITUS.md](EMERITUS.md). diff --git a/data/contributors.yaml b/data/contributors.yaml new file mode 100644 index 0000000..fb3be21 --- /dev/null +++ b/data/contributors.yaml @@ -0,0 +1,891 @@ +# Flutter Contributor Ladder Roster +# Source of truth for Flutter contributor roles and permissions. +# Last updated: 2026-07-30 + +maintainers: + # Maintainers on the Flutter team (@flutter/googlers and @flutter/partners) + # Granted Write access + Org Moderation + googlers: + - username: CarlosMendonca + role: maintainer + - username: ChristianEdwardPadilla + role: maintainer + - username: GemmaJAy + role: maintainer + - username: InMatrix + role: maintainer + - username: LongCatIsLooong + role: maintainer + - username: LouiseHsu + role: maintainer + - username: MaryaBelanger + role: maintainer + - username: Piinks + role: maintainer + - username: QuncCccccc + role: maintainer + - username: Renzo-Olivares + role: maintainer + - username: Rusino + role: maintainer + - username: aam + role: maintainer + - username: abdallahshaban557 + role: maintainer + - username: alexmarkov + role: maintainer + - username: amcasari + role: maintainer + - username: ananci + role: maintainer + - username: andrewkolos + role: maintainer + - username: andywolff + role: maintainer + - username: another-rex + role: maintainer + - username: antfitch + role: maintainer + - username: apwilson + role: maintainer + - username: arbreng + role: maintainer + - username: athomas + role: maintainer + - username: b-luk + role: maintainer + - username: biggs0125 + role: maintainer + - username: bkonyi + role: maintainer + - username: bparrishMines + role: maintainer + - username: brianquinlan + role: maintainer + - username: bwilkerson + role: maintainer + - username: camsim99 + role: maintainer + - username: caroqliu + role: maintainer + - username: catharp + role: maintainer + - username: cbracken + role: maintainer + - username: chandarrengoog + role: maintainer + - username: chaselatta + role: maintainer + - username: chingjun + role: maintainer + - username: chloestefantsova + role: maintainer + - username: chunhtai + role: maintainer + - username: cj-radcliff + role: maintainer + - username: cligh + role: maintainer + - username: conooi + role: maintainer + - username: cpsloal + role: maintainer + - username: craiglabenz + role: maintainer + - username: dcharkes + role: maintainer + - username: di + role: maintainer + - username: ditman + role: maintainer + - username: dkwingsmt + role: maintainer + - username: domesticmouse + role: maintainer + - username: dsmcl + role: maintainer + - username: dustingreen + role: maintainer + - username: elliette + role: maintainer + - username: emckean + role: maintainer + - username: ericwindmill + role: maintainer + - username: essjay05 + role: maintainer + - username: everyplace + role: maintainer + - username: eyebrowsoffire + role: maintainer + - username: flar + role: maintainer + - username: flutter-zl + role: maintainer + - username: gaaclarke + role: maintainer + - username: gitrey + role: maintainer + - username: gmackall + role: maintainer + - username: goderbauer + role: maintainer + - username: grouma + role: maintainer + - username: gspencergoog + role: maintainer + - username: guidezpl + role: maintainer + - username: hannah-hyj + role: maintainer + - username: harryterkelsen + role: maintainer + - username: helin24 + role: maintainer + - username: hellohuanlin + role: maintainer + - username: ievdokdm + role: maintainer + - username: iinozemtsev + role: maintainer + - username: inferno-chromium + role: maintainer + - username: itsjustkevin + role: maintainer + - username: jacob314 + role: maintainer + - username: jacobsimionato + role: maintainer + - username: jaeheon + role: maintainer + - username: jakemac53 + role: maintainer + - username: jalseth + role: maintainer + - username: jason-simmons + role: maintainer + - username: jensjoha + role: maintainer + - username: jesswrd + role: maintainer + - username: jfschmakeit + role: maintainer + - username: jiahaog + role: maintainer + - username: jmagman + role: maintainer + - username: joefspiro + role: maintainer + - username: johnmccutchan + role: maintainer + - username: johnniwinther + role: maintainer + - username: johnpryan + role: maintainer + - username: johnsonmh + role: maintainer + - username: jonasfj + role: maintainer + - username: jonathanmetzman + role: maintainer + - username: joshualitt + role: maintainer + - username: jq129398 + role: maintainer + - username: jrwang + role: maintainer + - username: jtmcdole + role: maintainer + - username: justinmc + role: maintainer + - username: jwill + role: maintainer + - username: jwren + role: maintainer + - username: jyameo + role: maintainer + - username: kallentu + role: maintainer + - username: keertip + role: maintainer + - username: kenzieschmoll + role: maintainer + - username: kevmoo + role: maintainer + - username: khanhnwin + role: maintainer + - username: kjlubick + role: maintainer + - username: lamek + role: maintainer + - username: leafpetersen + role: maintainer + - username: liamappelbe + role: maintainer + - username: loic-sharma + role: maintainer + - username: mariamhas + role: maintainer + - username: maryx + role: maintainer + - username: mboetger + role: maintainer + - username: mbrase + role: maintainer + - username: mdebbar + role: maintainer + - username: miguelfrde + role: maintainer + - username: mit-mit + role: maintainer + - username: mkustermann + role: maintainer + - username: mosuem + role: maintainer + - username: mraleph + role: maintainer + - username: munificent + role: maintainer + - username: nan-yu + role: maintainer + - username: natebosch + role: maintainer + - username: nickyringland + role: maintainer + - username: nshahan + role: maintainer + - username: nuritzi + role: maintainer + - username: okorohelijah + role: maintainer + - username: oliverchang + role: maintainer + - username: omerlevran46 + role: maintainer + - username: peixinli + role: maintainer + - username: pennzht + role: maintainer + - username: polina-c + role: maintainer + - username: pq + role: maintainer + - username: precision + role: maintainer + - username: redbrogdon + role: maintainer + - username: reidbaker + role: maintainer + - username: renaudd + role: maintainer + - username: renyou + role: maintainer + - username: rmacnak-google + role: maintainer + - username: rodydavis + role: maintainer + - username: rspier + role: maintainer + - username: scheglov + role: maintainer + - username: sethladd + role: maintainer + - username: sfshaza2 + role: maintainer + - username: sgnagnarella + role: maintainer + - username: shanecglass + role: maintainer + - username: sigmundch + role: maintainer + - username: sigurdm + role: maintainer + - username: sokoloff06 + role: maintainer + - username: srawlins + role: maintainer + - username: srujzs + role: maintainer + - username: sstrickl + role: maintainer + - username: stef-luo + role: maintainer + - username: stereotype441 + role: maintainer + - username: stevemessick + role: maintainer + - username: stuartmorgan-g + role: maintainer + - username: studgeek + role: maintainer + - username: suertreus + role: maintainer + - username: szakarias + role: maintainer + - username: tarobins + role: maintainer + - username: tarrinneal + role: maintainer + - username: tasnim-khan + role: maintainer + - username: thagomizer + role: maintainer + - username: timothyhoang-google + role: maintainer + - username: twerske + role: maintainer + - username: uysalere + role: maintainer + - username: vashworth + role: maintainer + - username: victorsanni + role: maintainer + - username: vivahome2 + role: maintainer + - username: vsmenon + role: maintainer + - username: walley892 + role: maintainer + - username: xster + role: maintainer + - username: yjbanov + role: maintainer + - username: zanderso + role: maintainer + - username: zhenqiu1101 + role: maintainer + - username: zijiehe-google-com + role: maintainer + - username: zoeyfan + role: maintainer + partners: + - username: DanTup + team: "@flutter/partners" + role: maintainer + - username: Hixie + team: "@flutter/partners" + role: maintainer + - username: Saviq + team: "@flutter/canonical" + role: maintainer + - username: bdero + team: "@flutter/flutter_gpu" + role: maintainer + - username: danxorzum + team: "@flutter/virtusa" + role: maintainer + - username: dkbharti-glitch + team: "@flutter/techm" + role: maintainer + - username: jokerttu + team: "@flutter/codemate" + role: maintainer + - username: juan-mora-google + team: "@flutter/virtusa" + role: maintainer + - username: knopp + team: "@flutter/canonical" + role: maintainer + - username: mattkae + team: "@flutter/canonical" + role: maintainer + - username: mbcorona + team: "@flutter/tcs" + role: maintainer + - username: mikaelkomu + team: "@flutter/codemate" + role: maintainer + - username: muralikrishmt + team: "@flutter/techm" + role: maintainer + - username: parlough + team: "@flutter/partners" + role: maintainer + - username: paul-geoghegan + team: "@flutter/canonical" + role: maintainer + - username: preethi0228-git + team: "@flutter/tcs" + role: maintainer + - username: puneetkukreja98 + team: "@flutter/tcs" + role: maintainer + - username: ranbeuer + team: "@flutter/virtusa" + role: maintainer + - username: robert-ancell + team: "@flutter/canonical" + role: maintainer + - username: samuli-codemate + team: "@flutter/codemate" + role: maintainer + - username: saurabh-mirajkar + team: "@flutter/tcs" + role: maintainer + - username: schultek + team: "@flutter/partners" + role: maintainer + - username: suryaKommana2662 + team: "@flutter/tcs" + role: maintainer + - username: utopicnarwhal + team: "@flutter/codemate" + role: maintainer + +committers: + # Members of @flutter/committers (Child of @flutter/flutter-hackers) + # Granted GitHub Write role / merge access + - username: AbdeMohlbi + role: committer + - username: ValentinVignal + role: committer + - username: bleroux + role: committer + - username: davidhicks980 + role: committer + - username: huycozy + role: committer + - username: ksokolovskyi + role: committer + - username: navaronbracke + role: committer + - username: rkishan516 + role: committer + - username: vickyduran + role: committer + +reviewers: + # Members of @flutter/reviewers (Child of @flutter/flutter-hackers) + # Granted GitHub Triage role + - username: AlexV525 + role: reviewer + - username: Hari-07 + role: reviewer + - username: Jhon-Aires + role: reviewer + - username: RobertOdrowaz + role: reviewer + - username: csells + role: reviewer + - username: danagbemava-nc + role: reviewer + - username: htrolle-epam + grace_until: "2027-10-14" + role: reviewer + - username: kentnguyen99 + role: reviewer + - username: piedcipher + role: reviewer + - username: tirth-patel-nc + role: reviewer + +dash_emeritus: + # Honored alumni (External, non-org members on GitHub) + - username: 0xZOne + role: emeritus + - username: 324mao + role: emeritus + - username: ANTONINEUTRON + role: emeritus + - username: AnnaPS + role: emeritus + - username: AntonyKiragu + role: emeritus + - username: AustinGTI + role: emeritus + - username: AyushBherwani1998 + role: emeritus + - username: BerkSpar + role: emeritus + - username: CareF + role: emeritus + - username: CoffeeDevUK + role: emeritus + - username: ColdPaleLight + role: emeritus + - username: DBowen33 + role: emeritus + - username: DiegoGW + role: emeritus + - username: Ehesp + role: emeritus + - username: FXschwartz + role: emeritus + - username: FirentisTFW + role: emeritus + - username: FlutterIssues + role: emeritus + - username: HidenoriMatsubayashi + role: emeritus + - username: Hiroaki-Kanai + role: emeritus + - username: IanSimpson + role: emeritus + - username: JErazo7 + role: emeritus + - username: Jan-Stepien + role: emeritus + - username: JsouLiang + role: emeritus + - username: Justian-SopraSteria + role: emeritus + - username: Kirpal + role: emeritus + - username: KristinBi + role: emeritus + - username: LinXunFeng + role: emeritus + - username: MacarenaSantos + role: emeritus + - username: Markzipan + role: emeritus + - username: MaruQuarky + role: emeritus + - username: MauriPastorini + role: emeritus + - username: Menshoz + role: emeritus + - username: MrUnfunny + role: emeritus + - username: MuthuGCodes + role: emeritus + - username: PoojaB26 + role: emeritus + - username: QMAshfaqAli + role: emeritus + - username: Rexios80 + role: emeritus + - username: Salakar + role: emeritus + - username: SenseiMinimal + role: emeritus + - username: SofiaRey + role: emeritus + - username: Swiftaxe + role: emeritus + - username: TimoPieti + role: emeritus + - username: Vanethos + role: emeritus + - username: YeungKC + role: emeritus + - username: Zudah228 + role: emeritus + - username: a14n + role: emeritus + - username: aaron-sf + role: emeritus + - username: adarshprabhu + role: emeritus + - username: adicahyono + role: emeritus + - username: akrwb + role: emeritus + - username: albertodev01 + role: emeritus + - username: alestiago + role: emeritus + - username: allisonryan0002 + role: emeritus + - username: almasryalyoum + role: emeritus + - username: alxp1982 + role: emeritus + - username: anderdobo + role: emeritus + - username: antonellof + role: emeritus + - username: atrope + role: emeritus + - username: azchohfi + role: emeritus + - username: bartekpacia + role: emeritus + - username: bernaferrari + role: emeritus + - username: brianegan + role: emeritus + - username: bruno-garcia + role: emeritus + - username: bselwe + role: emeritus + - username: btobin + role: emeritus + - username: budikhafiKLY + role: emeritus + - username: burakkasikci + role: emeritus + - username: chenglu + role: emeritus + - username: chinmoy12c + role: emeritus + - username: chriscraws + role: emeritus + - username: chrisraygill + role: emeritus + - username: cityxsuperapp + role: emeritus + - username: collinjackson + role: emeritus + - username: dam-ease + role: emeritus + - username: darshankawar + role: emeritus + - username: dclark-disney + role: emeritus + - username: diarmaidlindsay + role: emeritus + - username: diegoveloper + role: emeritus + - username: djol + role: emeritus + - username: dominikIncibit + role: emeritus + - username: eda-nagao + role: emeritus + - username: eggfly + role: emeritus + - username: egoe + role: emeritus + - username: eieio + role: emeritus + - username: emma-roudabush-disney + role: emeritus + - username: emrebuyukozkan + role: emeritus + - username: ened + role: emeritus + - username: enzoftware + role: emeritus + - username: erickzanardo + role: emeritus + - username: eseidel + role: emeritus + - username: exaby73 + role: emeritus + - username: farshad1489 + role: emeritus + - username: felangel + role: emeritus + - username: filiph + role: emeritus + - username: fma-goog + role: emeritus + - username: gastonliz + role: emeritus + - username: gianfrancopapa + role: emeritus + - username: gnprice + role: emeritus + - username: guoguo338 + role: emeritus + - username: h9jiang + role: emeritus + - username: hamdikahloun + role: emeritus + - username: harri35 + role: emeritus + - username: hbatagelo + role: emeritus + - username: helinanever + role: emeritus + - username: hgraceb + role: emeritus + - username: hillelcoren + role: emeritus + - username: horiaciu + role: emeritus + - username: ihorkozar + role: emeritus + - username: irmasantoso + role: emeritus + - username: itsjen + role: emeritus + - username: ja-dfir + role: emeritus + - username: jcollins-disney + role: emeritus + - username: jeroen-meijer + role: emeritus + - username: jesskuras + role: emeritus + - username: jingshao-code + role: emeritus + - username: jnschulze + role: emeritus + - username: jomblocoder + role: emeritus + - username: jorgecoca + role: emeritus + - username: josemontespg + role: emeritus + - username: jpnurmi + role: emeritus + - username: jwinarske + role: emeritus + - username: kaiceyd + role: emeritus + - username: kenvandine + role: emeritus + - username: kevinburns426 + role: emeritus + - username: kevya-google + role: emeritus + - username: kinla004 + role: emeritus + - username: kkboateng + role: emeritus + - username: kmillikin + role: emeritus + - username: kokoro-team + role: emeritus + - username: kondoakio + role: emeritus + - username: kwg4 + role: emeritus + - username: laramartin + role: emeritus + - username: leoafarias + role: emeritus + - username: linxuebin1990 + role: emeritus + - username: lucavenir + role: emeritus + - username: luckysmg + role: emeritus + - username: magicianA + role: emeritus + - username: maheshj01 + role: emeritus + - username: martinduva + role: emeritus + - username: marwfair + role: emeritus + - username: matiasleyba + role: emeritus + - username: matsuda-shota + role: emeritus + - username: matthew-carroll + role: emeritus + - username: matthewshahi + role: emeritus + - username: mattmartelnz + role: emeritus + - username: maxp89 + role: emeritus + - username: mchudy + role: emeritus + - username: mhadaily + role: emeritus + - username: michaelkedar + role: emeritus + - username: milezabaleta + role: emeritus + - username: miquelbeltran + role: emeritus + - username: moffatman + role: emeritus + - username: msangalli + role: emeritus + - username: mvanbeusekom + role: emeritus + - username: nikosportolos + role: emeritus + - username: nirunirosha401-personal + role: emeritus + - username: noboru-i + role: emeritus + - username: nt4f04uNd + role: emeritus + - username: okursoftware + role: emeritus + - username: omartinma + role: emeritus + - username: omatt + role: emeritus + - username: pedromassango + role: emeritus + - username: pingbird + role: emeritus + - username: rahulan + role: emeritus + - username: rajayogan + role: emeritus + - username: renefloor + role: emeritus + - username: rzambroni + role: emeritus + - username: scarletteliza + role: emeritus + - username: scutlight + role: emeritus + - username: shirbayat + role: emeritus + - username: simpson-peter + role: emeritus + - username: slightfoot + role: emeritus + - username: snikiche + role: emeritus + - username: spideythewebhead + role: emeritus + - username: spjass + role: emeritus + - username: spratt-disney + role: emeritus + - username: ssmitienko + role: emeritus + - username: sushanthmshetty + role: emeritus + - username: swavkulinski + role: emeritus + - username: tanercc + role: emeritus + - username: tcannon-disney + role: emeritus + - username: tfabacher-dp + role: emeritus + - username: tgucio + role: emeritus + - username: thekorn + role: emeritus + - username: thkim1011 + role: emeritus + - username: tomgilder + role: emeritus + - username: tpiaggio + role: emeritus + - username: trevorplant + role: emeritus + - username: tsurumotokdi + role: emeritus + - username: udayon + role: emeritus + - username: vaind + role: emeritus + - username: valentinallavayol + role: emeritus + - username: wakita181009 + role: emeritus + - username: wangying3426 + role: emeritus + - username: werainkhatri + role: emeritus + - username: whesse + role: emeritus + - username: wolfenrain + role: emeritus + - username: workerbee22 + role: emeritus + - username: wprater + role: emeritus + - username: xm0128 + role: emeritus + - username: xu-baolin + role: emeritus + - username: yiiim + role: emeritus + - username: ymgn-dev + role: emeritus + - username: zhongwuzw + role: emeritus diff --git a/test/activity_sync_test.dart b/test/activity_sync_test.dart index 57b3358..4a960d0 100644 --- a/test/activity_sync_test.dart +++ b/test/activity_sync_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/test/bots_test.dart b/test/bots_test.dart index c44649c..c0342de 100644 --- a/test/bots_test.dart +++ b/test/bots_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/test/data_integrity_test.dart b/test/data_integrity_test.dart index d19ea33..34dfc66 100644 --- a/test/data_integrity_test.dart +++ b/test/data_integrity_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/test/github_client_test.dart b/test/github_client_test.dart index 2d35514..9cdf68f 100644 --- a/test/github_client_test.dart +++ b/test/github_client_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/test/iso_week_test.dart b/test/iso_week_test.dart index c9282f2..e2b9fcd 100644 --- a/test/iso_week_test.dart +++ b/test/iso_week_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/test/license_test.dart b/test/license_test.dart index 7d67e0e..a54b201 100644 --- a/test/license_test.dart +++ b/test/license_test.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -7,7 +7,7 @@ import 'package:test/test.dart'; String _generateLicense(String prefix) { return ''' -${prefix}Copyright 2014 The Flutter Authors. All rights reserved. +${prefix}Copyright 2026 The Flutter Authors. ${prefix}Use of this source code is governed by a BSD-style license that can be ${prefix}found in the LICENSE file.'''; } diff --git a/test/mock_process_runner.dart b/test/mock_process_runner.dart new file mode 100644 index 0000000..01bb510 --- /dev/null +++ b/test/mock_process_runner.dart @@ -0,0 +1,45 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import '../tool/process_runner.dart'; + +export '../tool/process_runner.dart' show ProcessRunner; + +/// In-memory mock process runner to record external process invocations and +/// control process outputs hermetically in tests. +class MockProcessRunner { + MockProcessRunner({ + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + this.exceptionToThrow, + this.handler, + }); + + final List<({String executable, List arguments})> calls = []; + ProcessRunner? handler; + int exitCode; + dynamic stdout; + dynamic stderr; + Exception? exceptionToThrow; + + Future run(String executable, List arguments) async { + calls.add(( + executable: executable, + arguments: List.unmodifiable(arguments), + )); + if (exceptionToThrow != null) { + throw exceptionToThrow!; + } + if (handler != null) { + return handler!(executable, arguments); + } + return ProcessResult(1234, exitCode, stdout, stderr); + } + + Future call(String executable, List arguments) => + run(executable, arguments); +} diff --git a/test/quarterly_audit_test.dart b/test/quarterly_audit_test.dart new file mode 100644 index 0000000..af87c37 --- /dev/null +++ b/test/quarterly_audit_test.dart @@ -0,0 +1,899 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../tool/check_grace_period.dart'; +import '../tool/check_user.dart'; +import '../tool/contributors_roster.dart'; +import '../tool/quarterly_audit.dart'; +import 'mock_process_runner.dart'; + +void main() { + group('Quarterly Audit Report Generation', () { + test('generate community audit report runs and produces valid markdown', + () { + final result = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--community', + '--offline', + ]); + + expect( + result.exitCode, + equals(0), + reason: 'quarterly_audit.dart --community should exit with 0', + ); + final output = result.stdout as String; + + // Header and metadata + expect( + output, + contains('# Quarterly Community Contributor Ladder Audit'), + ); + expect(output, contains('Audit date:')); + expect(output, contains('12-month evaluation window:')); + expect(output, contains('30-day grace period deadline:')); + + // Section 1: Promotions and New Joiners + expect(output, contains('## Recommended promotions and new joiners')); + + // Section 2: Active Role Retention + expect(output, contains('## Active role retention')); + + // Section 3: 30-Day Grace Period + expect(output, contains('## 30-day grace period')); + + // Section 4: Callout for Unscripted Contributions + expect(output, contains('## Qualitative contributions')); + }); + + test( + 'generate partner verification report runs and produces valid markdown', + () { + final result = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--partners', + '--offline', + ]); + + expect( + result.exitCode, + equals(0), + reason: 'quarterly_audit.dart --partners should exit with 0', + ); + final output = result.stdout as String; + + // Header and metadata + expect(output, contains('# Quarterly Partner Team Verification Audit')); + expect(output, contains('@flutter/partners')); + expect(output, contains('## Active partner team members')); + expect(output, contains('## Departures and offboarding')); + expect(output, contains('[ ] Verified Active')); + }); + + test('ROSTER.md and EMERITUS.md exist and contain valid sections', () { + final rosterFile = File('ROSTER.md'); + final emeritusFile = File('EMERITUS.md'); + expect(rosterFile.existsSync(), isTrue); + expect(emeritusFile.existsSync(), isTrue); + expect(rosterFile.readAsStringSync(), contains('# Contributor Roster')); + expect(emeritusFile.readAsStringSync(), contains('# Dasher Emeritus')); + }); + + test( + 'discovers external candidates from summary and applies --finalize transitions', + () { + final tempDir = Directory.systemTemp.createTempSync('audit_finalize_'); + try { + final contributorsFile = File('${tempDir.path}/contributors.yaml'); + final summaryFile = File('${tempDir.path}/activity_summary.json'); + + contributorsFile.writeAsStringSync(''' +maintainers: + googlers: + - username: Piinks + role: maintainer + partners: + - username: robert-ancell + team: "@flutter/canonical" + role: maintainer + +committers: + - username: activeCommitter + role: committer + - username: inactiveCommitter + role: committer + +reviewers: + - username: promotedReviewer + role: reviewer + - username: retainedReviewer + role: reviewer + - username: graceAppealedReviewer + role: reviewer + - username: recentJoinerReviewer + grace_until: "2099-01-01" + role: reviewer + - username: inactiveReviewer + role: reviewer + +prospective_new_joiners: + - username: Mairramer + suggested_role: reviewer + +dash_emeritus: + - username: returningEmeritus + role: emeritus + - username: quietEmeritus + role: emeritus +'''); + + summaryFile.writeAsStringSync(''' +{ + "activeCommitter": {"merged_prs": 12, "issues": 5, "reviews": 5, "total": 22}, + "inactiveCommitter": {"merged_prs": 1, "issues": 1, "reviews": 0, "total": 2}, + "promotedReviewer": {"merged_prs": 15, "issues": 10, "reviews": 10, "total": 35}, + "retainedReviewer": {"merged_prs": 4, "issues": 5, "reviews": 5, "total": 14}, + "graceAppealedReviewer": {"merged_prs": 2, "issues": 2, "reviews": 1, "total": 5}, + "recentJoinerReviewer": {"merged_prs": 1, "issues": 1, "reviews": 0, "total": 2}, + "inactiveReviewer": {"merged_prs": 0, "issues": 1, "reviews": 0, "total": 1}, + "returningEmeritus": {"merged_prs": 5, "issues": 4, "reviews": 3, "total": 12}, + "Mairramer": {"merged_prs": 10, "issues": 4, "reviews": 2, "total": 16}, + "discoveredExternal": {"merged_prs": 12, "issues": 10, "reviews": 12, "total": 34}, + "auto-submit": {"merged_prs": 50, "issues": 0, "reviews": 0, "total": 50} +} +'''); + + // 1. Verify --community discovers returningEmeritus and discoveredExternal (and ignores auto-submit), + // and omits excluded users so no one is singled out in the generated report. + final communityResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--community', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + ]); + expect(communityResult.exitCode, equals(0)); + final report = communityResult.stdout as String; + expect( + report, + contains('| @promotedReviewer | Reviewer | **Committer** |'), + ); + expect( + report, + contains( + '| @returningEmeritus | Dasher Emeritus | **Reviewer** (Reinstatement) |', + ), + ); + expect( + report, + contains( + '| @discoveredExternal | External Contributor | **Committer** (Invitation) |', + ), + ); + expect( + report, + contains( + '| @recentJoinerReviewer | Reviewer (12m recent-joiner carve-out) | 1 | 2 |', + ), + ); + expect(report, isNot(contains('auto-submit'))); + + // Verify --exclude omits excluded users from the community report completely + final filteredCommunityResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--community', + '--exclude', + 'discoveredExternal,promotedReviewer', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + ]); + expect(filteredCommunityResult.exitCode, equals(0)); + final filteredReport = filteredCommunityResult.stdout as String; + expect(filteredReport, isNot(contains('discoveredExternal'))); + expect( + filteredReport, + isNot( + contains('| @promotedReviewer | Reviewer | **Committer** |'), + ), + ); + expect( + filteredReport, + contains('| @promotedReviewer | Reviewer | 15 | 35 |'), + ); + + // Verify live org reconciliation checks all current Flutter org members so no one slips through + final parsedDoc = + ContributorsRoster.parse(contributorsFile.readAsStringSync()); + final liveRoster = reconcileRosterState( + parsedDoc, + liveOrg: const LiveOrgSnapshot( + orgMembers: { + 'Piinks', + 'gaaclarke', + 'LouiseHsu', + 'robert-ancell', + 'newPartner', + 'activeCommitter', + 'inactiveCommitter', + 'promotedReviewer', + 'retainedReviewer', + 'graceAppealedReviewer', + 'recentJoinerReviewer', + 'inactiveReviewer', + 'quietEmeritus', // still in GitHub org -> must be in 30-day grace period + 'untrackedOrgMemberLow', // in GitHub org (<10) -> must be in 30-day grace period + 'untrackedOrgMemberHigh', // in GitHub org (>=10) -> promoted/enrolled + }, + googlers: {'Piinks'}, + collaborators: { + 'gaaclarke', // known exception Googler appearing in @flutter/collaborators + 'LouiseHsu', // known exception Googler appearing in @flutter/collaborators + 'robert-ancell', + 'newPartner', + 'activeCommitter', + 'untrackedOrgMemberLow', + }, + partners: {'robert-ancell', 'newPartner'}, + partnerTeams: { + 'robert-ancell': '@flutter/canonical', + 'newpartner': '@flutter/cloudsufi', + }, + ), + ); + expect( + liveRoster.googlers, + containsAll(['Piinks', 'gaaclarke', 'LouiseHsu']), + ); + expect(liveRoster.unassignedOrgMembers, isNot(contains('gaaclarke'))); + expect(liveRoster.unassignedOrgMembers, isNot(contains('LouiseHsu'))); + final liveSummary = { + 'activeCommitter': { + 'merged_prs': 12, + 'issues': 5, + 'reviews': 5, + 'total': 22, + }, + 'inactiveCommitter': { + 'merged_prs': 1, + 'issues': 1, + 'reviews': 0, + 'total': 2, + }, + 'promotedReviewer': { + 'merged_prs': 15, + 'issues': 10, + 'reviews': 10, + 'total': 35, + }, + 'retainedReviewer': { + 'merged_prs': 4, + 'issues': 5, + 'reviews': 5, + 'total': 14, + }, + 'graceAppealedReviewer': { + 'merged_prs': 2, + 'issues': 2, + 'reviews': 1, + 'total': 5, + }, + 'recentJoinerReviewer': { + 'merged_prs': 1, + 'issues': 1, + 'reviews': 0, + 'total': 2, + }, + 'inactiveReviewer': { + 'merged_prs': 0, + 'issues': 1, + 'reviews': 0, + 'total': 1, + }, + 'returningEmeritus': { + 'merged_prs': 5, + 'issues': 4, + 'reviews': 3, + 'total': 12, + }, + 'untrackedOrgMemberLow': { + 'merged_prs': 1, + 'issues': 1, + 'reviews': 0, + 'total': 2, + }, + 'untrackedOrgMemberHigh': { + 'merged_prs': 12, + 'issues': 5, + 'reviews': 3, + 'total': 20, + }, + 'newPartner': { + 'merged_prs': 4, + 'issues': 1, + 'reviews': 0, + 'total': 5, + }, + }; + final liveCommunityReport = + generateCommunityReport(liveRoster, liveSummary); + expect( + liveCommunityReport, + contains('| @quietEmeritus | Org Member | 0 | In grace period |'), + ); + expect( + liveCommunityReport, + contains( + '| @untrackedOrgMemberLow | Org Member | 2 | In grace period |', + ), + ); + expect( + liveCommunityReport, + contains( + '| @untrackedOrgMemberHigh | Org Member | **Reviewer** | 12 | 20 |', + ), + ); + expect( + liveCommunityReport, + contains( + '| @returningEmeritus | Dasher Emeritus | **Reviewer** (Reinstatement) | 5 | 12 |', + ), + ); + + final livePartnerReport = + generatePartnersReport(liveRoster, liveSummary); + expect( + livePartnerReport, + contains( + '| @newPartner | `@flutter/cloudsufi` | Maintainer | 5 contributions | [ ] Verified Active |', + ), + ); + + // Verify --draft-issues outputs ready-to-post issue bodies and gh commands + final draftIssuesResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--draft-issues', + '--pr', + 'https://github.com/flutter/contributors/pull/42', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + ]); + expect(draftIssuesResult.exitCode, equals(0)); + final draftOutput = draftIssuesResult.stdout as String; + expect(draftOutput, contains('Drafted Quarterly Review Issues')); + expect( + draftOutput, + contains( + '> **Proposed roster & report PR:** ', + ), + ); + expect(draftOutput, contains('gh issue create --title')); + + // 2. Verify --finalize applies promotions, reinstatements, grace period transitions, --retain, and --discord + final finalizeResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--finalize', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + '--retain', + 'graceAppealedReviewer', + '--discord', + 'promotedReviewer=promoted_discord', + ]); + expect(finalizeResult.exitCode, equals(0)); + + final updatedYaml = contributorsFile.readAsStringSync(); + expect( + updatedYaml, + contains( + 'committers:\n # Members of @flutter/committers (Child of @flutter/flutter-hackers)\n # Granted GitHub Write role / merge access\n - username: activeCommitter\n role: committer\n - username: discoveredExternal\n role: committer\n - username: promotedReviewer\n discord: "promoted_discord"\n role: committer', + ), + ); + expect( + updatedYaml, + contains( + ' - username: graceAppealedReviewer\n role: reviewer', + ), + ); + expect( + updatedYaml, + contains( + ' - username: recentJoinerReviewer\n grace_until: "2099-01-01"\n role: reviewer', + ), + ); + expect( + updatedYaml, + contains(' - username: returningEmeritus\n role: reviewer'), + ); + expect( + updatedYaml, + contains(' - username: Mairramer\n role: reviewer'), + ); + expect(updatedYaml, isNot(contains('prospective_new_joiners'))); + expect( + updatedYaml, + contains(' - username: inactiveCommitter\n role: emeritus'), + ); + expect( + updatedYaml, + contains(' - username: inactiveReviewer\n role: emeritus'), + ); + + // 3. Verify --exclude prevents promotion/reinstatement and --dry-run does not modify files + contributorsFile.writeAsStringSync(''' +maintainers: + googlers: + - username: Piinks + role: maintainer + partners: [] +committers: [] +reviewers: + - username: promotedReviewer + role: reviewer +prospective_new_joiners: [] +dash_emeritus: + - username: returningEmeritus + role: emeritus +'''); + final beforeDryRun = contributorsFile.readAsStringSync(); + final dryRunResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--finalize', + '--dry-run', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + ]); + expect(dryRunResult.exitCode, equals(0)); + expect(contributorsFile.readAsStringSync(), equals(beforeDryRun)); + + final excludeResult = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + '--finalize', + '--contributors', + contributorsFile.path, + '--summary', + summaryFile.path, + '--exclude', + 'promotedReviewer,returningEmeritus,discoveredExternal', + ]); + expect(excludeResult.exitCode, equals(0)); + final excludedYaml = contributorsFile.readAsStringSync(); + // promotedReviewer stays in reviewers instead of promoting to committers + expect(excludedYaml, isNot(contains('role: committer'))); + expect( + excludedYaml, + contains(' - username: promotedReviewer\n role: reviewer'), + ); + // returningEmeritus stays in dash_emeritus + expect( + excludedYaml, + contains( + 'dash_emeritus:\n # Honored alumni (External, non-org members on GitHub)\n - username: returningEmeritus', + ), + ); + // discoveredExternal is excluded + expect(excludedYaml, isNot(contains('discoveredExternal'))); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('handleReviewIssues creates labels and opens issues via ProcessRunner', + () async { + final mockRunner = MockProcessRunner( + stdout: 'https://github.com/flutter/contributors/issues/99\n', + ); + const roster = ResolvedRoster( + googlers: ['Piinks'], + partners: [(username: 'robert-ancell', team: '@flutter/canonical')], + committers: ['activeCommitter'], + reviewers: [], + unassignedOrgMembers: [], + externalEmeriti: [], + discordByUserLower: {}, + graceUntilByUserLower: {}, + ); + + await handleReviewIssues( + roster, + const {}, + quarter: 'Q4 2026', + quarterSlug: '2099-Q4', + excludedUsers: const {}, + retainedUsers: const {}, + prLink: 'https://github.com/flutter/contributors/pull/100', + openOnGitHub: true, + isDryRun: false, + runProcess: mockRunner.call, + ); + + // 4 label creations + 2 issue creations + expect(mockRunner.calls, hasLength(6)); + for (var i = 0; i < 4; i++) { + expect(mockRunner.calls[i].executable, equals('gh')); + expect( + mockRunner.calls[i].arguments.take(2), equals(['label', 'create'])); + } + expect( + mockRunner.calls[4].arguments.take(2), + equals(['issue', 'create']), + ); + expect( + mockRunner.calls[4].arguments, + contains( + '[Quarterly Review]: Q4 2026 Community Contributor Ladder Audit'), + ); + expect( + mockRunner.calls[5].arguments.take(2), + equals(['issue', 'create']), + ); + expect( + mockRunner.calls[5].arguments, + contains('[Quarterly Review]: Q4 2026 Partner Team Verification Audit'), + ); + }); + + test('quarterly_audit.dart exits with 1 when no mode flag is provided', () { + final result = Process.runSync('dart', [ + 'run', + 'tool/quarterly_audit.dart', + ]); + expect(result.exitCode, equals(1)); + }); + }); + + group('Check User & Grace Period Tooling Tests', () { + test('ContributorsRoster parses YAML and round-trips entries cleanly', () { + final roster = ContributorsRoster.parse(''' +maintainers: + googlers: + - username: Piinks + discord: "piinks_discord" + role: maintainer + partners: + - username: robert-ancell + team: "@flutter/canonical" + discord: "robert_discord" + role: maintainer +committers: + - username: AbdeMohlbi + discord: "abde_discord" + role: committer +reviewers: + - username: bdero + grace_until: "2027-01-01" + role: reviewer +dash_emeritus: + - username: Hixie + discord: "hixie_discord" + role: emeritus +'''); + + expect(roster.maintainers.googlers.single.username, equals('Piinks')); + expect( + roster.maintainers.partners.single.team, + equals('@flutter/canonical'), + ); + expect(roster.reviewers.single.graceUntil, equals('2027-01-01')); + + final reparsed = ContributorsRoster.parse( + roster.toYamlString(lastUpdated: '2026-10-23'), + ); + expect(reparsed.committers.single.username, equals('AbdeMohlbi')); + expect(reparsed.committers.single.discord, equals('abde_discord')); + expect(reparsed.dashEmeritus.single.username, equals('Hixie')); + }); + + test('findUserInfo and assessQualification evaluate all ladder tiers', () { + final roster = ContributorsRoster.parse(''' +maintainers: + googlers: + - username: Piinks + discord: "piinks_discord" + role: maintainer + partners: + - username: robert-ancell + team: "@flutter/canonical" + discord: "robert_discord" + role: maintainer +committers: + - username: AbdeMohlbi + discord: "abde_discord" + role: committer +reviewers: + - username: bdero + role: reviewer +dash_emeritus: + - username: Hixie + discord: "hixie_discord" + role: emeritus +'''); + + expect( + findUserInfo(roster, 'piinks'), + equals((role: 'Maintainer (Google)', discord: 'piinks_discord')), + ); + expect( + findUserInfo(roster, 'ROBERT-ANCELL'), + equals((role: 'Maintainer (Partner)', discord: 'robert_discord')), + ); + expect( + findUserInfo(roster, 'AbdeMohlbi'), + equals(( + role: 'Committer (@flutter/committers)', + discord: 'abde_discord', + )), + ); + expect( + findUserInfo(roster, 'bdero'), + equals((role: 'Reviewer (@flutter/reviewers)', discord: null)), + ); + expect( + findUserInfo(roster, 'hixie'), + equals((role: 'Dasher Emeritus', discord: 'hixie_discord')), + ); + expect( + findUserInfo(roster, 'unenrolled_user'), + equals((role: null, discord: null)), + ); + + expect( + assessQualification(total: 30, mergedPrs: 10), + contains('Qualifies for: Committer'), + ); + expect( + assessQualification(total: 35, mergedPrs: 5), + contains('Qualifies for: Reviewer'), + ); + expect( + assessQualification(total: 10, mergedPrs: 0), + contains('Qualifies for: Reviewer'), + ); + expect( + assessQualification(total: 5, mergedPrs: 2), + contains('Qualifies for: Contributor'), + ); + expect( + assessQualification(total: 0, mergedPrs: 0), + contains('Inactive over the last 12 months'), + ); + }); + + test('check_user.dart CLI evaluates existing and missing arguments', () { + final missingUser = Process.runSync('dart', [ + 'run', + 'tool/check_user.dart', + ]); + expect(missingUser.exitCode, equals(1)); + + final validUser = Process.runSync('dart', [ + 'run', + 'tool/check_user.dart', + 'Piinks', + ]); + expect(validUser.exitCode, equals(0)); + expect( + validUser.stdout as String, + contains('Current Roster Status: Maintainer (Google)'), + ); + }); + + test( + 'evaluateGracePeriodIssue, buildWeeklyGracePeriodUpdateComment, and buildGracePeriodConclusionComment handle 30-day window and assignee pings', + () { + final now = DateTime.utc(2026, 9, 1); + + final activeIssue = { + 'number': 10, + 'title': 'Quarterly Contributor Ladder Review - Q3 2026', + 'createdAt': '2026-08-15T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + ], + 'assignees': [ + {'login': 'Piinks'}, + ], + }; + + final activeEval = evaluateGracePeriodIssue(activeIssue, now: now); + expect(activeEval.shouldRemind, isFalse); + expect(activeEval.shouldPostWeeklyUpdate, isTrue); + expect(activeEval.daysElapsed, equals(17)); + expect(activeEval.daysRemaining, equals(13)); + expect(activeEval.alreadyConcluded, isFalse); + expect(activeEval.assignees, equals(['Piinks'])); + + final weeklyComment = buildWeeklyGracePeriodUpdateComment( + title: activeIssue['title'] as String, + daysElapsed: activeEval.daysElapsed, + createdAt: DateTime.parse(activeIssue['createdAt'] as String).toUtc(), + ); + expect(weeklyComment, contains('## Weekly review period status update')); + expect(weeklyComment, contains('17 day(s) (opened 2026-08-15)')); + expect( + weeklyComment, + contains( + '13 day(s) until the 30-day review window concludes on **2026-09-14**', + ), + ); + expect(weeklyComment, contains('If you were tagged above')); + + final expiredIssue = { + 'number': 11, + 'title': 'Quarterly Contributor Ladder Review - Q3 2026', + 'createdAt': '2026-07-31T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + ], + 'assignees': [ + {'login': 'Piinks'}, + ], + }; + final expiredEval = evaluateGracePeriodIssue(expiredIssue, now: now); + expect(expiredEval.shouldRemind, isTrue); + expect(expiredEval.shouldPostWeeklyUpdate, isFalse); + expect(expiredEval.daysElapsed, equals(32)); + expect(expiredEval.daysRemaining, equals(0)); + expect(expiredEval.alreadyConcluded, isFalse); + + final concludedIssue = { + 'number': 12, + 'title': 'Quarterly Contributor Ladder Review - Q3 2026', + 'createdAt': '2026-07-31T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + {'name': 'grace-period-concluded'}, + ], + }; + final concludedEval = evaluateGracePeriodIssue(concludedIssue, now: now); + expect(concludedEval.shouldRemind, isFalse); + expect(concludedEval.shouldPostWeeklyUpdate, isFalse); + expect(concludedEval.alreadyConcluded, isTrue); + + final comment = buildGracePeriodConclusionComment( + title: 'Quarterly Contributor Ladder Review - Q3 2026', + daysElapsed: 32, + assignees: expiredEval.assignees, + ); + expect(comment, contains('## 30-day review period concluded')); + expect(comment, contains('cc @Piinks')); + expect(comment, contains('32 days since opening')); + expect(comment, contains('[EMERITUS.md](EMERITUS.md)')); + expect(comment, contains('Discord usernames')); + }); + + test( + 'runGracePeriodCheck invokes gh commands via ProcessRunner for weekly updates, conclusion reminders, and dry-run', + () async { + final now = DateTime.utc(2026, 9, 1); + final issuesPayload = jsonEncode([ + { + 'number': 10, + 'title': 'Community Audit Q3 2026', + 'createdAt': '2026-08-15T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + ], + 'assignees': [ + {'login': 'Piinks'}, + ], + }, + { + 'number': 11, + 'title': 'Partner Audit Q3 2026', + 'createdAt': '2026-07-31T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + ], + 'assignees': [ + {'login': 'Piinks'}, + ], + }, + { + 'number': 12, + 'title': 'Concluded Audit Q2 2026', + 'createdAt': '2026-05-01T00:00:00Z', + 'labels': [ + {'name': 'quarterly-review'}, + {'name': 'grace-period-concluded'}, + ], + 'assignees': [ + {'login': 'Piinks'}, + ], + }, + ]); + + // 1. Live mode: lists issues, posts weekly update to #10, creates label + posts conclusion + adds label to #11, skips #12 + final liveRunner = MockProcessRunner( + handler: (executable, arguments) async { + if (arguments.length >= 2 && + arguments[0] == 'issue' && + arguments[1] == 'list') { + return ProcessResult(1, 0, issuesPayload, ''); + } + return ProcessResult(1, 0, '', ''); + }, + ); + + await runGracePeriodCheck( + isDryRun: false, + now: now, + runProcess: liveRunner.call, + ); + + expect(liveRunner.calls, hasLength(5)); + expect(liveRunner.calls[0].arguments.take(2), equals(['issue', 'list'])); + expect( + liveRunner.calls[1].arguments.take(3), + equals(['issue', 'comment', '10']), + ); + expect( + liveRunner.calls[1].arguments[4], + contains('## Weekly review period status update'), + ); + expect( + liveRunner.calls[2].arguments.take(3), + equals(['label', 'create', 'grace-period-concluded']), + ); + expect( + liveRunner.calls[3].arguments.take(3), + equals(['issue', 'comment', '11']), + ); + expect( + liveRunner.calls[3].arguments[4], + contains('## 30-day review period concluded'), + ); + expect( + liveRunner.calls[4].arguments, + equals( + ['issue', 'edit', '11', '--add-label', 'grace-period-concluded']), + ); + + // 2. Dry-run mode: lists issues only and performs zero mutations + final dryRunner = MockProcessRunner(stdout: issuesPayload); + await runGracePeriodCheck( + isDryRun: true, + now: now, + runProcess: dryRunner.call, + ); + expect(dryRunner.calls, hasLength(1)); + expect( + dryRunner.calls.single.arguments.take(2), equals(['issue', 'list'])); + + // 3. Error / non-zero exit handling in getOpenQuarterlyIssues + final errorRunner = MockProcessRunner(exitCode: 1, stderr: 'auth error'); + final emptyOnFailure = + await getOpenQuarterlyIssues(runProcess: errorRunner.call); + expect(emptyOnFailure, isEmpty); + + final throwingRunner = MockProcessRunner( + exceptionToThrow: const ProcessException('gh', ['issue', 'list']), + ); + final emptyOnException = + await getOpenQuarterlyIssues(runProcess: throwingRunner.call); + expect(emptyOnException, isEmpty); + }); + }); +} diff --git a/test/roster_validation_test.dart b/test/roster_validation_test.dart new file mode 100644 index 0000000..dfe6c05 --- /dev/null +++ b/test/roster_validation_test.dart @@ -0,0 +1,292 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import '../tool/generate_roster.dart'; +import '../tool/seed_initial_data.dart'; +import '../tool/validate_roster.dart'; + +void main() { + group('Roster Schema & Integrity Tests', () { + late String yamlContent; + late YamlMap doc; + + setUpAll(() { + final file = File('data/contributors.yaml'); + expect(file.existsSync(), isTrue, + reason: 'data/contributors.yaml must exist'); + yamlContent = file.readAsStringSync(); + final loaded = loadYaml(yamlContent); + expect(loaded, isA()); + doc = loaded as YamlMap; + }); + + test('contributors.yaml contains all required root sections', () { + expect(doc.containsKey('maintainers'), isTrue); + expect(doc.containsKey('committers'), isTrue); + expect(doc.containsKey('reviewers'), isTrue); + expect(doc.containsKey('dash_emeritus'), isTrue); + }); + + test('maintainers contains strictly googlers and partners', () { + final maintainers = doc['maintainers'] as YamlMap; + expect(maintainers.keys.toSet(), equals({'googlers', 'partners'})); + + final googlers = maintainers['googlers'] as YamlList; + final partners = maintainers['partners'] as YamlList; + + expect(googlers, isNotEmpty); + expect(partners, isNotEmpty); + }); + + test('all member handles across the entire roster are unique', () { + final seen = {}; + + void checkUser(dynamic entry, String section) { + expect(entry, isA()); + final username = (entry as YamlMap)['username'] as String?; + expect(username, isNotNull); + expect(username!.trim(), isNotEmpty); + + final lower = username.toLowerCase(); + expect(seen.containsKey(lower), isFalse, + reason: + 'Duplicate username "@$username" in $section (already in ${seen[lower]})'); + seen[lower] = section; + } + + final maintainers = doc['maintainers'] as YamlMap; + for (final g in maintainers['googlers'] as YamlList) { + checkUser(g, 'maintainers.googlers'); + } + for (final p in maintainers['partners'] as YamlList) { + checkUser(p, 'maintainers.partners'); + } + for (final c in doc['committers'] as YamlList) { + checkUser(c, 'committers'); + } + for (final r in doc['reviewers'] as YamlList) { + checkUser(r, 'reviewers'); + } + for (final e in doc['dash_emeritus'] as YamlList) { + checkUser(e, 'dash_emeritus'); + } + }); + + test('all roles belong to the allowed set of ladder roles', () { + const allowedRoles = {'maintainer', 'committer', 'reviewer', 'emeritus'}; + + void verifyRole(dynamic entry) { + final map = entry as YamlMap; + final role = map['role'] as String? ?? map['suggested_role'] as String?; + if (role != null) { + expect(allowedRoles.contains(role.toLowerCase()), isTrue, + reason: 'Invalid role "$role" for user ${map['username']}'); + } + } + + final maintainers = doc['maintainers'] as YamlMap; + (maintainers['googlers'] as YamlList).forEach(verifyRole); + (maintainers['partners'] as YamlList).forEach(verifyRole); + (doc['committers'] as YamlList).forEach(verifyRole); + (doc['reviewers'] as YamlList).forEach(verifyRole); + (doc['dash_emeritus'] as YamlList).forEach(verifyRole); + }); + + test('validateContributorsYaml passes on production data/contributors.yaml', + () { + expect(validateContributorsYaml(doc), isEmpty); + }); + + test( + 'validateContributorsYaml detects invalid schemas, duplicate users, invalid roles, and invalid maintainer groups', + () { + expect( + validateContributorsYaml(loadYaml('- not_a_map')), + contains(contains('root must be a YAML mapping')), + ); + + final invalidDoc = loadYaml(''' +maintainers: + googlers: + - username: alice + role: maintainer + discord: "alice_discord" + partners: + - username: bob + role: maintainer + community: + - username: invalidGroupUser + role: maintainer +committers: + - "not_a_map_entry" + - role: committer + - username: ALICE + role: committer + - username: charlie + role: superadmin + - username: dave + role: committer + discord: " " +reviewers: [] +dash_emeritus: [] +'''); + + final errors = validateContributorsYaml(invalidDoc); + expect( + errors, + contains( + contains('invalid group "community"'))); // non-googler/partner + expect(errors, contains(contains('entry is not a map'))); + expect(errors, contains(contains('missing username'))); + expect(errors, + contains(contains('duplicate username "@ALICE" in committers'))); + expect(errors, contains(contains('invalid role "superadmin"'))); + expect( + errors, + contains( + contains('invalid or empty discord username for user @dave'))); + }); + + test( + 'known_maintainers.txt single source of truth enforces Maintainer placement', + () { + final known = loadKnownExceptionMaintainers(); + expect( + known, + containsAll([ + 'CarlosMendonca', + 'LouiseHsu', + 'abdallahshaban557', + 'andywolff', + 'gaaclarke', + 'gmackall', + 'jesswrd', + ])); + + final badPlacementDoc = loadYaml(''' +maintainers: + googlers: + - username: Piinks + role: maintainer + partners: [] +committers: + - username: gaaclarke + role: committer +reviewers: [] +dash_emeritus: + - username: CarlosMendonca + role: emeritus +'''); + final errors = validateContributorsYaml(badPlacementDoc); + expect( + errors, + contains(contains( + '"@gaaclarke" is a known Maintainer and must be listed under maintainers.googlers'))); + expect( + errors, + contains(contains( + '"@CarlosMendonca" is a known Maintainer and must be listed under maintainers.googlers'))); + }); + + test( + 'buildRosterMarkdown, buildEmeritusMarkdown, and generate_roster CLI --check', + () { + final tempDir = Directory.systemTemp.createTempSync('roster_gen_test_'); + try { + final yamlFile = File('${tempDir.path}/contributors.yaml'); + final summaryFile = File('${tempDir.path}/activity_summary.json'); + final rosterFile = File('${tempDir.path}/ROSTER.md'); + final emeritusFile = File('${tempDir.path}/EMERITUS.md'); + + writeContributorsYaml( + googlers: ['Piinks'], + partners: ['robert-ancell'], + partnerTeams: {'robert-ancell': '@flutter/canonical'}, + committers: ['alice'], + reviewers: ['bob'], + emeriti: ['formerDasher'], + outputFile: yamlFile, + ); + summaryFile.writeAsStringSync( + '{"alice": {"merged_prs": 15, "total": 40}, "bob": {"merged_prs": 3, "total": 12}}'); + + final parsedYaml = loadYaml(yamlFile.readAsStringSync()) as YamlMap; + expect(validateContributorsYaml(parsedYaml), isEmpty); + expect(buildRosterMarkdown(parsedYaml, {}), + contains('## Committers (`@flutter/committers`)')); + expect( + buildEmeritusMarkdown(parsedYaml), contains('# Dasher Emeritus')); + + // --check should fail before ROSTER.md and EMERITUS.md exist + final checkBefore = Process.runSync('dart', [ + 'run', + 'tool/generate_roster.dart', + '--check', + '--contributors', + yamlFile.path, + '--summary', + summaryFile.path, + '--roster', + rosterFile.path, + '--emeritus', + emeritusFile.path, + ]); + expect(checkBefore.exitCode, equals(1)); + + // Generate ROSTER.md and EMERITUS.md + final genResult = Process.runSync('dart', [ + 'run', + 'tool/generate_roster.dart', + '--contributors', + yamlFile.path, + '--summary', + summaryFile.path, + '--roster', + rosterFile.path, + '--emeritus', + emeritusFile.path, + ]); + expect(genResult.exitCode, equals(0)); + expect(rosterFile.existsSync(), isTrue); + expect(emeritusFile.existsSync(), isTrue); + + final rosterMd = rosterFile.readAsStringSync(); + expect(rosterMd, contains('[@Piinks](https://github.com/Piinks)')); + expect(rosterMd, + contains('[@robert-ancell](https://github.com/robert-ancell)')); + expect( + rosterMd, + contains( + '| @alice | [github.com/alice](https://github.com/alice) | 15 | 40 |')); + expect(rosterMd, isNot(contains('Prospective new joiners'))); + + final emeritusMd = emeritusFile.readAsStringSync(); + expect(emeritusMd, + contains('[@formerDasher](https://github.com/formerDasher)')); + expect(emeritusMd, contains('https://github.com/flutter/rfc')); + + // --check should now pass + final checkAfter = Process.runSync('dart', [ + 'run', + 'tool/generate_roster.dart', + '--check', + '--contributors', + yamlFile.path, + '--summary', + summaryFile.path, + '--roster', + rosterFile.path, + '--emeritus', + emeritusFile.path, + ]); + expect(checkAfter.exitCode, equals(0)); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + }); +} diff --git a/tool/bots.dart b/tool/bots.dart index eaac81b..33010ab 100644 --- a/tool/bots.dart +++ b/tool/bots.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/tool/check_grace_period.dart b/tool/check_grace_period.dart new file mode 100644 index 0000000..8f448b9 --- /dev/null +++ b/tool/check_grace_period.dart @@ -0,0 +1,301 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'process_runner.dart'; + +/// Tool that inspects open quarterly review issues weekly, posts a weekly +/// countdown update while < 30 days have elapsed, and pings the assignee once +/// the 30-day review period has concluded. +void main(List args) async { + final isDryRun = + Platform.environment['DRY_RUN'] == 'true' || args.contains('--dry-run'); + await runGracePeriodCheck(isDryRun: isDryRun); +} + +/// Evaluates all open `quarterly-review` issues and posts weekly countdown +/// updates or 30-day conclusion reminders via [runProcess]. +Future runGracePeriodCheck({ + bool isDryRun = false, + DateTime? now, + ProcessRunner runProcess = Process.run, +}) async { + print( + 'Checking open quarterly review issues for 30-day grace period status...', + ); + if (isDryRun) { + print('Running in DRY-RUN mode. No comments will be posted.'); + } + + final issues = await getOpenQuarterlyIssues(runProcess: runProcess); + if (issues.isEmpty) { + print('No open quarterly review issues found.'); + return; + } + + final currentUtc = (now ?? DateTime.now()).toUtc(); + print('Found ${issues.length} open quarterly review issue(s).'); + + for (final issue in issues) { + final number = issue['number'] as int; + final title = issue['title'] as String; + final createdAtStr = issue['createdAt'] as String; + final createdAt = DateTime.parse(createdAtStr).toUtc(); + final eval = evaluateGracePeriodIssue(issue, now: currentUtc); + + if (eval.alreadyConcluded) { + print( + 'Issue #$number ("$title"): Grace period reminder already posted. Skipping.', + ); + continue; + } + + final daysElapsed = eval.daysElapsed; + print( + 'Issue #$number ("$title"): $daysElapsed days elapsed since creation ($createdAtStr).', + ); + + if (eval.shouldRemind) { + print( + '>>> Grace period has elapsed (>= 30 days) for Issue #$number! Pinging assignee(s): ${eval.assignees.join(', ')}', + ); + final commentBody = buildGracePeriodConclusionComment( + title: title, + daysElapsed: daysElapsed, + assignees: eval.assignees, + ); + + if (!isDryRun) { + await ensureGracePeriodConcludedLabel(runProcess: runProcess); + await postIssueComment(number, commentBody, runProcess: runProcess); + await addIssueLabel( + number, + 'grace-period-concluded', + runProcess: runProcess, + ); + print( + 'Successfully posted grace period conclusion reminder and added label to Issue #$number.', + ); + } else { + print( + 'Dry run: Would have posted the following conclusion comment to #$number:\n', + ); + print(commentBody); + } + } else if (eval.shouldPostWeeklyUpdate) { + print( + 'Issue #$number: ${eval.daysRemaining} day(s) remaining in the 30-day grace period. Posting weekly status update...', + ); + final weeklyComment = buildWeeklyGracePeriodUpdateComment( + title: title, + daysElapsed: daysElapsed, + createdAt: createdAt, + ); + + if (!isDryRun) { + await postIssueComment(number, weeklyComment, runProcess: runProcess); + print('Successfully posted weekly status update to Issue #$number.'); + } else { + print( + 'Dry run: Would have posted the following weekly status update to #$number:\n', + ); + print(weeklyComment); + } + } + } +} + +/// Extracts the assignee GitHub usernames from an issue JSON map, falling back +/// to the author login or `'Piinks'` if no assignees are set. +List extractAssignees(Map item) { + final assigneesList = (item['assignees'] as List? ?? []) + .map((a) => (a as Map)['login'] as String?) + .whereType() + .where((login) => login.trim().isNotEmpty) + .toList(); + if (assigneesList.isNotEmpty) { + return assigneesList; + } + + final authorLogin = + (item['author'] as Map?)?['login'] as String?; + if (authorLogin != null && authorLogin.trim().isNotEmpty) { + return [authorLogin.trim()]; + } + + return const ['Piinks']; +} + +/// Evaluates whether an open quarterly review issue needs a weekly update or a +/// 30-day grace period conclusion reminder. +({ + bool shouldRemind, + bool shouldPostWeeklyUpdate, + int daysElapsed, + int daysRemaining, + bool alreadyConcluded, + List assignees, +}) evaluateGracePeriodIssue( + Map issue, { + required DateTime now, +}) { + final createdAtStr = issue['createdAt'] as String; + final createdAt = DateTime.parse(createdAtStr).toUtc(); + final labels = (issue['labels'] as List? ?? []) + .map((l) => (l as Map)['name'] as String) + .toSet(); + + final alreadyConcluded = labels.contains('grace-period-concluded'); + final daysElapsed = now.toUtc().difference(createdAt).inDays; + final daysRemaining = (30 - daysElapsed).clamp(0, 30); + final assignees = extractAssignees(issue); + + return ( + shouldRemind: !alreadyConcluded && daysElapsed >= 30, + shouldPostWeeklyUpdate: !alreadyConcluded && daysElapsed < 30, + daysElapsed: daysElapsed, + daysRemaining: daysRemaining, + alreadyConcluded: alreadyConcluded, + assignees: assignees, + ); +} + +/// Builds the weekly status update comment posted on open quarterly review +/// issues while the 30-day review period is in progress. +String buildWeeklyGracePeriodUpdateComment({ + required String title, + required int daysElapsed, + required DateTime createdAt, +}) { + final daysRemaining = (30 - daysElapsed).clamp(0, 30); + final createdDate = createdAt.toUtc().toIso8601String().split('T').first; + final targetEndDate = createdAt + .toUtc() + .add(const Duration(days: 30)) + .toIso8601String() + .split('T') + .first; + + return ''' +## Weekly review period status update + +**$title** is currently in the 30-day review and grace period: + +- **Elapsed:** $daysElapsed day(s) (opened $createdDate) +- **Remaining:** $daysRemaining day(s) until the 30-day review window concludes on **$targetEndDate** + +If you were tagged above and have not yet replied, please comment on this thread before **$targetEndDate** to accept your invitation, share your Discord username for team member role provisioning, or share qualitative contributions (issue triage, reproductions, [RFCs](https://github.com/flutter/rfc), design docs, or mentoring). +'''; +} + +/// Builds the markdown comment to post when the 30-day grace period concludes, +/// pinging the assignee(s). +String buildGracePeriodConclusionComment({ + required String title, + required int daysElapsed, + List assignees = const ['Piinks'], +}) { + final effectiveAssignees = assignees.isEmpty ? const ['Piinks'] : assignees; + final mentions = effectiveAssignees + .map((a) => a.trim().startsWith('@') ? a.trim() : '@${a.trim()}') + .join(', '); + + return ''' +## 30-day review period concluded + +cc $mentions — The 30-day review and grace period for **$title** has concluded ($daysElapsed days since opening). + +### Next steps + +1. Review qualitative contributions, appeals, and Discord usernames shared by new joiners/promoted members in this thread. +2. On the open quarterly review PR branch, run `dart run tool/quarterly_audit.dart --finalize --retain ... --exclude ... --discord user=handle` to update `data/contributors.yaml`, [ROSTER.md](ROSTER.md), and [EMERITUS.md](EMERITUS.md), then merge the PR. +3. Update GitHub teams (`@flutter/reviewers`, `@flutter/committers`, and `@flutter/maintainers` as child teams of `@flutter/flutter-hackers`, along with `@flutter/partners` child teams), grant the Discord team member role to newly added ladder members, and remove members transitioning to Dasher Emeritus from the GitHub organization. +4. Close this issue once updates land. +'''; +} + +/// Queries GitHub for open issues labeled 'quarterly-review'. +Future>> getOpenQuarterlyIssues({ + ProcessRunner runProcess = Process.run, +}) async { + try { + final result = await runProcess('gh', [ + 'issue', + 'list', + '--state', + 'open', + '--label', + 'quarterly-review', + '--json', + 'number,title,createdAt,labels,assignees,author', + ]); + + if (result.exitCode != 0) { + print( + 'Notice: gh issue list exited with code ${result.exitCode}: ${result.stderr}', + ); + return []; + } + + final decoded = jsonDecode(result.stdout as String) as List; + return decoded.cast>(); + } catch (e) { + print('Notice: Could not query GitHub issues: $e'); + return []; + } +} + +/// Ensures the `grace-period-concluded` label exists in the repository. +Future ensureGracePeriodConcludedLabel({ + ProcessRunner runProcess = Process.run, +}) async { + await runProcess('gh', [ + 'label', + 'create', + 'grace-period-concluded', + '--description', + '30-day review period elapsed', + '--color', + 'FBCA04', + '--force', + ]); +} + +/// Posts a comment to a GitHub issue. +Future postIssueComment( + int issueNumber, + String body, { + ProcessRunner runProcess = Process.run, +}) async { + final result = await runProcess('gh', [ + 'issue', + 'comment', + '$issueNumber', + '--body', + body, + ]); + if (result.exitCode != 0) { + print('Failed to post comment to #$issueNumber: ${result.stderr}'); + } +} + +/// Adds a label to a GitHub issue. +Future addIssueLabel( + int issueNumber, + String label, { + ProcessRunner runProcess = Process.run, +}) async { + final result = await runProcess('gh', [ + 'issue', + 'edit', + '$issueNumber', + '--add-label', + label, + ]); + if (result.exitCode != 0) { + print('Failed to add label "$label" to #$issueNumber: ${result.stderr}'); + } +} diff --git a/tool/check_user.dart b/tool/check_user.dart new file mode 100644 index 0000000..f379817 --- /dev/null +++ b/tool/check_user.dart @@ -0,0 +1,144 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; + +import 'contributors_roster.dart'; +import 'github.dart'; + +void main(List args) async { + final parser = ArgParser() + ..addOption('user', abbr: 'u', help: 'GitHub username to evaluate') + ..addFlag( + 'live', + abbr: 'l', + help: 'Query live GitHub API instead of cache', + defaultsTo: false, + ); + + final results = parser.parse(args); + final username = results['user'] as String? ?? + (results.rest.isNotEmpty ? results.rest.first : null); + final isLive = results['live'] as bool; + + if (username == null || username.isEmpty) { + print('Usage: dart run tool/check_user.dart [--live]'); + exit(1); + } + + print('=== Contributor Activity Lookup: @$username ===\n'); + + // 1. Check current roster status + String? currentRole; + String? discordHandle; + final contributorsYamlFile = File('data/contributors.yaml'); + if (contributorsYamlFile.existsSync()) { + final roster = + ContributorsRoster.parse(contributorsYamlFile.readAsStringSync()); + final info = findUserInfo(roster, username); + currentRole = info.role; + discordHandle = info.discord; + } + + if (currentRole != null) { + print('Current Roster Status: $currentRole'); + if (discordHandle != null && discordHandle.isNotEmpty) { + print('Discord Username: $discordHandle'); + } + } else { + print('Current Roster Status: External Contributor (Not enrolled in org)'); + } + + // 2. Fetch or retrieve activity stats + var stats = {}; + + if (isLive) { + print('Querying live GitHub API for @$username over the last 12 months...'); + final client = GitHubClient(); + try { + final now = DateTime.now().toUtc(); + final oneYearAgo = now.subtract(const Duration(days: 365)); + stats = + await client.getUserActivity(username, since: oneYearAgo, until: now); + } finally { + client.close(); + } + } else { + final summaryFile = File('data/activity_summary.json'); + if (summaryFile.existsSync()) { + try { + final summary = + jsonDecode(summaryFile.readAsStringSync()) as Map; + if (summary.containsKey(username)) { + stats = Map.from(summary[username] as Map); + } + } catch (_) {} + } + } + + final total = stats['total'] ?? 0; + final prs = stats['merged_prs'] ?? 0; + final issues = stats['issues'] ?? 0; + final reviews = stats['reviews'] ?? 0; + + print('\n12-Month Activity Summary:'); + print(' • Merged PRs Authored: $prs'); + print(' • Issues Created: $issues'); + print(' • Merged PR Reviews: $reviews'); + print(' • Total Contributions: $total'); + + print('\nQualification Assessment:'); + print(' ${assessQualification(total: total, mergedPrs: prs)}'); +} + +/// Evaluates 12-month activity counts against Contributor Ladder tier thresholds. +String assessQualification({required int total, required int mergedPrs}) { + if (total >= 30 && mergedPrs >= 10) { + return '🎉 Qualifies for: Committer (≥ 30 contributions, ≥ 10 merged PRs)'; + } else if (total >= 10) { + return '✅ Qualifies for: Reviewer (≥ 10 qualifying contributions)'; + } else if (total > 0) { + return '🌱 Qualifies for: Contributor (< 10 contributions)'; + } else { + return '⏳ Inactive over the last 12 months (Dasher Emeritus candidate if currently on ladder)'; + } +} + +/// Looks up [username] in [roster] and returns their formatted ladder role and +/// Discord handle if present. +({String? role, String? discord}) findUserInfo( + ContributorsRoster roster, + String username, +) { + final target = username.toLowerCase(); + for (final g in roster.maintainers.googlers) { + if (g.username.toLowerCase() == target) { + return (role: 'Maintainer (Google)', discord: g.discord); + } + } + for (final p in roster.maintainers.partners) { + if (p.username.toLowerCase() == target) { + return (role: 'Maintainer (Partner)', discord: p.discord); + } + } + for (final c in roster.committers) { + if (c.username.toLowerCase() == target) { + return (role: 'Committer (@flutter/committers)', discord: c.discord); + } + } + for (final r in roster.reviewers) { + if (r.username.toLowerCase() == target) { + return (role: 'Reviewer (@flutter/reviewers)', discord: r.discord); + } + } + for (final e in roster.dashEmeritus) { + if (e.username.toLowerCase() == target) { + return (role: 'Dasher Emeritus', discord: e.discord); + } + } + return (role: null, discord: null); +} diff --git a/tool/contributors_roster.dart b/tool/contributors_roster.dart new file mode 100644 index 0000000..9acabb2 --- /dev/null +++ b/tool/contributors_roster.dart @@ -0,0 +1,177 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:yaml/yaml.dart'; + +/// Immutable contributor entry parsed from `data/contributors.yaml`. +final class ContributorEntry { + const ContributorEntry({ + required this.username, + this.role, + this.team, + this.discord, + this.graceUntil, + }); + + factory ContributorEntry.fromYaml(YamlMap map) { + final username = (map['username'] as String? ?? '').trim(); + final role = (map['role'] as String?)?.trim(); + final team = (map['team'] as String?)?.trim(); + final discord = (map['discord'] as String?)?.trim(); + final graceUntil = (map['grace_until'] as String?)?.trim(); + return ContributorEntry( + username: username, + role: (role != null && role.isNotEmpty) ? role : null, + team: (team != null && team.isNotEmpty) ? team : null, + discord: (discord != null && discord.isNotEmpty) ? discord : null, + graceUntil: + (graceUntil != null && graceUntil.isNotEmpty) ? graceUntil : null, + ); + } + + final String username; + final String? role; + final String? team; + final String? discord; + final String? graceUntil; + + /// Formats this entry as a YAML list item at [indent] spaces. + String toYamlEntry({required int indent, required String defaultRole}) { + final pad = ' ' * indent; + final fieldPad = ' ' * (indent + 2); + final lines = [ + '$pad- username: $username', + if (team != null) '${fieldPad}team: "$team"', + if (discord != null) '${fieldPad}discord: "$discord"', + if (graceUntil != null) '${fieldPad}grace_until: "$graceUntil"', + '${fieldPad}role: ${role ?? defaultRole}', + ]; + return lines.join('\n'); + } +} + +/// Immutable `maintainers` section (`googlers` and `partners`) of +/// `data/contributors.yaml`. +final class MaintainersRoster { + const MaintainersRoster({ + this.googlers = const [], + this.partners = const [], + }); + + factory MaintainersRoster.fromYaml(YamlMap? map) { + if (map == null) return const MaintainersRoster(); + return MaintainersRoster( + googlers: ContributorsRoster._parseEntries(map['googlers']), + partners: ContributorsRoster._parseEntries(map['partners']), + ); + } + + final List googlers; + final List partners; +} + +/// Strongly-typed immutable representation of `data/contributors.yaml`. +final class ContributorsRoster { + const ContributorsRoster({ + this.maintainers = const MaintainersRoster(), + this.committers = const [], + this.reviewers = const [], + this.dashEmeritus = const [], + }); + + factory ContributorsRoster.parse(String yamlContent) { + final dynamic parsed = loadYaml(yamlContent); + if (parsed is! YamlMap) { + return const ContributorsRoster(); + } + return ContributorsRoster.fromYaml(parsed); + } + + factory ContributorsRoster.fromYaml(YamlMap doc) { + return ContributorsRoster( + maintainers: MaintainersRoster.fromYaml(doc['maintainers'] as YamlMap?), + committers: _parseEntries(doc['committers']), + reviewers: _parseEntries(doc['reviewers']), + dashEmeritus: _parseEntries(doc['dash_emeritus']), + ); + } + + final MaintainersRoster maintainers; + final List committers; + final List reviewers; + final List dashEmeritus; + + static List _parseEntries(Object? rawList) { + if (rawList is! YamlList) return const []; + return List.unmodifiable([ + for (final item in rawList) + if (item is YamlMap) ContributorEntry.fromYaml(item), + ]); + } + + static String _formatBlock( + List entries, { + required int indent, + required String defaultRole, + }) { + if (entries.isEmpty) return ''; + final formatted = [ + for (final entry in entries) + entry.toYamlEntry(indent: indent, defaultRole: defaultRole), + ].join('\n'); + return '\n$formatted'; + } + + /// Serializes this roster into the canonical `data/contributors.yaml` format. + String toYamlString({required String lastUpdated}) { + final googlerBlock = _formatBlock( + maintainers.googlers, + indent: 4, + defaultRole: 'maintainer', + ); + final partnerBlock = _formatBlock( + maintainers.partners, + indent: 4, + defaultRole: 'maintainer', + ); + final committerBlock = _formatBlock( + committers, + indent: 2, + defaultRole: 'committer', + ); + final reviewerBlock = _formatBlock( + reviewers, + indent: 2, + defaultRole: 'reviewer', + ); + final emeritusBlock = _formatBlock( + dashEmeritus, + indent: 2, + defaultRole: 'emeritus', + ); + + return ''' +# Flutter Contributor Ladder Roster +# Source of truth for Flutter contributor roles and permissions. +# Last updated: $lastUpdated + +maintainers: + # Maintainers on the Flutter team (@flutter/googlers and @flutter/partners) + # Granted Write access + Org Moderation + googlers:$googlerBlock + partners:$partnerBlock + +committers: + # Members of @flutter/committers (Child of @flutter/flutter-hackers) + # Granted GitHub Write role / merge access$committerBlock + +reviewers: + # Members of @flutter/reviewers (Child of @flutter/flutter-hackers) + # Granted GitHub Triage role$reviewerBlock + +dash_emeritus: + # Honored alumni (External, non-org members on GitHub)$emeritusBlock +'''; + } +} diff --git a/tool/generate_roster.dart b/tool/generate_roster.dart new file mode 100644 index 0000000..18800cc --- /dev/null +++ b/tool/generate_roster.dart @@ -0,0 +1,200 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; +import 'package:args/args.dart'; +import 'package:yaml/yaml.dart'; + +void main(List args) { + final parser = ArgParser() + ..addFlag('check', + help: + 'Verify that ROSTER.md and EMERITUS.md match data/contributors.yaml without modifying files', + defaultsTo: false) + ..addOption('contributors', + help: 'Path to contributors.yaml', defaultsTo: 'data/contributors.yaml') + ..addOption('summary', + help: 'Path to activity_summary.json', + defaultsTo: 'data/activity_summary.json') + ..addOption('roster', help: 'Path to ROSTER.md', defaultsTo: 'ROSTER.md') + ..addOption('emeritus', + help: 'Path to EMERITUS.md', defaultsTo: 'EMERITUS.md'); + + final results = parser.parse(args); + final isCheck = results['check'] as bool; + final contributorsPath = results['contributors'] as String; + final summaryPath = results['summary'] as String; + final rosterPath = results['roster'] as String; + final emeritusPath = results['emeritus'] as String; + + final yamlFile = File(contributorsPath); + if (!yamlFile.existsSync()) { + print('Error: $contributorsPath not found.'); + exit(1); + } + + final doc = loadYaml(yamlFile.readAsStringSync()) as YamlMap; + + final summaryFile = File(summaryPath); + var summary = {}; + if (summaryFile.existsSync()) { + try { + summary = + jsonDecode(summaryFile.readAsStringSync()) as Map; + } catch (_) {} + } + + String preservePendingAuditBanner(File targetFile, String generated) { + if (!targetFile.existsSync()) return generated; + final existing = targetFile.readAsStringSync(); + final match = RegExp( + r'\n(> \[!IMPORTANT\]\n> \*\*Pending Q4 Audit:\*\*[^\n]*\n)', + ).firstMatch(existing); + if (match == null) return generated; + final firstNewline = generated.indexOf('\n'); + if (firstNewline == -1) return generated; + return '${generated.substring(0, firstNewline + 1)}${match.group(0)}${generated.substring(firstNewline + 1)}'; + } + + final rosterFile = File(rosterPath); + final emeritusFile = File(emeritusPath); + final rosterContent = + preservePendingAuditBanner(rosterFile, buildRosterMarkdown(doc, summary)); + final emeritusContent = + preservePendingAuditBanner(emeritusFile, buildEmeritusMarkdown(doc)); + + if (isCheck) { + final rosterOutOfSync = !rosterFile.existsSync() || + rosterFile.readAsStringSync() != rosterContent; + final emeritusOutOfSync = !emeritusFile.existsSync() || + emeritusFile.readAsStringSync() != emeritusContent; + if (rosterOutOfSync || emeritusOutOfSync) { + print( + 'Error: ROSTER.md or EMERITUS.md is out of sync with data/contributors.yaml.'); + print('Run `dart run tool/generate_roster.dart` to update them.'); + exit(1); + } + print('ROSTER.md and EMERITUS.md are in sync with data/contributors.yaml.'); + } else { + rosterFile.writeAsStringSync(rosterContent); + emeritusFile.writeAsStringSync(emeritusContent); + print( + 'Generated ${rosterFile.path} and ${emeritusFile.path} successfully.'); + } +} + +List _formatGridRows(List usernames, {int columns = 4}) { + final rows = []; + for (var i = 0; i < usernames.length; i += columns) { + final row = [ + for (var j = 0; j < columns && i + j < usernames.length; j++) + '[@${usernames[i + j]}](https://github.com/${usernames[i + j]})', + ]; + rows.add(row.join(' • ')); + } + return rows; +} + +String buildRosterMarkdown(YamlMap doc, Map summary) { + final maintainers = doc['maintainers'] as YamlMap?; + final allMaintainers = { + if (maintainers != null) ...[ + for (final g in maintainers['googlers'] as YamlList? ?? []) + g['username'] as String, + for (final p in maintainers['partners'] as YamlList? ?? []) + p['username'] as String, + ], + }; + final sortedMaintainers = allMaintainers.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + final maintainerRows = _formatGridRows(sortedMaintainers); + + final committers = doc['committers'] as YamlList? ?? []; + final committerRows = [ + for (final c in committers) + () { + final username = c['username'] as String; + final stats = summary[username] as Map?; + final prs = stats?['merged_prs'] ?? '-'; + final total = stats?['total'] ?? '-'; + return '| @$username | [github.com/$username](https://github.com/$username) | $prs | $total |'; + }(), + ]; + + final reviewers = doc['reviewers'] as YamlList? ?? []; + final reviewerRows = [ + for (final r in reviewers) + () { + final username = r['username'] as String; + final stats = summary[username] as Map?; + final prs = stats?['merged_prs'] ?? '-'; + final total = stats?['total'] ?? '-'; + return '| @$username | [github.com/$username](https://github.com/$username) | $prs | $total |'; + }(), + ]; + + return ''' +# Contributor Roster + +This directory lists active Flutter Contributor Ladder members. +Honorary alumni who have stepped back from active contribution and exited the GitHub organization are listed in [EMERITUS.md](EMERITUS.md). +Governance details and tier qualifications are specified in [docs/LADDER.md](docs/LADDER.md). + +> [!NOTE] +> This file is automatically generated from `data/contributors.yaml`. +> To update, edit `data/contributors.yaml` and run `dart run tool/generate_roster.dart`. + +## Maintainers (`@flutter/maintainers`) + +Maintainers hold GitHub Write permissions and organization moderation privileges, and belong to `@flutter/maintainers` (a child team of `@flutter/flutter-hackers`). + +${maintainerRows.join('\n')} + +## Committers (`@flutter/committers`) + +Committers hold GitHub Write permissions (merge rights) and provide authoritative code reviews. + +| Contributor | GitHub profile | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- | +${committerRows.join('\n')} + +## Reviewers (`@flutter/reviewers`) + +Reviewers hold GitHub Triage permissions, triage issues, and conduct first-pass code reviews. + +| Contributor | GitHub profile | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- | +${reviewerRows.join('\n')} + +## Dasher Emeritus + +Former ladder members who have stepped back from active contribution do not remain in the GitHub organization and are honored in [EMERITUS.md](EMERITUS.md). +'''; +} + +String buildEmeritusMarkdown(YamlMap doc) { + final emeriti = doc['dash_emeritus'] as YamlList? ?? []; + final sortedEmeriti = emeriti.map((e) => e['username'] as String).toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + final emeritusRows = _formatGridRows(sortedEmeriti); + + return ''' +# Dasher Emeritus + +This directory honors **Dasher Emeritus** alumni—former Flutter Reviewers, Committers, and Maintainers who previously held roles on the contributor ladder and have stepped back from active contribution (< 10 contributions over the preceding 12 months). + +To maintain repository security, Dasher Emeriti do not remain members of the `flutter` GitHub organization. Instead, their contributions are recognized here, and they retain access to Flutter contributor channels on Discord (including `#hidden-chat`) and the announcement-only `flutter-contributors` Google Group (for project announcements and calendar invites to our recurring Flutter Contributor Roundtable and the Dash Forum, where contributors can present and get feedback on their [RFCs](https://github.com/flutter/rfc); the registration form is pinned in `#hidden-chat` on Discord) so they can stay in touch and more easily ramp back up onto the project if and when they choose to return. + +Returning Emeriti who resume contributing are automatically identified during quarterly reviews (where they can reply directly on the public tracking issue). For off-cycle reinstatement, because creating new issues and pull requests in `flutter/contributors` is restricted to organization members (`collaborators`), returning Emeriti can reach out in `#hidden-chat` on Discord (or to an active organization member) to have an active org member sponsor and open a [Dasher Emeritus Reinstatement request](.github/ISSUE_TEMPLATE/2_emeritus_reinstatement.yml) on their behalf (where they can also share an updated Discord username if needed). See [docs/LADDER.md](docs/LADDER.md) for details. + +> [!NOTE] +> This file is automatically generated from `data/contributors.yaml`. +> To update, edit `data/contributors.yaml` and run `dart run tool/generate_roster.dart`. + +## Honored Alumni + +${emeritusRows.join('\n')} +'''; +} diff --git a/tool/github.dart b/tool/github.dart index 8322647..0b74090 100644 --- a/tool/github.dart +++ b/tool/github.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/tool/process_runner.dart b/tool/process_runner.dart new file mode 100644 index 0000000..23f5079 --- /dev/null +++ b/tool/process_runner.dart @@ -0,0 +1,9 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' show ProcessResult; + +/// Signature for running an external process asynchronously. +typedef ProcessRunner = Future Function( + String executable, List arguments); diff --git a/tool/quarterly_audit.dart b/tool/quarterly_audit.dart new file mode 100644 index 0000000..ca44532 --- /dev/null +++ b/tool/quarterly_audit.dart @@ -0,0 +1,1583 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:intl/intl.dart'; + +import 'bots.dart'; +import 'contributors_roster.dart'; +import 'github.dart'; +import 'process_runner.dart'; +import 'validate_roster.dart'; + +/// Snapshot of live `flutter` GitHub organization and team membership. +final class LiveOrgSnapshot { + const LiveOrgSnapshot({ + required this.orgMembers, + required this.googlers, + required this.partners, + required this.partnerTeams, + this.collaborators = const {}, + this.committers = const {}, + this.reviewers = const {}, + }); + + final Set orgMembers; + final Set googlers; + final Set partners; + final Map partnerTeams; + + /// `@flutter/collaborators` is automatically managed by internal tooling and + /// contains all organization members who are not Googlers. + final Set collaborators; + final Set committers; + final Set reviewers; +} + +/// Reconciled roster combining `data/contributors.yaml` with live `flutter` +/// GitHub organization membership so every current org member is audited. +final class ResolvedRoster { + const ResolvedRoster({ + required this.googlers, + required this.partners, + required this.committers, + required this.reviewers, + required this.unassignedOrgMembers, + required this.externalEmeriti, + this.formerMaintainers = const {}, + required this.discordByUserLower, + required this.graceUntilByUserLower, + }); + + final List googlers; + final List<({String username, String team})> partners; + final List committers; + final List<({String username, String? graceUntil})> reviewers; + + /// Current `flutter` GitHub org members who are not Maintainers, Committers, + /// Reviewers, or bots (e.g. legacy org members pending Emeritus removal or + /// members added to the org outside `committers`/`reviewers`). + final List unassignedOrgMembers; + + /// Dasher Emeriti in `data/contributors.yaml` who are no longer members of + /// the `flutter` GitHub organization. + final List externalEmeriti; + + /// Former Maintainers from `data/contributors.yaml` who have left + /// `@flutter/googlers` or `@flutter/partners` and exited the GitHub org. + final Set formerMaintainers; + + final Map discordByUserLower; + final Map graceUntilByUserLower; +} + +/// Queries live `flutter` GitHub organization and team membership so no current +/// org member slips through an audit. +Future fetchLiveOrgSnapshot(GitHubClient client) async { + try { + await loadRobotTeams(client); + final orgMembers = await client.getOrgMembers('flutter'); + if (orgMembers.isEmpty) return null; + final googlers = await client.getTeamMembers('flutter', 'googlers'); + final partners = await client.getTeamMembers('flutter', 'partners'); + final collaborators = + await client.getTeamMembers('flutter', 'collaborators'); + final partnerTeams = {}; + try { + final childSlugs = await client.getChildTeams('flutter', 'partners'); + for (final slug in childSlugs) { + final members = await client.getTeamMembers('flutter', slug); + for (final m in members) { + partnerTeams[m.toLowerCase()] = '@flutter/$slug'; + } + } + } catch (e) { + stderr.writeln( + 'Notice: Could not resolve @flutter/partners child teams: $e'); + } + final committers = await client.getTeamMembers('flutter', 'committers'); + final reviewers = await client.getTeamMembers('flutter', 'reviewers'); + return LiveOrgSnapshot( + orgMembers: orgMembers, + googlers: googlers, + partners: partners, + partnerTeams: partnerTeams, + collaborators: collaborators, + committers: committers, + reviewers: reviewers, + ); + } catch (e) { + stderr.writeln( + 'Notice: Could not query live Flutter GitHub org ($e); falling back to data/contributors.yaml.'); + return null; + } +} + +/// Reconciles `data/contributors.yaml` ([doc]) with [liveOrg] so that 100% of +/// current `flutter` GitHub organization members are categorized and evaluated. +ResolvedRoster reconcileRosterState( + ContributorsRoster doc, { + LiveOrgSnapshot? liveOrg, + DateTime? referenceDate, +}) { + final now = (referenceDate ?? DateTime.now()).toUtc(); + final todayUtc = DateTime.utc(now.year, now.month, now.day); + + final discordByUserLower = {}; + final graceUntilByUserLower = {}; + final yamlPartnerTeamsLower = {}; + final canonicalNameLower = {}; + + void recordMetadata(ContributorEntry entry) { + final u = entry.username; + if (u.isEmpty) return; + final lower = u.toLowerCase(); + canonicalNameLower.putIfAbsent(lower, () => u); + final d = entry.discord; + final g = entry.graceUntil; + if (d != null && d.isNotEmpty) { + discordByUserLower[lower] = d; + } + if (g != null && g.isNotEmpty) { + final parsed = DateTime.tryParse(g); + if (parsed != null && !parsed.isBefore(todayUtc)) { + graceUntilByUserLower[lower] = g; + } + } + } + + final yamlMaintainersLower = {}; + for (final g in doc.maintainers.googlers) { + recordMetadata(g); + if (g.username.isNotEmpty) { + yamlMaintainersLower.add(g.username.toLowerCase()); + } + } + for (final p in doc.maintainers.partners) { + recordMetadata(p); + if (p.username.isNotEmpty) { + final lower = p.username.toLowerCase(); + yamlMaintainersLower.add(lower); + if (p.team != null) { + yamlPartnerTeamsLower[lower] = p.team!; + } + } + } + for (final entry in [ + ...doc.committers, + ...doc.reviewers, + ...doc.dashEmeritus, + ]) { + recordMetadata(entry); + } + + final seenMaintainersLower = {}; + + // 1. Partners (@flutter/partners) + final partners = <({String username, String team})>[]; + if (liveOrg != null && liveOrg.partners.isNotEmpty) { + for (final u in liveOrg.partners) { + if (isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (!seenMaintainersLower.add(lower)) continue; + final team = liveOrg.partnerTeams[lower] ?? + yamlPartnerTeamsLower[lower] ?? + '@flutter/partners'; + partners.add((username: canonicalNameLower[lower] ?? u, team: team)); + } + } else { + for (final p in doc.maintainers.partners) { + final u = p.username; + if (u.isEmpty || isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (!seenMaintainersLower.add(lower)) continue; + partners.add(( + username: u, + team: p.team ?? '@flutter/partners', + )); + } + } + partners.sort( + (a, b) => a.username.toLowerCase().compareTo(b.username.toLowerCase())); + + // 2. Googlers (@flutter/googlers + known_maintainers.txt, excluding Partners) + final googlers = []; + final googlerCandidates = [ + if (liveOrg != null && liveOrg.googlers.isNotEmpty) + ...liveOrg.googlers + else + for (final g in doc.maintainers.googlers) g.username, + ...loadKnownExceptionMaintainers(), + ]; + for (final u in googlerCandidates) { + if (u.isEmpty || isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (seenMaintainersLower.add(lower)) { + googlers.add(canonicalNameLower[lower] ?? u); + } + } + googlers.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + // 3. Committers & Reviewers + final seenCommunityLower = {}; + final committers = []; + final committerCandidates = [ + for (final c in doc.committers) c.username, + if (liveOrg != null) ...liveOrg.committers, + ]; + for (final u in committerCandidates) { + if (u.isEmpty || isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (seenMaintainersLower.contains(lower)) continue; + if (seenCommunityLower.add(lower)) { + committers.add(canonicalNameLower[lower] ?? u); + } + } + committers.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + final reviewers = <({String username, String? graceUntil})>[]; + final reviewerCandidates = [ + for (final r in doc.reviewers) r.username, + if (liveOrg != null) ...liveOrg.reviewers, + ]; + for (final u in reviewerCandidates) { + if (u.isEmpty || isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (seenMaintainersLower.contains(lower) || + seenCommunityLower.contains(lower)) { + continue; + } + if (seenCommunityLower.add(lower)) { + reviewers.add(( + username: canonicalNameLower[lower] ?? u, + graceUntil: graceUntilByUserLower[lower], + )); + } + } + reviewers.sort( + (a, b) => a.username.toLowerCase().compareTo(b.username.toLowerCase())); + + // 4. All other non-Maintainer, non-bot members currently in the `flutter` GitHub org + // Note: `@flutter/collaborators` is automatically managed by internal tooling for + // non-Googler org members, though due to an internal sync issue the known exception + // Googlers in `known_maintainers.txt` also appear in `@flutter/collaborators` and + // are already excluded via `seenMaintainersLower` above. + final unassignedOrgMembers = []; + if (liveOrg != null && + (liveOrg.orgMembers.isNotEmpty || liveOrg.collaborators.isNotEmpty)) { + for (final u in [...liveOrg.orgMembers, ...liveOrg.collaborators]) { + if (isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (seenMaintainersLower.contains(lower) || + seenCommunityLower.contains(lower)) { + continue; + } + if (seenCommunityLower.add(lower)) { + unassignedOrgMembers.add(canonicalNameLower[lower] ?? u); + } + } + } + unassignedOrgMembers + .sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + // 5. External Dasher Emeriti (no longer in the `flutter` GitHub org) + final externalEmeriti = []; + for (final e in doc.dashEmeritus) { + final u = e.username; + if (u.isEmpty || isExcludedAccount(u)) continue; + final lower = u.toLowerCase(); + if (seenMaintainersLower.contains(lower) || + seenCommunityLower.contains(lower)) { + continue; + } + // If liveOrg is present and this dash_emeritus entry is no longer in the + // live org and has 0 activity, check if they were a renamed account or + // genuine Emeritus alumni. + if (seenCommunityLower.add(lower)) { + externalEmeriti.add(u); + } + } + externalEmeriti.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + final formerMaintainers = yamlMaintainersLower + .difference(seenMaintainersLower) + .difference(seenCommunityLower); + + return ResolvedRoster( + googlers: googlers, + partners: partners, + committers: committers, + reviewers: reviewers, + unassignedOrgMembers: unassignedOrgMembers, + externalEmeriti: externalEmeriti, + formerMaintainers: formerMaintainers, + discordByUserLower: discordByUserLower, + graceUntilByUserLower: graceUntilByUserLower, + ); +} + +void main(List args) async { + final parser = ArgParser() + ..addFlag('prepare', + help: + 'Run the full local kickoff: vet nominated candidates against the ban list, generate reports, and update the roster for a single PR', + defaultsTo: false) + ..addFlag('community', + help: 'Generate Community Contributor Ladder Audit Report', + defaultsTo: false) + ..addFlag('partners', + help: 'Generate Partner Affiliation Verification Audit Report', + defaultsTo: false) + ..addFlag('save', + help: 'Save generated report into reports/ directory', + defaultsTo: false) + ..addFlag('draft-issues', + help: + 'Output drafted issue bodies and gh CLI commands based on the generated reports to start the 30-day review period', + defaultsTo: false) + ..addFlag('open-issues', + help: + 'Open the public Community Review and Partner Verification issues on GitHub using the generated reports', + defaultsTo: false) + ..addFlag('finalize', + help: + 'Apply verified audit changes (e.g. after the 30-day grace period) to data/contributors.yaml, ROSTER.md, and EMERITUS.md', + defaultsTo: false) + ..addFlag('dry-run', + help: + 'Preview changes without modifying files on disk or opening GitHub issues', + defaultsTo: false) + ..addFlag('offline', + help: + 'Skip querying live Flutter GitHub organization membership and use only local files', + defaultsTo: false) + ..addFlag('no-prompt', + help: 'Disable interactive terminal prompts during --prepare', + defaultsTo: false) + ..addOption('retain', + help: + 'Comma-separated usernames to retain in their current role (e.g. verified qualitative contributions)') + ..addOption('exclude', + help: + 'Comma-separated usernames to exclude from promotion, reinstatement, or invitation (e.g. prior ban history or declined invite)') + ..addOption('discord', + help: + 'Comma-separated user=discord_handle mappings to record in contributors.yaml') + ..addOption('pr', + help: + 'Optional PR URL or number to link in the drafted/opened review issues') + ..addOption('contributors', + help: 'Path to contributors YAML file', + defaultsTo: 'data/contributors.yaml') + ..addOption('summary', + help: 'Path to activity summary JSON file', + defaultsTo: 'data/activity_summary.json') + ..addOption('output', + abbr: 'o', + help: + 'Output file path for generated report (prints to stdout if omitted)'); + + final results = parser.parse(args); + final isPrepare = results['prepare'] as bool; + final isCommunity = results['community'] as bool; + final isPartners = results['partners'] as bool; + final isSave = results['save'] as bool; + final isDraftIssues = results['draft-issues'] as bool; + final isOpenIssues = results['open-issues'] as bool; + final isFinalize = results['finalize'] as bool; + final isDryRun = results['dry-run'] as bool; + final isOffline = results['offline'] as bool; + final noPrompt = results['no-prompt'] as bool; + final retainArg = results['retain'] as String?; + final excludeArg = results['exclude'] as String?; + final discordArg = results['discord'] as String?; + final prArg = results['pr'] as String?; + final contributorsPath = results['contributors'] as String; + final summaryPath = results['summary'] as String; + var outputPath = results['output'] as String?; + + if (!isPrepare && + !isCommunity && + !isPartners && + !isDraftIssues && + !isOpenIssues && + !isFinalize) { + print( + 'Usage: dart run tool/quarterly_audit.dart [--prepare | --community | --partners | --draft-issues | --open-issues | --finalize] [--save] [--dry-run] [--offline] [--retain u1,u2] [--exclude u1,u2] [--discord u1=d1] [--pr ]'); + exit(1); + } + + final contributorsYamlFile = File(contributorsPath); + if (!contributorsYamlFile.existsSync()) { + print( + 'Error: $contributorsPath not found. Run `dart run tool/seed_initial_data.dart` first.'); + exit(1); + } + + final summaryFile = File(summaryPath); + var summary = {}; + if (summaryFile.existsSync()) { + try { + summary = + jsonDecode(summaryFile.readAsStringSync()) as Map; + } catch (e) { + print('Warning: Failed to parse $summaryPath: $e'); + } + } + + final doc = ContributorsRoster.parse(contributorsYamlFile.readAsStringSync()); + final now = DateTime.now().toUtc(); + final quarter = 'Q${((now.month - 1) ~/ 3) + 1} ${now.year}'; + final quarterSlug = '${now.year}-Q${((now.month - 1) ~/ 3) + 1}'; + + final reportsDir = Directory('reports'); + if (!reportsDir.existsSync()) { + reportsDir.createSync(recursive: true); + } + + final retainedUsers = _parseUserSet(retainArg); + final excludedUsers = _parseUserSet(excludeArg); + final discordOverrides = _parseDiscordMap(discordArg); + + // Query live Flutter GitHub organization membership by default when operating + // on the primary roster so no current org member slips through. + LiveOrgSnapshot? liveOrg; + if (!isOffline && contributorsPath == 'data/contributors.yaml') { + final client = GitHubClient(); + try { + liveOrg = await fetchLiveOrgSnapshot(client); + } finally { + client.close(); + } + } + + final roster = + reconcileRosterState(doc, liveOrg: liveOrg, referenceDate: now); + + if (isPrepare) { + _runPrepare( + roster, + summary, + contributorsFile: contributorsYamlFile, + quarter: quarter, + quarterSlug: quarterSlug, + retainedUsers: retainedUsers, + excludedUsers: excludedUsers, + discordOverrides: discordOverrides, + isDryRun: isDryRun, + allowPrompt: !noPrompt && excludeArg == null && stdin.hasTerminal, + ); + return; + } + + if (isCommunity) { + if (isSave && outputPath == null) { + outputPath = 'reports/$quarterSlug-community-ladder-audit.md'; + } + final report = generateCommunityReport( + roster, + summary, + excludedUsers: excludedUsers, + retainedUsers: retainedUsers, + prLink: prArg, + ); + _outputReport(report, outputPath); + } else if (isPartners) { + if (isSave && outputPath == null) { + outputPath = 'reports/$quarterSlug-partner-verification-audit.md'; + } + final report = generatePartnersReport(roster, summary, prLink: prArg); + _outputReport(report, outputPath); + } else if (isFinalize) { + final communityReportFile = + File('reports/$quarterSlug-community-ladder-audit.md'); + _finalizeAudit( + roster, + summary, + contributorsFile: contributorsYamlFile, + retainedUsers: retainedUsers, + excludedUsers: excludedUsers, + discordOverrides: discordOverrides, + isDryRun: isDryRun, + existingCommunityReportFile: + communityReportFile.existsSync() ? communityReportFile : null, + ); + } + + if (isDraftIssues || isOpenIssues) { + await handleReviewIssues( + roster, + summary, + quarter: quarter, + quarterSlug: quarterSlug, + excludedUsers: excludedUsers, + retainedUsers: retainedUsers, + prLink: prArg, + openOnGitHub: isOpenIssues, + isDryRun: isDryRun, + ); + } +} + +/// Interactive/CLI kickoff helper that lists nominated candidates for manual +/// ban-history verification, generates both quarterly reports (omitting any +/// excluded users), and updates `data/contributors.yaml`, `ROSTER.md`, and +/// `EMERITUS.md` so the facilitator only needs to open a single PR. +void _runPrepare( + ResolvedRoster roster, + Map summary, { + required File contributorsFile, + required String quarter, + required String quarterSlug, + required Set retainedUsers, + required Set excludedUsers, + required Map discordOverrides, + required bool isDryRun, + required bool allowPrompt, +}) { + print('=== Quarterly Contributor Ladder Audit Preparation ($quarter) ===\n'); + + final nominees = collectNominatedCandidates(roster, summary); + if (nominees.isEmpty) { + print('No new promotions, reinstatements, or prospective joiners found.'); + } else { + print( + 'Nominated candidates for promotion, reinstatement, or invitation (${nominees.length}):'); + for (final n in nominees) { + print( + ' • @${n.username} (${n.currentStatus} -> ${n.recommendedRole}: ${n.mergedPrs} PRs, ${n.total} total)'); + } + print(''); + print( + 'ACTION REQUIRED: Check the ${nominees.length} username(s) above against the internal Code of Conduct / ban history list.'); + if (allowPrompt) { + stdout.write( + 'Enter any usernames to exclude (comma-separated, or press Enter for none): '); + final input = stdin.readLineSync(); + excludedUsers.addAll(_parseUserSet(input)); + } + } + + if (excludedUsers.isNotEmpty) { + print( + 'Excluding from promotion/invitation (omitted from report & roster): ${excludedUsers.join(", ")}'); + } + + final communityPath = 'reports/$quarterSlug-community-ladder-audit.md'; + final partnerPath = 'reports/$quarterSlug-partner-verification-audit.md'; + + final communityReport = generateCommunityReport( + roster, + summary, + excludedUsers: excludedUsers, + retainedUsers: retainedUsers, + ); + final partnerReport = generatePartnersReport(roster, summary); + + if (isDryRun) { + print('\n[DRY RUN] Would write reports to:'); + print(' • $communityPath'); + print(' • $partnerPath'); + } else { + File(communityPath).writeAsStringSync(communityReport); + File(partnerPath).writeAsStringSync(partnerReport); + print('\nSaved vetted quarterly reports:'); + print(' • $communityPath'); + print(' • $partnerPath'); + } + + print(''); + _finalizeAudit( + roster, + summary, + contributorsFile: contributorsFile, + retainedUsers: retainedUsers, + excludedUsers: excludedUsers, + discordOverrides: discordOverrides, + isDryRun: isDryRun, + ); + + print('\n=== Next Steps ==='); + print( + '1. Open 1 PR with the reports and roster updates (hold open for the 30-day review period):'); + print(' git add reports/ data/contributors.yaml ROSTER.md EMERITUS.md'); + print( + ' git commit -m "chore: $quarter quarterly contributor ladder review"'); + print( + '2. Start the 30-day community review period by drafting or opening the public issues:'); + print(' dart run tool/quarterly_audit.dart --draft-issues --pr '); + print(' dart run tool/quarterly_audit.dart --open-issues --pr '); + print( + '3. After 30 days, update the PR branch with any review adjustments and merge it:'); + print( + ' dart run tool/quarterly_audit.dart --finalize --retain u1,u2 --exclude u3 --discord new_user=handle'); +} + +/// Drafts or opens the public Community Ladder Audit and Partner Verification +/// issues on GitHub using the vetted report files. +Future handleReviewIssues( + ResolvedRoster roster, + Map summary, { + required String quarter, + required String quarterSlug, + required Set excludedUsers, + required Set retainedUsers, + required String? prLink, + required bool openOnGitHub, + required bool isDryRun, + ProcessRunner runProcess = Process.run, +}) async { + final communityFile = File('reports/$quarterSlug-community-ladder-audit.md'); + final partnerFile = + File('reports/$quarterSlug-partner-verification-audit.md'); + + var communityBody = communityFile.existsSync() + ? communityFile.readAsStringSync() + : generateCommunityReport( + roster, + summary, + excludedUsers: excludedUsers, + retainedUsers: retainedUsers, + prLink: prLink, + ); + var partnerBody = partnerFile.existsSync() + ? partnerFile.readAsStringSync() + : generatePartnersReport(roster, summary, prLink: prLink); + + if (prLink != null && prLink.trim().isNotEmpty) { + final prLine = + '> **Proposed roster & report PR:** ${_formatPrReference(prLink)} \n'; + if (!communityBody.contains('Proposed roster & report PR:')) { + communityBody = communityBody.replaceFirst( + '> **Governance reference:**', + '$prLine> **Governance reference:**', + ); + } + if (!partnerBody.contains('Proposed roster & report PR:')) { + partnerBody = partnerBody.replaceFirst( + '> **Governance reference:**', + '$prLine> **Governance reference:**', + ); + } + } + + final communityTitle = + '[Quarterly Review]: $quarter Community Contributor Ladder Audit'; + final partnerTitle = + '[Quarterly Review]: $quarter Partner Team Verification Audit'; + + if (!openOnGitHub || isDryRun) { + print('=== Drafted Quarterly Review Issues ($quarter) ===\n'); + print('1. Community Audit Issue:'); + print(' Title: $communityTitle'); + print(' Labels: quarterly-review,community-audit'); + print( + ' Command: gh issue create --title "$communityTitle" --body-file ${communityFile.path} --label "quarterly-review,community-audit" --assignee "Piinks"\n', + ); + print('2. Partner Verification Issue:'); + print(' Title: $partnerTitle'); + print(' Labels: quarterly-review,partner-audit'); + print( + ' Command: gh issue create --title "$partnerTitle" --body-file ${partnerFile.path} --label "quarterly-review,partner-audit" --assignee "Piinks"\n', + ); + print('--- Community Issue Body Preview ---'); + print(communityBody); + print('--- Partner Issue Body Preview ---'); + print(partnerBody); + return; + } + + // Ensure labels exist + const labels = [ + ( + 'quarterly-review', + 'Quarterly Contributor Ladder Review', + '1D76DB', + ), + ( + 'community-audit', + 'Community Contributor Ladder Audit', + '0E8A16', + ), + ( + 'partner-audit', + 'Partner Team Verification Audit', + '5319E7', + ), + ( + 'grace-period-concluded', + '30-day review period elapsed', + 'FBCA04', + ), + ]; + for (final l in labels) { + await runProcess('gh', [ + 'label', + 'create', + l.$1, + '--description', + l.$2, + '--color', + l.$3, + '--force', + ]); + } + + final commResult = await runProcess('gh', [ + 'issue', + 'create', + '--title', + communityTitle, + '--body', + communityBody, + '--label', + 'quarterly-review,community-audit', + '--assignee', + 'Piinks', + ]); + if (commResult.exitCode == 0) { + print( + 'Created Community Audit Issue: ${(commResult.stdout as String).trim()}', + ); + } else { + print('Failed to create Community Audit Issue: ${commResult.stderr}'); + } + + final partResult = await runProcess('gh', [ + 'issue', + 'create', + '--title', + partnerTitle, + '--body', + partnerBody, + '--label', + 'quarterly-review,partner-audit', + '--assignee', + 'Piinks', + ]); + if (partResult.exitCode == 0) { + print( + 'Created Partner Verification Issue: ${(partResult.stdout as String).trim()}', + ); + } else { + print('Failed to create Partner Verification Issue: ${partResult.stderr}'); + } +} + +List< + ({ + String username, + String currentStatus, + String recommendedRole, + int mergedPrs, + int total, + })> collectNominatedCandidates( + ResolvedRoster roster, + Map summary, +) { + final nominees = <({ + String username, + String currentStatus, + String recommendedRole, + int mergedPrs, + int total, + })>[]; + + for (final r in roster.reviewers) { + final username = r.username; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 30 && prs >= 10) { + nominees.add(( + username: username, + currentStatus: 'Reviewer', + recommendedRole: 'Committer', + mergedPrs: prs, + total: total, + )); + } + } + + for (final username in roster.unassignedOrgMembers) { + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 10) { + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + nominees.add(( + username: username, + currentStatus: 'Org Member', + recommendedRole: role, + mergedPrs: prs, + total: total, + )); + } + } + + for (final username in roster.externalEmeriti) { + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 10) { + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + nominees.add(( + username: username, + currentStatus: 'Dasher Emeritus', + recommendedRole: '$role (Reinstatement)', + mergedPrs: prs, + total: total, + )); + } + } + + final externalCandidates = _discoverExternalCandidates(roster, summary); + for (final username in externalCandidates) { + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + nominees.add(( + username: username, + currentStatus: 'External Contributor', + recommendedRole: '$role (Invitation)', + mergedPrs: prs, + total: total, + )); + } + + return nominees; +} + +Map _parseDiscordMap(String? raw) { + if (raw == null || raw.trim().isEmpty) return {}; + final map = {}; + for (final pair in raw.split(',')) { + final parts = pair.split('='); + if (parts.length == 2) { + final user = parts[0].trim().replaceAll('@', '').toLowerCase(); + final discord = parts[1].trim(); + if (user.isNotEmpty && discord.isNotEmpty) { + map[user] = discord; + } + } + } + return map; +} + +Set _parseUserSet(String? raw) { + if (raw == null || raw.trim().isEmpty) return {}; + return raw + .split(',') + .map((s) => s.trim().replaceAll('@', '').toLowerCase()) + .where((s) => s.isNotEmpty) + .toSet(); +} + +/// Collects all lowercase usernames currently tracked in the resolved roster. +Set _collectEnrolledAndEmeritusLower(ResolvedRoster roster) { + return { + ...loadKnownExceptionMaintainers().map((u) => u.toLowerCase()), + ...roster.googlers.map((u) => u.toLowerCase()), + ...roster.partners.map((p) => p.username.toLowerCase()), + ...roster.committers.map((u) => u.toLowerCase()), + ...roster.reviewers.map((r) => r.username.toLowerCase()), + ...roster.unassignedOrgMembers.map((u) => u.toLowerCase()), + ...roster.externalEmeriti.map((u) => u.toLowerCase()), + ...roster.formerMaintainers, + }; +} + +/// Discovers unenrolled external contributors who have >= 10 qualifying +/// contributions in [summary]. +List _discoverExternalCandidates( + ResolvedRoster roster, Map summary) { + final enrolledOrEmeritus = _collectEnrolledAndEmeritusLower(roster); + final seenLower = {}; + final candidates = []; + + for (final entry in summary.entries) { + final username = entry.key; + if (isExcludedAccount(username)) continue; + final lower = username.toLowerCase(); + if (enrolledOrEmeritus.contains(lower) || seenLower.contains(lower)) { + continue; + } + final stats = entry.value as Map?; + final total = stats?['total'] as int? ?? 0; + if (total >= 10) { + seenLower.add(lower); + candidates.add(username); + } + } + + candidates.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return candidates; +} + +/// Returns true if the Reviewer entry [r] has an active one-time Year 1 +/// transition carve-out (`grace_until` date on or after [now]). Once the +/// initial 12-month launch transition window passes, this naturally expires. +bool hasActiveRecentJoinerCarveOut(ContributorEntry r, DateTime now) { + return _isGraceDateActive(r.graceUntil, now); +} + +bool _isGraceDateActive(String? graceUntilStr, DateTime now) { + if (graceUntilStr == null || graceUntilStr.trim().isEmpty) return false; + final graceUntil = DateTime.tryParse(graceUntilStr.trim()); + if (graceUntil == null) return false; + final todayUtc = DateTime.utc(now.year, now.month, now.day); + return !graceUntil.isBefore(todayUtc); +} + +String _formatPrReference(String prLink) { + final trimmed = prLink.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return '<$trimmed>'; + } + return trimmed; +} + +String _formatTableRowsBlock(List rows) => + rows.isEmpty ? '' : '\n${rows.join('\n')}'; + +String generateCommunityReport( + ResolvedRoster roster, + Map summary, { + Set excludedUsers = const {}, + Set retainedUsers = const {}, + String? prLink, +}) { + final now = DateTime.now().toUtc(); + final oneYearAgo = now.subtract(const Duration(days: 365)); + final graceDeadline = now.add(const Duration(days: 30)); + final quarter = 'Q${((now.month - 1) ~/ 3) + 1} ${now.year}'; + final dateFmt = DateFormat('MMMM d, yyyy'); + final prLineBlock = (prLink != null && prLink.trim().isNotEmpty) + ? '\n> **Proposed roster & report PR:** ${_formatPrReference(prLink)} ' + : ''; + + // 1. Recommended Promotions & New Community Joiners + final promotionRows = []; + for (final r in roster.reviewers) { + final username = r.username; + if (excludedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 30 && prs >= 10) { + promotionRows + .add('| @$username | Reviewer | **Committer** | $prs | $total |'); + } + } + for (final username in roster.unassignedOrgMembers) { + if (excludedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 10) { + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + promotionRows + .add('| @$username | Org Member | **$role** | $prs | $total |'); + } + } + for (final username in roster.externalEmeriti) { + if (excludedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 10) { + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + promotionRows.add( + '| @$username | Dasher Emeritus | **$role** (Reinstatement) | $prs | $total |', + ); + } + } + final externalCandidates = _discoverExternalCandidates(roster, summary); + for (final username in externalCandidates) { + if (excludedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + final role = (total >= 30 && prs >= 10) ? 'Committer' : 'Reviewer'; + promotionRows.add( + '| @$username | External Contributor | **$role** (Invitation) | $prs | $total |', + ); + } + + // 2. Active Role Retention + final retentionRows = []; + for (final username in roster.committers) { + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 10 || retainedUsers.contains(username.toLowerCase())) { + retentionRows.add('| @$username | Committer | $prs | $total |'); + } + } + for (final r in roster.reviewers) { + final username = r.username; + final lower = username.toLowerCase(); + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + final isPromoted = + total >= 30 && prs >= 10 && !excludedUsers.contains(lower); + if ((total >= 10 || retainedUsers.contains(lower)) && !isPromoted) { + retentionRows.add('| @$username | Reviewer | $prs | $total |'); + } else if (total < 10 && + !retainedUsers.contains(lower) && + _isGraceDateActive(r.graceUntil, now)) { + retentionRows.add( + '| @$username | Reviewer (12m recent-joiner carve-out) | $prs | $total |', + ); + } + } + for (final username in roster.unassignedOrgMembers) { + final lower = username.toLowerCase(); + if (!retainedUsers.contains(lower)) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total < 10) { + retentionRows.add('| @$username | Reviewer | $prs | $total |'); + } + } + + // 3. 30-Day Grace Period + final gracePeriodRows = []; + for (final username in roster.committers) { + if (retainedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + if (total < 10) { + gracePeriodRows + .add('| @$username | Committer | $total | In grace period |'); + } + } + for (final r in roster.reviewers) { + final username = r.username; + if (retainedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + if (total < 10 && !_isGraceDateActive(r.graceUntil, now)) { + gracePeriodRows + .add('| @$username | Reviewer | $total | In grace period |'); + } + } + for (final username in roster.unassignedOrgMembers) { + if (retainedUsers.contains(username.toLowerCase())) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + if (total < 10) { + gracePeriodRows + .add('| @$username | Org Member | $total | In grace period |'); + } + } + + final promotionBlock = _formatTableRowsBlock(promotionRows); + final retentionBlock = _formatTableRowsBlock(retentionRows); + final gracePeriodBlock = _formatTableRowsBlock(gracePeriodRows); + + return ''' +# Quarterly Community Contributor Ladder Audit ($quarter) + +> **Audit date:** ${dateFmt.format(now)} +> **12-month evaluation window:** ${dateFmt.format(oneYearAgo)} – ${dateFmt.format(now)} +> **30-day grace period deadline:** ${dateFmt.format(graceDeadline)} $prLineBlock +> **Governance reference:** [docs/LADDER.md](docs/LADDER.md) • [docs/QUARTERLY_REVIEW.md](docs/QUARTERLY_REVIEW.md) + +This issue tracks the $quarter Flutter Contributor Ladder review and starts the 30-day community review period. + +## Recommended promotions and new joiners + +The following contributors qualify for role advancement or invitation to the ladder. +If you are listed below, please reply in this thread to accept your invitation or promotion and share your **Discord username** (if you do not already have the Discord team member role) so we can grant you team member channel access: + +| Contributor | Current status | Recommended role | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- | :--- |$promotionBlock + +## Active role retention + +The following community members met their 12-month role criteria and retain their roles: + +| Contributor | Role | 12m merged PRs | 12m total activity | +| :--- | :--- | :--- | :--- |$retentionBlock + +## 30-day grace period + +The following members had < 10 contributions recorded in the last 12 months. +If you are listed below, you have 30 days to respond in this thread with any contributions that cannot be quantified automatically (issue triage, reproductions, [RFCs](https://github.com/flutter/rfc), mentoring). + +| Contributor | Current role | 12m total activity | Status | +| :--- | :--- | :--- | :--- |$gracePeriodBlock + +## Qualitative contributions + +If you contributed through work not reflected in automated PR counts: + +- Verifying issue reproductions, closing obsolete issues, and issue triage +- Reviewing design docs and [RFCs](https://github.com/flutter/rfc) +- Mentoring contributors or helping in Discord + +Please leave a comment below with links or details before this review concludes on **${dateFmt.format(graceDeadline)}**. +'''; +} + +String generatePartnersReport( + ResolvedRoster roster, + Map summary, { + String? prLink, +}) { + final now = DateTime.now().toUtc(); + final quarter = 'Q${((now.month - 1) ~/ 3) + 1} ${now.year}'; + final dateFmt = DateFormat('MMMM d, yyyy'); + final prLineBlock = (prLink != null && prLink.trim().isNotEmpty) + ? '\n> **Proposed roster & report PR:** ${_formatPrReference(prLink)} ' + : ''; + + final partnerRows = [ + for (final p in roster.partners) + () { + final stats = summary[p.username] as Map?; + final total = stats?['total'] as int? ?? 0; + return '| @${p.username} | `${p.team}` | Maintainer | $total contributions | [ ] Verified Active |'; + }(), + ]; + final partnerBlock = _formatTableRowsBlock(partnerRows); + + return ''' +# Quarterly Partner Team Verification Audit ($quarter) + +> **Audit date:** ${dateFmt.format(now)} +> **Audit quarter:** $quarter +> **Target team:** `@flutter/partners` $prLineBlock +> **Governance reference:** [docs/QUARTERLY_REVIEW.md](docs/QUARTERLY_REVIEW.md) • [docs/GOVERNANCE.md](docs/GOVERNANCE.md) + +This issue tracks quarterly verification for `@flutter/partners`. +The audit facilitator reaches out to each partner organization to confirm active participation for the members below. + +## Active partner team members + +| Contributor | Partner team | Role | 12m activity snapshot | Status verification | +| :--- | :--- | :--- | :--- | :--- |$partnerBlock + +## Departures and offboarding + +If any listed engineer has concluded their engagement with the partners team: + +1. Confirm offboarding to revoke `@flutter/partners` write access. +2. If the departing engineer remains an active open-source contributor, specify their community ladder placement (`Committer` or `Reviewer`). + +- [ ] All partner memberships confirmed with partner organizations. +'''; +} + +/// Parses an existing `reports/YYYY-QX-community-ladder-audit.md` file to +/// recover the pre-audit status of contributors in case `--finalize` is run on +/// a PR branch where `--prepare` already updated `data/contributors.yaml`. +({ + Map gracePeriodRolesLower, + Map promotedInitialStatusLower, +}) _parseExistingReportState(File? reportFile) { + final gracePeriodRolesLower = {}; + final promotedInitialStatusLower = {}; + if (reportFile == null || !reportFile.existsSync()) { + return ( + gracePeriodRolesLower: gracePeriodRolesLower, + promotedInitialStatusLower: promotedInitialStatusLower, + ); + } + + var section = ''; + for (final line in reportFile.readAsLinesSync()) { + if (line.startsWith('## ')) { + section = line.trim(); + continue; + } + if (!line.startsWith('| @')) continue; + final cells = line.split('|').map((c) => c.trim()).toList(); + if (cells.length < 4) continue; + final username = cells[1].replaceAll('@', '').toLowerCase(); + if (section.contains('Recommended promotions')) { + promotedInitialStatusLower[username] = cells[2]; + } else if (section.contains('30-day grace period')) { + gracePeriodRolesLower[username] = cells[2]; + } + } + + return ( + gracePeriodRolesLower: gracePeriodRolesLower, + promotedInitialStatusLower: promotedInitialStatusLower, + ); +} + +void _finalizeAudit( + ResolvedRoster roster, + Map summary, { + required File contributorsFile, + required Set retainedUsers, + required Set excludedUsers, + required Map discordOverrides, + required bool isDryRun, + File? existingCommunityReportFile, +}) { + print('Applying quarterly review updates to ${contributorsFile.path}...'); + if (isDryRun) { + print('Running in DRY-RUN mode. No files will be modified.'); + } + + final reportState = _parseExistingReportState(existingCommunityReportFile); + + final discordByUserLower = { + ...roster.discordByUserLower, + ...discordOverrides, + }; + final graceUntilByUserLower = { + ...roster.graceUntilByUserLower, + }; + + String? getDiscord(String username) => + discordByUserLower[username.toLowerCase()]; + + final updatedCommitters = []; + final updatedReviewers = []; + final updatedEmeriti = []; + + final promotedToCommitter = []; + final reinstatedUsers = []; + final onboardedNewJoiners = []; + final transitionedToEmeritus = []; + + // 1. Evaluate current Committers + for (final username in roster.committers) { + final lower = username.toLowerCase(); + + // If `--finalize` is re-run on a PR branch where `--prepare` already moved + // a candidate to Committers, check if they were subsequently excluded. + if (excludedUsers.contains(lower)) { + final priorStatus = reportState.promotedInitialStatusLower[lower]; + if (priorStatus == 'Reviewer') { + updatedReviewers.add(username); + continue; + } else if (priorStatus == 'Dasher Emeritus' || + priorStatus == 'Org Member') { + updatedEmeriti.add(username); + continue; + } else if (priorStatus == 'External Contributor') { + continue; + } + } + + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + + if (total >= 10 || retainedUsers.contains(lower)) { + updatedCommitters.add(username); + } else { + updatedEmeriti.add(username); + transitionedToEmeritus.add('$username (from Committer)'); + } + } + + // 2. Evaluate current Reviewers + final now = DateTime.now().toUtc(); + for (final r in roster.reviewers) { + final username = r.username; + final lower = username.toLowerCase(); + + if (excludedUsers.contains(lower)) { + final priorStatus = reportState.promotedInitialStatusLower[lower]; + if (priorStatus == 'Dasher Emeritus' || priorStatus == 'Org Member') { + updatedEmeriti.add(username); + continue; + } else if (priorStatus == 'External Contributor') { + continue; + } + } + + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + + if (total >= 30 && prs >= 10 && !excludedUsers.contains(lower)) { + updatedCommitters.add(username); + promotedToCommitter.add(username); + } else if (total >= 10 || + retainedUsers.contains(lower) || + _isGraceDateActive(r.graceUntil, now)) { + updatedReviewers.add(username); + } else { + updatedEmeriti.add(username); + transitionedToEmeritus.add('$username (from Reviewer)'); + } + } + + // 3. Evaluate unassigned live Org Members + for (final username in roster.unassignedOrgMembers) { + final lower = username.toLowerCase(); + + if (retainedUsers.contains(lower)) { + final priorRole = reportState.gracePeriodRolesLower[lower]; + if (priorRole == 'Committer') { + updatedCommitters.add(username); + } else { + updatedReviewers.add(username); + } + continue; + } + + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + + if (total >= 30 && prs >= 10 && !excludedUsers.contains(lower)) { + updatedCommitters.add(username); + promotedToCommitter.add(username); + } else if (total >= 10 && !excludedUsers.contains(lower)) { + updatedReviewers.add(username); + reinstatedUsers.add('$username (to Reviewer)'); + } else { + updatedEmeriti.add(username); + transitionedToEmeritus.add('$username (from Org Member)'); + } + } + + // 4. Evaluate external Dasher Emeriti for reinstatement + for (final username in roster.externalEmeriti) { + final lower = username.toLowerCase(); + + if (retainedUsers.contains(lower)) { + final priorRole = reportState.gracePeriodRolesLower[lower]; + if (priorRole == 'Committer') { + updatedCommitters.add(username); + } else { + updatedReviewers.add(username); + } + continue; + } + + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + + if (total >= 30 && prs >= 10 && !excludedUsers.contains(lower)) { + updatedCommitters.add(username); + reinstatedUsers.add('$username (to Committer)'); + } else if (total >= 10 && !excludedUsers.contains(lower)) { + updatedReviewers.add(username); + reinstatedUsers.add('$username (to Reviewer)'); + } else { + updatedEmeriti.add(username); + } + } + + // 5. Onboard accepted external new joiners + final rawExternal = _discoverExternalCandidates(roster, summary); + for (final username in rawExternal) { + final lower = username.toLowerCase(); + if (excludedUsers.contains(lower)) continue; + final stats = summary[username] as Map?; + final total = stats?['total'] as int? ?? 0; + final prs = stats?['merged_prs'] as int? ?? 0; + if (total >= 30 && prs >= 10) { + updatedCommitters.add(username); + onboardedNewJoiners.add('$username (to Committer)'); + } else if (total >= 10) { + updatedReviewers.add(username); + onboardedNewJoiners.add('$username (to Reviewer)'); + } + } + + updatedCommitters.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + updatedReviewers.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + updatedEmeriti.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + + print('Audit Update Summary:'); + print( + ' • Promoted to Committer (${promotedToCommitter.length}): ${promotedToCommitter.isEmpty ? "none" : promotedToCommitter.join(", ")}'); + print( + ' • Reinstated / enrolled from Org or Emeritus (${reinstatedUsers.length}): ${reinstatedUsers.isEmpty ? "none" : reinstatedUsers.join(", ")}'); + print( + ' • Onboarded new joiners (${onboardedNewJoiners.length}): ${onboardedNewJoiners.isEmpty ? "none" : onboardedNewJoiners.join(", ")}'); + print( + ' • Transitioned to Dasher Emeritus (${transitionedToEmeritus.length}): ${transitionedToEmeritus.isEmpty ? "none" : transitionedToEmeritus.length}'); + + if (isDryRun) { + print('Dry run complete. Exiting without modifying files.'); + return; + } + + if (existingCommunityReportFile != null && + existingCommunityReportFile.existsSync()) { + final updatedReport = _updateReportOnFinalize( + existingCommunityReportFile.readAsStringSync(), + summary, + retainedUsers: retainedUsers, + excludedUsers: excludedUsers, + ); + existingCommunityReportFile.writeAsStringSync(updatedReport); + print('Updated ${existingCommunityReportFile.path}.'); + } + + final today = DateTime.now().toUtc().toIso8601String().split('T').first; + final updatedRoster = ContributorsRoster( + maintainers: MaintainersRoster( + googlers: [ + for (final g in roster.googlers) + ContributorEntry( + username: g, + discord: getDiscord(g), + role: 'maintainer', + ), + ], + partners: [ + for (final p in roster.partners) + ContributorEntry( + username: p.username, + team: p.team, + discord: getDiscord(p.username), + role: 'maintainer', + ), + ], + ), + committers: [ + for (final c in updatedCommitters) + ContributorEntry( + username: c, + discord: getDiscord(c), + role: 'committer', + ), + ], + reviewers: [ + for (final r in updatedReviewers) + ContributorEntry( + username: r, + discord: getDiscord(r), + graceUntil: graceUntilByUserLower[r.toLowerCase()], + role: 'reviewer', + ), + ], + dashEmeritus: [ + for (final e in updatedEmeriti) + ContributorEntry( + username: e, + discord: getDiscord(e), + role: 'emeritus', + ), + ], + ); + + contributorsFile + .writeAsStringSync(updatedRoster.toYamlString(lastUpdated: today)); + print('Applied verified role updates to ${contributorsFile.path}.'); + + if (contributorsFile.path == 'data/contributors.yaml') { + final result = + Process.runSync('dart', ['run', 'tool/generate_roster.dart']); + if (result.exitCode == 0) { + stdout.write(result.stdout); + } else { + print( + 'Run `dart run tool/generate_roster.dart` to rebuild ROSTER.md and EMERITUS.md (${result.stderr}).'); + } + } else { + print( + 'Run `dart run tool/generate_roster.dart` to rebuild ROSTER.md and EMERITUS.md.'); + } +} + +String _updateReportOnFinalize( + String reportContent, + Map summary, { + required Set retainedUsers, + required Set excludedUsers, +}) { + final lines = reportContent.split('\n'); + final newRetentionRows = []; + final filteredLines = []; + var section = ''; + + for (final line in lines) { + if (line.startsWith('## ')) { + section = line.trim(); + filteredLines.add(line); + continue; + } + if (line.startsWith('| @')) { + final cells = line.split('|').map((c) => c.trim()).toList(); + if (cells.length >= 5) { + final rawUser = cells[1].replaceAll('@', ''); + final lower = rawUser.toLowerCase(); + if (section.contains('Recommended promotions')) { + if (excludedUsers.contains(lower)) { + if (cells[2] == 'Reviewer') { + newRetentionRows + .add('| @$rawUser | Reviewer | ${cells[4]} | ${cells[5]} |'); + } + continue; + } + } else if (section.contains('30-day grace period')) { + if (retainedUsers.contains(lower)) { + final role = cells[2] == 'Committer' ? 'Committer' : 'Reviewer'; + final stats = summary[rawUser] as Map?; + final prs = stats?['merged_prs'] as int? ?? 0; + final total = cells[3]; + newRetentionRows.add('| @$rawUser | $role | $prs | $total |'); + continue; + } else { + filteredLines.add(line.replaceFirst( + '| In grace period |', '| Moved to Emeritus |')); + continue; + } + } + } + } + filteredLines.add(line); + } + + if (newRetentionRows.isEmpty) { + return filteredLines.join('\n'); + } + + final resultLines = []; + var inRetentionTable = false; + var insertedRetentionRows = false; + for (var i = 0; i < filteredLines.length; i++) { + final line = filteredLines[i]; + if (line.startsWith('## Active role retention')) { + inRetentionTable = true; + } else if (inRetentionTable && line.startsWith('## ')) { + if (!insertedRetentionRows) { + resultLines.addAll(newRetentionRows); + insertedRetentionRows = true; + } + inRetentionTable = false; + } else if (inRetentionTable && + line.trim().isEmpty && + i > 0 && + filteredLines[i - 1].startsWith('|')) { + resultLines.addAll(newRetentionRows); + insertedRetentionRows = true; + inRetentionTable = false; + } + resultLines.add(line); + } + if (inRetentionTable && !insertedRetentionRows) { + resultLines.addAll(newRetentionRows); + } + return resultLines.join('\n'); +} + +void _outputReport(String report, String? outputPath) { + if (outputPath != null) { + File(outputPath).writeAsStringSync(report); + print('Generated report saved to $outputPath'); + } else { + print(report); + } +} diff --git a/tool/seed_initial_data.dart b/tool/seed_initial_data.dart new file mode 100644 index 0000000..2222f70 --- /dev/null +++ b/tool/seed_initial_data.dart @@ -0,0 +1,480 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; +import 'package:args/args.dart'; +import 'bots.dart'; +import 'github.dart'; +import 'validate_roster.dart'; + +void main(List args) async { + final parser = ArgParser() + ..addFlag('live', + help: + 'Fetch live org membership from GitHub API instead of using historical baseline', + defaultsTo: false) + ..addOption('token', help: 'GitHub personal access token'); + + final results = parser.parse(args); + final isLive = results['live'] as bool; + final token = results['token'] as String?; + + print('=== Flutter Contributor Ladder Bootstrap & Seeding Tool ==='); + + final dataDir = Directory('data'); + if (!dataDir.existsSync()) { + dataDir.createSync(recursive: true); + } + final activityDir = Directory('data/activity'); + if (!activityDir.existsSync()) { + activityDir.createSync(recursive: true); + } + + if (isLive) { + await _runLiveBootstrap(token); + } else { + _runHistoricalBootstrap(); + } + + print( + '\nBootstrap complete! Run `dart run tool/generate_roster.dart` to generate ROSTER.md and EMERITUS.md.'); +} + +/// Runs bootstrap against live GitHub organization data. +Future _runLiveBootstrap(String? token) async { + final client = GitHubClient(token: token); + print('Querying Flutter GitHub organization members...'); + + try { + await loadRobotTeams(client); + // 1. Fetch all members of flutter org + final allOrgMembers = await client.getOrgMembers('flutter'); + print( + 'Found ${allOrgMembers.length} total members in flutter organization.'); + + // 2. Fetch Googlers and Partners teams (merged with known exception Maintainers) + print('Querying @flutter/googlers and @flutter/partners teams...'); + final googlers = { + ...await client.getTeamMembers('flutter', 'googlers'), + ...loadKnownExceptionMaintainers(), + }; + final partners = await client.getTeamMembers('flutter', 'partners'); + print('Found ${googlers.length} Googlers and ${partners.length} Partners.'); + + // Query child teams of @flutter/partners to resolve exact child team / POC handles + print('Resolving child teams of @flutter/partners...'); + final partnerTeamMap = {}; + try { + final childTeamSlugs = await client.getChildTeams('flutter', 'partners'); + for (final childSlug in childTeamSlugs) { + final childMembers = await client.getTeamMembers('flutter', childSlug); + for (final m in childMembers) { + partnerTeamMap[m] = '@flutter/$childSlug'; + } + } + print( + 'Resolved child teams for ${partnerTeamMap.length} partner members.'); + } catch (e) { + print('Notice: Could not resolve child teams: $e'); + } + + // 3. Separate Maintainers from Community Candidates + final maintainers = {...googlers, ...partners}; + final communityCandidates = allOrgMembers.difference(maintainers).toList() + ..sort(); + + print('Maintainers (Googlers + Partners): ${maintainers.length}'); + print( + 'Non-Googler/Non-Partner org members to evaluate: ${communityCandidates.length}'); + print( + 'API Optimization: Skipping activity queries for all ${maintainers.length} Maintainers!'); + + final now = DateTime.now().toUtc(); + final oneYearAgo = now.subtract(const Duration(days: 365)); + + print( + '\nEvaluating 12-month rolling activity (${oneYearAgo.toIso8601String().split('T').first} to ${now.toIso8601String().split('T').first}) using batched GraphQL queries...'); + + final activitySummary = await client.getBatchUserActivity( + communityCandidates, + since: oneYearAgo, + until: now); + + final committers = []; + final reviewers = []; + final emeriti = []; + + for (final member in communityCandidates) { + if (isExcludedAccount(member)) continue; + final activity = activitySummary[member] ?? + {'total': 0, 'merged_prs': 0, 'issues': 0, 'reviews': 0}; + final total = activity['total'] ?? 0; + final prs = activity['merged_prs'] ?? 0; + + if (total >= 30 && prs >= 10) { + committers.add(member); + } else if (total >= 10) { + reviewers.add(member); + } else { + emeriti.add(member); + } + } + + print('Results for current org members:'); + print(' • Maintainers: ${maintainers.length}'); + print(' • Committers: ${committers.length}'); + print(' • Reviewers: ${reviewers.length}'); + print(' • Dasher Emeritus (Inactive): ${emeriti.length}'); + + // 4. External candidate discovery (non-org contributors with high activity) + print('\nDiscovering active external community candidates...'); + final prospective = []; + try { + final externalList = [ + 'Mairramer', + 'koji-1009', + ]; + final externalStats = await client.getBatchUserActivity(externalList, + since: oneYearAgo, until: now); + for (final entry in externalStats.entries) { + activitySummary[entry.key] = entry.value; + if ((entry.value['total'] ?? 0) >= 10) { + prospective.add(entry.key); + } + } + print( + 'Discovered ${prospective.length} active external candidates for invitation: $prospective'); + } catch (e) { + print('Notice: External discovery completed with defaults: $e'); + } + + // 5. Save data/activity_summary.json + final summaryFile = File('data/activity_summary.json'); + summaryFile.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(activitySummary)); + print('Saved ${summaryFile.path}'); + + // 6. Write data/contributors.yaml + writeContributorsYaml( + googlers: googlers.toList()..sort(), + partners: partners.toList()..sort(), + partnerTeams: partnerTeamMap, + committers: committers..sort(), + reviewers: reviewers..sort(), + emeriti: emeriti..sort(), + ); + } finally { + client.close(); + } +} + +/// Runs bootstrap using the vetted historical analysis from the proposal. +void _runHistoricalBootstrap() { + print( + 'Seeding Day 1 roster and baseline metrics from approved proposal data...'); + + // Canonical engineers / Partners + final partners = [ + 'kenvandine', + 'robert-ancell', + ]; + + // Active Committers from proposal analysis (>= 30 total, >= 10 PRs) + final committers = [ + 'AbdeMohlbi', + 'AlexV525', + 'DanTup', + 'Jhon-Aires', + 'RobertOdrowaz', + 'ValentinVignal', + 'bleroux', + 'csells', + 'davidhicks980', + 'huycozy', + 'knopp', + 'ksokolovskyi', + 'mattkae', + 'navaronbracke', + 'parlough', + 'rkishan516', + 'schultek', + 'vickyduran', + ]; + + // Active Reviewers from proposal analysis (>= 10 total) + final reviewers = [ + 'bdero', + 'danagbemava-nc', + 'filiph', + 'gnprice', + 'nt4f04uNd', + 'pedromassango', + 'piedcipher', + 'tirth-patel-nc', + 'yiiim', + ]; + + // Dasher Emeriti (< 10 total in 12m historical analysis, bots filtered out) + final emeriti = [ + '0xZOne', + 'AnnaPS', + 'AyushBherwani1998', + 'CareF', + 'ColdPaleLight', + 'DiegoGW', + 'Ehesp', + 'FXschwartz', + 'FirentisTFW', + 'Hari-07', + 'HidenoriMatsubayashi', + 'Hixie', + 'Jan-Stepien', + 'JsouLiang', + 'Kirpal', + 'KristinBi', + 'LinXunFeng', + 'MacarenaSantos', + 'Rexios80', + 'Salakar', + 'SofiaRey', + 'Swiftaxe', + 'Vanethos', + 'YeungKC', + 'a14n', + 'alestiago', + 'allisonryan0002', + 'atrope', + 'azchohfi', + 'bartekpacia', + 'bernaferrari', + 'brianegan', + 'bruno-garcia', + 'bselwe', + 'btobin', + 'chenglu', + 'chinmoy12c', + 'chriscraws', + 'collinjackson', + 'dam-ease', + 'darshankawar', + 'eggfly', + 'ened', + 'erickzanardo', + 'eseidel', + 'exaby73', + 'felangel', + 'fluttercerberus', + 'gastonliz', + 'guoguo338', + 'hamdikahloun', + 'harri35', + 'hbatagelo', + 'helinanever', + 'hgraceb', + 'horiaciu', + 'jingshao-code', + 'jnschulze', + 'jokerttu', + 'jpnurmi', + 'jwinarske', + 'kaiceyd', + 'kevinburns426', + 'kmillikin', + 'linxuebin1990', + 'luckysmg', + 'magicianA', + 'martinduva', + 'marwfair', + 'matiasleyba', + 'matthew-carroll', + 'mbcorona', + 'mchudy', + 'milezabaleta', + 'miquelbeltran', + 'moffatman', + 'mvanbeusekom', + 'omartinma', + 'pingbird', + 'puneetkukreja98', + 'renefloor', + 'rzambroni', + 'saurabh-mirajkar', + 'scarletteliza', + 'scutlight', + 'simpson-peter', + 'slightfoot', + 'tgucio', + 'tpiaggio', + 'vaind', + 'valentinallavayol', + 'wangying3426', + 'werainkhatri', + 'whesse', + 'wolfenrain', + 'xu-baolin', + 'zhongwuzw', + ]..removeWhere(isBot); + + // Googlers placeholder list for baseline (managed via @flutter/googlers on GitHub) + final googlers = [ + 'Piinks', + ]; + + // Seed baseline metrics dictionary + final activitySummary = >{ + 'AbdeMohlbi': { + 'merged_prs': 158, + 'issues': 10, + 'reviews': 12, + 'total': 180 + }, + 'parlough': { + 'merged_prs': 223, + 'issues': 155, + 'reviews': 300, + 'total': 678 + }, + 'bleroux': {'merged_prs': 78, 'issues': 20, 'reviews': 27, 'total': 125}, + 'ksokolovskyi': { + 'merged_prs': 54, + 'issues': 10, + 'reviews': 21, + 'total': 85 + }, + 'robert-ancell': { + 'merged_prs': 57, + 'issues': 12, + 'reviews': 15, + 'total': 84 + }, + 'ValentinVignal': { + 'merged_prs': 53, + 'issues': 10, + 'reviews': 20, + 'total': 83 + }, + 'navaronbracke': { + 'merged_prs': 24, + 'issues': 20, + 'reviews': 35, + 'total': 79 + }, + 'rkishan516': {'merged_prs': 69, 'issues': 2, 'reviews': 2, 'total': 73}, + 'DanTup': {'merged_prs': 16, 'issues': 20, 'reviews': 36, 'total': 72}, + 'huycozy': {'merged_prs': 34, 'issues': 15, 'reviews': 20, 'total': 69}, + 'csells': {'merged_prs': 29, 'issues': 16, 'reviews': 20, 'total': 65}, + 'schultek': {'merged_prs': 46, 'issues': 8, 'reviews': 10, 'total': 64}, + 'Jhon-Aires': {'merged_prs': 47, 'issues': 5, 'reviews': 4, 'total': 56}, + 'RobertOdrowaz': {'merged_prs': 35, 'issues': 5, 'reviews': 5, 'total': 45}, + 'AlexV525': {'merged_prs': 18, 'issues': 10, 'reviews': 10, 'total': 38}, + 'knopp': {'merged_prs': 22, 'issues': 6, 'reviews': 10, 'total': 38}, + 'davidhicks980': { + 'merged_prs': 11, + 'issues': 10, + 'reviews': 12, + 'total': 33 + }, + 'vickyduran': {'merged_prs': 24, 'issues': 4, 'reviews': 5, 'total': 33}, + 'mattkae': {'merged_prs': 29, 'issues': 30, 'reviews': 36, 'total': 95}, + 'tirth-patel-nc': { + 'merged_prs': 5, + 'issues': 15, + 'reviews': 10, + 'total': 30 + }, + 'gnprice': {'merged_prs': 5, 'issues': 12, 'reviews': 10, 'total': 27}, + 'danagbemava-nc': { + 'merged_prs': 0, + 'issues': 15, + 'reviews': 8, + 'total': 23 + }, + 'Mairramer': {'merged_prs': 13, 'issues': 4, 'reviews': 4, 'total': 21}, + 'piedcipher': {'merged_prs': 9, 'issues': 5, 'reviews': 5, 'total': 19}, + 'yiiim': {'merged_prs': 14, 'issues': 1, 'reviews': 2, 'total': 17}, + 'filiph': {'merged_prs': 5, 'issues': 6, 'reviews': 5, 'total': 16}, + 'koji-1009': {'merged_prs': 13, 'issues': 1, 'reviews': 1, 'total': 15}, + 'nate-thegrate': {'merged_prs': 5, 'issues': 4, 'reviews': 4, 'total': 13}, + 'nt4f04uNd': {'merged_prs': 1, 'issues': 5, 'reviews': 5, 'total': 11}, + 'pedromassango': {'merged_prs': 6, 'issues': 3, 'reviews': 2, 'total': 11}, + 'bdero': {'merged_prs': 5, 'issues': 3, 'reviews': 2, 'total': 10}, + }; + + final summaryFile = File('data/activity_summary.json'); + summaryFile.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(activitySummary)); + print('Wrote ${summaryFile.path}'); + + writeContributorsYaml( + googlers: googlers, + partners: partners, + committers: committers, + reviewers: reviewers, + emeriti: emeriti, + ); +} + +void writeContributorsYaml({ + required List googlers, + required List partners, + Map? partnerTeams, + required List committers, + required List reviewers, + required List emeriti, + File? outputFile, +}) { + final today = DateTime.now().toUtc().toIso8601String().split('T').first; + final googlerEntries = [ + for (final g in googlers) ' - username: $g\n role: maintainer', + ].join('\n'); + final partnerEntries = [ + for (final p in partners) + ' - username: $p\n' + ' team: "${partnerTeams?[p] ?? '@flutter/partners'}"\n' + ' role: maintainer', + ].join('\n'); + final committerEntries = [ + for (final c in committers) ' - username: $c\n role: committer', + ].join('\n'); + final reviewerEntries = [ + for (final r in reviewers) ' - username: $r\n role: reviewer', + ].join('\n'); + final emeritusEntries = [ + for (final e in emeriti) ' - username: $e\n role: emeritus', + ].join('\n'); + + final content = ''' +# Flutter Contributor Ladder Roster +# Source of truth for Flutter contributor roles and permissions. +# Last updated: $today + +maintainers: + # Maintainers on the Flutter team (@flutter/googlers and @flutter/partners) + # Granted Write access + Org Moderation + googlers: +$googlerEntries + partners: +$partnerEntries + +committers: + # Members of @flutter/committers (Child of @flutter/flutter-hackers) + # Granted GitHub Write role / merge access +$committerEntries + +reviewers: + # Members of @flutter/reviewers (Child of @flutter/flutter-hackers) + # Granted GitHub Triage role +$reviewerEntries + +dash_emeritus: + # Honored alumni (External, non-org members on GitHub) +$emeritusEntries +'''; + + final file = outputFile ?? File('data/contributors.yaml'); + file.writeAsStringSync(content); + print( + 'Wrote ${file.path} (${committers.length} Committers, ${reviewers.length} Reviewers, ${emeriti.length} Emeriti).'); +} diff --git a/tool/sync_activity.dart b/tool/sync_activity.dart index 8fb9fb8..c9f0c83 100644 --- a/tool/sync_activity.dart +++ b/tool/sync_activity.dart @@ -1,4 +1,4 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/tool/validate_roster.dart b/tool/validate_roster.dart new file mode 100644 index 0000000..d9de0eb --- /dev/null +++ b/tool/validate_roster.dart @@ -0,0 +1,195 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; +import 'package:yaml/yaml.dart'; +import 'bots.dart'; + +const validRoles = {'maintainer', 'committer', 'reviewer', 'emeritus'}; + +const knownMaintainersPath = + '.agents/skills/quarterly-ladder-analysis/references/known_maintainers.txt'; +const knownBotsPath = + '.agents/skills/quarterly-ladder-analysis/references/bots.txt'; + +/// Loads known bot/automation accounts from [knownBotsPath]. +Set loadKnownBotReferences({String path = knownBotsPath}) { + final file = File(path); + if (!file.existsSync()) return {}; + final result = {}; + for (final line in file.readAsLinesSync()) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { + result.add(trimmed.toLowerCase()); + } + } + return result; +} + +/// Loads known exception Maintainers from the single source of truth file +/// ([knownMaintainersPath]). +Set loadKnownExceptionMaintainers( + {String path = knownMaintainersPath}) { + final file = File(path); + if (!file.existsSync()) return {}; + final result = {}; + for (final line in file.readAsLinesSync()) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { + result.add(trimmed); + } + } + return result; +} + +/// Returns true if [username] is listed in [knownMaintainersPath]. +bool isKnownExceptionMaintainer(String username, + {Set? knownMaintainers}) { + final lower = username.toLowerCase().trim(); + final set = knownMaintainers ?? loadKnownExceptionMaintainers(); + return set.any((m) => m.toLowerCase() == lower); +} + +/// Validates a parsed `contributors.yaml` document and returns a list of +/// human-readable error strings (empty if valid). +List validateContributorsYaml(dynamic doc) { + if (doc is! YamlMap) { + return ['Error: data/contributors.yaml root must be a YAML mapping.']; + } + + final seenUsers = {}; + final errors = []; + final knownExceptionMaintainers = loadKnownExceptionMaintainers(); + final knownExceptionLower = + knownExceptionMaintainers.map((u) => u.toLowerCase()).toSet(); + final knownBotsLower = loadKnownBotReferences(); + + void validateUser(dynamic entry, String section, + {bool isMaintainer = false}) { + if (entry is! YamlMap) { + errors.add('Error in $section: entry is not a map: $entry'); + return; + } + + final username = entry['username'] as String?; + if (username == null || username.trim().isEmpty) { + errors.add('Error in $section: missing username: $entry'); + return; + } + + final lowerUser = username.toLowerCase(); + if (isExcludedAccount(username) || knownBotsLower.contains(lowerUser)) { + errors.add( + 'Error in $section: "@$username" is a bot or excluded automation account and must not be listed in contributors.yaml.'); + } + if (seenUsers.containsKey(lowerUser)) { + errors.add( + 'Error: duplicate username "@$username" in $section (already defined in ${seenUsers[lowerUser]})'); + } else { + seenUsers[lowerUser] = section; + } + + if (!isMaintainer && knownExceptionLower.contains(lowerUser)) { + errors.add( + 'Error in $section: "@$username" is a known Maintainer and must be listed under maintainers.googlers.'); + } + + final role = entry['role'] as String? ?? entry['suggested_role'] as String?; + if (role != null && !validRoles.contains(role.toLowerCase())) { + errors.add( + 'Error in $section: invalid role "$role" for user @$username. Valid roles: $validRoles'); + } + + if (entry.containsKey('discord')) { + final discord = entry['discord']; + if (discord is! String || discord.trim().isEmpty) { + errors.add( + 'Error in $section: invalid or empty discord username for user @$username.'); + } + } + + if (entry.containsKey('grace_until')) { + final graceUntil = entry['grace_until']; + if (graceUntil is! String || + DateTime.tryParse(graceUntil.trim()) == null) { + errors.add( + 'Error in $section: invalid grace_until date "$graceUntil" for user @$username (expected YYYY-MM-DD).'); + } + } + } + + // Validate Maintainers (must strictly be googlers or partners) + final maintainers = doc['maintainers']; + if (maintainers is YamlMap) { + for (final key in maintainers.keys) { + if (key != 'googlers' && key != 'partners') { + errors.add( + 'Error in maintainers: invalid group "$key". All Maintainers must be in "googlers" or "partners".'); + } + } + final googlers = maintainers['googlers']; + if (googlers is YamlList) { + for (final g in googlers) { + validateUser(g, 'maintainers.googlers', isMaintainer: true); + } + } + final partners = maintainers['partners']; + if (partners is YamlList) { + for (final p in partners) { + validateUser(p, 'maintainers.partners', isMaintainer: true); + } + } + } + + // Validate Committers + final committers = doc['committers']; + if (committers is YamlList) { + for (final c in committers) { + validateUser(c, 'committers'); + } + } + + // Validate Reviewers + final reviewers = doc['reviewers']; + if (reviewers is YamlList) { + for (final r in reviewers) { + validateUser(r, 'reviewers'); + } + } + + // Validate Dasher Emeritus + final emeriti = doc['dash_emeritus']; + if (emeriti is YamlList) { + for (final e in emeriti) { + validateUser(e, 'dash_emeritus'); + } + } + + return errors; +} + +void main(List args) { + final filePath = args.isNotEmpty ? args.first : 'data/contributors.yaml'; + final file = File(filePath); + if (!file.existsSync()) { + print('Error: $filePath does not exist.'); + exit(1); + } + + print('Validating $filePath...'); + final content = file.readAsStringSync(); + final dynamic doc = loadYaml(content); + + final errors = validateContributorsYaml(doc); + for (final err in errors) { + print(err); + } + + if (errors.isNotEmpty) { + print('\nValidation FAILED with ${errors.length} errors.'); + exit(1); + } + + print('Validation SUCCESS: $filePath is valid.'); +}