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/docs/SETUP.md b/docs/SETUP.md index 85cc586..0fdeb8f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -150,6 +150,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/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/fix-yaml-config.ts b/scripts/src/actions/fix-yaml-config.ts index a88b191..733d370 100644 --- a/scripts/src/actions/fix-yaml-config.ts +++ b/scripts/src/actions/fix-yaml-config.ts @@ -10,18 +10,7 @@ async function run(): Promise { const accessChangesDescription = await runDescribeAccessChanges() - core.setOutput( - 'comment', - `The following access changes will be introduced as a result of applying the plan: - -
Access Changes - -\`\`\` -${accessChangesDescription} -\`\`\` - -
` - ) + core.setOutput('comment', accessChangesDescription) } run() 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[] }[]