diff --git a/.github/workflows/apply.yml b/.github/workflows/apply.yml index 6dcaca9..21b3665 100644 --- a/.github/workflows/apply.yml +++ b/.github/workflows/apply.yml @@ -47,19 +47,65 @@ jobs: GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} run: node lib/actions/find-sha-for-plan.js working-directory: scripts - apply: + classify: needs: [prepare] if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != '' + permissions: + contents: read + name: Classify + runs-on: ubuntu-latest + environment: read + outputs: + matrix: ${{ steps.classify.outputs.matrix }} + env: + TF_IN_AUTOMATION: 1 + TF_INPUT: 0 + AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} + WORKSPACES: ${{ needs.prepare.outputs.workspaces }} + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup terraform + uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0 + with: + terraform_version: 1.12.0 + terraform_wrapper: false + - name: Initialize terraform + run: terraform init + working-directory: terraform + - name: Install pnpm + uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6 + with: + version: 10 + - name: Use Node.js lts/* + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + cache: '' + - run: pnpm install --frozen-lockfile && pnpm run build + working-directory: scripts + - name: Classify workspaces + id: classify + env: + MODE: write + run: node lib/actions/classify-allow-destroy.js + working-directory: scripts + apply: + needs: [prepare, classify] + if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != '' permissions: actions: read contents: read strategy: fail-fast: false - matrix: - workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }} + matrix: ${{ fromJson(needs.classify.outputs.matrix) }} name: Apply runs-on: ubuntu-latest - environment: write + environment: ${{ matrix.environment }} env: TF_IN_AUTOMATION: 1 TF_INPUT: 0 @@ -84,6 +130,9 @@ jobs: terraform_wrapper: false - name: Initialize terraform run: terraform init + - name: Allow destroy in guarded environment + if: matrix.environment == 'write-allow-destroy' + run: cp allow_destroy_override.tf.disabled allow_destroy_override.tf - name: Download reviewed terraform plan env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -91,7 +140,7 @@ jobs: run: gh run download -n "${TF_WORKSPACE}_${SHA}.tfplan" --repo "${GITHUB_REPOSITORY}" - name: Replan merged commit run: | - terraform show -json > $TF_WORKSPACE.tfstate.json + terraform show -json > "$TF_WORKSPACE.tfstate.json" terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.merged.tfplan" -no-color - name: Compare reviewed and merged plans run: | diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml index 8b95d94..3dae02f 100644 --- a/.github/workflows/plan.yml +++ b/.github/workflows/plan.yml @@ -47,18 +47,69 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} timeout-minutes: 10 - plan: + classify: needs: [prepare] + permissions: + contents: read + pull-requests: read + name: Classify + runs-on: ubuntu-latest + environment: read + outputs: + matrix: ${{ steps.classify.outputs.matrix }} + env: + TF_IN_AUTOMATION: 1 + TF_INPUT: 0 + AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} + WORKSPACES: ${{ needs.prepare.outputs.workspaces }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - if: github.event_name == 'pull_request_target' + env: + NUMBER: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch origin "pull/${NUMBER}/head" + rm -rf github && git checkout "${SHA}" -- github + - name: Setup terraform + uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0 + with: + terraform_version: 1.12.0 + terraform_wrapper: false + - name: Initialize terraform + run: terraform init + working-directory: terraform + - name: Install pnpm + uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6 + with: + version: 10 + - name: Use Node.js lts/* + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + cache: '' + - run: pnpm install --frozen-lockfile && pnpm run build + working-directory: scripts + - name: Classify workspaces + id: classify + env: + MODE: read + run: node lib/actions/classify-allow-destroy.js + working-directory: scripts + plan: + needs: [prepare, classify] permissions: contents: read pull-requests: read strategy: fail-fast: false - matrix: - workspace: ${{ fromJson(needs.prepare.outputs.workspaces || '[]') }} + matrix: ${{ fromJson(needs.classify.outputs.matrix) }} name: Plan runs-on: ubuntu-latest - environment: read + environment: ${{ matrix.environment }} env: TF_IN_AUTOMATION: 1 TF_INPUT: 0 @@ -88,9 +139,13 @@ jobs: - name: Initialize terraform run: terraform init working-directory: terraform + - name: Allow destroy in guarded environment + if: matrix.environment == 'read-allow-destroy' + run: cp allow_destroy_override.tf.disabled allow_destroy_override.tf + working-directory: terraform - name: Plan terraform run: | - terraform show -json > $TF_WORKSPACE.tfstate.json + terraform show -json > "$TF_WORKSPACE.tfstate.json" terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color working-directory: terraform - name: Upload terraform plan diff --git a/.github/workflows/update-members.yml b/.github/workflows/update-members.yml new file mode 100644 index 0000000..6d9165a --- /dev/null +++ b/.github/workflows/update-members.yml @@ -0,0 +1,130 @@ +name: Update Members + +on: + workflow_dispatch: + inputs: + organization: + description: Organization config to update + required: true + cutoff-date: + description: Inactive before this date (YYYY-MM-DD) + required: false + limit: + description: Remove up to this many longest-inactive members + required: false + ignore: + description: Comma, space, or newline separated usernames to ignore + required: false + only: + description: Comma, space, or newline separated usernames to target + required: false + public-repo-access: + description: Whether to retain effective access to public repositories + required: true + default: retain + type: choice + options: + - retain + - remove + organization-membership: + description: Whether selected users remain organization members + required: true + default: keep + type: choice + options: + - keep + - remove + +defaults: + run: + shell: bash + +jobs: + update: + permissions: + contents: write + pull-requests: write + name: Update members + runs-on: ubuntu-latest + environment: push + env: + GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }} + GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', github.event.inputs.organization)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }} + GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} + TF_WORKSPACE: ${{ github.event.inputs.organization }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install pnpm + uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6 + with: + version: 10 + - name: Use Node.js lts/* + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + cache: "" + - name: Initialize scripts + run: pnpm install --frozen-lockfile && pnpm run build + working-directory: scripts + - name: Update members + id: update + run: node lib/actions/update-members.js + working-directory: scripts + env: + CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }} + LIMIT: ${{ github.event.inputs.limit }} + IGNORE: ${{ github.event.inputs.ignore }} + ONLY: ${{ github.event.inputs.only }} + PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }} + ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }} + - name: Check if organization config was modified + id: config-modified + env: + ORGANIZATION: ${{ github.event.inputs.organization }} + run: | + if [ -z "$(git status --porcelain -- "github/${ORGANIZATION}.yml")" ]; then + echo "this=false" >> $GITHUB_OUTPUT + else + echo "this=true" >> $GITHUB_OUTPUT + fi + - uses: ./.github/actions/git-config-user + if: steps.config-modified.outputs.this == 'true' + - name: Create draft pull request + if: steps.config-modified.outputs.this == 'true' + env: + ORGANIZATION: ${{ github.event.inputs.organization }} + CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }} + LIMIT: ${{ github.event.inputs.limit }} + IGNORE: ${{ github.event.inputs.ignore }} + ONLY: ${{ github.event.inputs.only }} + PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }} + ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }} + AFFECTED_USERS: ${{ steps.update.outputs.affected-users }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + branch="update-members-${ORGANIZATION}-${GITHUB_RUN_ID}" + body="$(mktemp)" + { + echo 'The changes in this PR were made by a bot. Please review carefully.' + echo + echo "Organization: ${ORGANIZATION}" + echo "Cutoff date: ${CUTOFF_DATE:-not set}" + echo "Limit: ${LIMIT:-not set}" + echo "Ignore: ${IGNORE:-not set}" + echo "Only: ${ONLY:-not set}" + echo "Public repo access: ${PUBLIC_REPO_ACCESS}" + echo "Organization membership: ${ORGANIZATION_MEMBERSHIP}" + echo "Affected users: ${AFFECTED_USERS:-none}" + } > "${body}" + + git checkout -B "${branch}" + git add "github/${ORGANIZATION}.yml" + git commit -m "update-members@${GITHUB_RUN_ID} ${ORGANIZATION}" + git push origin "${branch}" --force + gh pr create \ + --draft \ + --title "Update members for ${ORGANIZATION}" \ + --body-file "${body}" \ + --head "${branch}" \ + --base "${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 34adc7e..356c12f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ crash.log # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan # example: *tfplan* *.tfplan + +# Enabled only by guarded allow-destroy workflow jobs +terraform/allow_destroy_override.tf diff --git a/CHANGELOG.md b/CHANGELOG.md index 5549e1d..59bfc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- allow-destroy workflow environments for guarded repository and membership deletion plans/applies - shared action for adding a collaborator to all repositories - clean workflow which removes resources from state - information on how to handle private GitHub Management repository diff --git a/docs/ABOUT.md b/docs/ABOUT.md index c7a44ef..d442054 100644 --- a/docs/ABOUT.md +++ b/docs/ABOUT.md @@ -27,6 +27,8 @@ The workflow for introducing changes to GitHub via YAML configuration file is as 1. Review the plan. 1. Merge the PR and wait for the GitHub Action workflow triggered on pushes to the default branch to apply it. +Plans that remove managed repositories or organization memberships are routed through the `read-allow-destroy` GitHub Actions environment before the PR plan is created. The matching apply is routed through `write-allow-destroy`. These environments must be protected separately from the normal `read` and `write` environments. Outside the allow-destroy environments, Terraform keeps `prevent_destroy` enabled for repository and membership resources. + Neither creating the terraform plan nor applying it refreshes the underlying terraform state i.e. going through this workflow does **NOT** ask GitHub if the actual GitHub configuration state has changed. This makes the workflow fast and rate limit friendly because the number of requests to GitHub is minimised. This can result in the plan failing to be applied, e.g. if the underlying resource has been deleted. This assumes that YAML configuration is the main source of truth for GitHub configuration state. The plans that are created during the PR GitHub Action workflow are compared against plans regenerated from the merged commit before applying. The workflow for synchronising the current GitHub configuration state with YAML configuration file is as follows: diff --git a/docs/SETUP.md b/docs/SETUP.md index 85cc586..be67265 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -116,7 +116,12 @@ ## GitHub Actions Environments and Secrets -- [ ] Create GitHub Actions environments named `read`, `write`, and `push`, and configure protection rules such as required reviewers. Workflows that read organization state reference `read`; workflows that write organization state reference `write`; workflows that push generated changes to the GitHub Management repository reference `push`. +- [ ] Create GitHub Actions environments named `read`, `read-allow-destroy`, `write`, `write-allow-destroy`, and `push`, and configure protection rules such as required reviewers. Workflows that read organization state reference `read`; workflows that write organization state reference `write`; workflows that push generated changes to the GitHub Management repository reference `push`. +- [ ] Configure `read-allow-destroy` and `write-allow-destroy` with stricter protection rules for repository and membership deletion plans/applies: + - [ ] Require reviewers + - [ ] Prevent self-review + - [ ] Restrict deployment branches to `master` + - [ ] Disable administrator bypass where available - [ ] [Create encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-organization) for the GitHub organization and allow the repository to access them (\*replace `$GITHUB_ORGANIZATION_NAME` with the GitHub organization name) - *these secrets are read by the GitHub Action workflows* - [ ] Go to `https://github.com/organizations/$GITHUB_ORGANIZATION_NAME/settings/apps/$GITHUB_APP_NAME` and copy the `App ID` - [ ] `RO_GITHUB_APP_ID` @@ -150,6 +155,11 @@ - [ ] Follow [How to synchronize GitHub Management with GitHub?](HOWTOS.md#synchronize-github-management-with-github) to commit the terraform lock and initialize terraform state +## Member Update Workflows + +- [ ] Use `Update Members` to create a draft PR that removes selected members from teams and repository collaborators in `github/$ORGANIZATION_NAME.yml`. The workflow requires either `cutoff-date` or `only`, supports `ignore` and `limit`, can retain effective public repository access by converting that access to direct public repository collaborators, and can optionally remove selected users from organization membership in the YAML config. +- [ ] Review and merge the draft PR through the normal GitHub Management PR flow. + ## GitHub Management Repository Protections *NOTE*: Advanced users might have to skip/adjust this step if they are not managing some of the arguments/attributes mentioned here with GitHub Management. diff --git a/scripts/__tests__/actions/access-summary.test.ts b/scripts/__tests__/actions/access-summary.test.ts new file mode 100644 index 0000000..14e5854 --- /dev/null +++ b/scripts/__tests__/actions/access-summary.test.ts @@ -0,0 +1,138 @@ +import 'reflect-metadata' + +import assert from 'node:assert' +import {describe, it} from 'node:test' +import {Config} from '../../src/yaml/config.js' +import {State} from '../../src/terraform/state.js' +import { + categorizeAccessSummary, + getAccessSummaryFrom +} from '../../src/actions/shared/access-summary.js' +import { + describeAccessChanges, + describeAccessReport +} from '../../src/actions/shared/describe-access-changes.js' +import {StateSchema} from '../../src/terraform/schema.js' + +describe('access summaries', () => { + it('categorizes post-change users', () => { + const config = new Config(` +members: + member: + - alice + - carol + - dave + - kept # KEEP: manual exception +repositories: + private-repo: + collaborators: + pull: + - outside + visibility: private + public-repo: + collaborators: + pull: + - alice + visibility: public + team-only-repo: + teams: + push: + - guests + visibility: public + team-repo: + teams: + push: + - maintainers + visibility: public +teams: + guests: + members: + member: + - team-only-non-member + maintainers: + members: + member: + - dave +`) + + const summary = getAccessSummaryFrom(config) + const categories = categorizeAccessSummary(summary) + + assert.equal(summary.outside.isMember, false) + assert.equal(summary.outside.isOutsideCollaborator, true) + assert.equal(summary['team-only-non-member'].isMember, false) + assert.equal(summary['team-only-non-member'].isOutsideCollaborator, false) + assert.deepEqual(categories.outsideCollaborators, ['outside']) + assert.deepEqual(categories.potentialOutsideCollaborators, ['alice']) + assert.deepEqual(categories.potentialNoMembers, ['carol']) + assert.deepEqual(categories.anyOtherMembers, ['dave', 'kept']) + }) + + it('annotates repository visibility in access changes and summaries', () => { + const state = new State( + JSON.stringify({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + index: 'alice', + address: 'github_membership.this["alice"]', + type: 'github_membership', + values: { + username: 'alice', + role: 'member' + } + }, + { + mode: 'managed', + index: 'public-repo', + address: 'github_repository.this["public-repo"]', + type: 'github_repository', + values: { + name: 'public-repo', + visibility: 'public' + } + }, + { + mode: 'managed', + index: 'public-repo:alice', + address: + 'github_repository_collaborator.this["public-repo:alice"]', + type: 'github_repository_collaborator', + values: { + repository: 'public-repo', + username: 'alice', + permission: 'pull' + } + } + ] + } + } + } satisfies StateSchema) + ) + const config = new Config(` +members: + member: + - alice +repositories: + public-repo: + collaborators: + push: + - alice + visibility: public +`) + + const changes = describeAccessChanges(state, config) + const report = describeAccessReport(state, config) + + assert.match( + changes, + /will have the permission to public-repo \(public\) change from pull to push/ + ) + assert.match(report, /Potential outside collaborators<\/summary>/) + assert.match(report, /Affected users: alice/) + assert.match(report, /User alice \(member\):/) + assert.match(report, /has push permission to public-repo \(public\)/) + }) +}) diff --git a/scripts/__tests__/actions/classify-allow-destroy.test.ts b/scripts/__tests__/actions/classify-allow-destroy.test.ts new file mode 100644 index 0000000..974911b --- /dev/null +++ b/scripts/__tests__/actions/classify-allow-destroy.test.ts @@ -0,0 +1,181 @@ +import 'reflect-metadata' + +import {describe, it} from 'node:test' +import assert from 'node:assert' +import { + getEnvironment, + hasAllowDestroyChange +} from '../../src/actions/classify-allow-destroy.js' +import {Config} from '../../src/yaml/config.js' +import {State} from '../../src/terraform/state.js' +import {Locals} from '../../src/terraform/locals.js' + +function setManagedResourceTypes(resourceTypes: string[]): void { + Locals.locals = { + resource_types: resourceTypes, + ignore: { + repositories: [], + teams: [], + users: [] + } + } +} + +function state(source: object): State { + return new State(JSON.stringify(source)) +} + +describe('allow destroy classification', () => { + it('routes repository deletes to allow-destroy environments', async () => { + setManagedResourceTypes(['github_repository', 'github_membership']) + + const allowDestroy = await hasAllowDestroyChange( + new Config(` +repositories: + kept: {} +`), + state({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + type: 'github_repository', + values: {name: 'kept'} + }, + { + mode: 'managed', + type: 'github_repository', + values: {name: 'removed'} + } + ] + } + } + }) + ) + + assert.equal(allowDestroy, true) + assert.equal(getEnvironment('read', allowDestroy), 'read-allow-destroy') + assert.equal(getEnvironment('write', allowDestroy), 'write-allow-destroy') + }) + + it('routes membership deletes to allow-destroy environments', async () => { + setManagedResourceTypes(['github_repository', 'github_membership']) + + const allowDestroy = await hasAllowDestroyChange( + new Config(` +members: + admin: + - kept +`), + state({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + type: 'github_membership', + values: {username: 'kept', role: 'admin'} + }, + { + mode: 'managed', + type: 'github_membership', + values: {username: 'removed', role: 'admin'} + } + ] + } + } + }) + ) + + assert.equal(allowDestroy, true) + }) + + it('keeps repository and membership updates in normal environments', async () => { + setManagedResourceTypes(['github_repository', 'github_membership']) + + const allowDestroy = await hasAllowDestroyChange( + new Config(` +members: + member: + - octocat +repositories: + github: + description: updated +`), + state({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + type: 'github_repository', + values: {name: 'github', description: 'old'} + }, + { + mode: 'managed', + type: 'github_membership', + values: {username: 'octocat', role: 'member'} + } + ] + } + } + }) + ) + + assert.equal(allowDestroy, false) + assert.equal(getEnvironment('read', allowDestroy), 'read') + assert.equal(getEnvironment('write', allowDestroy), 'write') + }) + + it('ignores deletes for other resource types', async () => { + setManagedResourceTypes(['github_team']) + + const allowDestroy = await hasAllowDestroyChange( + new Config('{}'), + state({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + type: 'github_team', + values: {name: 'removed'} + } + ] + } + } + }) + ) + + assert.equal(allowDestroy, false) + }) + + it('ignores repository and membership types that are not managed', async () => { + setManagedResourceTypes([]) + + const allowDestroy = await hasAllowDestroyChange( + new Config('{}'), + state({ + values: { + root_module: { + resources: [ + { + mode: 'managed', + type: 'github_repository', + values: {name: 'removed'} + }, + { + mode: 'managed', + type: 'github_membership', + values: {username: 'removed', role: 'member'} + } + ] + } + } + }) + ) + + assert.equal(allowDestroy, false) + }) +}) diff --git a/scripts/__tests__/actions/update-members.test.ts b/scripts/__tests__/actions/update-members.test.ts new file mode 100644 index 0000000..b115ef7 --- /dev/null +++ b/scripts/__tests__/actions/update-members.test.ts @@ -0,0 +1,183 @@ +import 'reflect-metadata' + +import assert from 'node:assert' +import {describe, it} from 'node:test' +import {Config} from '../../src/yaml/config.js' +import { + parseCutoffDate, + parseLimit, + parseOrganizationMembership, + selectMembersForUpdate, + updateMembersConfig +} from '../../src/actions/update-members.js' +import {Member} from '../../src/resources/member.js' +import {TeamMember} from '../../src/resources/team-member.js' +import {RepositoryCollaborator} from '../../src/resources/repository-collaborator.js' + +describe('update members', () => { + it('requires cutoff date or only list', () => { + const config = new Config('members:\n member:\n - alice\n') + + assert.throws(() => + selectMembersForUpdate(config, [], { + ignore: [], + only: [], + publicRepoAccess: 'retain', + organizationMembership: 'keep' + }) + ) + }) + + it('validates workflow inputs', () => { + assert.equal( + parseCutoffDate('2025-01-02')?.toISOString(), + '2025-01-02T00:00:00.000Z' + ) + assert.equal(parseLimit('2'), 2) + assert.equal(parseOrganizationMembership('keep'), 'keep') + assert.equal(parseOrganizationMembership('remove'), 'remove') + assert.throws(() => parseCutoffDate('01-02-2025')) + assert.throws(() => parseLimit('0')) + assert.throws(() => parseOrganizationMembership('invalid')) + }) + + it('selects inactive members with only, ignore, limit, and KEEP handling', () => { + const config = new Config(` +members: + member: + - active + - ignored + - kept # KEEP: manual exception + - manual + - never-active + - old +`) + + const selected = selectMembersForUpdate( + config, + [ + {username: 'active', latestActivity: new Date('2025-01-01T00:00:00Z')}, + {username: 'old', latestActivity: new Date('2023-01-01T00:00:00Z')} + ], + { + cutoffDate: new Date('2024-01-01T00:00:00Z'), + limit: 2, + ignore: ['ignored'], + only: ['active', 'ignored', 'kept', 'manual', 'never-active', 'old'], + publicRepoAccess: 'retain', + organizationMembership: 'keep' + } + ) + + assert.deepEqual(selected, ['manual', 'never-active']) + }) + + it('retains effective public repository access when configured', () => { + const config = new Config(` +members: + member: + - alice +repositories: + private-repo: + collaborators: + pull: + - alice + teams: + admin: + - maintainers + visibility: private + public-repo: + collaborators: + pull: + - alice + teams: + push: + - maintainers + visibility: public +teams: + maintainers: + members: + member: + - alice +`) + + updateMembersConfig(config, ['alice'], 'retain', 'keep') + + assert.equal( + config + .getResources(TeamMember) + .some(teamMember => teamMember.username === 'alice'), + false + ) + assert.equal( + config + .getResources(RepositoryCollaborator) + .some( + collaborator => + collaborator.username === 'alice' && + collaborator.repository === 'private-repo' + ), + false + ) + + const publicCollaborator = config + .getResources(RepositoryCollaborator) + .find( + collaborator => + collaborator.username === 'alice' && + collaborator.repository === 'public-repo' + ) + + assert.equal(publicCollaborator?.permission, 'push') + }) + + it('removes public repository access when configured', () => { + const config = new Config(` +members: + member: + - alice +repositories: + public-repo: + collaborators: + pull: + - alice + teams: + push: + - maintainers + visibility: public +teams: + maintainers: + members: + member: + - alice +`) + + updateMembersConfig(config, ['alice'], 'remove', 'keep') + + assert.equal( + config + .getResources(RepositoryCollaborator) + .some(collaborator => collaborator.username === 'alice'), + false + ) + }) + + it('removes organization membership when configured', () => { + const config = new Config(` +members: + member: + - alice + - bob +repositories: + public-repo: + visibility: public +`) + + updateMembersConfig(config, ['alice'], 'remove', 'remove') + + assert.deepEqual( + config.getResources(Member).map(member => member.username), + ['bob'] + ) + }) +}) diff --git a/scripts/src/actions/classify-allow-destroy.ts b/scripts/src/actions/classify-allow-destroy.ts new file mode 100644 index 0000000..22fdc74 --- /dev/null +++ b/scripts/src/actions/classify-allow-destroy.ts @@ -0,0 +1,120 @@ +import * as core from '@actions/core' +import {pathToFileURL} from 'url' +import {Config} from '../yaml/config.js' +import {State} from '../terraform/state.js' +import { + Resource, + ResourceConstructor, + ResourceConstructors +} from '../resources/resource.js' +import {Member} from '../resources/member.js' +import {Repository} from '../resources/repository.js' + +const ALLOW_DESTROY_RESOURCE_CLASSES: ResourceConstructor[] = [ + Member, + Repository +] + +type Mode = 'read' | 'write' + +type Matrix = { + include: { + workspace: string + environment: string + }[] +} + +function getStateAddress(resource: Resource): string { + return resource.getStateAddress().toLowerCase() +} + +function hasMissingResources( + config: Config, + state: State, + resourceClass: ResourceConstructor +): boolean { + const desiredAddresses = new Set( + config.getResources(resourceClass).map(getStateAddress) + ) + return state + .getResources(resourceClass) + .some(resource => !desiredAddresses.has(getStateAddress(resource))) +} + +export async function hasAllowDestroyChange( + config: Config, + state: State +): Promise { + for (const resourceClass of ALLOW_DESTROY_RESOURCE_CLASSES) { + if ( + ResourceConstructors.includes(resourceClass) && + !(await state.isIgnored(resourceClass)) && + hasMissingResources(config, state, resourceClass) + ) { + return true + } + } + + return false +} + +export function getEnvironment(mode: Mode, allowDestroy: boolean): string { + return allowDestroy ? `${mode}-allow-destroy` : mode +} + +export async function classifyWorkspaces({ + mode, + workspaces, + githubDir +}: { + mode: Mode + workspaces: string[] + githubDir: string +}): Promise { + const include = [] + const originalWorkspace = process.env.TF_WORKSPACE + + try { + for (const workspace of workspaces) { + process.env.TF_WORKSPACE = workspace + const config = Config.FromPath(`${githubDir}/${workspace}.yml`) + const state = await State.New() + const allowDestroy = await hasAllowDestroyChange(config, state) + const environment = getEnvironment(mode, allowDestroy) + core.info(`${workspace}: ${environment}`) + include.push({workspace, environment}) + } + } finally { + if (originalWorkspace === undefined) { + delete process.env.TF_WORKSPACE + } else { + process.env.TF_WORKSPACE = originalWorkspace + } + } + + return {include} +} + +async function run(): Promise { + const mode = (process.env.MODE ?? 'read') as Mode + if (mode !== 'read' && mode !== 'write') { + throw new Error(`MODE must be one of "read" or "write", got "${mode}"`) + } + + const workspaces = JSON.parse(process.env.WORKSPACES?.trim() || '[]') + if (!Array.isArray(workspaces)) { + throw new Error('WORKSPACES must be a JSON array') + } + + const matrix = await classifyWorkspaces({ + mode, + workspaces, + githubDir: process.env.GITHUB_DIR ?? '../github' + }) + + core.setOutput('matrix', JSON.stringify(matrix)) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch(error => core.setFailed(error)) +} diff --git a/scripts/src/actions/shared/access-summary.ts b/scripts/src/actions/shared/access-summary.ts new file mode 100644 index 0000000..9561a38 --- /dev/null +++ b/scripts/src/actions/shared/access-summary.ts @@ -0,0 +1,311 @@ +import {Config} from '../../yaml/config.js' +import {State} from '../../terraform/state.js' +import {RepositoryCollaborator} from '../../resources/repository-collaborator.js' +import {Member} from '../../resources/member.js' +import {TeamMember} from '../../resources/team-member.js' +import {RepositoryTeam} from '../../resources/repository-team.js' +import {Repository, Visibility} from '../../resources/repository.js' + +export type RepositoryAccess = { + permission: string + visibility: Visibility +} + +export type UserAccess = { + role?: string + isMember: boolean + isOutsideCollaborator: boolean + repositories: Record + directRepositories: Record + teams: string[] + hasKeepComment: boolean +} + +export type AccessSummary = Record + +export type AccessCategory = + | 'outsideCollaborators' + | 'potentialOutsideCollaborators' + | 'potentialNoMembers' + | 'anyOtherMembers' + +export type AccessCategories = Record + +export const permissions = ['admin', 'maintain', 'push', 'triage', 'pull'] + +export function betterPermission(current: string, next: string): string { + return permissions.indexOf(next) < permissions.indexOf(current) + ? next + : current +} + +export function parseUserList(source?: string): string[] { + return Array.from( + new Set( + (source || '') + .split(/[\s,]+/) + .map(username => username.trim().toLowerCase()) + .filter(username => username !== '') + ) + ).sort() +} + +export function hasKeepComment(config: Config, member: Member): boolean { + const node = config.document.getIn( + member.getSchemaPath(config.get()), + true + ) as {comment?: string} | undefined + return node?.comment?.includes('KEEP:') ?? false +} + +export function getAccessSummaryFrom(source: State | Config): AccessSummary { + const members = source.getResources(Member) + const teamMembers = source.getResources(TeamMember) + const teamRepositories = source.getResources(RepositoryTeam) + const repositoryCollaborators = source.getResources(RepositoryCollaborator) + const repositories = source.getResources(Repository) + + const repositoryVisibility = new Map( + repositories.map(repository => [ + repository.name.toLowerCase(), + repository.visibility ?? Visibility.Private + ]) + ) + const archivedRepositories = repositories + .filter(repository => repository.archived) + .map(repository => repository.name.toLowerCase()) + + const usernames = new Set([ + ...members.map(member => member.username.toLowerCase()), + ...teamMembers.map(teamMember => teamMember.username.toLowerCase()), + ...repositoryCollaborators.map(collaborator => + collaborator.username.toLowerCase() + ) + ]) + + const accessSummary: AccessSummary = {} + + for (const username of Array.from(usernames).sort()) { + const member = members.find( + candidate => candidate.username.toLowerCase() === username + ) + const role = member?.role + const teams = teamMembers + .filter(teamMember => teamMember.username.toLowerCase() === username) + .map(teamMember => teamMember.team.toLowerCase()) + .sort() + const repositoryCollaborator = repositoryCollaborators + .filter(collaborator => collaborator.username.toLowerCase() === username) + .filter( + collaborator => + !archivedRepositories.includes(collaborator.repository.toLowerCase()) + ) + const teamRepository = teamRepositories + .filter(repository => teams.includes(repository.team.toLowerCase())) + .filter( + repository => + !archivedRepositories.includes(repository.repository.toLowerCase()) + ) + + const repositories: Record = {} + const directRepositories: Record = {} + + for (const rc of repositoryCollaborator) { + const repository = rc.repository.toLowerCase() + const access = { + permission: rc.permission, + visibility: repositoryVisibility.get(repository) ?? Visibility.Private + } + directRepositories[repository] = directRepositories[repository] + ? { + ...access, + permission: betterPermission( + directRepositories[repository].permission, + access.permission + ) + } + : access + repositories[repository] = repositories[repository] + ? { + ...access, + permission: betterPermission( + repositories[repository].permission, + access.permission + ) + } + : access + } + + for (const tr of teamRepository) { + const repository = tr.repository.toLowerCase() + const access = { + permission: tr.permission, + visibility: repositoryVisibility.get(repository) ?? Visibility.Private + } + repositories[repository] = repositories[repository] + ? { + ...access, + permission: betterPermission( + repositories[repository].permission, + access.permission + ) + } + : access + } + + const hasKeep = + source instanceof Config && member !== undefined + ? hasKeepComment(source, member) + : false + + const isMember = role !== undefined + const isOutsideCollaborator = + !isMember && Object.keys(directRepositories).length > 0 + + if (isMember || isOutsideCollaborator || teams.length > 0) { + accessSummary[username] = { + role, + isMember, + isOutsideCollaborator, + repositories, + directRepositories, + teams, + hasKeepComment: hasKeep + } + } + } + + return deepSort(accessSummary) +} + +export function getComparableAccessSummary(source: State | Config): Record< + string, + { + role?: string + repositories: Record + } +> { + return Object.fromEntries( + Object.entries(getAccessSummaryFrom(source)).map(([username, access]) => [ + username, + { + role: access.role, + repositories: Object.fromEntries( + Object.entries(access.repositories).map(([repository, value]) => [ + repository, + {permission: value.permission} + ]) + ) + } + ]) + ) +} + +export function categorizeAccessSummary( + summary: AccessSummary +): AccessCategories { + const categories: AccessCategories = { + outsideCollaborators: [], + potentialOutsideCollaborators: [], + potentialNoMembers: [], + anyOtherMembers: [] + } + + for (const [username, access] of Object.entries(summary)) { + const repositories = Object.values(access.repositories) + const directRepositories = Object.values(access.directRepositories) + if (access.isOutsideCollaborator) { + categories.outsideCollaborators.push(username) + } else if ( + access.isMember && + !access.hasKeepComment && + access.teams.length === 0 && + repositories.length > 0 && + repositories.every( + repository => repository.visibility === Visibility.Public + ) + ) { + categories.potentialOutsideCollaborators.push(username) + } else if ( + access.isMember && + !access.hasKeepComment && + directRepositories.length === 0 && + access.teams.length === 0 + ) { + categories.potentialNoMembers.push(username) + } else if (access.isMember) { + categories.anyOtherMembers.push(username) + } + } + + for (const users of Object.values(categories)) { + users.sort() + } + + return categories +} + +export function formatRepositoryAccess( + repository: string, + access: RepositoryAccess +): string { + return `${repository} (${access.visibility})` +} + +export function formatAccessSummarySection( + title: string, + users: string[], + summary: AccessSummary +): string { + const lines = [ + `
${title}`, + '', + `Affected users: ${users.length > 0 ? users.join(', ') : 'none'}`, + '', + '```' + ] + + if (users.length === 0) { + lines.push('No users in this section') + } + + for (const username of users) { + const access = summary[username] + const kind = access.isOutsideCollaborator + ? 'outside collaborator' + : 'member' + lines.push(`User ${username} (${kind}):`) + const repositories = Object.entries(access.repositories) + if (repositories.length === 0) { + lines.push(' - has no repository access') + } else { + for (const [repository, repositoryAccess] of repositories) { + lines.push( + ` - has ${repositoryAccess.permission} permission to ${formatRepositoryAccess( + repository, + repositoryAccess + )}` + ) + } + } + } + + lines.push('```', '', '
') + return lines.join('\n') +} + +// deep sort object +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function deepSort(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(deepSort) + } else if (obj !== null && typeof obj === 'object') { + const sorted: Record = {} + for (const key of Object.keys(obj).sort()) { + sorted[key] = deepSort(obj[key]) + } + return sorted + } else { + return obj + } +} diff --git a/scripts/src/actions/shared/describe-access-changes.ts b/scripts/src/actions/shared/describe-access-changes.ts index df76407..93f602d 100644 --- a/scripts/src/actions/shared/describe-access-changes.ts +++ b/scripts/src/actions/shared/describe-access-changes.ts @@ -1,128 +1,87 @@ import {Config} from '../../yaml/config.js' import {State} from '../../terraform/state.js' -import {RepositoryCollaborator} from '../../resources/repository-collaborator.js' -import {Member} from '../../resources/member.js' -import {TeamMember} from '../../resources/team-member.js' -import {RepositoryTeam} from '../../resources/repository-team.js' import diff from 'deep-diff' import * as core from '@actions/core' -import {Repository} from '../../resources/repository.js' - -type AccessSummary = Record< - string, - { - role?: string - repositories: Record - } -> - -function getAccessSummaryFrom(source: State | Config): AccessSummary { - const members = source.getResources(Member) - const teamMembers = source.getResources(TeamMember) - const teamRepositories = source.getResources(RepositoryTeam) - const repositoryCollaborators = source.getResources(RepositoryCollaborator) - - const archivedRepositories = source - .getResources(Repository) - .filter(repository => repository.archived) - .map(repository => repository.name.toLowerCase()) - - const usernames = new Set([ - ...members.map(member => member.username.toLowerCase()), - ...repositoryCollaborators.map(collaborator => - collaborator.username.toLowerCase() - ) - ]) - - const accessSummary: AccessSummary = {} - const permissions = ['admin', 'maintain', 'push', 'triage', 'pull'] - - for (const username of usernames) { - const role = members.find( - member => member.username.toLowerCase() === username - )?.role - const teams = teamMembers - .filter(teamMember => teamMember.username.toLowerCase() === username) - .map(teamMember => teamMember.team.toLowerCase()) - const repositoryCollaborator = repositoryCollaborators - .filter(collaborator => collaborator.username.toLowerCase() === username) - .filter( - collaborator => - !archivedRepositories.includes(collaborator.repository.toLowerCase()) - ) - const teamRepository = teamRepositories - .filter(repository => teams.includes(repository.team.toLowerCase())) - .filter( - repository => - !archivedRepositories.includes(repository.repository.toLowerCase()) - ) - - const repositories: Record = {} - - for (const rc of repositoryCollaborator) { - const repository = rc.repository.toLowerCase() - repositories[repository] = repositories[repository] ?? {} - if ( - !repositories[repository].permission || - permissions.indexOf(rc.permission) < - permissions.indexOf(repositories[repository].permission) - ) { - repositories[repository].permission = rc.permission - } - } - - for (const tr of teamRepository) { - const repository = tr.repository.toLowerCase() - repositories[repository] = repositories[repository] ?? {} - if ( - !repositories[repository].permission || - permissions.indexOf(tr.permission) < - permissions.indexOf(repositories[repository].permission) - ) { - repositories[repository].permission = tr.permission - } - } - - if (role !== undefined || Object.keys(repositories).length > 0) { - accessSummary[username] = { - role, - repositories - } - } - } - - return deepSort(accessSummary) -} - -// deep sort object -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function deepSort(obj: any): any { - if (Array.isArray(obj)) { - return obj.map(deepSort) - } else if (typeof obj === 'object') { - const sorted: Record = {} - for (const key of Object.keys(obj).sort()) { - sorted[key] = deepSort(obj[key]) - } - return sorted - } else { - return obj - } +import { + categorizeAccessSummary, + formatAccessSummarySection, + formatRepositoryAccess, + getAccessSummaryFrom, + getComparableAccessSummary, + RepositoryAccess +} from './access-summary.js' + +function repositoryLabel( + repository: string, + afterSummary: ReturnType, + beforeSummary: ReturnType +): string { + const access = + Object.values(afterSummary) + .map(user => user.repositories[repository]) + .find(Boolean) ?? + Object.values(beforeSummary) + .map(user => user.repositories[repository]) + .find(Boolean) ?? + ({permission: 'pull', visibility: 'private'} as RepositoryAccess) + + return formatRepositoryAccess(repository, access) } export async function runDescribeAccessChanges(): Promise { const state = await State.New() const config = Config.FromPath() - return await describeAccessChanges(state, config) + return describeAccessReport(state, config) } -export async function describeAccessChanges( - state: State, - config: Config -): Promise { - const before = getAccessSummaryFrom(state) +export function describeAccessReport(state: State, config: Config): string { + const accessChangesDescription = describeAccessChanges(state, config) const after = getAccessSummaryFrom(config) + const categories = categorizeAccessSummary(after) + + return [ + 'The following access changes will be introduced as a result of applying the plan:', + '', + '
Access Changes', + '', + '```', + accessChangesDescription, + '```', + '', + '
', + '', + formatAccessSummarySection( + 'Outside collaborators', + categories.outsideCollaborators, + after + ), + '', + formatAccessSummarySection( + 'Potential outside collaborators', + categories.potentialOutsideCollaborators, + after + ), + '', + formatAccessSummarySection( + 'Potential no members', + categories.potentialNoMembers, + after + ), + '', + formatAccessSummarySection( + 'Any other members', + categories.anyOtherMembers, + after + ) + ].join('\n') +} + +export function describeAccessChanges(state: State, config: Config): string { + const before = getComparableAccessSummary(state) + const after = getComparableAccessSummary(config) + const beforeWithVisibility = getAccessSummaryFrom(state) + const afterWithVisibility = getAccessSummaryFrom(config) core.info(JSON.stringify({before, after}, null, 2)) @@ -136,11 +95,10 @@ export async function describeAccessChanges( throw new Error(`Change ${change.kind} has no path`) } const path = change.path - changesByUser[path[0]] = changesByUser[path[0]] || [] - changesByUser[path[0]].push(change) + changesByUser[String(path[0])] = changesByUser[String(path[0])] || [] + changesByUser[String(path[0])].push(change) } - // iterate over changesByUser and build a description const lines = [] for (const [username, userChanges] of Object.entries(changesByUser)) { lines.push(`User ${username}:`) @@ -157,15 +115,20 @@ export async function describeAccessChanges( ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` ) } else if (change.rhs === undefined) { - lines.push(` - will leave the organization`) + lines.push(' - will leave the organization') } else { lines.push( ` - will have the role in the organization change from ${change.lhs} to ${change.rhs}` ) } } else { + const repository = String(path[2]) lines.push( - ` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}` + ` - will have the permission to ${repositoryLabel( + repository, + afterWithVisibility, + beforeWithVisibility + )} change from ${change.lhs} to ${change.rhs}` ) } break @@ -173,7 +136,7 @@ export async function describeAccessChanges( if (path.length === 1) { if (change.rhs.role) { lines.push( - ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` + ` - will join the organization as a ${change.rhs.role} (remind them to accept the email invitation)` ) } if (change.rhs.repositories) { @@ -185,20 +148,29 @@ export async function describeAccessChanges( repositories )) { lines.push( - ` - will gain ${permission} permission to ${repository}` + ` - will gain ${permission} permission to ${repositoryLabel( + repository, + afterWithVisibility, + beforeWithVisibility + )}` ) } } } else { + const repository = String(path[2]) lines.push( - ` - will gain ${change.rhs.permission} permission to ${path[2]}` + ` - will gain ${change.rhs.permission} permission to ${repositoryLabel( + repository, + afterWithVisibility, + beforeWithVisibility + )}` ) } break case 'D': if (path.length === 1) { if (change.lhs.role) { - lines.push(` - will leave the organization`) + lines.push(' - will leave the organization') } if (change.lhs.repositories) { const repositories = change.lhs.repositories as unknown as Record< @@ -209,13 +181,22 @@ export async function describeAccessChanges( repositories )) { lines.push( - ` - will lose ${permission} permission to ${repository}` + ` - will lose ${permission} permission to ${repositoryLabel( + repository, + afterWithVisibility, + beforeWithVisibility + )}` ) } } } else { + const repository = String(path[2]) lines.push( - ` - will lose ${change.lhs.permission} permission to ${path[2]}` + ` - will lose ${change.lhs.permission} permission to ${repositoryLabel( + repository, + afterWithVisibility, + beforeWithVisibility + )}` ) } break diff --git a/scripts/src/actions/update-members.ts b/scripts/src/actions/update-members.ts new file mode 100644 index 0000000..77613a4 --- /dev/null +++ b/scripts/src/actions/update-members.ts @@ -0,0 +1,328 @@ +import 'reflect-metadata' +import * as core from '@actions/core' +import {pathToFileURL} from 'url' +import {Config} from '../yaml/config.js' +import {Member} from '../resources/member.js' +import {Repository, Visibility} from '../resources/repository.js' +import { + Permission as RepositoryCollaboratorPermission, + RepositoryCollaborator +} from '../resources/repository-collaborator.js' +import {TeamMember} from '../resources/team-member.js' +import {GitHub} from '../github.js' +import { + betterPermission, + getAccessSummaryFrom, + hasKeepComment, + parseUserList +} from './shared/access-summary.js' + +export type PublicRepoAccess = 'retain' | 'remove' +export type OrganizationMembership = 'keep' | 'remove' + +export type MemberActivity = { + username: string + latestActivity?: Date +} + +export type UpdateMembersOptions = { + cutoffDate?: Date + limit?: number + ignore: string[] + only: string[] + publicRepoAccess: PublicRepoAccess + organizationMembership: OrganizationMembership +} + +type ActivityRecord = { + username: string + createdAt: Date +} + +export function parseCutoffDate(source?: string): Date | undefined { + if (source === undefined || source.trim() === '') { + return undefined + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(source)) { + throw new Error('cutoff-date must use YYYY-MM-DD format') + } + const date = new Date(`${source}T00:00:00.000Z`) + if (Number.isNaN(date.valueOf())) { + throw new Error('cutoff-date must be a valid date') + } + return date +} + +export function parseLimit(source?: string): number | undefined { + if (source === undefined || source.trim() === '') { + return undefined + } + const limit = Number(source) + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('limit must be a positive integer') + } + return limit +} + +export function parsePublicRepoAccess(source?: string): PublicRepoAccess { + if (source === 'retain' || source === 'remove') { + return source + } + throw new Error('public-repo-access must be retain or remove') +} + +export function parseOrganizationMembership( + source?: string +): OrganizationMembership { + if (source === 'keep' || source === 'remove') { + return source + } + throw new Error('organization-membership must be keep or remove') +} + +export function selectMembersForUpdate( + config: Config, + activities: MemberActivity[], + options: UpdateMembersOptions +): string[] { + if (options.cutoffDate === undefined && options.only.length === 0) { + throw new Error('Either cutoff-date or only must be provided') + } + + const activityByUsername = new Map( + activities.map(activity => [activity.username.toLowerCase(), activity]) + ) + const ignore = new Set(options.ignore) + const only = new Set(options.only) + + const candidates = config + .getResources(Member) + .filter(member => !hasKeepComment(config, member)) + .map(member => member.username.toLowerCase()) + .filter(username => !ignore.has(username)) + .filter(username => only.size === 0 || only.has(username)) + .filter(username => { + if (options.cutoffDate === undefined) { + return true + } + const latestActivity = activityByUsername.get(username)?.latestActivity + return latestActivity === undefined || latestActivity < options.cutoffDate + }) + .sort((a, b) => { + const aActivity = activityByUsername.get(a)?.latestActivity?.valueOf() + const bActivity = activityByUsername.get(b)?.latestActivity?.valueOf() + if (aActivity === undefined && bActivity === undefined) { + return a.localeCompare(b) + } + if (aActivity === undefined) { + return -1 + } + if (bActivity === undefined) { + return 1 + } + return aActivity - bActivity || a.localeCompare(b) + }) + + return options.limit === undefined + ? candidates + : candidates.slice(0, options.limit) +} + +export function updateMembersConfig( + config: Config, + usernames: string[], + publicRepoAccess: PublicRepoAccess, + organizationMembership: OrganizationMembership +): string[] { + const targets = new Set(usernames.map(username => username.toLowerCase())) + const repositories = new Map( + config + .getResources(Repository) + .map(repository => [repository.name.toLowerCase(), repository]) + ) + const accessBefore = getAccessSummaryFrom(config) + const retainedPublicAccess = new Map< + string, + Map + >() + + if (publicRepoAccess === 'retain') { + for (const username of targets) { + const userAccess = accessBefore[username] + if (userAccess === undefined) { + continue + } + for (const [repository, access] of Object.entries( + userAccess.repositories + )) { + const configRepository = repositories.get(repository) + if ( + configRepository?.archived || + access.visibility !== Visibility.Public + ) { + continue + } + const retainedRepositories = + retainedPublicAccess.get(username) ?? new Map() + const current = retainedRepositories.get(repository) + retainedRepositories.set( + repository, + (current === undefined + ? access.permission + : betterPermission( + current, + access.permission + )) as RepositoryCollaboratorPermission + ) + retainedPublicAccess.set(username, retainedRepositories) + } + } + } + + for (const teamMember of config.getResources(TeamMember)) { + if (targets.has(teamMember.username.toLowerCase())) { + core.info(`Removing ${teamMember.username} from ${teamMember.team} team`) + config.removeResource(teamMember) + } + } + + for (const collaborator of config.getResources(RepositoryCollaborator)) { + if (targets.has(collaborator.username.toLowerCase())) { + core.info( + `Removing ${collaborator.username} from ${collaborator.repository} repository` + ) + config.removeResource(collaborator) + } + } + + for (const [username, retainedRepositories] of retainedPublicAccess) { + for (const [repository, permission] of retainedRepositories) { + core.info( + `Retaining ${username} ${permission} access to public repository ${repository}` + ) + config.addResource( + new RepositoryCollaborator(repository, username, permission) + ) + } + } + + if (organizationMembership === 'remove') { + for (const member of config.getResources(Member)) { + if (targets.has(member.username.toLowerCase())) { + core.info(`Removing ${member.username} from the organization`) + config.removeResource(member) + } + } + } + + return Array.from(targets).sort() +} + +function latestActivityByUser(activities: ActivityRecord[]): MemberActivity[] { + const latest = new Map() + for (const activity of activities) { + const username = activity.username.toLowerCase() + const previous = latest.get(username) + if (previous === undefined || activity.createdAt > previous) { + latest.set(username, activity.createdAt) + } + } + return Array.from(latest.entries()).map(([username, latestActivity]) => ({ + username, + latestActivity + })) +} + +async function collectActivities(since: Date): Promise { + const github = await GitHub.getGitHub() + const [ + githubRepositoryActivities, + githubRepositoryIssues, + githubRepositoryPullRequestReviewComments, + githubRepositoryIssueComments, + githubRepositoryCommitComments + ] = await Promise.all([ + github.listRepositoryActivities(since), + github.listRepositoryIssues(since), + github.listRepositoryPullRequestReviewComments(since), + github.listRepositoryIssueComments(since), + github.listRepositoryCommitComments(since) + ]) + + return latestActivityByUser( + [ + ...githubRepositoryActivities.map(({activity}) => ({ + username: activity.actor?.login, + createdAt: new Date(activity.timestamp) + })), + ...githubRepositoryIssues.map(({issue}) => ({ + username: issue.user?.login, + createdAt: new Date(issue.created_at) + })), + ...githubRepositoryPullRequestReviewComments.map(({comment}) => ({ + username: comment.user?.login, + createdAt: new Date(comment.created_at) + })), + ...githubRepositoryIssueComments.map(({comment}) => ({ + username: comment.user?.login, + createdAt: new Date(comment.created_at) + })), + ...githubRepositoryCommitComments.map(({comment}) => ({ + username: comment.user?.login, + createdAt: new Date(comment.created_at) + })) + ] + .filter( + ( + activity + ): activity is { + username: string + createdAt: Date + } => activity.username !== undefined + ) + .filter(activity => !Number.isNaN(activity.createdAt.valueOf())) + ) +} + +async function run(): Promise { + const cutoffDate = parseCutoffDate(process.env.CUTOFF_DATE) + const limit = parseLimit(process.env.LIMIT) + const ignore = parseUserList(process.env.IGNORE) + const only = parseUserList(process.env.ONLY) + const publicRepoAccess = parsePublicRepoAccess(process.env.PUBLIC_REPO_ACCESS) + const organizationMembership = parseOrganizationMembership( + process.env.ORGANIZATION_MEMBERSHIP || 'keep' + ) + + const config = Config.FromPath() + const activities = + cutoffDate === undefined + ? [] + : await collectActivities(limit === undefined ? cutoffDate : new Date(0)) + const selectedMembers = selectMembersForUpdate(config, activities, { + cutoffDate, + limit, + ignore, + only, + publicRepoAccess, + organizationMembership + }) + const affectedUsers = updateMembersConfig( + config, + selectedMembers, + publicRepoAccess, + organizationMembership + ) + + config.save() + + core.setOutput('affected-users', affectedUsers.join(', ')) + core.setOutput('affected-users-json', JSON.stringify(affectedUsers)) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run() +} diff --git a/scripts/src/terraform/schema.ts b/scripts/src/terraform/schema.ts index fded2c2..0f7b027 100644 --- a/scripts/src/terraform/schema.ts +++ b/scripts/src/terraform/schema.ts @@ -53,6 +53,8 @@ type ResourceSchema = { type: 'github_repository' values: { name: string + archived?: boolean + visibility?: 'private' | 'public' pages?: { source?: object[] }[] diff --git a/terraform/allow_destroy_override.tf.disabled b/terraform/allow_destroy_override.tf.disabled new file mode 100644 index 0000000..012e584 --- /dev/null +++ b/terraform/allow_destroy_override.tf.disabled @@ -0,0 +1,11 @@ +resource "github_membership" "this" { + lifecycle { + prevent_destroy = false + } +} + +resource "github_repository" "this" { + lifecycle { + prevent_destroy = false + } +}