Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support the org permissions cleanup initiative #117

Merged
merged 15 commits into from
Feb 19, 2024
Merged
1 change: 1 addition & 0 deletions .github/actions/git-config-user/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ runs:
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
shell: bash
5 changes: 4 additions & 1 deletion .github/workflows/apply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3
with:
terraform_version: 1.2.9
terraform_wrapper: false
- name: Initialize terraform
run: terraform init
- name: Terraform Plan Download
Expand All @@ -78,4 +79,6 @@ jobs:
SHA: ${{ needs.prepare.outputs.sha }}
run: gh run download -n "${TF_WORKSPACE}_${SHA}.tfplan" --repo "${GITHUB_REPOSITORY}"
- name: Terraform Apply
run: terraform apply -lock-timeout=0s -no-color "${TF_WORKSPACE}.tfplan"
run: |
terraform show -json > $TF_WORKSPACE.tfstate.json
terraform apply -lock-timeout=0s -no-color "${TF_WORKSPACE}.tfplan"
13 changes: 12 additions & 1 deletion .github/workflows/fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
if: needs.prepare.outputs.skip-fix == 'false'
permissions:
contents: read
pull-requests: read
pull-requests: write
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -97,13 +97,15 @@ jobs:
uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3
with:
terraform_version: 1.2.9
terraform_wrapper: false
- name: Initialize terraform
run: terraform init
working-directory: terraform
- name: Initialize scripts
run: npm ci && npm run build
working-directory: scripts
- name: Fix
id: fix
run: node lib/actions/fix-yaml-config.js
working-directory: scripts
- name: Upload YAML config
Expand All @@ -113,6 +115,15 @@ jobs:
path: github/${{ env.TF_WORKSPACE }}.yml
if-no-files-found: error
retention-days: 1
# NOTE(galargh, 2024-02-15): This will only work if GitHub as Code is used for a single organization
- name: Comment on pull request
if: github.event_name == 'pull_request_target' && steps.fix.outputs.comment
uses: marocchino/sticky-pull-request-comment@fcf6fe9e4a0409cd9316a5011435be0f3327f1e1 # v2.3.1
with:
header: fix
number: ${{ github.event.pull_request.number }}
message: ${{ steps.fix.outputs.comment }}

push:
needs: [prepare, fix]
permissions:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ jobs:
uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3
with:
terraform_version: 1.2.9
terraform_wrapper: false
- name: Initialize terraform
run: terraform init
working-directory: terraform
- name: Plan terraform
run: terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color
run: |
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
uses: actions/upload-artifact@v3
Expand Down Expand Up @@ -156,6 +159,7 @@ jobs:
- name: Comment on pull request
uses: marocchino/sticky-pull-request-comment@fcf6fe9e4a0409cd9316a5011435be0f3327f1e1 # v2.3.1
with:
header: plan
number: ${{ github.event.pull_request.number }}
message: |
Before merge, verify that all the following plans are correct. They will be applied as-is after the merge.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ jobs:
terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}"
echo "TF_WORKSPACE=${TF_WORKSPACE_OPT}" >> $GITHUB_ENV
working-directory: terraform
- name: Pull terraform state
run: |
terraform show -json > $TF_WORKSPACE.tfstate.json
working-directory: terraform
- name: Sync
run: |
npm ci
Expand Down
31 changes: 23 additions & 8 deletions scripts/src/actions/fix-yaml-config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import 'reflect-metadata'

/*
Put `github/*.yml` transform rules that you want executed during `Fix` workflow runs here.
import {toggleArchivedRepos} from './shared/toggle-archived-repos'
import {describeAccessChanges} from './shared/describe-access-changes'

*Example*
```ts
import {protectDefaultBranches} from './shared/protect-default-branches'
import * as core from '@actions/core'

protectDefaultBranches()
```
*/
async function run(): Promise<void> {
await toggleArchivedRepos()

const accessChangesDescription = await describeAccessChanges()

core.setOutput(
'comment',
`The following access changes will be introduced as a result of applying the plan:

<details><summary>Access Changes</summary>

\`\`\`
${accessChangesDescription}
\`\`\`

</details>`
)
}

run()
132 changes: 132 additions & 0 deletions scripts/src/actions/shared/describe-access-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {Config} from '../../yaml/config'
import {State} from '../../terraform/state'
import {RepositoryCollaborator} from '../../resources/repository-collaborator'
import {Member} from '../../resources/member'
import {TeamMember} from '../../resources/team-member'
import {RepositoryTeam} from '../../resources/repository-team'
import {diff} from 'deep-diff'
import * as core from '@actions/core'

