diff --git a/.all-contributorsrc b/.all-contributorsrc
index 1935bcec9..e7007454c 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -824,7 +824,8 @@
"avatar_url": "https://avatars.githubusercontent.com/u/9788612?v=4",
"profile": "https://github.com/JacobStruiksma",
"contributions": [
- "code"
+ "code",
+ "bug"
]
},
{
@@ -998,6 +999,69 @@
"contributions": [
"bug"
]
+ },
+ {
+ "login": "Christopherloof",
+ "name": "Christopherloof",
+ "avatar_url": "https://avatars.githubusercontent.com/u/119003550?v=4",
+ "profile": "https://github.com/Christopherloof",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "haflidif",
+ "name": "Haflidi Fridthjofsson",
+ "avatar_url": "https://avatars.githubusercontent.com/u/26624010?v=4",
+ "profile": "https://linktr.ee/haflidif",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "josesarsa",
+ "name": "josearsa",
+ "avatar_url": "https://avatars.githubusercontent.com/u/89536192?v=4",
+ "profile": "https://github.com/josesarsa",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "sharmilamusunuru",
+ "name": "sharmilamusunuru",
+ "avatar_url": "https://avatars.githubusercontent.com/u/70754330?v=4",
+ "profile": "https://github.com/sharmilamusunuru",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "xan-archera",
+ "name": "xan-archera",
+ "avatar_url": "https://avatars.githubusercontent.com/u/248541667?v=4",
+ "profile": "https://github.com/xan-archera",
+ "contributions": [
+ "bug"
+ ]
+ },
+ {
+ "login": "bpurchell",
+ "name": "Brian Purchell",
+ "avatar_url": "https://avatars.githubusercontent.com/u/40211715?v=4",
+ "profile": "https://github.com/bpurchell",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "danfiedler-msft",
+ "name": "Dan Fiedler",
+ "avatar_url": "https://avatars.githubusercontent.com/u/151573964?v=4",
+ "profile": "https://github.com/danfiedler-msft",
+ "contributions": [
+ "code"
+ ]
}
],
"commitType": "docs",
diff --git a/.claude/commands/release.md b/.claude/commands/release.md
index 86cdfb623..88e131e51 100644
--- a/.claude/commands/release.md
+++ b/.claude/commands/release.md
@@ -191,7 +191,7 @@ A draft is only visible to maintainers and doesn't create the tag, so it is safe
2. Write the body from the previous release's body (`gh release view v{N-1} --json body --jq .body`) and this version's changelog section:
- **New in {Month yyyy}**: one line per tool for new features, each with the tool emoji and a link to its page (don't link individual features).
- **Updated**: one line per tool for changes and fixes, in the same tool order as the previous release. Keep it to the notable items; don't list every change.
- - **Contributors**: code contributors come from `gh api --paginate repos/microsoft/finops-toolkit/compare/v{N-1}...dev --jq '.commits[].author.login'` (drop bots; list `@MSBrett` and `@flanakin` last). Supporters are issue reporters from the milestone (`gh issue list --milestone v{N} --state all --json author`) who aren't contributors, sorted alphabetically. Tell the user these lists miss reviewers and discussion participants, so they need a final review.
+ - **Contributors**: code contributors come from `gh api --paginate repos/microsoft/finops-toolkit/compare/v{N-1}...dev --jq '.commits[].author.login'` (drop bots). List external contributors first, then the regular team members (`@RolandKrummenacher`, then `@MSBrett` and `@flanakin` last), so one-time and occasional contributors stand out. Cross-check against merged PR authors (`gh pr list --state merged --search "milestone:v{N}" --json author`), because a few commits have no GitHub login. Supporters are issue reporters from the milestone (`gh issue list --milestone v{N} --state all --json author`) who aren't contributors, sorted alphabetically. Everyone named in either list must also be in `.all-contributorsrc` with the right contribution types, so check each login and credit what they did, treating every type equally (see "Recognizing contributors" in `CONTRIBUTING.md`): `code` for code PRs, `doc` for documentation PRs, `bug` for issue reporters, `ideas` for feature requesters, `review` for reviewers, `question` for people who answered questions, and any other type that fits. Find missing credits with `./src/scripts/Update-Contributors.ps1 -Milestone v{N}` (a dry run that infers `code`, `doc`, `review`, `bug`, and `ideas` from merged PRs, reviews, and issues), then run `npm ci --ignore-scripts` once and rerun it with `-Apply`, which also keeps the `.all-contributorsrc` diff to the added entries. Commit the changed `.all-contributorsrc`, `README.md`, and `docs/README.md`. The weekly `update-contributors` workflow opens a PR with the same changes, so check for `automation/update-contributors` first. For types the script can't infer, or issue authors it lists as needing a human, add them with `npm run contrib -- {login} {type} [{type}...]` and put the arrays in `.all-contributorsrc` back on multiple lines. Don't skip someone because their type is unclear; credit `code` or `doc` for a merged PR and ask the user about the rest. Tell the user these lists miss reviewers and discussion participants, so they need a final review.
- **Footer**: copy the previous footer with the version updated. Keep the blog and download links commented out until the release is published.
3. Set the body with `gh api --method PATCH repos/microsoft/finops-toolkit/releases/{id} -F body=@{file}`. Find `{id}` with `gh api repos/microsoft/finops-toolkit/releases --jq '.[] | select(.draft and .tag_name=="v{N}") | .id'`.
4. After Power BI packaging, upload the remaining files with `gh release upload`. `gh` can't find a draft by its `v{N}` tag, so use the `untagged-β¦` name from the draft's URL: `gh release upload untagged-{hash} release/PowerBI-demo.zip --repo microsoft/finops-toolkit`.
diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml
new file mode 100644
index 000000000..fe76fb5fd
--- /dev/null
+++ b/.github/workflows/update-contributors.yml
@@ -0,0 +1,198 @@
+name: Update contributors
+
+# Finds contributors who are missing from .all-contributorsrc (merged PR authors, PR reviewers, and the reporters of
+# fixed or accepted bugs and feature requests) and opens a pull request that adds them. Only contribution types that
+# GitHub data can prove are handled; everything else (question, design, security, mentoring, promotion, leader) is
+# left to humans. See "Recognizing contributors" in CONTRIBUTING.md and src/scripts/Update-Contributors.ps1.
+#
+# Requires the repo setting "Allow GitHub Actions to create and approve pull requests" (Settings > Actions >
+# General). If it is off, or the org blocks it, the branch is still pushed and the job summary links to the
+# "create pull request" page, like the open data workflows.
+#
+# Pull requests opened with GITHUB_TOKEN don't start other workflows, so close and reopen the PR to run its checks.
+#
+# Two ways to run:
+# - On every pull request from this repo, the author (code, doc) and reviewers (review) of that PR are credited with a
+# commit on the PR branch, like the ms.date workflow. Pull requests from forks can't be pushed to, so they are
+# skipped here.
+# - Weekly (and on demand), everything from the last 60 days is checked, which catches fork PRs, reviews added after
+# the PR was last updated, and fixed or accepted bugs and feature requests.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ schedule:
+ - cron: '0 7 * * 1' # Every Monday at 07:00 UTC
+ workflow_dispatch:
+ inputs:
+ since:
+ description: 'Credit contributions since this date (YYYY-MM-DD). Default is 60 days ago.'
+ required: false
+ type: string
+
+# Each job asks for only what it needs
+permissions: {}
+
+jobs:
+ credit-pr:
+ name: Credit pull request contributors
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ # Same-repo pull requests only (forks have a read-only token). Skip the weekly PR, which is already the credit.
+ if: >-
+ github.event_name == 'pull_request'
+ && github.event.pull_request.head.repo.full_name == github.repository
+ && github.head_ref != 'automation/update-contributors'
+ concurrency:
+ group: update-contributors-pr-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.head_ref }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ # all-contributors-cli needs Node 22.22 or later
+ - name: Set up Node.js
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '22.22'
+
+ - name: Install all-contributors-cli
+ run: npm ci --ignore-scripts
+
+ - name: Add missing credits for this pull request
+ shell: pwsh
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: ./src/scripts/Update-Contributors.ps1 -PullRequest ([int]$env:PR_NUMBER) -Apply
+
+ - name: Commit and push changes
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ run: |
+ if git diff --quiet -- .all-contributorsrc README.md docs/README.md; then
+ echo "No missing credits."
+ exit 0
+ fi
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add .all-contributorsrc README.md docs/README.md
+ git commit -m "docs: Credit contributors"
+
+ # Other workflows (like ms.date) push to the same branch on the same event, so rebase onto theirs and retry
+ for attempt in 1 2 3 4; do
+ if git push; then exit 0; fi
+ echo "Push failed (attempt $attempt). Rebasing onto the remote branch and retrying."
+ sleep $((attempt * 5))
+ git pull --rebase origin "$HEAD_REF"
+ done
+ echo "::error::Could not push the contributor credits after 4 attempts."
+ exit 1
+
+ update:
+ name: Credit new contributors
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ pull-requests: write
+ concurrency:
+ group: update-contributors
+ cancel-in-progress: false
+ # Scheduled workflows also run in forks, where there is nothing to credit and no permission to push. Only run in
+ # the upstream repo, from dev, so manual runs from other branches can't push their commits into the PR.
+ if: github.repository == 'microsoft/finops-toolkit' && github.ref == 'refs/heads/dev'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BRANCH: automation/update-contributors
+ steps:
+ - name: Checkout dev
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ # The repo's own devDependency (all-contributors-cli). Scripts are not run during install.
+ # all-contributors-cli needs Node 22.22 or later
+ - name: Set up Node.js
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '22.22'
+
+ - name: Install all-contributors-cli
+ run: npm ci --ignore-scripts
+
+ # The default window is long on purpose: the script only adds what is missing, so re-checking recent work is
+ # harmless, and an unmerged PR from a previous week keeps its entries when the branch is rebuilt from dev.
+ - name: Add missing contributors
+ id: update
+ shell: pwsh
+ env:
+ SINCE_INPUT: ${{ inputs.since }}
+ run: |
+ $since = if ($env:SINCE_INPUT) { $env:SINCE_INPUT } else { (Get-Date).AddDays(-60).ToString('yyyy-MM-dd') }
+ "since=$since" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ ./src/scripts/Update-Contributors.ps1 -Since $since -Apply -SummaryPath "$env:RUNNER_TEMP/contributors.md"
+ # A failure for one login shouldn't lose the credits that worked. The last step fails the job afterwards.
+ continue-on-error: true
+
+ - name: Check for changes
+ id: changes
+ run: |
+ if git diff --quiet -- .all-contributorsrc README.md docs/README.md; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "No missing contributors."
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ git diff --stat -- .all-contributorsrc README.md docs/README.md
+ fi
+
+ - name: Push branch and open pull request
+ if: steps.changes.outputs.changed == 'true'
+ env:
+ SINCE: ${{ steps.update.outputs.since }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git checkout -b "$BRANCH"
+ git add .all-contributorsrc README.md docs/README.md
+ git commit -m "docs: Credit new contributors"
+
+ # The branch is rebuilt from dev every run, so an open PR is updated in place
+ git push --force origin "$BRANCH"
+
+ {
+ echo "Adds contributor credits found by [\`Update-Contributors.ps1\`](https://github.com/${GITHUB_REPOSITORY}/blob/dev/src/scripts/Update-Contributors.ps1) for work since $SINCE."
+ echo ""
+ cat "$RUNNER_TEMP/contributors.md"
+ echo ""
+ echo "Review before merging: credits come from GitHub data only (merged PRs, reviews, and fixed or accepted bugs and feature requests). Other types, like question, design, security, mentoring, and promotion, are added by hand. See [Recognizing contributors](https://github.com/${GITHUB_REPOSITORY}/blob/dev/CONTRIBUTING.md#-recognizing-contributors)."
+ echo ""
+ echo "_Opened by the [Update contributors](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) workflow. Close and reopen this PR to run its checks._"
+ } > "$RUNNER_TEMP/pr-body.md"
+ cat "$RUNNER_TEMP/pr-body.md" >> "$GITHUB_STEP_SUMMARY"
+
+ PR_NUMBER=$(gh pr list --head "$BRANCH" --base dev --state open --json number --jq '.[0].number // empty')
+ if [ -n "$PR_NUMBER" ]; then
+ gh pr edit "$PR_NUMBER" --body-file "$RUNNER_TEMP/pr-body.md"
+ echo "Updated PR #$PR_NUMBER"
+ elif ! gh pr create --base dev --head "$BRANCH" --title "[Docs] Credit new contributors" --body-file "$RUNNER_TEMP/pr-body.md"; then
+ # Creating PRs from Actions is a repo/org setting. Don't lose the work if it is off.
+ PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/dev...${BRANCH}?expand=1"
+ echo "::warning::Could not open the pull request (is \"Allow GitHub Actions to create and approve pull requests\" off?). The branch $BRANCH was pushed."
+ {
+ echo ""
+ echo "GitHub Actions could not open the pull request. Branch \`$BRANCH\` was pushed, so open it manually:"
+ echo ""
+ echo "[**Create pull request**]($PR_URL)"
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Fail if credits could not be added
+ if: steps.update.outcome == 'failure'
+ run: |
+ echo "::error::Update-Contributors.ps1 failed. See the 'Add missing contributors' step."
+ exit 1
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8f21334b1..78ac723b5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -62,6 +62,26 @@ Want to stay connected with the team? Join our [weekly contributor sync](https:/
For details about contributing code changes and submitting pull requests, see [How to contribute](../../wiki/How-to-contribute).
-->
+## π
Recognizing contributors
+
+We recognize every kind of contribution equally with the [all-contributors](https://allcontributors.org) list in the [README](./README.md#contributing). Anyone who contributes in any of the ways below should be listed. If we missed you, let us know.
+
+| Type | Emoji | Credit for |
+| ---- | ----- | ---------- |
+| `code` | π» | Code changes in a merged pull request |
+| `doc` | π | Documentation changes in a merged pull request |
+| `bug` | π | Reporting a bug that was confirmed or fixed |
+| `ideas` | π€ | Suggesting a feature or idea that shaped the product |
+| `review` | π | Reviewing pull requests |
+| `question` | π¬ | Answering questions in issues and discussions |
+| `design` | π¨ | Design work, like report layouts and visuals |
+| `security` | π‘οΈ | Finding or fixing security issues |
+| `mentoring` | π§βπ« | Mentoring other contributors |
+| `promotion` | π£ | Promoting the toolkit in blogs, talks, and events |
+| `leader` | π | Serving on the governing board |
+
+The `code`, `doc`, `review`, `bug`, and `ideas` credits are found automatically. The [update-contributors](.github/workflows/update-contributors.yml) workflow adds a commit crediting the author and reviewers to every pull request from this repo, and a weekly run opens a pull request with whatever else is missing (fork pull requests, bugs, and feature requests), and you can check any time with `./src/scripts/Update-Contributors.ps1 -Since 2026-01-01` (add `-Apply` to update the list). Other types are added by hand: comment `@allcontributors please add @username for question, design` on any issue or pull request in this repo, or run `npm ci --ignore-scripts` once and then `npm run contrib -- username question,design`.
+
# π Thank you!
diff --git a/README.md b/README.md
index 71c297e71..b9d538ced 100644
--- a/README.md
+++ b/README.md
@@ -137,7 +137,7 @@ There are many ways to participate. From reporting bugs and requesting features
NicholasBrand π» π
- Jacob Struiksma π»
+ Jacob Struiksma π» π
Adeel Aziz π»
shasulin π
Kasim Rehman π»
@@ -163,6 +163,15 @@ There are many ways to participate. From reporting bugs and requesting features
TakuSe π»
sandhyakumili π
+ Christopherloof π
+ Haflidi Fridthjofsson π
+ josearsa π
+ sharmilamusunuru π
+
+
+ xan-archera π
+ Brian Purchell π»
+ Dan Fiedler π»
diff --git a/docs/README.md b/docs/README.md
index 229a28a1c..ffbcf2b0d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -221,7 +221,7 @@ Whether you're looking for a little assistance or are interested in contributing
NicholasBrand π» π
- Jacob Struiksma π»
+ Jacob Struiksma π» π
Adeel Aziz π»
shasulin π
Kasim Rehman π»
@@ -247,6 +247,15 @@ Whether you're looking for a little assistance or are interested in contributing
TakuSe π»
sandhyakumili π
+ Christopherloof π
+ Haflidi Fridthjofsson π
+ josearsa π
+ sharmilamusunuru π
+
+
+ xan-archera π
+ Brian Purchell π»
+ Dan Fiedler π»
diff --git a/src/powershell/Tests/Unit/Action.UpdateContributors.Tests.ps1 b/src/powershell/Tests/Unit/Action.UpdateContributors.Tests.ps1
new file mode 100644
index 000000000..a44b1ab27
--- /dev/null
+++ b/src/powershell/Tests/Unit/Action.UpdateContributors.Tests.ps1
@@ -0,0 +1,88 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe 'update-contributors GitHub Action' {
+ BeforeAll {
+ $repoRoot = (Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent.FullName
+ $workflowPath = Join-Path $repoRoot '.github/workflows/update-contributors.yml'
+ $workflowContent = Get-Content -Path $workflowPath -Raw
+ }
+
+ It 'Should exist' {
+ Test-Path $workflowPath | Should -BeTrue
+ }
+
+ It 'Should run weekly and on demand with a since input' {
+ $workflowContent | Should -Match 'schedule:\s*\r?\n\s+- cron:'
+ $workflowContent | Should -Match 'workflow_dispatch:\s*\r?\n\s+inputs:\s*\r?\n\s+since:'
+ }
+
+ It 'Should run on pull requests but never on push or pull_request_target' {
+ # pull_request_target would give fork PRs a write token
+ $workflowContent | Should -Match 'on:\s*\r?\n\s+pull_request:\s*\r?\n\s+types:\s*\[opened, synchronize, reopened\]'
+ $workflowContent | Should -Not -Match '\n\s+(push|pull_request_target):'
+ }
+
+ It 'Should only credit same-repo pull requests and skip its own automation branch' {
+ $workflowContent | Should -Match 'github\.event\.pull_request\.head\.repo\.full_name == github\.repository'
+ $workflowContent | Should -Match "github\.head_ref != 'automation/update-contributors'"
+ }
+
+ It 'Should credit the pull request with -PullRequest' {
+ $workflowContent | Should -Match 'Update-Contributors\.ps1 -PullRequest \(\[int\]\$env:PR_NUMBER\) -Apply'
+ $workflowContent | Should -Match 'PR_NUMBER:\s*\$\{\{\s*github\.event\.pull_request\.number\s*\}\}'
+ }
+
+ It 'Should grant no permissions by default and only what each job needs' {
+ $workflowContent | Should -Match '(?m)^permissions:\s*\{\}'
+ # credit-pr only pushes a commit; update also opens the pull request
+ $workflowContent | Should -Match 'credit-pr:[\s\S]*?permissions:\s*\r?\n\s+contents:\s*write\s*\r?\n\s+# '
+ $workflowContent | Should -Match 'update:[\s\S]*?permissions:\s*\r?\n\s+contents:\s*write\s*\r?\n\s+pull-requests:\s*write'
+ }
+
+ It 'Should set up Node.js 22.22 or later before installing the CLI' {
+ $workflowContent | Should -Match "node-version:\s*'22\.22'"
+ }
+
+ It 'Should retry the push after rebasing onto the PR branch' {
+ $workflowContent | Should -Match 'git pull --rebase origin'
+ }
+
+ It 'Should only run the weekly job in the upstream repo, from dev' {
+ $workflowContent | Should -Match "if:\s*github\.repository == 'microsoft/finops-toolkit' && github\.ref == 'refs/heads/dev'"
+ }
+
+ It 'Should pin every action to a full commit SHA' {
+ $uses = [regex]::Matches($workflowContent, '(?m)^\s*(?:-\s*)?uses:\s*(\S+)') | ForEach-Object { $_.Groups[1].Value }
+ $uses | Should -Not -BeNullOrEmpty
+ foreach ($u in $uses) { $u | Should -Match '@[0-9a-f]{40}$' }
+ }
+
+ It 'Should install only the declared dev dependencies without running install scripts' {
+ $workflowContent | Should -Match 'npm ci --ignore-scripts'
+ }
+
+ It 'Should run the script in apply mode' {
+ $workflowContent | Should -Match 'Update-Contributors\.ps1 -Since \$since -Apply'
+ }
+
+ It 'Should not put workflow inputs directly in a script' {
+ # Inputs reach scripts through env vars so they can't inject commands
+ # (single-line values and | or > blocks)
+ $runs = [regex]::Matches($workflowContent, '(?m)^( *)(?:- )?run:[ \t]*(?:[|>][-+]?[ \t]*\r?\n((?:\1 +.*\r?\n|[ \t]*\r?\n)+)|(.*))') |
+ ForEach-Object { $_.Groups[2].Value + $_.Groups[3].Value }
+ $runs | Should -Not -BeNullOrEmpty
+ foreach ($run in $runs) { $run | Should -Not -Match '\$\{\{' }
+ $workflowContent | Should -Match 'SINCE_INPUT:\s*\$\{\{\s*inputs\.since\s*\}\}'
+ }
+
+ It 'Should push a fixed automation branch and open or update a PR to dev' {
+ $workflowContent | Should -Match 'BRANCH:\s*automation/update-contributors'
+ $workflowContent | Should -Match 'gh pr create --base dev'
+ $workflowContent | Should -Match 'gh pr edit'
+ }
+
+ It 'Should only stage the contributor files' {
+ $workflowContent | Should -Match 'git add \.all-contributorsrc README\.md docs/README\.md'
+ }
+}
diff --git a/src/powershell/Tests/Unit/Update-Contributors.Tests.ps1 b/src/powershell/Tests/Unit/Update-Contributors.Tests.ps1
new file mode 100644
index 000000000..7e77a4101
--- /dev/null
+++ b/src/powershell/Tests/Unit/Update-Contributors.Tests.ps1
@@ -0,0 +1,335 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe 'Update-Contributors' {
+ BeforeAll {
+ # Update-Contributors.ps1 has top-level script logic that calls GitHub, so load only its functions via
+ # the PowerShell AST. They are pure (no gh calls) except the ones under "GitHub access", which are not
+ # exercised here.
+ $scriptPath = Join-Path (Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.FullName 'scripts/Update-Contributors.ps1'
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$null)
+ $functions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)
+ if (-not $functions) { throw "No functions found in $scriptPath" }
+ foreach ($function in $functions) { . ([scriptblock]::Create($function.Extent.Text)) }
+
+ $repoRoot = (Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.Parent.FullName
+ $configText = Get-Content -Path (Join-Path $repoRoot '.all-contributorsrc') -Raw
+
+ # Simulates the prettier pass in all-contributors-cli, which puts arrays of strings on one line
+ function ConvertTo-CollapsedArray([string]$Text)
+ {
+ [regex]::Replace($Text, '\[\s*\r?\n((?:\s*"[^"]*",?\s*\r?\n)+)\s*\]', {
+ param($m)
+ $items = [regex]::Matches($m.Groups[1].Value, '"[^"]*"') | ForEach-Object { $_.Value }
+ '[' + ($items -join ', ') + ']'
+ })
+ }
+
+ function Join-ConfigLine([string[]]$Lines) { ($Lines -join "`n") + "`n" }
+ }
+
+ Context 'Test-BotLogin' {
+ It 'Skips ' -ForEach @(
+ @{ login = 'app/dependabot' }
+ @{ login = 'app/copilot-swe-agent' }
+ @{ login = 'dependabot[bot]' }
+ @{ login = 'copilot-pull-request-reviewer[bot]' }
+ @{ login = 'github-actions' }
+ @{ login = 'allcontributors' }
+ @{ login = 'allcontributors[bot]' }
+ @{ login = 'OssSecurityBot' }
+ @{ login = 'Copilot' }
+ @{ login = 'some-new-app[bot]' }
+ @{ login = '' }
+ ) {
+ Test-BotLogin -Login $login | Should -BeTrue
+ }
+
+ It 'Skips accounts flagged as bots even when the name looks human' {
+ Test-BotLogin -Login 'someone' -IsBot $true | Should -BeTrue
+ }
+
+ It 'Keeps ' -ForEach @(
+ @{ login = 'flanakin' }
+ @{ login = 'MSBrett' }
+ @{ login = 'RolandKrummenacher' }
+ @{ login = 'robot-fan' }
+ @{ login = 'apple' }
+ ) {
+ Test-BotLogin -Login $login -IsBot $false | Should -BeFalse
+ }
+ }
+
+ Context 'Get-FileContributionType' {
+ It 'Returns code for files outside the docs paths' {
+ Get-FileContributionType -Path 'src/templates/finops-hub/main.bicep', 'src/powershell/Public/Get-FinOpsHub.ps1' | Should -Be @('code')
+ }
+
+ It 'Returns doc for ' -ForEach @(
+ @{ path = 'docs-mslearn/toolkit/hubs/overview.md' }
+ @{ path = 'docs-wiki/Home.md' }
+ @{ path = 'docs/_layouts/default.html' }
+ @{ path = 'README.md' }
+ @{ path = 'src/templates/finops-hub/README.md' }
+ ) {
+ Get-FileContributionType -Path $path | Should -Be @('doc')
+ }
+
+ It 'Returns both code and doc, code first, when a PR touches both' {
+ Get-FileContributionType -Path 'docs-mslearn/toolkit/hubs/overview.md', 'src/scripts/Build-Toolkit.ps1' | Should -Be @('code', 'doc')
+ }
+
+ It 'Accepts Windows path separators and leading slashes' {
+ Get-FileContributionType -Path 'docs-wiki\Home.md', '/docs/index.md' | Should -Be @('doc')
+ }
+
+ It 'Ignores changelog entries, which almost every code PR has' {
+ Get-FileContributionType -Path 'docs-mslearn/toolkit/changelog.md', 'src/templates/finops-hub/main.bicep' | Should -Be @('code')
+ Get-FileContributionType -Path 'docs-mslearn/toolkit/changelog.md' | Should -BeNullOrEmpty
+ }
+
+ It 'Ignores the files the contributor CLI rewrites' {
+ Get-FileContributionType -Path 'README.md', 'docs/README.md' -IgnorePath 'README.md', 'docs/README.md' | Should -BeNullOrEmpty
+ Get-FileContributionType -Path 'README.md', 'src/templates/finops-hub/main.bicep' -IgnorePath 'README.md' | Should -Be @('code')
+ Get-FileContributionType -Path 'src/templates/finops-hub/README.md' -IgnorePath 'README.md' | Should -Be @('doc')
+ }
+
+ It 'Ignores the contributor list itself' {
+ Get-FileContributionType -Path '.all-contributorsrc' | Should -BeNullOrEmpty
+ }
+
+ It 'Returns nothing for no files' {
+ Get-FileContributionType -Path @() | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'Get-IssueContributionType' {
+ It 'Maps Type: Bug to bug and Type: Feature to ideas' {
+ Get-IssueContributionType -Label 'Type: Bug π', 'Tool: FinOps hubs' | Should -Be @('bug')
+ Get-IssueContributionType -Label 'Type: Feature π' | Should -Be @('ideas')
+ }
+
+ It 'Returns nothing for other labels' {
+ Get-IssueContributionType -Label 'Type: Cleanup', 'Type: Documentation π' | Should -BeNullOrEmpty
+ Get-IssueContributionType -Label @() | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'Test-IssueCredit' {
+ It 'Credits issues closed as completed' {
+ Test-IssueCredit -Label 'Type: Bug π' -StateReason 'COMPLETED' | Should -BeTrue
+ }
+
+ It 'Credits open issues that are in a milestone' {
+ Test-IssueCredit -Label 'Type: Bug π' -StateReason '' -HasMilestone $true | Should -BeTrue
+ }
+
+ It 'Does not credit open, unplanned issues' {
+ Test-IssueCredit -Label 'Type: Bug π', 'Needs: Attention π' -StateReason '' | Should -BeFalse
+ }
+
+ It 'Does not credit issues closed as ' -ForEach @(
+ @{ reason = 'NOT_PLANNED' }
+ @{ reason = 'DUPLICATE' }
+ ) {
+ Test-IssueCredit -Label 'Type: Bug π' -StateReason $reason -HasMilestone $true | Should -BeFalse
+ }
+
+ It 'Does not credit issues labeled , even when completed' -ForEach @(
+ @{ label = 'Resolution: Duplicate' }
+ @{ label = "Resolution: Won't fix" }
+ @{ label = 'Resolution: External' }
+ @{ label = 'Resolution: No activity' }
+ ) {
+ Test-IssueCredit -Label 'Type: Bug π', $label -StateReason 'COMPLETED' | Should -BeFalse
+ }
+ }
+
+ Context 'Add-ContributionNeed' {
+ It 'Dedupes logins case-insensitively and merges types' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'MSBrett' -Type 'code' -Evidence 'PR #1'
+ Add-ContributionNeed -Needs $needs -Login 'msbrett' -Type 'code', 'review' -Evidence 'PR #2'
+ Add-ContributionNeed -Needs $needs -Login 'MSBRETT' -Type 'Code' -Evidence 'PR #1'
+
+ $needs.Count | Should -Be 1
+ $needs['msbrett'].Login | Should -Be 'MSBrett'
+ @($needs['msbrett'].Types | Sort-Object) | Should -Be @('code', 'review')
+ $needs['msbrett'].Evidence['code'] | Should -Be @('PR #1', 'PR #2')
+ $needs['msbrett'].Evidence['review'] | Should -Be @('PR #2')
+ }
+ }
+
+ Context 'Get-MissingContribution' {
+ BeforeAll {
+ $contributors = @(
+ [pscustomobject]@{ login = 'MSBrett'; contributions = @('leader', 'code', 'doc', 'review') }
+ [pscustomobject]@{ login = 'flanakin'; contributions = @('code') }
+ )
+ }
+
+ It 'Finds only the types that are missing, matching logins case-insensitively' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'msbrett' -Type 'code', 'doc', 'bug' -Evidence 'issue #9'
+ $missing = @(Get-MissingContribution -Needs $needs -Contributor $contributors)
+
+ $missing.Count | Should -Be 1
+ $missing[0].Login | Should -Be 'MSBrett' # config casing, so the CLI updates the existing entry
+ $missing[0].Missing | Should -Be @('bug')
+ $missing[0].IsNew | Should -BeFalse
+ }
+
+ It 'Reports nothing when everyone is already credited' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'MSBrett' -Type 'code', 'review'
+ Add-ContributionNeed -Needs $needs -Login 'FLANAKIN' -Type 'code'
+ @(Get-MissingContribution -Needs $needs -Contributor $contributors) | Should -BeNullOrEmpty
+ }
+
+ It 'Reports new people with every type, ordered code, doc, bug, ideas, review' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'newperson' -Type 'review', 'ideas', 'doc', 'bug', 'code'
+ $missing = @(Get-MissingContribution -Needs $needs -Contributor $contributors)
+
+ $missing[0].IsNew | Should -BeTrue
+ $missing[0].Missing | Should -Be @('code', 'doc', 'bug', 'ideas', 'review')
+ }
+
+ It 'Treats maintainers like everyone else' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'flanakin' -Type 'doc'
+ @(Get-MissingContribution -Needs $needs -Contributor $contributors)[0].Missing | Should -Be @('doc')
+ }
+
+ It 'Cites evidence only for the missing types' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'flanakin' -Type 'code' -Evidence 'PR #1'
+ Add-ContributionNeed -Needs $needs -Login 'flanakin' -Type 'bug' -Evidence 'issue #2'
+ @(Get-MissingContribution -Needs $needs -Contributor $contributors)[0].Evidence | Should -Be @('issue #2')
+ }
+
+ It 'Skips excluded logins' {
+ $needs = @{}
+ Add-ContributionNeed -Needs $needs -Login 'newperson' -Type 'code'
+ @(Get-MissingContribution -Needs $needs -Contributor $contributors -Exclude 'NewPerson') | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'Expand-JsonInlineArray' {
+ It 'Puts each item on its own line using the indentation of the line' {
+ $text = Join-ConfigLine '{', ' "contributors": [', ' {', ' "login": "a",', ' "contributions": ["code", "bug"]', ' }', ' ]', '}'
+ $expected = Join-ConfigLine '{', ' "contributors": [', ' {', ' "login": "a",', ' "contributions": [', ' "code",', ' "bug"', ' ]', ' }', ' ]', '}'
+ Expand-JsonInlineArray -Text $text | Should -Be $expected
+ }
+
+ It 'Keeps the trailing comma and handles a single item' {
+ Expand-JsonInlineArray -Text ' "files": ["README.md"],' | Should -Be (' "files": [', ' "README.md"', ' ],' -join "`n")
+ }
+
+ It 'Keeps items verbatim, including commas and escapes inside strings' {
+ Expand-JsonInlineArray -Text '"a": ["x, y", "q\"r"]' | Should -Be ('"a": [', ' "x, y",', ' "q\"r"', ']' -join "`n")
+ }
+
+ It 'Leaves empty arrays, nested arrays, and already expanded arrays alone' {
+ $text = ('"a": [],', '"b": [[1, 2]],', '"c": [', ' "x"', ']' -join "`n")
+ Expand-JsonInlineArray -Text $text | Should -Be $text
+ }
+
+ It 'Is idempotent' {
+ $once = Expand-JsonInlineArray -Text ('{', ' "a": 1,', ' "b": ["x", "y"]', '}' -join "`n")
+ Expand-JsonInlineArray -Text $once | Should -Be $once
+ }
+
+ It 'Converts CRLF input to LF' {
+ Expand-JsonInlineArray -Text "{`r`n `"a`": [`"x`"]`r`n}" | Should -Be ('{', ' "a": [', ' "x"', ' ]', '}' -join "`n")
+ }
+
+ It 'Restores the committed .all-contributorsrc after the prettier collapse' {
+ $collapsed = ConvertTo-CollapsedArray $configText
+ $collapsed.Split("`n").Count | Should -BeLessThan ($configText.Split("`n").Count - 100) # sanity check that it collapsed
+ $expanded = Expand-JsonInlineArray -Text $collapsed.TrimEnd()
+ $expanded | Should -Be (($configText -replace '\r\n', "`n").TrimEnd())
+ }
+ }
+
+ Context 'ConvertTo-LineEnding' {
+ It 'Converts to CRLF' { ConvertTo-LineEnding -Text "a`nb`r`nc" -Crlf $true | Should -Be "a`r`nb`r`nc" }
+ It 'Converts to LF' { ConvertTo-LineEnding -Text "a`r`nb`nc" -Crlf $false | Should -Be "a`nb`nc" }
+ }
+
+ Context 'Test-ContributorConfigChange' {
+ BeforeAll {
+ $before = Expand-JsonInlineArray -Text (@(
+ '{'
+ ' "files": ["README.md"],'
+ ' "contributors": ['
+ ' {'
+ ' "login": "a",'
+ ' "name": "A",'
+ ' "contributions": ["code"]'
+ ' },'
+ ' {'
+ ' "login": "b",'
+ ' "name": "B",'
+ ' "contributions": ["doc"]'
+ ' }'
+ ' ],'
+ ' "contributorsPerLine": 6'
+ '}'
+ ) -join "`n")
+ }
+
+ It 'Accepts a new credit and a new contributor' {
+ $after = $before -replace '"doc"', "`"doc`",`n `"bug`""
+ $after = $after -replace '(?s)\}\n \],', "},`n {`n `"login`": `"c`",`n `"name`": `"C`",`n `"contributions`": [`n `"code`"`n ]`n }`n ],"
+ $result = Test-ContributorConfigChange -Before $before -After $after
+ $result.Ok | Should -BeTrue -Because $result.Reason
+ $result.Removed | Should -BeLessOrEqual 2
+ }
+
+ It 'Rejects a file that prettier collapsed' {
+ $result = Test-ContributorConfigChange -Before $before -After (ConvertTo-CollapsedArray $before)
+ $result.Ok | Should -BeFalse
+ $result.Reason | Should -Match 'reformatted'
+ }
+
+ It 'Rejects removed credits' {
+ $result = Test-ContributorConfigChange -Before $before -After ($before -replace '"doc"', '"bug"')
+ $result.Ok | Should -BeFalse
+ $result.Reason | Should -Match 'removed'
+ }
+
+ It 'Rejects reordered contributors' {
+ $after = $before -replace '"login": "a"', '"login": "x"' -replace '"login": "b"', '"login": "a"' -replace '"login": "x"', '"login": "b"'
+ (Test-ContributorConfigChange -Before $before -After $after).Ok | Should -BeFalse
+ }
+
+ It 'Rejects changed settings' {
+ (Test-ContributorConfigChange -Before $before -After ($before -replace '"contributorsPerLine": 6', '"contributorsPerLine": 7')).Ok | Should -BeFalse
+ }
+
+ It 'Rejects invalid JSON' {
+ (Test-ContributorConfigChange -Before $before -After '{ nope').Ok | Should -BeFalse
+ }
+ }
+
+ Context 'Format-MissingContributionMarkdown' {
+ It 'Says so when nothing is missing' {
+ Format-MissingContributionMarkdown -Missing @() | Should -Be 'No missing contributor credits.'
+ }
+
+ It 'Lists credits with links to the evidence' {
+ $missing = [pscustomobject]@{ Login = 'newperson'; Missing = @('code', 'bug'); IsNew = $true; Evidence = @('PR #12', 'issue #34') }
+ $markdown = Format-MissingContributionMarkdown -Missing $missing -Repository 'microsoft/finops-toolkit'
+ $markdown | Should -Match '\[@newperson\]\(https://github.com/newperson\) \| code, bug \| New'
+ $markdown | Should -Match '\[#12\]\(https://github.com/microsoft/finops-toolkit/issues/12\), \[#34\]'
+ }
+
+ It 'Lists issue authors that need a human to pick a type' {
+ $human = [pscustomobject]@{ Login = 'reporter'; Evidence = @('issue #5') }
+ $markdown = Format-MissingContributionMarkdown -Missing @() -NeedsHuman $human
+ $markdown | Should -Match 'human needs to choose a type'
+ $markdown | Should -Match '\[@reporter\]'
+ }
+ }
+}
diff --git a/src/scripts/README.md b/src/scripts/README.md
index 4cda88315..27bd2351a 100644
--- a/src/scripts/README.md
+++ b/src/scripts/README.md
@@ -23,6 +23,7 @@ On this page:
- [π New-Directory](#-new-directory)
- [πΏ New-FeatureBranch](#-new-featurebranch)
- [π Merge-DevBranch](#-merge-devbranch)
+- [π
Update-Contributors](#-update-contributors)
---
@@ -756,3 +757,40 @@ Examples:
```
+
+## π
Update-Contributors
+
+[Update-Contributors.ps1](./Update-Contributors.ps1) finds contributors who are missing from the [all-contributors](../../.all-contributorsrc) list and adds them. It uses the `gh` CLI to read merged PRs, reviews, and issues, then credits `code`, `doc`, `review`, `bug`, and `ideas`. Other types are added by hand. The default is a dry run. Running it twice changes nothing the second time.
+
+| Parameter | Description |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `βSince` | Optional. Credit PRs merged and issues created or closed on or after this date. Either `βSince`, `βMilestone`, or `βPullRequest` is required. |
+| `βMilestone` | Optional. Credit PRs and issues in this milestone, for example `v15`. |
+| `βPullRequest` | Optional. Credit only this pull request's author and reviewers, merged or not. Use alone. |
+| `βApply` | Optional. Adds the missing credits to `.all-contributorsrc`, `README.md`, and `docs/README.md`. Requires `npm ci --ignore-scripts` once. |
+| `βExclude` | Optional. Logins to never credit. |
+| `βSummaryPath` | Optional. Writes a markdown summary to this file. |
+
+Examples:
+
+- Show who is missing for the v15 release.
+
+ ```powershell
+ ./Update-Contributors -Milestone v15
+ ```
+
+- Add everyone who is missing credits for work since April 29.
+
+ ```powershell
+ ./Update-Contributors -Since 2026-04-29 -Apply
+ ```
+
+- Credit the author and reviewers of one pull request.
+
+ ```powershell
+ ./Update-Contributors -PullRequest 2395 -Apply
+ ```
+
+The [update-contributors](../../.github/workflows/update-contributors.yml) workflow runs this on every pull request from this repo, adding a commit with the author's and reviewers' credits to the PR branch. It also runs weekly and opens a pull request with everything else, including fork PRs, bugs, and feature requests.
+
+
diff --git a/src/scripts/Update-Contributors.ps1 b/src/scripts/Update-Contributors.ps1
new file mode 100644
index 000000000..706ae5da1
--- /dev/null
+++ b/src/scripts/Update-Contributors.ps1
@@ -0,0 +1,891 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+#Requires -Version 7.0
+
+<#
+ .SYNOPSIS
+ Finds contributors who are missing from the all-contributors list and adds them.
+
+ .DESCRIPTION
+ Collects recent contributions from GitHub with the gh CLI, compares them with .all-contributorsrc, prints
+ what is missing, and (with -Apply) adds the missing credits with the repo's own all-contributors-cli.
+
+ Only contribution types that can be inferred from GitHub data are handled. Everything else (question,
+ design, security, mentoring, promotion, leader) is left to humans. See "Recognizing contributors" in
+ CONTRIBUTING.md.
+
+ | Type | Inferred from |
+ | ------ | ----------------------------------------------------------------------------------------------- |
+ | code | Author of a merged PR that changed any file outside the documentation paths |
+ | doc | Author of a merged PR that changed a file in docs/, docs-mslearn/, docs-wiki/, or any *.md file |
+ | review | Human who submitted a review (approved, requested changes, or commented) on a merged PR |
+ | bug | Author of an issue labeled "Type: Bug" that was fixed or accepted (see below) |
+ | ideas | Author of an issue labeled "Type: Feature" that was implemented or accepted (see below) |
+
+ An issue is fixed or accepted when it was closed as completed or is assigned to a milestone. Issues closed as
+ not planned or duplicate, and issues labeled Resolution: Duplicate, Won't fix, Can't repro, No activity,
+ External, or Support request, are not credited.
+
+ Changelog entries and .all-contributorsrc itself do not count toward code or doc, because nearly every code PR
+ touches the changelog.
+
+ Bots and apps are skipped (names ending in [bot], starting with app/, and a short list of known bot
+ accounts). Nobody else is skipped: maintainers who are missing a type are credited too. Logins are compared
+ case-insensitively.
+
+ The default is a dry run that only prints what is missing. With -Apply, the script runs the all-contributors
+ CLI once per login and then puts the arrays in .all-contributorsrc back on multiple lines. The CLI
+ runs the file through prettier, which collapses short arrays onto one line and would turn a 20-line change
+ into a ~500-line diff. The script verifies that the result only adds entries and restores the original files if
+ it does not. Running the script twice changes nothing the second time.
+
+ Prerequisites: PowerShell 7, an authenticated gh CLI (gh auth login, or GH_TOKEN), and for -Apply, Node.js and
+ "npm ci --ignore-scripts" run once at the repo root.
+
+ .PARAMETER Since
+ Optional. Credit contributions since this date: PRs merged on or after it, and issues created or closed on or
+ after it. At least one of -Since or -Milestone is required. If both are specified, both must match.
+
+ .PARAMETER Milestone
+ Optional. Credit contributions in this milestone (for example, v15).
+
+ .PARAMETER PullRequest
+ Optional. Credit only this pull request: its author (code, doc) and its reviewers (review), whether or not it is
+ merged. Issues are not checked. Use alone; it replaces -Since and -Milestone. The workflow uses this to credit
+ a pull request's author on the pull request itself.
+
+ .PARAMETER Repository
+ Optional. Repository to read from, as owner/name. Default = microsoft/finops-toolkit.
+
+ .PARAMETER ConfigPath
+ Optional. Path to the all-contributors config file. The CLI runs in this file's folder, so the files it lists
+ (README.md, docs/README.md) are resolved from there. Default = .all-contributorsrc at the repo root.
+
+ .PARAMETER Exclude
+ Optional. Logins to never credit (for example, someone who asked to be removed from the list).
+
+ .PARAMETER Apply
+ Optional. Updates the config and the README files. Without this switch, the script only prints what is missing.
+ Combine with -WhatIf to see what would run.
+
+ .PARAMETER SummaryPath
+ Optional. Writes a markdown summary of what was missing (or added) to this file. The workflow uses it as the
+ PR description.
+
+ .PARAMETER PassThru
+ Optional. Returns the missing credits as objects.
+
+ .EXAMPLE
+ ./Update-Contributors.ps1 -Milestone v15
+
+ Prints who is missing from the contributor list for milestone v15. Changes nothing.
+
+ .EXAMPLE
+ ./Update-Contributors.ps1 -Since 2026-04-29 -Apply
+
+ Adds everyone who is missing credits for work since April 29, 2026.
+
+ .EXAMPLE
+ ./Update-Contributors.ps1 -PullRequest 2395 -Apply
+
+ Adds the author and reviewers of pull request 2395 if they are missing credits for it.
+#>
+[CmdletBinding(SupportsShouldProcess)]
+param (
+ [datetime]
+ $Since,
+
+ [string]
+ $Milestone,
+
+ [int]
+ $PullRequest,
+
+ [ValidatePattern('^[\w.-]+/[\w.-]+$')]
+ [string]
+ $Repository = 'microsoft/finops-toolkit',
+
+ [string]
+ $ConfigPath = (Join-Path $PSScriptRoot '../../.all-contributorsrc'),
+
+ [string[]]
+ $Exclude = @(),
+
+ [switch]
+ $Apply,
+
+ [string]
+ $SummaryPath,
+
+ [switch]
+ $PassThru
+)
+
+#==============================================================================
+# Pure logic (unit tested in src/powershell/Tests/Unit/Update-Contributors.Tests.ps1)
+#==============================================================================
+
+# Order credits are listed in (and added in). Anything else sorts after these.
+function Get-ContributionTypeOrder
+{
+ return @('code', 'doc', 'bug', 'ideas', 'review')
+}
+
+# Accounts that look like people but aren't. Compared after removing "app/" and "[bot]".
+function Get-KnownBotLogin
+{
+ return @(
+ 'dependabot'
+ 'copilot'
+ 'copilot-swe-agent'
+ 'copilot-pull-request-reviewer'
+ 'github-actions'
+ 'github-advanced-security'
+ 'allcontributors'
+ 'osssecuritybot'
+ 'microsoft-github-policy-service'
+ )
+}
+
+function Test-BotLogin
+{
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param (
+ [AllowNull()][AllowEmptyString()]
+ [string]
+ $Login,
+
+ [bool]
+ $IsBot = $false
+ )
+
+ if ($IsBot -or [string]::IsNullOrWhiteSpace($Login)) { return $true }
+ if ($Login -match '^app/' -or $Login -match '\[bot\]$') { return $true }
+ return (Get-KnownBotLogin) -contains $Login.ToLowerInvariant()
+}
+
+# Returns 'code' and/or 'doc' for the files a PR changed.
+function Get-FileContributionType
+{
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [AllowNull()][AllowEmptyCollection()]
+ [string[]]
+ $Path,
+
+ # Files the contributor CLI rewrites (the "files" in .all-contributorsrc), which are bookkeeping too
+ [AllowNull()][AllowEmptyCollection()]
+ [string[]]
+ $IgnorePath = @()
+ )
+
+ $ignored = @($IgnorePath | ForEach-Object { ("$_" -replace '\\', '/').TrimStart('/') } | Where-Object { $_ })
+ $code = $false
+ $doc = $false
+ foreach ($file in $Path)
+ {
+ $file = ($file -replace '\\', '/').TrimStart('/')
+ if (-not $file) { continue }
+
+ # The contributor list and the files generated from it are bookkeeping, and every code PR adds a changelog
+ # entry, so none of them earn credit
+ if ($file -ieq '.all-contributorsrc' -or $ignored -contains $file -or $file -match '(^|/)changelog\.md$') { continue }
+
+ if ($file -match '^(docs-mslearn|docs-wiki|docs)/' -or $file -match '\.(md|mdx)$') { $doc = $true }
+ else { $code = $true }
+ }
+
+ return @(
+ if ($code) { 'code' }
+ if ($doc) { 'doc' }
+ )
+}
+
+# Records that a login deserves a type. Logins are deduplicated case-insensitively and types are a set.
+function Add-ContributionNeed
+{
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [hashtable]
+ $Needs,
+
+ [Parameter(Mandatory)]
+ [string]
+ $Login,
+
+ [Parameter(Mandatory)]
+ [string[]]
+ $Type,
+
+ [string]
+ $Evidence
+ )
+
+ $key = $Login.ToLowerInvariant()
+ if (-not $Needs.ContainsKey($key))
+ {
+ $Needs[$key] = [pscustomobject]@{
+ Login = $Login
+ Types = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ Evidence = @{} # type -> references, so a row only cites what it is missing
+ }
+ }
+
+ foreach ($t in $Type)
+ {
+ $t = $t.ToLowerInvariant()
+ $null = $Needs[$key].Types.Add($t)
+ if (-not $Needs[$key].Evidence.ContainsKey($t)) { $Needs[$key].Evidence[$t] = [System.Collections.Generic.List[string]]::new() }
+ if ($Evidence -and -not $Needs[$key].Evidence[$t].Contains($Evidence)) { $Needs[$key].Evidence[$t].Add($Evidence) }
+ }
+}
+
+# Compares what people deserve with what the config says. Returns one row per login that is missing a type.
+function Get-MissingContribution
+{
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [hashtable]
+ $Needs,
+
+ [AllowNull()]
+ [object[]]
+ $Contributor,
+
+ [string[]]
+ $Exclude = @()
+ )
+
+ $order = Get-ContributionTypeOrder
+ $excluded = @($Exclude | ForEach-Object { $_.ToLowerInvariant() })
+ $existingByLogin = @{}
+ foreach ($c in $Contributor) { if ($c.login) { $existingByLogin[$c.login.ToLowerInvariant()] = $c } }
+
+ foreach ($key in ($Needs.Keys | Sort-Object))
+ {
+ if ($excluded -contains $key) { continue }
+
+ $need = $Needs[$key]
+ $existing = $existingByLogin[$key]
+ $have = @(if ($existing) { $existing.contributions | ForEach-Object { "$_".ToLowerInvariant() } })
+ $missing = @(
+ $need.Types
+ | Where-Object { $have -notcontains $_ }
+ | Sort-Object { $i = [array]::IndexOf($order, $_); if ($i -lt 0) { 99 } else { $i } }, { $_ }
+ )
+ if ($missing.Count -eq 0) { continue }
+
+ [pscustomobject]@{
+ # Use the casing from the config so the CLI updates the existing entry instead of adding a duplicate
+ Login = if ($existing) { $existing.login } else { $need.Login }
+ Missing = $missing
+ IsNew = -not $existing
+ Evidence = @($missing | ForEach-Object { $need.Evidence[$_] } | Select-Object -Unique)
+ }
+ }
+}
+
+# Decides whether an issue's author earns credit. CONTRIBUTING.md credits bugs that were "confirmed or fixed" and
+# ideas that "shaped the product", so a label alone is not enough: the issue must have been fixed (closed as
+# completed) or accepted (assigned to a milestone), and must not be a duplicate, external, or won't-fix.
+function Test-IssueCredit
+{
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param (
+ [AllowNull()][AllowEmptyCollection()]
+ [string[]]
+ $Label,
+
+ [AllowNull()][AllowEmptyString()]
+ [string]
+ $StateReason,
+
+ [bool]
+ $HasMilestone = $false
+ )
+
+ if (@($Label | Where-Object { $_ -match "^Resolution:\s*(Duplicate|Won't fix|Can't repro|No activity|External|Support request)" })) { return $false }
+ if ($StateReason -in 'NOT_PLANNED', 'DUPLICATE') { return $false }
+ return ($StateReason -eq 'COMPLETED') -or $HasMilestone
+}
+
+# Returns 'bug' for Type: Bug and 'ideas' for Type: Feature labels.
+function Get-IssueContributionType
+{
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [AllowNull()][AllowEmptyCollection()]
+ [string[]]
+ $Label
+ )
+
+ return @(
+ if (@($Label) -match '^Type:\s*Bug') { 'bug' }
+ if (@($Label) -match '^Type:\s*Feature') { 'ideas' }
+ )
+}
+
+# Puts single-line JSON arrays of simple values back on multiple lines, one item per line, using the indentation
+# of the line they are on. Lines with nested arrays/objects and empty arrays are left alone.
+function Expand-JsonInlineArray
+{
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory)]
+ [AllowEmptyString()]
+ [string]
+ $Text
+ )
+
+ $lines = $Text -split '\r?\n', 0 # 0 = no limit (-1 would not split)
+ $result = foreach ($line in $lines)
+ {
+ $m = [regex]::Match($line, '^(?\s*)(?"(?:[^"\\]|\\.)*":\s*)\[(?[^\[\]{}]*)\](?,?)\s*$')
+ if (-not $m.Success -or -not $m.Groups['items'].Value.Trim()) { $line; continue }
+
+ # Tokens are kept verbatim so strings are not re-escaped
+ $items = @([regex]::Matches($m.Groups['items'].Value, '"(?:[^"\\]|\\.)*"|[^,\s]+') | ForEach-Object { $_.Value })
+ $indent = $m.Groups['indent'].Value
+ "$indent$($m.Groups['key'].Value)["
+ for ($i = 0; $i -lt $items.Count; $i++)
+ {
+ "$indent $($items[$i])$(if ($i -lt $items.Count - 1) { ',' })"
+ }
+ "$indent]$($m.Groups['comma'].Value)"
+ }
+
+ return $result -join "`n"
+}
+
+# Converts line endings to match a previous version of the text (CRLF or LF).
+function ConvertTo-LineEnding
+{
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory)]
+ [AllowEmptyString()]
+ [string]
+ $Text,
+
+ [Parameter(Mandatory)]
+ [bool]
+ $Crlf
+ )
+
+ $lf = $Text -replace '\r\n', "`n"
+ if ($Crlf) { return $lf -replace '\n', "`r`n" }
+ return $lf
+}
+
+# Verifies that the config only gained credits: same contributors in the same order, no credits removed, other
+# settings unchanged, and a text diff that is limited to added lines (plus the comma on the line before an addition).
+function Test-ContributorConfigChange
+{
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [string]
+ $Before,
+
+ [Parameter(Mandatory)]
+ [string]
+ $After
+ )
+
+ $fail = { param($reason) [pscustomobject]@{ Ok = $false; Reason = $reason; Added = 0; Removed = 0 } }
+
+ try
+ {
+ $b = $Before | ConvertFrom-Json -Depth 20
+ $a = $After | ConvertFrom-Json -Depth 20
+ }
+ catch { return & $fail "Config is not valid JSON: $($_.Exception.Message)" }
+
+ $oldContributors = @($b.contributors)
+ $newContributors = @($a.contributors)
+ if ($newContributors.Count -lt $oldContributors.Count) { return & $fail 'Contributors were removed.' }
+
+ $touched = 0
+ for ($i = 0; $i -lt $oldContributors.Count; $i++)
+ {
+ $o = $oldContributors[$i]
+ $n = $newContributors[$i]
+ if ($o.login -ne $n.login) { return & $fail "Contributor order changed at position $($i + 1) ($($o.login))." }
+
+ $oMeta = $o | Select-Object -Property * -ExcludeProperty contributions | ConvertTo-Json -Depth 5 -Compress
+ $nMeta = $n | Select-Object -Property * -ExcludeProperty contributions | ConvertTo-Json -Depth 5 -Compress
+ if ($oMeta -ne $nMeta) { return & $fail "Details changed for $($o.login)." }
+
+ $lost = @($o.contributions | Where-Object { @($n.contributions) -notcontains $_ })
+ if ($lost) { return & $fail "Credits were removed from $($o.login): $($lost -join ', ')." }
+ if (@($n.contributions).Count -ne @($o.contributions).Count) { $touched++ }
+ }
+
+ $oSettings = $b | Select-Object -Property * -ExcludeProperty contributors | ConvertTo-Json -Depth 20 -Compress
+ $nSettings = $a | Select-Object -Property * -ExcludeProperty contributors | ConvertTo-Json -Depth 20 -Compress
+ if ($oSettings -ne $nSettings) { return & $fail 'Settings other than contributors changed.' }
+
+ # Text diff: additions are expected; the only removals allowed are the last item of an array that grew
+ # (it gains a trailing comma), one per touched contributor, plus one for the last contributor object.
+ $beforeLines = @($Before -split '\r?\n' | Where-Object { $_.Trim() })
+ $afterLines = @($After -split '\r?\n' | Where-Object { $_.Trim() })
+ $diff = @(Compare-Object -ReferenceObject $beforeLines -DifferenceObject $afterLines)
+ $removed = @($diff | Where-Object SideIndicator -eq '<=').Count
+ $added = @($diff | Where-Object SideIndicator -eq '=>').Count
+ if ($removed -gt $touched + 1)
+ {
+ return [pscustomobject]@{ Ok = $false; Reason = "Diff removes $removed lines; expected at most $($touched + 1). The file was probably reformatted."; Added = $added; Removed = $removed }
+ }
+
+ return [pscustomobject]@{ Ok = $true; Reason = ''; Added = $added; Removed = $removed }
+}
+
+# Formats the missing credits as a markdown table.
+function Format-MissingContributionMarkdown
+{
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [AllowNull()]
+ [object[]]
+ $Missing,
+
+ [string]
+ $Repository = 'microsoft/finops-toolkit',
+
+ [AllowNull()]
+ [object[]]
+ $NeedsHuman
+ )
+
+ # GitHub redirects /issues/N to the PR when N is a pull request. Long lists are shortened.
+ $link = {
+ param($evidence)
+ $refs = @($evidence | Select-Object -First 8 | ForEach-Object { $_ -replace '^(?:PR|issue) #(\d+)$', "[#`$1](https://github.com/$Repository/issues/`$1)" })
+ ($refs -join ', ') + $(if (@($evidence).Count -gt 8) { " (+$(@($evidence).Count - 8) more)" })
+ }
+
+ $sections = @()
+ if ($Missing)
+ {
+ $rows = foreach ($m in $Missing)
+ {
+ "| [@$($m.Login)](https://github.com/$($m.Login)) | $($m.Missing -join ', ') | $(if ($m.IsNew) { 'New' } else { 'Existing' }) | $(& $link $m.Evidence) |"
+ }
+ $sections += (@('| Contributor | Credits | Entry | Evidence |', '| ----------- | ------- | ----- | -------- |') + $rows) -join "`n"
+ }
+ else { $sections += 'No missing contributor credits.' }
+
+ if ($NeedsHuman)
+ {
+ $rows = foreach ($h in $NeedsHuman) { "| [@$($h.Login)](https://github.com/$($h.Login)) | $(& $link $h.Evidence) |" }
+ $sections += (@(
+ 'These people filed fixed or accepted issues that have no `Type:` label, so a human needs to choose a type (for example, `bug` or `ideas`):'
+ ''
+ '| Contributor | Issues |'
+ '| ----------- | ------ |'
+ ) + $rows) -join "`n"
+ }
+
+ return $sections -join "`n`n"
+}
+
+#==============================================================================
+# GitHub access
+#==============================================================================
+
+# Runs gh and returns stdout lines. Retries transient server errors and turns auth and rate limit failures
+# into clear messages.
+function Invoke-Gh
+{
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [string[]]
+ $Argument
+ )
+
+ for ($attempt = 1; $attempt -le 3; $attempt++)
+ {
+ $output = & $script:GhPath @Argument 2>&1
+ if ($LASTEXITCODE -eq 0)
+ {
+ return @($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_" })
+ }
+
+ $err = ($output | ForEach-Object { "$_" }) -join "`n"
+ Resolve-GhError -Message $err -Command ($Argument -join ' ')
+ if ($attempt -eq 3) { throw "gh $($Argument -join ' ') failed after $attempt attempts: $err" }
+ Start-Sleep -Seconds (2 * $attempt)
+ }
+}
+
+# Throws a friendly error for failures that retrying will not fix. Returns for transient failures.
+function Resolve-GhError
+{
+ [CmdletBinding()]
+ param (
+ [string]
+ $Message,
+
+ [string]
+ $Command
+ )
+
+ if ($Message -match 'rate limit|HTTP 429|secondary rate')
+ {
+ $reset = ''
+ try
+ {
+ $epoch = & $script:GhPath api rate_limit --jq '[.resources.core.reset, .resources.graphql.reset, .resources.search.reset] | max' 2>$null
+ if ($epoch) { $reset = " Limits reset at $([DateTimeOffset]::FromUnixTimeSeconds([long]$epoch).UtcDateTime.ToString('u'))." }
+ }
+ catch { Write-Verbose "Could not read the rate limit reset time: $($_.Exception.Message)" }
+ throw "GitHub API rate limit exceeded.$reset Wait and run again, or authenticate with a token that has a higher limit (gh auth login). Command: gh $Command"
+ }
+
+ if ($Message -match 'HTTP 401|Bad credentials|gh auth login|authentication')
+ {
+ throw "gh is not authenticated or the token was rejected. Run 'gh auth login' (or set GH_TOKEN) and try again. Command: gh $Command"
+ }
+
+ if ($Message -match 'HTTP 404|Could not resolve to a Repository')
+ {
+ throw "GitHub returned not found for '$Command'. Check -Repository and that your gh account can read it. $Message"
+ }
+
+ if ($Message -match 'HTTP 403')
+ {
+ throw "GitHub denied access for '$Command' (HTTP 403). $Message"
+ }
+
+ # Anything else (HTTP 5xx, timeouts, connection resets) is treated as transient by the caller
+}
+
+function Get-MergedPullRequest
+{
+ [CmdletBinding()]
+ param (
+ [string]
+ $Repository,
+
+ [AllowNull()]
+ [Nullable[datetime]]
+ $Since,
+
+ [string]
+ $Milestone
+ )
+
+ $query = @(
+ if ($Since) { "merged:>=$($Since.ToString('yyyy-MM-dd'))" }
+ if ($Milestone) { "milestone:`"$Milestone`"" }
+ ) -join ' '
+ $json = Invoke-Gh @('pr', 'list', '-R', $Repository, '--state', 'merged', '--search', $query, '--limit', '1000', '--json', 'number,author,mergedAt')
+ $prs = @($json -join '' | ConvertFrom-Json)
+ if ($prs.Count -ge 1000) { Write-Warning 'Found 1000 merged PRs, which is the limit. Use a narrower -Since or -Milestone.' }
+ return $prs
+}
+
+# Reads the changed files and reviews of many PRs in parallel (two REST calls per PR).
+function Get-PullRequestDetail
+{
+ [CmdletBinding()]
+ param (
+ [string]
+ $Repository,
+
+ [object[]]
+ $PullRequest
+ )
+
+ $gh = $script:GhPath
+ $details = $PullRequest | ForEach-Object -ThrottleLimit 6 -Parallel {
+ $number = $_.number
+ $result = [pscustomobject]@{ Number = $number; Files = @(); Reviews = @(); Error = $null }
+ $calls = @{
+ Files = @('api', '--paginate', "repos/$($using:Repository)/pulls/$number/files", '--jq', '.[].filename')
+ Reviews = @('api', '--paginate', "repos/$($using:Repository)/pulls/$number/reviews", '--jq', '.[] | select(.user != null) | [.user.login, .state, .user.type] | @tsv')
+ }
+ foreach ($name in $calls.Keys)
+ {
+ for ($attempt = 1; $attempt -le 3; $attempt++)
+ {
+ $out = & $using:gh @($calls[$name]) 2>&1
+ if ($LASTEXITCODE -eq 0)
+ {
+ $result.$name = @($out | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_" })
+ $result.Error = $null
+ break
+ }
+ $result.Error = ($out | ForEach-Object { "$_" }) -join "`n"
+ if ($result.Error -match 'rate limit|HTTP 4\d\d') { break }
+ Start-Sleep -Seconds (2 * $attempt)
+ }
+ if ($result.Error) { break }
+ }
+ $result
+ }
+
+ # Surface the first failure with the same friendly messages as everywhere else
+ $failed = @($details | Where-Object Error | Select-Object -First 1)
+ if ($failed) { Resolve-GhError -Message $failed.Error -Command "api pulls/$($failed.Number)"; throw "Could not read PR #$($failed.Number): $($failed.Error)" }
+
+ return $details
+}
+
+function Get-IssueCandidate
+{
+ [CmdletBinding()]
+ param (
+ [string]
+ $Repository,
+
+ [AllowNull()]
+ [Nullable[datetime]]
+ $Since,
+
+ [string]
+ $Milestone
+ )
+
+ $milestoneQuery = if ($Milestone) { "milestone:`"$Milestone`"" }
+ $searches = if ($Since)
+ {
+ # An issue counts if it was reported or resolved in the window
+ $date = $Since.ToString('yyyy-MM-dd')
+ @("created:>=$date $milestoneQuery", "closed:>=$date $milestoneQuery")
+ }
+ else { @($milestoneQuery) }
+
+ $issues = @{}
+ foreach ($search in $searches)
+ {
+ $json = Invoke-Gh @('issue', 'list', '-R', $Repository, '--state', 'all', '--search', $search.Trim(), '--limit', '1000', '--json', 'number,author,labels,stateReason,milestone')
+ $found = @($json -join '' | ConvertFrom-Json)
+ if ($found.Count -ge 1000) { Write-Warning 'Found 1000 issues, which is the limit. Use a narrower -Since or -Milestone.' }
+ foreach ($issue in $found) { $issues[[int]$issue.number] = $issue }
+ }
+ return $issues.Values
+}
+
+#==============================================================================
+# Main
+#==============================================================================
+
+# Tests extract the functions above, so the main body only runs when the script is executed
+if ($MyInvocation.InvocationName -eq '.') { return }
+
+$ErrorActionPreference = 'Stop'
+
+if (-not $Since -and -not $Milestone -and -not $PullRequest)
+{
+ throw 'Specify -Since , -Milestone , or -PullRequest so the script knows which contributions to look at.'
+}
+if ($PullRequest -and ($Since -or $Milestone))
+{
+ throw '-PullRequest cannot be combined with -Since or -Milestone.'
+}
+
+# Find gh (it is not always on PATH, especially on Windows)
+$script:GhPath = (Get-Command gh -ErrorAction SilentlyContinue)?.Source
+if (-not $script:GhPath -and $IsWindows) { $script:GhPath = Join-Path $env:ProgramFiles 'GitHub CLI/gh.exe' }
+if (-not $script:GhPath -or -not (Test-Path $script:GhPath))
+{
+ throw 'The GitHub CLI (gh) is required. Install it from https://cli.github.com and run gh auth login.'
+}
+
+& $script:GhPath auth status *> $null
+if ($LASTEXITCODE -ne 0)
+{
+ throw "gh is not authenticated. Run 'gh auth login' (or set GH_TOKEN) and try again."
+}
+
+$ConfigPath = (Resolve-Path $ConfigPath -ErrorAction Stop).Path
+$repoRoot = Split-Path $ConfigPath -Parent
+$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json -Depth 20
+$needs = @{}
+$unclassified = @{} # authors of fixed/accepted issues with no Type label, which only a human can classify
+
+$scope = @(
+ if ($Since) { "since $($Since.ToString('yyyy-MM-dd'))" }
+ if ($Milestone) { "in milestone $Milestone" }
+ if ($PullRequest) { "in PR #$PullRequest" }
+) -join ' and '
+Write-Host "Looking for contributions to $Repository $scope..."
+
+# Merged PRs: authors get code/doc, reviewers get review
+$prs = if ($PullRequest) { @((Invoke-Gh @('pr', 'view', "$PullRequest", '-R', $Repository, '--json', 'number,author')) -join '' | ConvertFrom-Json) }
+ else { @(Get-MergedPullRequest -Repository $Repository -Since $Since -Milestone $Milestone) }
+Write-Host " $($prs.Count) $(if ($PullRequest) { 'PR' } else { 'merged PRs' })"
+if ($prs.Count -gt 0)
+{
+ # PR details use 2 REST calls each. Stop early with a clear message instead of failing partway through.
+ $limits = (Invoke-Gh @('api', 'rate_limit', '--jq', '.resources.core | "\(.remaining) \(.reset)"')) -split ' '
+ if ([int]$limits[0] -lt (2 * $prs.Count + 50))
+ {
+ $reset = [DateTimeOffset]::FromUnixTimeSeconds([long]$limits[1]).UtcDateTime.ToString('u')
+ throw "GitHub API rate limit is too low: $($limits[0]) calls left and about $(2 * $prs.Count) are needed. Limits reset at $reset."
+ }
+
+ $details = Get-PullRequestDetail -Repository $Repository -PullRequest $prs
+ $authors = @{}
+ $prs | ForEach-Object { $authors[[int]$_.number] = $_.author }
+ foreach ($detail in $details)
+ {
+ $author = $authors[[int]$detail.Number]
+ $authorLogin = $author.login -replace '^app/', ''
+ if (-not (Test-BotLogin -Login $author.login -IsBot ([bool]$author.is_bot)))
+ {
+ $types = Get-FileContributionType -Path $detail.Files -IgnorePath $config.files
+ if ($types) { Add-ContributionNeed -Needs $needs -Login $authorLogin -Type $types -Evidence "PR #$($detail.Number)" }
+ }
+
+ foreach ($line in $detail.Reviews)
+ {
+ $login, $state, $userType = $line -split "`t"
+ if ($state -eq 'PENDING') { continue }
+ if ($login -ieq $authorLogin -or (Test-BotLogin -Login $login -IsBot ($userType -eq 'Bot'))) { continue }
+ Add-ContributionNeed -Needs $needs -Login $login -Type 'review' -Evidence "PR #$($detail.Number)"
+ }
+ }
+}
+
+# Issues: Bug label gets bug, Feature label gets ideas
+$issues = if ($PullRequest) { @() } else { @(Get-IssueCandidate -Repository $Repository -Since $Since -Milestone $Milestone) }
+Write-Host " $($issues.Count) issues"
+foreach ($issue in $issues)
+{
+ if (Test-BotLogin -Login $issue.author.login -IsBot ([bool]$issue.author.is_bot)) { continue }
+ $labels = @($issue.labels.name)
+ if (-not (Test-IssueCredit -Label $labels -StateReason $issue.stateReason -HasMilestone ([bool]$issue.milestone))) { continue }
+ $types = Get-IssueContributionType -Label $labels
+ if ($types) { Add-ContributionNeed -Needs $needs -Login $issue.author.login -Type $types -Evidence "issue #$($issue.number)" }
+ elseif (-not ($labels -match '^Type:')) { Add-ContributionNeed -Needs $unclassified -Login $issue.author.login -Type 'unknown' -Evidence "issue #$($issue.number)" }
+}
+
+$missing = @(Get-MissingContribution -Needs $needs -Contributor $config.contributors -Exclude $Exclude)
+
+# People who aren't listed at all and filed a fixed/accepted issue we couldn't classify
+$listed = @($config.contributors.login + $missing.Login + $Exclude | ForEach-Object { "$_".ToLowerInvariant() })
+$manual = @(
+ $unclassified.Keys | Sort-Object | Where-Object { $listed -notcontains $_ }
+ | ForEach-Object { [pscustomobject]@{ Login = $unclassified[$_].Login; Evidence = @($unclassified[$_].Evidence['unknown']) } }
+)
+
+Write-Host ''
+if ($manual.Count -gt 0)
+{
+ Write-Host "$($manual.Count) issue author(s) are not listed but their issues have no Type label, so a human needs to choose a type (bug, ideas, ...):" -ForegroundColor Yellow
+ $manual | Select-Object Login, @{ n = 'Evidence'; e = { $_.Evidence -join ', ' } } | Format-Table -AutoSize | Out-Host
+}
+if ($missing.Count -eq 0)
+{
+ Write-Host "Nobody is missing. All $($needs.Count) people with inferable contributions are already credited in $(Split-Path $ConfigPath -Leaf)." -ForegroundColor Green
+}
+else
+{
+ Write-Host "$($missing.Count) contributor(s) missing credits:" -ForegroundColor Yellow
+ $missing `
+ | Select-Object Login, @{ n = 'Missing'; e = { $_.Missing -join ', ' } }, @{ n = 'Entry'; e = { if ($_.IsNew) { 'new' } else { 'existing' } } }, @{ n = 'Evidence'; e = { (($_.Evidence | Select-Object -First 4) -join ', ') + $(if ($_.Evidence.Count -gt 4) { " (+$($_.Evidence.Count - 4) more)" }) } } `
+ | Format-Table -AutoSize -Wrap `
+ | Out-Host
+}
+
+$failed = @()
+if ($missing.Count -gt 0 -and $Apply)
+{
+ if (-not $PSCmdlet.ShouldProcess($ConfigPath, "Add $($missing.Count) contributor(s) with all-contributors"))
+ {
+ Write-Host 'Skipped (WhatIf).'
+ }
+ else
+ {
+ $cli = Join-Path $PSScriptRoot '../../node_modules/all-contributors-cli/dist/cli.js'
+ if (-not (Test-Path $cli)) { throw "all-contributors-cli is not installed. Run 'npm ci --ignore-scripts' at the repo root first." }
+ $node = (Get-Command node -ErrorAction SilentlyContinue)?.Source
+ if (-not $node) { throw 'Node.js is required for -Apply (it runs all-contributors-cli).' }
+
+ # Snapshot everything the CLI can change so a bad result can be rolled back
+ $targets = @($ConfigPath) + @($config.files | ForEach-Object { Join-Path $repoRoot $_ })
+ $snapshots = @{}
+ foreach ($t in $targets) { if (Test-Path $t) { $snapshots[$t] = [System.IO.File]::ReadAllText($t) } }
+
+ # The CLI looks up each profile on GitHub; a token avoids the low anonymous rate limit
+ $previousToken = $env:PRIVATE_TOKEN
+ if (-not $env:PRIVATE_TOKEN) { $env:PRIVATE_TOKEN = $env:GH_TOKEN ?? $env:GITHUB_TOKEN ?? (& $script:GhPath auth token 2>$null) }
+
+ Push-Location $repoRoot
+ try
+ {
+ foreach ($m in $missing)
+ {
+ Write-Host "Adding $($m.Login): $($m.Missing -join ', ')"
+ $out = & $node $cli add $m.Login ($m.Missing -join ',') 2>&1
+ if ($LASTEXITCODE -ne 0)
+ {
+ $failed += $m.Login
+ Write-Warning "all-contributors failed for $($m.Login): $($out -join ' ')"
+ }
+ }
+ }
+ finally
+ {
+ Pop-Location
+ $env:PRIVATE_TOKEN = $previousToken
+ }
+
+ # The CLI writes LF and collapses arrays. Match the original line endings and trailing blank lines, then
+ # expand the arrays so the diff only shows what was added.
+ $original = $snapshots[$ConfigPath]
+ $crlf = $original.Contains("`r`n")
+ $trailing = [regex]::Match($original, '\s*$').Value
+ $text = Expand-JsonInlineArray -Text ([System.IO.File]::ReadAllText($ConfigPath).TrimEnd())
+ $text = ConvertTo-LineEnding -Text ($text + (ConvertTo-LineEnding -Text $trailing -Crlf $false)) -Crlf $crlf
+ [System.IO.File]::WriteAllText($ConfigPath, $text)
+ foreach ($t in $targets | Where-Object { $_ -ne $ConfigPath -and $snapshots.ContainsKey($_) })
+ {
+ [System.IO.File]::WriteAllText($t, (ConvertTo-LineEnding -Text ([System.IO.File]::ReadAllText($t)) -Crlf $snapshots[$t].Contains("`r`n")))
+ }
+
+ $check = Test-ContributorConfigChange -Before $original -After $text
+ if (-not $check.Ok)
+ {
+ foreach ($t in $snapshots.Keys) { [System.IO.File]::WriteAllText($t, $snapshots[$t]) }
+ throw "Unexpected changes to $(Split-Path $ConfigPath -Leaf), so the original files were restored. $($check.Reason)"
+ }
+
+ Write-Host "Updated $(Split-Path $ConfigPath -Leaf): $($check.Added) lines added, $($check.Removed) removed." -ForegroundColor Green
+ Write-Host 'Review and commit the changed files: ' -NoNewline
+ Write-Host (($snapshots.Keys | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' } | Sort-Object) -join ', ')
+ }
+}
+elseif ($missing.Count -gt 0)
+{
+ Write-Host 'Dry run. Run again with -Apply to add them.'
+}
+
+if ($SummaryPath)
+{
+ # Built after -Apply so logins that failed aren't listed as added
+ $added = @($missing | Where-Object { $failed -notcontains $_.Login })
+ $summary = Format-MissingContributionMarkdown -Missing $added -Repository $Repository -NeedsHuman $manual
+ [System.IO.File]::WriteAllText($SummaryPath, $summary + "`n")
+}
+
+if ($PassThru) { $missing }
+
+if ($failed.Count -gt 0)
+{
+ Write-Error "Could not add: $($failed -join ', '). Everyone else was added." -ErrorAction Continue
+ exit 1
+}