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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7886fa6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/*.g.dart linguist-generated=true diff --git a/.github/workflows/check_grace_period.yml b/.github/workflows/check_grace_period.yml new file mode 100644 index 0000000..df0d009 --- /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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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..70ce772 --- /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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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/ROSTER.md b/ROSTER.md index d3624c6..da7392d 100644 --- a/ROSTER.md +++ b/ROSTER.md @@ -78,7 +78,7 @@ Committers hold GitHub Write permissions (merge rights) and provide authoritativ | :--- | :--- | :--- | :--- | | @AbdeMohlbi | [github.com/AbdeMohlbi](https://github.com/AbdeMohlbi) | 138 | 236 | | @huycozy | [github.com/huycozy](https://github.com/huycozy) | 11 | 33 | -| @JavadAsadi | [github.com/JavadAsadi](https://github.com/JavadAsadi) | 42 | 48 | +| @JavadAsadi | [github.com/JavadAsadi](https://github.com/JavadAsadi) | 43 | 49 | | @ksokolovskyi | [github.com/ksokolovskyi](https://github.com/ksokolovskyi) | 21 | 34 | | @Mairramer | [github.com/Mairramer](https://github.com/Mairramer) | 28 | 43 | | @navaronbracke | [github.com/navaronbracke](https://github.com/navaronbracke) | 43 | 334 | diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..50a6d9b --- /dev/null +++ b/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + header: | + // 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. + + // GENERATED CODE - DO NOT MODIFY BY HAND diff --git a/data/activity/2026-W44.json b/data/activity/2026-W44.json index 1e392c2..9df68bb 100644 --- a/data/activity/2026-W44.json +++ b/data/activity/2026-W44.json @@ -455,12 +455,6 @@ "reviews": 13, "total": 13 }, - "renzo-olivares-agent": { - "merged_prs": 0, - "issues": 0, - "reviews": 1, - "total": 1 - }, "romelt777": { "merged_prs": 1, "issues": 0, diff --git a/data/activity_summary.json b/data/activity_summary.json index d8fdcda..1521d50 100644 --- a/data/activity_summary.json +++ b/data/activity_summary.json @@ -16793,12 +16793,6 @@ "reviews": 1, "total": 1 }, - "renzo-olivares-agent": { - "merged_prs": 0, - "issues": 0, - "reviews": 1, - "total": 1 - }, "ressen12334": { "merged_prs": 0, "issues": 1, diff --git a/pubspec.yaml b/pubspec.yaml index ee13301..69adddb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,9 +10,12 @@ dependencies: args: ^2.4.2 http: ^1.2.0 intl: ^0.19.0 + json_annotation: ^4.12.0 path: ^1.9.0 yaml: ^3.1.2 dev_dependencies: + build_runner: ^2.17.1 + json_serializable: ^6.15.2 lints: ^3.0.0 test: ^1.25.0 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..ab7a824 --- /dev/null +++ b/test/quarterly_audit_test.dart @@ -0,0 +1,917 @@ +// 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')); + + final jsonRoundTrip = ContributorsRoster.fromJson(roster.toJson()); + expect( + jsonRoundTrip.maintainers.partners.single.team, + equals('@flutter/canonical'), + ); + expect(jsonRoundTrip.reviewers.single.graceUntil, equals('2027-01-01')); + + final emptySections = ContributorsRoster.parse(''' +maintainers: + googlers: + partners: +committers: +reviewers: +dash_emeritus: +'''); + expect(emptySections.maintainers.googlers, isEmpty); + expect(emptySections.committers, isEmpty); + }); + + 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/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..34f614e --- /dev/null +++ b/tool/contributors_roster.dart @@ -0,0 +1,205 @@ +// 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:json_annotation/json_annotation.dart'; +import 'package:yaml/yaml.dart'; + +part 'contributors_roster.g.dart'; + +String _trimString(Object? value) => (value == null ? '' : '$value').trim(); + +String? _trimNullableString(Object? value) { + if (value == null) return null; + final trimmed = '$value'.trim(); + return trimmed.isNotEmpty ? trimmed : null; +} + +/// Immutable contributor entry parsed from `data/contributors.yaml`. +@JsonSerializable(fieldRename: FieldRename.snake) +final class ContributorEntry { + const ContributorEntry({ + this.username = '', + this.role, + this.team, + this.discord, + this.graceUntil, + }); + + factory ContributorEntry.fromJson(Map json) => + _$ContributorEntryFromJson(json); + + @JsonKey(fromJson: _trimString) + final String username; + + @JsonKey(fromJson: _trimNullableString) + final String? role; + + @JsonKey(fromJson: _trimNullableString) + final String? team; + + @JsonKey(fromJson: _trimNullableString) + final String? discord; + + @JsonKey(fromJson: _trimNullableString) + final String? graceUntil; + + Map toJson() => _$ContributorEntryToJson(this); + + /// 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`. +@JsonSerializable(explicitToJson: true) +final class MaintainersRoster { + const MaintainersRoster({ + this.googlers = const [], + this.partners = const [], + }); + + factory MaintainersRoster.fromJson(Map json) => + _$MaintainersRosterFromJson(json); + + final List googlers; + final List partners; + + Map toJson() => _$MaintainersRosterToJson(this); +} + +/// Strongly-typed immutable representation of `data/contributors.yaml`. +@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true) +final class ContributorsRoster { + const ContributorsRoster({ + this.maintainers = const MaintainersRoster(), + this.committers = const [], + this.reviewers = const [], + this.dashEmeritus = const [], + }); + + factory ContributorsRoster.fromJson(Map json) => + _$ContributorsRosterFromJson(json); + + factory ContributorsRoster.fromYaml(YamlMap? yaml) => + ContributorsRoster.fromJson(yaml?.asMap ?? const {}); + + factory ContributorsRoster.parse(String yamlContent) { + final dynamic parsed = loadYaml(yamlContent); + if (parsed is! YamlMap) { + return const ContributorsRoster(); + } + return ContributorsRoster.fromYaml(parsed); + } + + final MaintainersRoster maintainers; + final List committers; + final List reviewers; + final List dashEmeritus; + + Map toJson() => _$ContributorsRosterToJson(this); + + 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 +'''; + } +} + +extension _YamlMapToMap on YamlMap { + Map get asMap => { + for (final MapEntry(:key, :value) in entries) + if (value is YamlMap) + '$key': value.asMap + else if (value is YamlList) + '$key': value.asList + else if (value is YamlScalar) + '$key': value.value + else + '$key': value, + }; +} + +extension _YamlListToList on YamlList { + List get asList => [ + for (final node in nodes) + if (node is YamlMap) + node.asMap + else if (node is YamlList) + node.asList + else if (node is YamlScalar) + node.value + else + node, + ]; +} diff --git a/tool/contributors_roster.g.dart b/tool/contributors_roster.g.dart new file mode 100644 index 0000000..70d7f3f --- /dev/null +++ b/tool/contributors_roster.g.dart @@ -0,0 +1,75 @@ +// 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contributors_roster.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContributorEntry _$ContributorEntryFromJson(Map json) => + ContributorEntry( + username: json['username'] == null ? '' : _trimString(json['username']), + role: _trimNullableString(json['role']), + team: _trimNullableString(json['team']), + discord: _trimNullableString(json['discord']), + graceUntil: _trimNullableString(json['grace_until']), + ); + +Map _$ContributorEntryToJson(ContributorEntry instance) => + { + 'username': instance.username, + 'role': instance.role, + 'team': instance.team, + 'discord': instance.discord, + 'grace_until': instance.graceUntil, + }; + +MaintainersRoster _$MaintainersRosterFromJson(Map json) => + MaintainersRoster( + googlers: (json['googlers'] as List?) + ?.map((e) => ContributorEntry.fromJson(e as Map)) + .toList() ?? + const [], + partners: (json['partners'] as List?) + ?.map((e) => ContributorEntry.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$MaintainersRosterToJson(MaintainersRoster instance) => + { + 'googlers': instance.googlers.map((e) => e.toJson()).toList(), + 'partners': instance.partners.map((e) => e.toJson()).toList(), + }; + +ContributorsRoster _$ContributorsRosterFromJson(Map json) => + ContributorsRoster( + maintainers: json['maintainers'] == null + ? const MaintainersRoster() + : MaintainersRoster.fromJson( + json['maintainers'] as Map), + committers: (json['committers'] as List?) + ?.map((e) => ContributorEntry.fromJson(e as Map)) + .toList() ?? + const [], + reviewers: (json['reviewers'] as List?) + ?.map((e) => ContributorEntry.fromJson(e as Map)) + .toList() ?? + const [], + dashEmeritus: (json['dash_emeritus'] as List?) + ?.map((e) => ContributorEntry.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ContributorsRosterToJson(ContributorsRoster instance) => + { + 'maintainers': instance.maintainers.toJson(), + 'committers': instance.committers.map((e) => e.toJson()).toList(), + 'reviewers': instance.reviewers.map((e) => e.toJson()).toList(), + 'dash_emeritus': instance.dashEmeritus.map((e) => e.toJson()).toList(), + }; 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..4caa938 --- /dev/null +++ b/tool/quarterly_audit.dart @@ -0,0 +1,1582 @@ +// 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.'); + 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); + } +}