type AccessSummary = Record<
string,
{
role?: string
repositories: Record<string, {permission: string}>
}
>

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 usernames = new Set<string>([
...members.map(member => member.username),
...repositoryCollaborators.map(collaborator => collaborator.username)
])

const accessSummary: AccessSummary = {}
const permissions = ['admin', 'maintain', 'push', 'triage', 'pull']

for (const username of usernames) {
const role = members.find(member => member.username === username)?.role
const teams = teamMembers
.filter(teamMember => teamMember.username === username)
.map(teamMember => teamMember.team)
const repositoryCollaborator = repositoryCollaborators.filter(
repositoryCollaborator => repositoryCollaborator.username === username
)
const teamRepository = teamRepositories.filter(teamRepository =>
teams.includes(teamRepository.team)
)

const repositories: Record<string, {permission: string}> = {}

for (const rc of repositoryCollaborator) {
repositories[rc.repository] = repositories[rc.repository] ?? {}
if (
permissions.indexOf(rc.permission) <
permissions.indexOf(repositories[rc.repository].permission)
) {
repositories[rc.repository].permission = rc.permission
}
}

for (const tr of teamRepository) {
repositories[tr.repository] = repositories[tr.repository] ?? {}
if (
permissions.indexOf(tr.permission) <
permissions.indexOf(repositories[tr.repository].permission)
) {
repositories[tr.repository].permission = tr.permission
}
}

accessSummary[username] = {
role,
repositories
}
}

return accessSummary
}

export async function describeAccessChanges(): Promise<string> {
const state = await State.New()
const config = Config.FromPath()

const before = getAccessSummaryFrom(state)
const after = getAccessSummaryFrom(config)

core.info(JSON.stringify({before, after}))

const changes = diff(before, after) || []

const changesByUser: Record<string, any> = {}
for (const change of changes) {
const path = change.path!
changesByUser[path[0]] = changesByUser[path[0]] || []
changesByUser[path[0]].push(change)
}

// iterate over changesByUser and build a description
const lines = []
for (const [username, changes] of Object.entries(changesByUser)) {
lines.push(`User ${username}:`)
for (const change of changes) {
const path = change.path!
switch (change.kind) {
case 'E':
if (path[1] === 'role') {
if (change.lhs === undefined) {
lines.push(
` - will join the organization as a ${change.rhs} (remember to accept the email invitation)`
)
} else if (change.rhs === undefined) {
lines.push(` - will leave the organization`)
} else {
lines.push(
` - will have the role in the organization change from ${change.lhs} to ${change.rhs}`
)
}
} else {
lines.push(
` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}`
)
}
break
case 'N':
lines.push(` - will gain ${change.rhs} permission to ${path[2]}`)
break
case 'D':
lines.push(` - will lose ${change.lhs} permission to ${path[2]}`)
break
}
}
}

return changes.length > 0
? lines.join('\n')
: 'There will be no access changes'
}
38 changes: 38 additions & 0 deletions scripts/src/actions/shared/toggle-archived-repos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {Config} from '../../yaml/config'
import {Repository} from '../../resources/repository'
import {State} from '../../terraform/state'

export async function toggleArchivedRepos(): Promise<void> {
const state = await State.New()
const config = Config.FromPath()

const resources = state.getAllResources()
const stateRepositories = state.getResources(Repository)
const configRepositories = config.getResources(Repository)

for (const configRepository of configRepositories) {
if (configRepository.archived) {
config.removeResource(configRepository)
const repository = new Repository(configRepository.name)
repository.archived = true
config.addResource(repository)
} else {
const stateRepository = stateRepositories.find(
r => r.name === configRepository.name
)
if (stateRepository !== undefined && stateRepository.archived) {
config.addResource(stateRepository)
for (const resource of resources) {
if (
'repository' in resource &&
(resource as any).repository === stateRepository.name
) {
config.addResource(resource)
}
}
}
}
}

config.save()
}
12 changes: 11 additions & 1 deletion scripts/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import 'reflect-metadata'
import {sync} from './sync'
import {State} from './terraform/state'
import {Config} from './yaml/config'
import {toggleArchivedRepos} from './actions/shared/toggle-archived-repos'

async function run(): Promise<void> {
async function runSync(): Promise<void> {
const state = await State.New()
const config = Config.FromPath()

Expand All @@ -12,4 +13,13 @@ async function run(): Promise<void> {
config.save()
}

async function runToggleArchivedRepos(): Promise<void> {
await toggleArchivedRepos()
}

async function run(): Promise<void> {
await runSync()
await runToggleArchivedRepos()
}

run()
Loading