> $GITHUB_ENV
152 | if [[ $(wc -c TERRAFORM_PLANS.md | cut -d' ' -f1) -ge 65000 ]]; then
153 | echo "Terraform plans are too long to post as a comment. Please inspect [Plan > Comment > Show terraform plans](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) instead." >> $GITHUB_ENV
154 | else
155 | cat TERRAFORM_PLANS.md >> $GITHUB_ENV
156 | fi
157 | echo 'EOF' >> $GITHUB_ENV
158 | working-directory: terraform
159 | - name: Comment on pull request
160 | uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
161 | with:
162 | header: plan
163 | number: ${{ github.event.pull_request.number }}
164 | message: |
165 | Before merge, verify that all the following plans are correct. They will be applied as-is after the merge.
166 |
167 | #### Terraform plans
168 | ${{ env.COMMENT }}
169 |
--------------------------------------------------------------------------------
/.github/workflows/sync.yml:
--------------------------------------------------------------------------------
1 | name: Sync
2 |
3 | on:
4 | schedule:
5 | - cron: 0 0 * * 0
6 | workflow_dispatch:
7 | inputs:
8 | workspaces:
9 | description: Space separated list of workspaces to sync (leave blank to sync all)
10 | required: false
11 | lock:
12 | description: Whether to acquire terraform state lock during sync
13 | required: false
14 | default: "true"
15 |
16 | jobs:
17 | prepare:
18 | name: Prepare
19 | runs-on: ubuntu-latest
20 | outputs:
21 | workspaces: ${{ steps.workspaces.outputs.this }}
22 | defaults:
23 | run:
24 | shell: bash
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v4
28 | - name: Discover workspaces
29 | id: workspaces
30 | env:
31 | WORKSPACES: ${{ github.event.inputs.workspaces }}
32 | run: |
33 | if [[ -z "${WORKSPACES}" ]]; then
34 | workspaces="$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)"
35 | else
36 | workspaces="$(echo "${WORKSPACES}" | jq --raw-input 'split(" ")')"
37 | fi
38 | echo "this=${workspaces}" >> $GITHUB_OUTPUT
39 | sync:
40 | needs: [prepare]
41 | if: needs.prepare.outputs.workspaces != ''
42 | permissions:
43 | contents: write
44 | strategy:
45 | fail-fast: false
46 | matrix:
47 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }}
48 | name: Sync
49 | runs-on: ubuntu-latest
50 | env:
51 | TF_IN_AUTOMATION: 1
52 | TF_INPUT: 0
53 | TF_LOCK: ${{ github.event.inputs.lock }}
54 | TF_WORKSPACE_OPT: ${{ matrix.workspace }}
55 | AWS_ACCESS_KEY_ID: ${{ secrets.RW_AWS_ACCESS_KEY_ID }}
56 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RW_AWS_SECRET_ACCESS_KEY }}
57 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }}
58 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }}
59 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }}
60 | TF_VAR_write_delay_ms: 300
61 | defaults:
62 | run:
63 | shell: bash
64 | steps:
65 | - name: Checkout
66 | uses: actions/checkout@v4
67 | - name: Setup terraform
68 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
69 | with:
70 | terraform_version: 1.12.0
71 | terraform_wrapper: false
72 | - name: Initialize terraform
73 | run: terraform init -upgrade
74 | working-directory: terraform
75 | - name: Select terraform workspace
76 | run: |
77 | terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}"
78 | echo "TF_WORKSPACE=${TF_WORKSPACE_OPT}" >> $GITHUB_ENV
79 | working-directory: terraform
80 | - name: Pull terraform state
81 | run: |
82 | terraform show -json > $TF_WORKSPACE.tfstate.json
83 | working-directory: terraform
84 | - name: Sync
85 | run: |
86 | npm ci
87 | npm run build
88 | npm run main
89 | working-directory: scripts
90 | - uses: ./.github/actions/git-config-user
91 | - env:
92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
93 | run: |
94 | git_branch="${GITHUB_REF_NAME}-sync-${TF_WORKSPACE}"
95 | git checkout -B "${git_branch}"
96 | git add --all
97 | git diff-index --quiet HEAD || git commit --message="sync@${GITHUB_RUN_ID} ${TF_WORKSPACE}"
98 | git push origin "${git_branch}" --force
99 | push:
100 | needs: [prepare, sync]
101 | if: needs.prepare.outputs.workspaces != ''
102 | name: Push
103 | runs-on: ubuntu-latest
104 | defaults:
105 | run:
106 | shell: bash
107 | steps:
108 | - name: Generate app token
109 | id: token
110 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
111 | with:
112 | app_id: ${{ secrets.RW_GITHUB_APP_ID }}
113 | installation_retrieval_mode: id
114 | installation_retrieval_payload: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }}
115 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }}
116 | - name: Checkout
117 | uses: actions/checkout@v4
118 | with:
119 | token: ${{ steps.token.outputs.token }}
120 | - uses: ./.github/actions/git-config-user
121 | - env:
122 | WORKSPACES: ${{ needs.prepare.outputs.workspaces }}
123 | run: |
124 | echo "${GITHUB_RUN_ID}" > .sync
125 | git add .sync
126 | git commit --message="sync@${GITHUB_RUN_ID}"
127 | while read workspace; do
128 | workspace_branch="${GITHUB_REF_NAME}-sync-${workspace}"
129 | git fetch origin "${workspace_branch}"
130 | git merge --strategy-option=theirs "origin/${workspace_branch}"
131 | git push origin --delete "${workspace_branch}"
132 | done <<< "$(jq -r '.[]' <<< "${WORKSPACES}")"
133 | - run: git push origin "${GITHUB_REF_NAME}" --force
134 |
--------------------------------------------------------------------------------
/.github/workflows/update.yml:
--------------------------------------------------------------------------------
1 | name: Update
2 |
3 | on:
4 | workflow_run:
5 | workflows:
6 | - "Apply"
7 | types:
8 | - completed
9 | workflow_dispatch:
10 |
11 | jobs:
12 | update:
13 | if: (github.event_name == 'workflow_dispatch' &&
14 | github.ref_name == github.event.repository.default_branch) ||
15 | (github.event_name == 'workflow_run' &&
16 | github.event.workflow_run.conclusion == 'success')
17 | name: Update
18 | runs-on: ubuntu-latest
19 | defaults:
20 | run:
21 | shell: bash
22 | steps:
23 | - uses: actions/checkout@v4
24 | - run: npm ci && npm run build
25 | working-directory: scripts
26 | - name: Update PRs
27 | env:
28 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }}
29 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }}
30 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }}
31 | run: node lib/actions/update-pull-requests.js
32 | working-directory: scripts
33 |
--------------------------------------------------------------------------------
/.github/workflows/upgrade.yml:
--------------------------------------------------------------------------------
1 | name: Upgrade
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | ref:
7 | required: false
8 | description: The github-mgmt-template ref to upgrade to
9 | default: master
10 |
11 | jobs:
12 | upgrade:
13 | uses: ipdxco/github-as-code/.github/workflows/upgrade_reusable.yml@master
14 | with:
15 | ref: inputs.ref
16 | secrets:
17 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }}
18 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }}
19 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }}
20 |
--------------------------------------------------------------------------------
/.github/workflows/upgrade_reusable.yml:
--------------------------------------------------------------------------------
1 | name: Upgrade (reusable)
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | ref:
7 | type: string
8 | required: false
9 | description: The github-mgmt-template ref to upgrade to
10 | default: master
11 | secrets:
12 | GITHUB_APP_ID:
13 | required: true
14 | GITHUB_APP_INSTALLATION_ID:
15 | required: true
16 | GITHUB_APP_PEM_FILE:
17 | required: true
18 |
19 | jobs:
20 | upgrade:
21 | name: Upgrade
22 | runs-on: ubuntu-latest
23 | defaults:
24 | run:
25 | shell: bash
26 | steps:
27 | - name: Generate app token
28 | id: token
29 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
30 | with:
31 | app_id: ${{ secrets.GITHUB_APP_ID }}
32 | installation_retrieval_mode: id
33 | installation_retrieval_payload: ${{ secrets.GITHUB_APP_INSTALLATION_ID }}
34 | private_key: ${{ secrets.GITHUB_APP_PEM_FILE }}
35 | - name: Checkout GitHub Management template
36 | uses: actions/checkout@v4
37 | with:
38 | repository: pl-strflt/github-mgmt-template
39 | path: github-mgmt-template
40 | ref: ${{ github.event.inputs.ref }}
41 | - name: Checkout GitHub Management
42 | uses: actions/checkout@v4
43 | with:
44 | path: github-mgmt
45 | token: ${{ steps.token.outputs.token }}
46 | - name: Copy files from the template
47 | run: |
48 | for file in $(git ls-files ':!:github/*.yml' ':!:scripts/src/actions/fix-yaml-config.ts' ':!:terraform/*_override.tf' ':!:.github/dependabot.yml' ':!:.github/workflows/*_reusable.yml' ':!:README.md'); do
49 | mkdir -p "../github-mgmt/$(dirname "${file}")"
50 | cp -f "${file}" "../github-mgmt/${file}"
51 | done
52 | working-directory: github-mgmt-template
53 | - uses: ./github-mgmt-template/.github/actions/git-config-user
54 | - run: |
55 | git add --all
56 | git diff-index --quiet HEAD || git commit --message="upgrade@${GITHUB_RUN_ID}"
57 | working-directory: github-mgmt
58 | - uses: ./github-mgmt-template/.github/actions/git-push
59 | env:
60 | GITHUB_TOKEN: ${{ steps.token.outputs.token }}
61 | with:
62 | suffix: upgrade
63 | working-directory: github-mgmt
64 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Local .terraform directories
2 | **/.terraform/*
3 |
4 | # .tfstate files
5 | *.tfstate
6 | *.tfstate.*
7 |
8 | # Crash log files
9 | crash.log
10 |
11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most
12 | # .tfvars files are managed as part of configuration and so should be included in
13 | # version control.
14 | #
15 | # example.tfvars
16 |
17 | # Ignore override files as they are usually used to override resources locally and so
18 | # are not checked in
19 | # override.tf
20 | # override.tf.json
21 | # *_override.tf
22 | # *_override.tf.json
23 |
24 | # Include override files you do wish to add to version control using negated pattern
25 | #
26 | # !example_override.tf
27 |
28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
29 | # example: *tfplan*
30 | *.tfplan
31 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 | ### Added
9 | - shared action for adding a collaborator to all repositories
10 | - clean workflow which removes resources from state
11 | - information on how to handle private GitHub Management repository
12 | - warning about GitHub Management repository access
13 | - PR template
14 | - examples to HOWTOs
15 | - repository_file support
16 | - repository.default_branch support
17 | - weekly schedule to the synchronization workflow
18 | - fix workflow which executes user defined config transforms on PRs and after Apply
19 | - shared config fix rule which adds missing default branch protections
20 | - shared action for adding a file to all repositories
21 | - shared action for adding a label to all repositories
22 | - issue_label support
23 | - new args for repositories and branch protection rules
24 |
25 | ### Changed
26 | - **BREAKING**: added support for efficient labels handling via the `github_issue_labels` resource (please clean `github_issue_label.this.*` from the terraform state and update `locals_override.tf` and `resources_override.tf` before syncing)
27 | - **BREAKING**: upgraded to terraform 1.12.0 and github provider 6.6.0 (please clean `github_branch_protection.this.*` from the terraform state and update `resources_override.tf` before syncing the upgrade)
28 | - **BREAKING**: turned scripts into an ESM project (please ensure you remove the following files during the upgrade: `scripts/.eslintignore`, `scripts/.eslintrc.json`, `scripts/jest.config.js`, `jest.d.ts`, `jest.setup.ts`; please update your imports in the `scripts/src/actions/fix-yaml-config.ts` file to include the `.js` extension)
29 | - **BREAKING**: Updated the signatures of all the shared actions; now the runAction function will persist the changes to disk while action functions will operate on the in-memory state (please update your imports in the `scripts/src/actions/fix-yaml-config.ts` file accordingly)
30 | - Synchronization script: to use GitHub API directly instead of relying on TF GH Provider's Data Sources
31 | - Configuration: replaced multiple JSONs with a single, unified YAML
32 | - Synchronization script: rewrote the script in JS
33 | - Upgrade (reusable) workflow: included docs and CHANGELOG in the upgrades
34 | - README: extracted sections to separate docs
35 | - GitHub Provider: upgraded to v4.23.0
36 | - Upgrade workflows: accept github-mgmt-template ref to upgrade to
37 | - Commit message for repository files: added chore: prefix and [skip ci] suffix
38 | - scripts: to export tf resource definitions and always sort before save
39 | - plan: to be triggered on pull_request_target
40 | - plan: to only check out github directory from the PR
41 | - plan: to wait for Apply workflow runs to finish
42 | - defaults: not to ignore any properties by default
43 | - add-file-to-all-repos: to accept a repo filter instead of an repo exclude list
44 | - sync: to push changes directly to the branch
45 | - automated commit messages: to include github run id information
46 | - apply: not to use deprecated GitHub API anymore
47 | - workflows: not to use deprecated GitHub Actions runners anymore
48 | - workflows: not to use deprecated GitHub Actions expressions anymore
49 | - tf: to prevent destroy of membership and repository resources
50 | - apply: find sha for plan using proper credentials
51 | - updated upload and download artifacts actions to v4
52 |
53 | ### Fixed
54 | - include labels in the config resources only if they are explicitly defined in the config
55 | - always assert state type when creating resources from state
56 | - do not break long file content lines
57 | - source TF_WORKING_DIR from env helper instead of process.env in locals helper
58 | - fixed how terraform state is accessed before it the initial synchronization
59 | - links to supported resources in HOWTOs
60 | - posting PR comments when terraform plan output is very long
61 | - PR parsing in the update workflow
62 | - array head retrieval in scripts
63 | - team imports
64 | - parent_team_id retrieval from state
65 | - saving config sync result
66 | - how dry run flag is passed in the clean workflow
67 | - how sync invalidates PR plans
68 | - support for pull_request_bypassers in branch protection rules
69 | - how repository files are imported
70 | - how sync handles ignored types
71 | - how indices are represented in the state (always lowercase)
72 | - how sync handles pending invitations (now it does not ignore them)
73 | - removed references to other resources from for_each expressions
74 | - downgraded terraform to 1.2.9 to fix an import bug affecting for_each expressions
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | 
4 |
5 | # GitHub Management as Code
6 |
7 | A robust template for managing GitHub configurations as code via Pull Requests. Designed with the effective administration of multiple medium to large-sized GitHub organizations in mind.
8 |
9 |
10 |
11 | ## Importance of Access Control
12 |
13 | **NOTE**: Possessing write access to the GitHub Management repository can equate to having administrative privileges over the organizations managed by that repository. Please exercise caution and ensure proper access control measures.
14 |
15 | ## Dive Deeper
16 |
17 | - Learn more about [what GitHub Management is and how it works](docs/ABOUT.md).
18 | - Get started with [setting up GitHub Management](docs/SETUP.md).
19 | - Uncover [how to effectively utilize GitHub Management](docs/HOWTOS.md).
20 |
21 | ## Active Users
22 |
23 | A broad range of diverse organizations are already leveraging our GitHub Management solution via Pull Request Workflow. You can find a comprehensive list of these organizations on our [GitHub Management Router repository](https://github.com/pl-strflt/github-mgmt-router). This list showcases the versatility and effectiveness of our GitHub Management system. Our solution can be tailored to meet your unique requirements, driving efficiency and streamlined GitHub organization management.
24 |
--------------------------------------------------------------------------------
/docs/ABOUT.md:
--------------------------------------------------------------------------------
1 | # Key features
2 |
3 | - 2-way sync between GitHub Management and the actual GitHub configuration (including bootstrapping)
4 | - PR-based configuration change review process which guarantees the reviewed plan is the one being applied
5 | - control over what resources and what properties are managed by GitHub Management
6 | - auto-upgrades from the template repository
7 |
8 | # How does it work?
9 |
10 | GitHub Management allows management of GitHub configuration as code. It uses Terraform and GitHub Actions to achieve this.
11 |
12 | A GitHub organization is configured through a YAML configuration file - [github/$ORGANIZATION_NAME.yml](../github/$ORGANIZATION_NAME.yml). GitHub Management lets you manage multiple organizations from a single repository. It uses separate terraform workspaces for each organisation. The workspace names are the same as the organization names. Each workspace has its state hosted in the remote [S3 backend](https://www.terraform.io/language/settings/backends/s3).
13 |
14 | The configuration files follow [github/.schema.json](../github/.schema.json) schema. You can configure your editor to validate the schema for you, e.g. [a plugin for VS Code](https://github.com/redhat-developer/vscode-yaml).
15 |
16 | You can have a look at an [EXAMPLE.yml](./EXAMPLE.yml) which defines all the resources with all the attributes that can be managed through GitHub Management.
17 |
18 | Whether resources of a specific type are managed via GitHub Management or not is controlled through [resource_types] array in [terraform/locals_override.tf](../terraform/locals_override.tf). It accepts [supported resource](#supported-resources) names:
19 |
20 | Which properties of a resource are managed via GitHub Management is controlled through `lifecycle.ignore_changes` array in [terraform/resources_override.tf](../terraform/resources_override.tf) with a fallback to [terraform/resources.tf](../terraform/resources.tf). By default all but required properties are ignored.
21 |
22 | GitHub Management is capable of both applying the changes made to the YAML configuration to GitHub and of translating the current GitHub configuration state back into the YAML configuration file.
23 |
24 | The workflow for introducing changes to GitHub via YAML configuration file is as follows:
25 | 1. Modify the YAML configuration file.
26 | 1. Create a PR and wait for the GitHub Action workflow triggered on PRs to comment on it with a terraform plan.
27 | 1. Review the plan.
28 | 1. Merge the PR and wait for the GitHub Action workflow triggered on pushes to the default branch to apply it.
29 |
30 | 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 applied exactly as-is after the merge.
31 |
32 | The workflow for synchronising the current GitHub configuration state with YAML configuration file is as follows:
33 | 1. Run the `Sync` GitHub Action workflow and wait for the PR to be created.
34 | 1. If a PR was created, wait for the GitHub Action workflow triggered on PRs to comment on it with a terraform plan.
35 | 1. Ensure that the plan introduces no changes.
36 | 1. Merge the PR.
37 |
38 | Running the `Sync` GitHub Action workflows refreshes the underlying terraform state. It also automatically imports all the resources that were created outside GitHub Management into the state (except for `github_repository_file`s) and removes any that were deleted. After the `Sync` flow, all the other open PRs will have their GitHub Action workflows rerun (thanks to the `Update` workflow) because merging them without it would result in the application of their plans to fail due to the plans being created against a different state.
39 |
40 | # Supported Resources
41 |
42 | - [github_membership](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/membership)
43 | - [github_repository](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository)
44 | - [github_repository_collaborator](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator)
45 | - [github_branch_protection](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection)
46 | - [github_team](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team)
47 | - [github_team_repository](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository)
48 | - [github_team_membership](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_membership)
49 | - [github_repository_file](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file)
50 | - [github_issue_labels](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_labels)
51 |
52 | # Config Fix Rules
53 |
54 | With GitHub Management, you can write config fix rules in TypeScript. Your code will get executed by the `Fix` workflow on each PR (if the repository isn't private) and after each `Apply` workflow run. If your code execution results in any changes to the YAML configuration files, they will be either pushed directly in case of PRs or proposed through PRs otherwise.
55 |
56 | Config fix rules have to be put inside `scripts/src/actions/fix-yaml-config.ts` file. Look around `scripts/src` to find useful abstractions for YAML manipulation. You can also browse through a catalog of ready-made rules in `scripts/src/actions/shared`.
57 |
58 | You can instruct GitHub Management to skip `Fix` workflow execution on your commit by adding a `[skip fix]` suffix to the first line of your commit message.
59 |
--------------------------------------------------------------------------------
/docs/EXAMPLE.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../github/.schema.json
2 |
3 | members: # This group defines org members (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/membership)
4 | admin: # This array defines org members with role=admin
5 | - peter # This is a GitHub username
6 | member: # This array defines org members with role=member
7 | - adam
8 | teams: # This group defines teams (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team)
9 | employees: {}
10 | developers:
11 | members: # This group defines team members (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_membership)
12 | maintainer: # This array defines team members with role=maintainer
13 | - peter
14 | member: # This member defines team members with role=member
15 | - adam
16 | description: Developers Team
17 | parent_team_id: employees # This field, unlike its terraform counterpart, accepts a team name
18 | privacy: closed # This field accepts either secret or closed (i.e. visible) - https://docs.github.com/en/organizations/organizing-members-into-teams/changing-team-visibility
19 | repositories: # This group defines repositories (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository)
20 | github-mgmt:
21 | files: # This group defines repository files (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file)
22 | README.md:
23 | content: README.md # This field accepts either a relative path to a file from ./files directory...
24 | docs/HELLO.md:
25 | content: | # ... or a content string
26 | Hi!
27 | branch_protection: # This group defines branch protection rules (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection)
28 | master: # This key accepts only EXACT branch names, unlike the terraform resource which accepts any pattern
29 | allows_deletions: false
30 | allows_force_pushes: false
31 | enforce_admins: false
32 | require_conversation_resolution: false
33 | require_signed_commits: false
34 | required_linear_history: false
35 | required_pull_request_reviews:
36 | dismiss_stale_reviews: false
37 | dismissal_restrictions: [] # This field accepts node IDs (TODO: make this field accept human friendly names too)
38 | pull_request_bypassers: [] # This field accepts node IDs (TODO: make this field accept human friendly names too)
39 | require_code_owner_reviews: false
40 | required_approving_review_count: 1
41 | restrict_dismissals: false
42 | required_status_checks:
43 | contexts:
44 | - Comment
45 | strict: true
46 | collaborators: # This group defines repository collaborators (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator)
47 | admin: # This array defines repository collaborators with permission=admin
48 | - peter
49 | maintain: # This array defines repository collaborators with permission=maintain
50 | - adam
51 | push: [] # This array defines repository collaborators with permission=push
52 | triage: [] # This array defines repository collaborators with permission=triage
53 | pull: [] # This array defines repository collaborators with permission=pull
54 | teams: # This group defines teams with access to the repository (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository)
55 | admin: [] # This array defines teams with permission=admin
56 | maintain: [] # This array defines teams with permission=maintain
57 | push: # This array defines teams with permission=push
58 | - developers
59 | triage: # This array defines teams with permission=triage
60 | - employees
61 | pull: [] # This array defines teams with permission=pull
62 | allow_merge_commit: true
63 | allow_rebase_merge: true
64 | allow_squash_merge: true
65 | has_downloads: true
66 | has_issues: true
67 | has_projects: true
68 | has_wiki: true
69 | visibility: public # This field accepts either public or private
70 | allow_auto_merge: false
71 | archived: false
72 | auto_init: false
73 | default_branch: master
74 | delete_branch_on_merge: false
75 | description: GitHub Management
76 | homepage_url: https://github.com/pl-strflt/github-mgmt-template
77 | is_template: false
78 | vulnerability_alerts: false
79 | archive_on_destroy: true
80 | gitignore_template: Terraform # This field accepts a name of a template from https://github.com/github/gitignore without extension
81 | ignore_vulnerability_alerts_during_read: false
82 | license_template: mit # This field accepts a name of a template from https://github.com/github/choosealicense.com/tree/gh-pages/_licenses without extension
83 | pages:
84 | cname: ""
85 | source:
86 | branch: master
87 | path: /docs
88 | template:
89 | owner: pl-strflt
90 | repository: github-mgmt-template
91 | topics:
92 | - github
93 |
--------------------------------------------------------------------------------
/docs/HOWTOS.md:
--------------------------------------------------------------------------------
1 | ## How to...
2 |
3 | ### ...create a new resource?
4 |
5 | *NOTE*: You do not have to specify all the attributes when creating a new resource. If you don't, defaults as defined by the [GitHub Provider](https://registry.terraform.io/providers/integrations/github/latest/docs) will be used. The next `Sync` will fill out the remaining attributes in the YAML configuration file.
6 |
7 | *NOTE*: When creating a new resource, you can specify all the attributes that the resource supports even if changes to them are ignored. If you do specify attributes to which changes are ignored, their values are going to be applied during creation but a future `Sync` will remove them from YAML configuration file.
8 |
9 | - Add a new entry to the YAML configuration file - see [EXAMPLE.yml](EXAMPLE.yml) for inspiration
10 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) to create your newly added resource
11 |
12 | *Example*
13 |
14 | I want to invite `galargh` as an admin to `pl-strflt` organization through GitHub Management.
15 |
16 | I ensure the YAML configuration file has the following entry:
17 | ```yaml
18 | members:
19 | admin:
20 | - galargh
21 | ```
22 |
23 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges it if everything looks OK.
24 |
25 | ### ...modify an existing resource?
26 |
27 | - Change the value of an attribute in the YAML configuration file - see [EXAMPLE.yml](EXAMPLE.yml) for inspiration
28 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) to create your newly added resource
29 |
30 | *Example*
31 |
32 | I want to demote `galargh` from being an `admin` of `pl-strflt` organization to a regular `member` through GitHub Management.
33 |
34 | I change the entry for `galargh` in the YAML configuration file from:
35 | ```yaml
36 | members:
37 | admin:
38 | - galargh
39 | ```
40 | to:
41 | ```yaml
42 | members:
43 | member:
44 | - galargh
45 | ```
46 |
47 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges it if everything looks OK.
48 |
49 | ### ...start managing new resource type with GitHub Management?
50 |
51 | - Add one of the [supported resources](ABOUT.md#supported-resources) names to the `resource_types` array in [terraform/locals_override.tf](../terraform/locals_override.tf)
52 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) - *the plan should not contain any changes*
53 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github) to import all the resources you want to manage for the organization
54 |
55 | *Example*
56 |
57 | I want to be able to configure who the member of the `pl-strflt` organization is through GitHub Management.
58 |
59 | I add `github_membership` to `resource_types` array in [terraform/locals_override.tf](../terraform/locals_override.tf). I push my changes to a new branch and create a PR. An admin reviews the PR and merges the PR if everything looks OK. Then, they synchronize GitHub Management with GitHub configuration.
60 |
61 | ### ...stop managing a resource attribute through GitHub Management?
62 |
63 | - If it doesn't exist yet, create an entry for the resource in [terraform/resources_override.tf](../terraform/resources_override.tf) and copy the `lifecycle.ignore_changes` block from the corresponding resource in [terraform/resources.tf](../terraform/resources.tf)
64 | - Add the attribute name to the `lifecycle.ignore_changes` block of the resource
65 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) - *the plan should not contain any changes*
66 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github) to remove all the resource attributes you do not want to manage for the organization anymore
67 |
68 | *Example*
69 |
70 | I do not want to configure the roles of `pl-strflt` organization members through GitHub Management anymore.
71 |
72 | I ensure that `terraform/resources_override.tf` contains the following entry:
73 | ```tf
74 | resource "github_membership" "this" {
75 | lifecycle {
76 | # @resources.membership.ignore_changes
77 | ignore_changes = [
78 | role
79 | ]
80 | }
81 | }
82 | ```
83 |
84 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges the PR if everything looks OK. Then, they synchronize GitHub Management with GitHub configuration.
85 |
86 | ### ...apply GitHub Management changes to GitHub?
87 |
88 | - [Create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) from the branch to the default branch
89 | - Merge the pull request once the `Comment` check passes and you verify the plan posted as a comment
90 | - Confirm that the `Apply` GitHub Action workflow run applied the plan by inspecting the output
91 |
92 | ### ...synchronize GitHub Management with GitHub?
93 |
94 | *NOTE*: Remember that the `Sync` operation modifes terraform state. Even if you run it from a branch, it modifies the global state that is shared with other branches. There is only one terraform state per organization.
95 |
96 | *NOTE*: `Sync` will force push changes directly to the branch you run it from.
97 |
98 | - Run `Sync` GitHub Action workflow from your desired `branch` - *this will import all the resources from the actual GitHub configuration state into GitHub Management*
99 |
100 | ### ...upgrade GitHub Management?
101 |
102 | - Run `Upgrade` GitHub Action workflow
103 | - Merge the pull request that the workflow created once the `Comment` check passes and you verify the plan posted as a comment - *the plan should not contain any changes*
104 |
105 | ### ...remove resources from GitHub Management state?
106 |
107 | - Run `Clean` GitHub Action workflow with a chosen regex
108 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github)
109 |
110 | ### ...add a new config fix rule?
111 |
112 | - Create or modify `scripts/src/actions/fix-yaml-config.ts` file
113 |
114 | *Example*
115 |
116 | I want to ensure that all the public repositories in my organization have their default branches protected.
117 |
118 | To do that, I ensure the following content is present in `scripts/src/actions/fix-yaml-config.ts`:
119 | ```ts
120 | import 'reflect-metadata'
121 | import { runProtectDefaultBranches } from './shared/protect-default-branches'
122 |
123 | runProtectDefaultBranches()
124 | ```
125 |
--------------------------------------------------------------------------------
/files/README.md:
--------------------------------------------------------------------------------
1 | Put files that you want to distribute through GitHub Management here.
2 |
3 | In `repositories.*.files.*.content`, put a relative path to the file. E.g. a reference to this file would be just `README.md`.
4 |
5 | You can create a tree directory structure here.
6 |
--------------------------------------------------------------------------------
/github/$ORGANIZATION_NAME.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=.schema.json
2 |
3 | {}
4 |
--------------------------------------------------------------------------------
/scripts/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | node_modules
3 |
4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
15 |
16 | # Runtime data
17 | pids
18 | *.pid
19 | *.seed
20 | *.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 | lib-cov
24 |
25 | # Coverage directory used by tools like istanbul
26 | coverage
27 | *.lcov
28 |
29 | # nyc test coverage
30 | .nyc_output
31 |
32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
33 | .grunt
34 |
35 | # Bower dependency directory (https://bower.io/)
36 | bower_components
37 |
38 | # node-waf configuration
39 | .lock-wscript
40 |
41 | # Compiled binary addons (https://nodejs.org/api/addons.html)
42 | build/Release
43 |
44 | # Dependency directories
45 | jspm_packages/
46 |
47 | # TypeScript v1 declaration files
48 | typings/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional REPL history
60 | .node_repl_history
61 |
62 | # Output of 'npm pack'
63 | *.tgz
64 |
65 | # Yarn Integrity file
66 | .yarn-integrity
67 |
68 | # dotenv environment variables file
69 | .env
70 | .env.test
71 |
72 | # parcel-bundler cache (https://parceljs.org/)
73 | .cache
74 |
75 | # next.js build output
76 | .next
77 |
78 | # nuxt.js build output
79 | .nuxt
80 |
81 | # vuepress build output
82 | .vuepress/dist
83 |
84 | # Serverless directories
85 | .serverless/
86 |
87 | # FuseBox cache
88 | .fusebox/
89 |
90 | # DynamoDB Local files
91 | .dynamodb/
92 |
93 | # OS metadata
94 | .DS_Store
95 | Thumbs.db
96 |
97 | # Ignore built ts files
98 | __tests__/runner/*
99 | lib/**/*
100 |
101 | # VSCode
102 | .vscode
103 |
--------------------------------------------------------------------------------
/scripts/.prettierignore:
--------------------------------------------------------------------------------
1 | lib/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/scripts/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": false,
9 | "arrowParens": "avoid"
10 | }
11 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/.gitignore:
--------------------------------------------------------------------------------
1 | !terraform.tfstate
2 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/files/README.md:
--------------------------------------------------------------------------------
1 | # GitHub Management via Terraform: pl-strflt
2 |
3 | This repository is responsible for managing GitHub configuration of `pl-strflt` organisation as code with Terraform. It was created from [github-mgmt-template](https://github.com/pl-strflt/github-mgmt-template) and it will receive updates from that repository.
4 |
5 | **IMPORTANT**: Having write access to GitHub Management repository can be as powerful as having admin access to the organizations managed by that repository.
6 |
7 | *NOTE*: Because we don't have merge queue functionality enabled for the repository yet, after a merge, wait for the `Apply` and `Update` workflows to complete before merging any other PRs.
8 |
9 | To learn more, check out:
10 | - [What is GitHub Management and how does it work?](docs/ABOUT.md)
11 | - [How to set up GitHub Management?](docs/SETUP.md)
12 | - [How to work with GitHub Management?](docs/HOWTOS.md)
13 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/github/default.yml:
--------------------------------------------------------------------------------
1 | members:
2 | admin:
3 | - galargh
4 | - laurentsenta
5 | repositories:
6 | github-action-releaser:
7 | advanced_security: false
8 | allow_auto_merge: false
9 | allow_merge_commit: true
10 | allow_rebase_merge: true
11 | allow_squash_merge: true
12 | archived: false
13 | auto_init: false
14 | default_branch: main
15 | delete_branch_on_merge: false
16 | description: Release steps for github actions.
17 | has_downloads: true
18 | has_issues: true
19 | has_projects: true
20 | has_wiki: true
21 | is_template: false
22 | labels:
23 | topic dx:
24 | color: '#57cc2c'
25 | description: Topic DX
26 | topic/ci:
27 | color: '#57cc2c'
28 | description: Topic CI
29 | secret_scanning_push_protection: false
30 | secret_scanning: false
31 | teams:
32 | maintain:
33 | - ipdx
34 | visibility: public
35 | vulnerability_alerts: false
36 | github-mgmt:
37 | advanced_security: false
38 | allow_auto_merge: false
39 | allow_merge_commit: true
40 | allow_rebase_merge: true
41 | allow_squash_merge: true
42 | archived: false
43 | auto_init: false
44 | branch_protection:
45 | master:
46 | allows_deletions: false
47 | allows_force_pushes: false
48 | enforce_admins: false
49 | require_conversation_resolution: false
50 | require_signed_commits: false
51 | required_linear_history: false
52 | required_pull_request_reviews:
53 | dismiss_stale_reviews: false
54 | require_code_owner_reviews: false
55 | required_approving_review_count: 1
56 | restrict_dismissals: false
57 | required_status_checks:
58 | contexts:
59 | - Plan
60 | strict: true
61 | collaborators:
62 | admin:
63 | - galargh
64 | default_branch: master
65 | delete_branch_on_merge: true
66 | files:
67 | README.md:
68 | content: README.md
69 | overwrite_on_create: false
70 | has_downloads: true
71 | has_issues: true
72 | has_projects: false
73 | has_wiki: false
74 | is_template: false
75 | secret_scanning_push_protection: false
76 | secret_scanning: false
77 | teams:
78 | triage:
79 | - ipdx
80 | template:
81 | owner: pl-strflt
82 | repository: github-mgmt-template
83 | visibility: public
84 | vulnerability_alerts: false
85 | ipdx:
86 | advanced_security: false
87 | allow_auto_merge: false
88 | allow_merge_commit: true
89 | allow_rebase_merge: true
90 | allow_squash_merge: true
91 | archived: false
92 | auto_init: false
93 | default_branch: main
94 | delete_branch_on_merge: false
95 | has_downloads: true
96 | has_issues: true
97 | has_projects: true
98 | has_wiki: true
99 | is_template: false
100 | secret_scanning_push_protection: false
101 | secret_scanning: false
102 | teams:
103 | admin:
104 | - ipdx
105 | visibility: public
106 | vulnerability_alerts: false
107 | projects-migration:
108 | advanced_security: false
109 | allow_auto_merge: false
110 | allow_merge_commit: true
111 | allow_rebase_merge: true
112 | allow_squash_merge: true
113 | archived: false
114 | auto_init: false
115 | default_branch: main
116 | delete_branch_on_merge: false
117 | has_downloads: true
118 | has_issues: true
119 | has_projects: true
120 | has_wiki: true
121 | is_template: false
122 | pages:
123 | source:
124 | branch: main
125 | path: /docs
126 | secret_scanning_push_protection: false
127 | secret_scanning: false
128 | teams:
129 | maintain:
130 | - ipdx
131 | topics:
132 | - github
133 | - graphql
134 | visibility: public
135 | vulnerability_alerts: false
136 | projects-status-history:
137 | advanced_security: false
138 | allow_auto_merge: false
139 | allow_merge_commit: true
140 | allow_rebase_merge: true
141 | allow_squash_merge: true
142 | archived: false
143 | auto_init: false
144 | default_branch: main
145 | delete_branch_on_merge: false
146 | has_downloads: true
147 | has_issues: true
148 | has_projects: true
149 | has_wiki: true
150 | is_template: false
151 | labels:
152 | stale:
153 | color: '#57cc2c'
154 | description: Stale
155 | secret_scanning_push_protection: false
156 | secret_scanning: false
157 | teams:
158 | maintain:
159 | - ipdx
160 | visibility: public
161 | vulnerability_alerts: false
162 | rust-sccache-action:
163 | archived: true
164 | tf-aws-gh-runner:
165 | advanced_security: false
166 | allow_auto_merge: false
167 | allow_merge_commit: true
168 | allow_rebase_merge: true
169 | allow_squash_merge: true
170 | archived: false
171 | auto_init: false
172 | default_branch: main
173 | delete_branch_on_merge: false
174 | has_downloads: true
175 | has_issues: true
176 | has_projects: true
177 | has_wiki: true
178 | is_template: false
179 | secret_scanning_push_protection: false
180 | secret_scanning: false
181 | teams:
182 | maintain:
183 | - ipdx
184 | visibility: public
185 | vulnerability_alerts: false
186 | teams:
187 | ipdx:
188 | members:
189 | maintainer:
190 | - galargh
191 | - laurentsenta
192 | parent_team_id: w3dt-stewards
193 | privacy: closed
194 | w3dt-stewards:
195 | privacy: closed
196 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/terraform/locals.tf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ipdxco/github-as-code/d62014d5e32935b976875b01410da76a9d2be395/scripts/__tests__/__resources__/terraform/locals.tf
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/terraform/locals_override.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | resource_types = [
3 | "github_membership",
4 | "github_repository_collaborator",
5 | "github_repository",
6 | "github_team_membership",
7 | "github_team_repository",
8 | "github_team",
9 | "github_branch_protection",
10 | "github_repository_file",
11 | "github_issue_labels"
12 | ]
13 | ignore = {
14 | "repositories" = ["ignored"]
15 | "teams" = ["ignored"]
16 | "users" = ["ignored"]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/terraform/resources.tf:
--------------------------------------------------------------------------------
1 | resource "github_membership" "this" {
2 | lifecycle {
3 | ignore_changes = []
4 | }
5 | }
6 | resource "github_repository" "this" {
7 | lifecycle {
8 | ignore_changes = []
9 | }
10 | }
11 | resource "github_repository_collaborator" "this" {
12 | lifecycle {
13 | ignore_changes = []
14 | }
15 | }
16 | resource "github_branch_protection" "this" {
17 | lifecycle {
18 | ignore_changes = []
19 | }
20 | }
21 | resource "github_team" "this" {
22 | lifecycle {
23 | ignore_changes = []
24 | }
25 | }
26 | resource "github_team_repository" "this" {
27 | lifecycle {
28 | ignore_changes = []
29 | }
30 | }
31 | resource "github_team_membership" "this" {
32 | lifecycle {
33 | ignore_changes = []
34 | }
35 | }
36 | resource "github_repository_file" "this" {
37 | lifecycle {
38 | ignore_changes = []
39 | }
40 | }
41 | resource "github_issue_labels" "this" {
42 | lifecycle {
43 | ignore_changes = []
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/scripts/__tests__/__resources__/terraform/resources_override.tf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ipdxco/github-as-code/d62014d5e32935b976875b01410da76a9d2be395/scripts/__tests__/__resources__/terraform/resources_override.tf
--------------------------------------------------------------------------------
/scripts/__tests__/github.test.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 |
3 | import {before, describe, it} from 'node:test'
4 | import assert from 'node:assert'
5 | import {mockGitHub} from './github.js'
6 | import {GitHub} from '../src/github.js'
7 |
8 | describe('github', () => {
9 | let github: GitHub
10 |
11 | before(async () => {
12 | mockGitHub({
13 | invitations: [
14 | {
15 | login: 'ignored'
16 | },
17 | {
18 | login: 'unignored'
19 | }
20 | ],
21 | members: [
22 | {
23 | login: 'ignored'
24 | },
25 | {
26 | login: 'unignored'
27 | }
28 | ],
29 | repositories: [
30 | {
31 | name: 'ignored',
32 | branchProtectionRules: [
33 | {
34 | pattern: 'ignored'
35 | },
36 | {
37 | pattern: 'unignored'
38 | }
39 | ],
40 | collaborators: [
41 | {
42 | login: 'ignored'
43 | },
44 | {
45 | login: 'unignored'
46 | }
47 | ],
48 | invitations: [
49 | {
50 | login: 'ignored'
51 | },
52 | {
53 | login: 'unignored'
54 | }
55 | ],
56 | labels: [
57 | {
58 | name: 'ignored'
59 | },
60 | {
61 | name: 'unignored'
62 | }
63 | ]
64 | },
65 | {
66 | name: 'unignored',
67 | branchProtectionRules: [
68 | {
69 | pattern: 'ignored'
70 | },
71 | {
72 | pattern: 'unignored'
73 | }
74 | ],
75 | collaborators: [
76 | {
77 | login: 'ignored'
78 | },
79 | {
80 | login: 'unignored'
81 | }
82 | ],
83 | invitations: [
84 | {
85 | login: 'ignored'
86 | },
87 | {
88 | login: 'unignored'
89 | }
90 | ],
91 | labels: [
92 | {
93 | name: 'ignored'
94 | },
95 | {
96 | name: 'unignored'
97 | }
98 | ]
99 | }
100 | ],
101 | teams: [
102 | {
103 | name: 'ignored',
104 | members: [
105 | {
106 | login: 'ignored'
107 | },
108 | {
109 | login: 'unignored'
110 | }
111 | ],
112 | invitations: [
113 | {
114 | login: 'ignored'
115 | },
116 | {
117 | login: 'unignored'
118 | }
119 | ],
120 | repositories: [
121 | {
122 | name: 'ignored'
123 | },
124 | {
125 | name: 'unignored'
126 | }
127 | ]
128 | },
129 | {
130 | name: 'unignored',
131 | members: [
132 | {
133 | login: 'ignored'
134 | },
135 | {
136 | login: 'unignored'
137 | }
138 | ],
139 | invitations: [
140 | {
141 | login: 'ignored'
142 | },
143 | {
144 | login: 'unignored'
145 | }
146 | ],
147 | repositories: [
148 | {
149 | name: 'ignored'
150 | },
151 | {
152 | name: 'unignored'
153 | }
154 | ]
155 | }
156 | ]
157 | })
158 | github = await GitHub.getGitHub()
159 | })
160 |
161 | it('listInvitations', async () => {
162 | const invitations = await github.listInvitations()
163 | assert.ok(invitations.length > 0)
164 | assert.ok(!invitations.some(i => i.login === 'ignored'))
165 | })
166 |
167 | it('listMembers', async () => {
168 | const members = await github.listMembers()
169 | assert.ok(members.length > 0)
170 | assert.ok(!members.some(m => m.user?.login === 'ignored'))
171 | })
172 |
173 | it('listRepositories', async () => {
174 | const repositories = await github.listRepositories()
175 | assert.ok(repositories.length > 0)
176 | assert.ok(!repositories.some(r => r.name === 'ignored'))
177 | })
178 |
179 | it('listRepositoryBranchProtectionRules', async () => {
180 | const rules = await github.listRepositoryBranchProtectionRules()
181 | assert.ok(rules.length > 0)
182 | assert.ok(!rules.some(r => r.repository.name === 'ignored'))
183 | // NOTE: Ignoring rules by pattern is not supported yet
184 | assert.ok(rules.some(r => r.branchProtectionRule.pattern === 'ignored'))
185 | })
186 |
187 | it('listRepositoryCollaborators', async () => {
188 | const collaborators = await github.listRepositoryCollaborators()
189 | assert.ok(collaborators.length > 0)
190 | assert.ok(!collaborators.some(c => c.repository.name === 'ignored'))
191 | assert.ok(!collaborators.some(c => c.collaborator.login === 'ignored'))
192 | })
193 |
194 | it('listRepositoryInvitations', async () => {
195 | const invitations = await github.listRepositoryInvitations()
196 | assert.ok(invitations.length > 0)
197 | assert.ok(!invitations.some(i => i.repository.name === 'ignored'))
198 | assert.ok(!invitations.some(i => i.invitee?.login === 'ignored'))
199 | })
200 |
201 | it('listRepositoryLabels', async () => {
202 | const labels = await github.listRepositoryLabels()
203 | assert.ok(labels.length > 0)
204 | assert.ok(!labels.some(l => l.repository.name === 'ignored'))
205 | // NOTE: Ignoring labels by name is not supported yet
206 | assert.ok(labels.some(l => l.label.name === 'ignored'))
207 | })
208 |
209 | it('listTeams', async () => {
210 | const teams = await github.listTeams()
211 | assert.ok(teams.length > 0)
212 | assert.ok(!teams.some(t => t.name === 'ignored'))
213 | })
214 |
215 | it('listTeamInvitations', async () => {
216 | const invitations = await github.listTeamInvitations()
217 | assert.ok(invitations.length > 0)
218 | assert.ok(!invitations.some(i => i.team.name === 'ignored'))
219 | assert.ok(!invitations.some(i => i.invitation.login === 'ignored'))
220 | })
221 |
222 | it('listTeamMembers', async () => {
223 | const members = await github.listTeamMembers()
224 | assert.ok(members.length > 0)
225 | assert.ok(!members.some(m => m.team.name === 'ignored'))
226 | assert.ok(!members.some(m => m.member.login === 'ignored'))
227 | })
228 |
229 | it('listTeamRepositories', async () => {
230 | const repositories = await github.listTeamRepositories()
231 | assert.ok(repositories.length > 0)
232 | assert.ok(!repositories.some(r => r.team.name === 'ignored'))
233 | assert.ok(!repositories.some(r => r.repository.name === 'ignored'))
234 | })
235 | })
236 |
--------------------------------------------------------------------------------
/scripts/__tests__/github.ts:
--------------------------------------------------------------------------------
1 | import {mock} from 'node:test'
2 |
3 | export interface GitHubConfig {
4 | repositories?: {
5 | name: string
6 | collaborators?: {
7 | login: string
8 | }[]
9 | branchProtectionRules?: {
10 | pattern: string
11 | }[]
12 | invitations?: {
13 | login: string
14 | }[]
15 | labels?: {
16 | name: string
17 | }[]
18 | }[]
19 | teams?: {
20 | name: string
21 | members?: {
22 | login: string
23 | }[]
24 | invitations?: {
25 | login: string
26 | }[]
27 | repositories?: {
28 | name: string
29 | }[]
30 | }[]
31 | invitations?: {
32 | login: string
33 | }[]
34 | members?: {
35 | login: string
36 | }[]
37 | }
38 |
39 | export function mockGitHub(config: GitHubConfig = {}): void {
40 | mock.module('@octokit/auth-app', {
41 | namedExports: {
42 | createAppAuth: () => () => ({token: undefined})
43 | }
44 | })
45 |
46 | mock.module('@octokit/rest', {
47 | namedExports: {
48 | Octokit: {
49 | plugin: () => {
50 | // return Constructor of Octokit-like object
51 | return class {
52 | issues = {
53 | listCommentsForRepo: async () => [],
54 | listForRepo: async () => [],
55 | listLabelsForRepo: async (opts: {repo: string}) =>
56 | config?.repositories?.find(r => r.name === opts.repo)?.labels ??
57 | []
58 | }
59 | orgs = {
60 | getMembershipForUser: async () => ({
61 | data: {}
62 | }),
63 | listMembers: async () => config?.members ?? [],
64 | listPendingInvitations: async () => config?.invitations ?? []
65 | }
66 | pulls = {
67 | listReviewCommentsForRepo: async () => []
68 | }
69 | repos = {
70 | get: async (opts: {owner: string; repo: string}) => ({
71 | data: {
72 | owner: {
73 | login: opts.owner
74 | },
75 | name: opts.repo,
76 | default_branch: 'main'
77 | }
78 | }),
79 | getContent: async (opts: {
80 | owner: string
81 | repo: string
82 | path: string
83 | ref: string
84 | }) => ({
85 | data: {
86 | path: opts.path,
87 | url: `https://github.com/${opts.owner}/${opts.repo}/blob/${opts.ref}/${opts.path}`,
88 | ref: opts.ref
89 | }
90 | }),
91 | listActivities: async () => [],
92 | listCollaborators: async (opts: {repo: string}) =>
93 | config?.repositories?.find(r => r.name === opts.repo)
94 | ?.collaborators ?? [],
95 | listCommitCommentsForRepo: async () => [],
96 | listForOrg: async () => config?.repositories ?? [],
97 | listInvitations: async (opts: {repo: string}) => {
98 | const repository = config?.repositories?.find(
99 | r => r.name === opts.repo
100 | )
101 |
102 | return repository?.invitations?.map(invitee => ({
103 | repository,
104 | invitee
105 | }))
106 | }
107 | }
108 | teams = {
109 | getMembershipForUserInOrg: async () => ({
110 | data: {}
111 | }),
112 | list: async () =>
113 | config?.teams?.map(t => ({slug: t.name, ...t})) ?? [],
114 | listMembersInOrg: async (opts: {team_slug: string}) =>
115 | config?.teams?.find(t => t.name === opts.team_slug)?.members ??
116 | [],
117 | listPendingInvitationsInOrg: async (opts: {team_slug: string}) =>
118 | config?.teams?.find(t => t.name === opts.team_slug)
119 | ?.invitations ?? [],
120 | listReposInOrg: async (opts: {team_slug: string}) =>
121 | config?.teams?.find(t => t.name === opts.team_slug)
122 | ?.repositories ?? []
123 | }
124 | async paginate(
125 | f: (opts: K) => Promise,
126 | opts: K
127 | ): Promise {
128 | return f(opts)
129 | }
130 | async graphql(query: string): Promise {
131 | // extract owner and repo from query using repository\(owner: \"([^\"]+)\", name: \"([^\"]+)\"\)
132 | const match = query.match(
133 | /repository\(owner: "([^"]+)", name: "([^"]+)"\)/
134 | )
135 | if (match === null) {
136 | throw new Error(`Could not find repository in query: ${query}`)
137 | }
138 | const [, , repo] = match
139 | const nodes =
140 | config.repositories?.find(r => r.name === repo)
141 | ?.branchProtectionRules ?? []
142 | return {
143 | repository: {
144 | branchProtectionRules: {
145 | nodes
146 | }
147 | }
148 | }
149 | }
150 | }
151 | }
152 | }
153 | }
154 | })
155 | }
156 |
--------------------------------------------------------------------------------
/scripts/__tests__/resources/counts.ts:
--------------------------------------------------------------------------------
1 | import {Member} from '../../src/resources/member.js'
2 | import {RepositoryBranchProtectionRule} from '../../src/resources/repository-branch-protection-rule.js'
3 | import {RepositoryCollaborator} from '../../src/resources/repository-collaborator.js'
4 | import {RepositoryFile} from '../../src/resources/repository-file.js'
5 | import {RepositoryLabel} from '../../src/resources/repository-label.js'
6 | import {RepositoryTeam} from '../../src/resources/repository-team.js'
7 | import {Repository} from '../../src/resources/repository.js'
8 | import {TeamMember} from '../../src/resources/team-member.js'
9 | import {Team} from '../../src/resources/team.js'
10 |
11 | export const ConfigResourceCounts = {
12 | [Member.name]: 2,
13 | [Repository.name]: 7,
14 | [Team.name]: 2,
15 | [RepositoryCollaborator.name]: 1,
16 | [RepositoryBranchProtectionRule.name]: 1,
17 | [RepositoryTeam.name]: 6,
18 | [TeamMember.name]: 2,
19 | [RepositoryFile.name]: 1,
20 | [RepositoryLabel.name]: 3
21 | }
22 | export const ConfigResourcesCount = Object.values(ConfigResourceCounts).reduce(
23 | (a, b) => a + b,
24 | 0
25 | )
26 | export const StateResourceCounts = {
27 | [Member.name]: 2,
28 | [Repository.name]: 7,
29 | [Team.name]: 2,
30 | [RepositoryCollaborator.name]: 1,
31 | [RepositoryBranchProtectionRule.name]: 1,
32 | [RepositoryTeam.name]: 7,
33 | [TeamMember.name]: 2,
34 | [RepositoryFile.name]: 1,
35 | [RepositoryLabel.name]: 3
36 | }
37 | export const StateResourcesCount = Object.values(StateResourceCounts).reduce(
38 | (a, b) => a + b,
39 | 0
40 | )
41 |
--------------------------------------------------------------------------------
/scripts/__tests__/resources/repository-file.test.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 | import {findFileByContent} from '../../src/resources/repository-file.js'
3 | import {describe, it} from 'node:test'
4 | import assert from 'node:assert'
5 |
6 | describe('repository file', () => {
7 | it('finds file by content', async () => {
8 | const filePath = '__tests__/__resources__/files/README.md'
9 | const fileContent = fs.readFileSync(filePath).toString()
10 | const foundFilePath = findFileByContent(
11 | '__tests__/__resources__',
12 | fileContent
13 | )
14 | assert.equal(foundFilePath, filePath)
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/scripts/__tests__/sync.test.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 |
3 | import * as YAML from 'yaml'
4 |
5 | import {Config} from '../src/yaml/config.js'
6 | import {State} from '../src/terraform/state.js'
7 | import {sync} from '../src/sync.js'
8 | import {RepositoryFile} from '../src/resources/repository-file.js'
9 | import {StateSchema} from '../src/terraform/schema.js'
10 | import {toggleArchivedRepos} from '../src/actions/shared/toggle-archived-repos.js'
11 | import {before, describe, it, mock} from 'node:test'
12 | import assert from 'node:assert'
13 | import {mockGitHub} from './github.js'
14 |
15 | describe('sync', () => {
16 | before(() => {
17 | mockGitHub()
18 | })
19 |
20 | it('sync', async () => {
21 | const yamlConfig = new Config('{}')
22 | const tfConfig = await State.New()
23 |
24 | const expectedYamlConfig = Config.FromPath()
25 |
26 | await sync(tfConfig, yamlConfig)
27 | await toggleArchivedRepos(tfConfig, yamlConfig)
28 |
29 | yamlConfig.format()
30 |
31 | assert.equal(yamlConfig.toString(), expectedYamlConfig.toString())
32 | })
33 |
34 | it('sync new repository file', async () => {
35 | const yamlSource = {
36 | repositories: {
37 | blog: {
38 | files: {
39 | 'README.md': {
40 | content: 'Hello, world!'
41 | }
42 | }
43 | }
44 | }
45 | }
46 | const tfSource: StateSchema = {
47 | values: {
48 | root_module: {
49 | resources: []
50 | }
51 | }
52 | }
53 |
54 | const yamlConfig = new Config(YAML.stringify(yamlSource))
55 | const tfConfig = new State(JSON.stringify(tfSource))
56 |
57 | mock.module('../src/terraform/state.js', {
58 | namedExports: {
59 | loadState: async () => JSON.stringify(tfSource)
60 | }
61 | })
62 |
63 | const resources = [
64 | {
65 | mode: 'managed',
66 | index: 'blog:README.md',
67 | address: 'github_repository_file.this["blog/readme.md"]',
68 | type: RepositoryFile.StateType,
69 | values: {
70 | repository: 'blog',
71 | file: 'README.md',
72 | content: 'Hello, world!'
73 | }
74 | }
75 | ]
76 |
77 | tfConfig.addResourceAt = async (_id: string, address: string) => {
78 | const resource = resources.find(r => r.address === address)
79 | if (resource !== undefined) {
80 | tfSource?.values?.root_module?.resources?.push(resource)
81 | }
82 | }
83 |
84 | const expectedYamlConfig = new Config(YAML.stringify(yamlSource))
85 |
86 | await sync(tfConfig, yamlConfig)
87 |
88 | yamlConfig.format()
89 |
90 | assert.equal(yamlConfig.toString(), expectedYamlConfig.toString())
91 | })
92 | })
93 |
--------------------------------------------------------------------------------
/scripts/__tests__/terraform/state.test.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 |
3 | import {State} from '../../src/terraform/state.js'
4 | import {Resource, ResourceConstructors} from '../../src/resources/resource.js'
5 | import {Repository} from '../../src/resources/repository.js'
6 | import {Id} from '../../src/terraform/schema.js'
7 | import {describe, it, mock} from 'node:test'
8 | import assert from 'node:assert'
9 | import {StateResourceCounts, StateResourcesCount} from '../resources/counts.js'
10 |
11 | describe('state', () => {
12 | it('can retrieve resources from tf state', async () => {
13 | const config = await State.New()
14 |
15 | const resources = []
16 | for (const resourceClass of ResourceConstructors) {
17 | const classResources = config.getResources(resourceClass)
18 | assert.equal(
19 | classResources.length,
20 | StateResourceCounts[resourceClass.name]
21 | )
22 | resources.push(...classResources)
23 | }
24 |
25 | assert.equal(resources.length, StateResourcesCount)
26 | })
27 |
28 | it('can ignore resource types', async () => {
29 | const state = await State.New()
30 |
31 | assert.equal(await state.isIgnored(Repository), false)
32 |
33 | mock.module('../../src/terraform/locals.js', {
34 | namedExports: {
35 | Locals: {
36 | getLocals: () => {
37 | return {
38 | resource_types: []
39 | }
40 | }
41 | }
42 | }
43 | })
44 |
45 | await state.refresh()
46 |
47 | assert.equal(await state.isIgnored(Repository), true)
48 | })
49 |
50 | it('can ignore resource properties', async () => {
51 | const config = await State.New()
52 |
53 | const resource = config.getResources(Repository)[0]
54 | assert.notEqual(resource.description, undefined)
55 |
56 | config['_ignoredProperties'] = {github_repository: ['description']}
57 | await config.refresh()
58 |
59 | const refreshedResource = config.getResources(Repository)[0]
60 | assert.equal(refreshedResource.description, undefined)
61 | })
62 |
63 | it('can add and remove resources through sync', async () => {
64 | const config = await State.New()
65 |
66 | const addResourceAtMock = mock.fn(config.addResourceAt.bind(config))
67 | const removeResourceAtMock = mock.fn(config.removeResourceAt.bind(config))
68 |
69 | config.addResourceAt = addResourceAtMock
70 | config.removeResourceAt = removeResourceAtMock
71 |
72 | const desiredResources: [Id, Resource][] = []
73 | const resources = config.getAllResources()
74 |
75 | await config.sync(desiredResources)
76 |
77 | assert.equal(addResourceAtMock.mock.calls.length, 0)
78 | assert.equal(
79 | removeResourceAtMock.mock.calls.length,
80 | new Set(resources.map(r => r.getStateAddress().toLowerCase())).size
81 | )
82 |
83 | addResourceAtMock.mock.resetCalls()
84 | removeResourceAtMock.mock.resetCalls()
85 |
86 | for (const resource of resources) {
87 | desiredResources.push(['id', resource])
88 | }
89 |
90 | await config.sync(desiredResources)
91 | assert.equal(addResourceAtMock.mock.calls.length, 1) // adding github-mgmt/readme.md
92 | assert.equal(removeResourceAtMock.mock.calls.length, 1) // removing github-mgmt/README.md
93 |
94 | addResourceAtMock.mock.resetCalls()
95 | removeResourceAtMock.mock.resetCalls()
96 |
97 | desiredResources.push(['id', new Repository('test')])
98 | desiredResources.push(['id', new Repository('test2')])
99 | desiredResources.push(['id', new Repository('test3')])
100 | desiredResources.push(['id', new Repository('test4')])
101 |
102 | await config.sync(desiredResources)
103 |
104 | assert.equal(
105 | addResourceAtMock.mock.calls.length,
106 | 1 + desiredResources.length - resources.length
107 | )
108 | assert.equal(removeResourceAtMock.mock.calls.length, 1)
109 | })
110 | })
111 |
--------------------------------------------------------------------------------
/scripts/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import eslint from '@eslint/js';
4 | import tseslint from 'typescript-eslint';
5 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
6 |
7 | export default tseslint.config(
8 | eslint.configs.recommended,
9 | tseslint.configs.recommended,
10 | eslintPluginPrettierRecommended,
11 | {
12 | rules: {
13 | "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}],
14 | }
15 | }
16 | );
17 |
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scripts",
3 | "version": "0.0.0",
4 | "private": true,
5 | "main": "lib/main.js",
6 | "type": "module",
7 | "scripts": {
8 | "build": "tsc --project tsconfig.build.json",
9 | "format": "prettier --write '**/*.ts'",
10 | "format:check": "prettier --check '**/*.ts'",
11 | "lint": "eslint \"src/**/*.ts\" \"__tests__/**/*.ts\"",
12 | "lint:fix": "eslint --fix \"src/**/*.ts\" \"__tests__/**/*.ts\"",
13 | "test": "TF_EXEC=false TF_LOCK=false TF_WORKING_DIR=__tests__/__resources__/terraform GITHUB_DIR=__tests__/__resources__/github FILES_DIR=__tests__/__resources__/files GITHUB_ORG=default node --import tsx/esm --test --experimental-test-module-mocks \"__tests__/**/*.test.ts\"",
14 | "test:only": "TF_EXEC=false TF_LOCK=false TF_WORKING_DIR=__tests__/__resources__/terraform GITHUB_DIR=__tests__/__resources__/github FILES_DIR=__tests__/__resources__/files GITHUB_ORG=default node --import tsx/esm --test --test-only --experimental-test-module-mocks \"__tests__/**/*.test.ts\"",
15 | "all": "npm run build && npm run format && npm run lint && npm test",
16 | "schema": "ts-json-schema-generator --tsconfig tsconfig.json --path src/yaml/schema.ts --type ConfigSchema --out ../github/.schema.json",
17 | "main": "node lib/main.js"
18 | },
19 | "dependencies": {
20 | "@actions/core": "^1.11.1",
21 | "@actions/exec": "^1.1.1",
22 | "@actions/github": "^6.0.0",
23 | "@octokit/auth-app": "^7.2.0",
24 | "@octokit/graphql": "^8.2.2",
25 | "@octokit/plugin-paginate-rest": "^11.6.0",
26 | "@octokit/plugin-retry": "^7.2.1",
27 | "@octokit/plugin-throttling": "^9.6.1",
28 | "@octokit/rest": "^21.1.1",
29 | "class-transformer": "^0.5.1",
30 | "deep-diff": "^1.0.2",
31 | "hcl2-parser": "^1.0.3",
32 | "reflect-metadata": "^0.2.2",
33 | "yaml": "^2.7.1"
34 | },
35 | "devDependencies": {
36 | "@eslint/js": "^9.24.0",
37 | "@octokit/types": "^14.0.0",
38 | "@types/deep-diff": "^1.0.5",
39 | "@types/node": "^22.14.1",
40 | "eslint": "^9.25.0",
41 | "eslint-config-prettier": "^10.1.2",
42 | "eslint-plugin-prettier": "^5.2.6",
43 | "prettier": "^3.5.3",
44 | "ts-json-schema-generator": "^2.4.0",
45 | "tsx": "^4.19.3",
46 | "typescript": "^5.8.3",
47 | "typescript-eslint": "^8.29.1"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/scripts/src/actions/find-sha-for-plan.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {context} from '@actions/github'
3 | import * as core from '@actions/core'
4 |
5 | async function findShaForPlan(): Promise {
6 | const github = await GitHub.getGitHub()
7 |
8 | if (context.eventName !== 'push') {
9 | return context.sha
10 | }
11 |
12 | const pulls = await github.client.paginate(
13 | github.client.search.issuesAndPullRequests,
14 | {
15 | q: `repository:${context.repo.owner}/${context.repo.repo} ${context.sha} type:pr is:merged`,
16 | advanced_search: 'true'
17 | }
18 | )
19 |
20 | if (pulls.length === 0) {
21 | return ''
22 | }
23 |
24 | const pull = pulls[0]
25 | const commits = await github.client.paginate(
26 | github.client.pulls.listCommits,
27 | {
28 | ...context.repo,
29 | pull_number: pull.number
30 | }
31 | )
32 |
33 | if (commits.length === 0) {
34 | return ''
35 | }
36 |
37 | return commits[commits.length - 1].sha
38 | }
39 |
40 | async function run(): Promise {
41 | const sha = await findShaForPlan()
42 | core.setOutput('result', sha)
43 | }
44 |
45 | run()
46 |
--------------------------------------------------------------------------------
/scripts/src/actions/fix-yaml-config.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 |
3 | import {runToggleArchivedRepos} from './shared/toggle-archived-repos.js'
4 | import {runDescribeAccessChanges} from './shared/describe-access-changes.js'
5 |
6 | import * as core from '@actions/core'
7 |
8 | async function run(): Promise {
9 | await runToggleArchivedRepos()
10 |
11 | const accessChangesDescription = await runDescribeAccessChanges()
12 |
13 | core.setOutput(
14 | 'comment',
15 | `The following access changes will be introduced as a result of applying the plan:
16 |
17 | Access Changes
18 |
19 | \`\`\`
20 | ${accessChangesDescription}
21 | \`\`\`
22 |
23 | `
24 | )
25 | }
26 |
27 | run()
28 |
--------------------------------------------------------------------------------
/scripts/src/actions/format-yaml-config.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 | import {Config} from '../yaml/config.js'
3 |
4 | async function run(): Promise {
5 | const config = Config.FromPath()
6 | config.save()
7 | }
8 |
9 | run()
10 |
--------------------------------------------------------------------------------
/scripts/src/actions/remove-inactive-members.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 | import {RepositoryTeam} from '../resources/repository-team.js'
3 | import {Team} from '../resources/team.js'
4 | import {Config} from '../yaml/config.js'
5 | import {Member} from '../resources/member.js'
6 | import {RepositoryCollaborator} from '../resources/repository-collaborator.js'
7 | import {Resource, ResourceConstructor} from '../resources/resource.js'
8 | import {Role, TeamMember} from '../resources/team-member.js'
9 | import {GitHub} from '../github.js'
10 |
11 | function getResources(
12 | config: Config,
13 | resourceClass: ResourceConstructor
14 | ): T[] {
15 | const schema = config.get()
16 | return config.getResources(resourceClass).filter(resource => {
17 | const node = config.document.getIn(
18 | resource.getSchemaPath(schema),
19 | true
20 | ) as {comment?: string}
21 | return !node.comment?.includes('KEEP:')
22 | })
23 | }
24 |
25 | /* This function is used to remove inactive members from the config.
26 | *
27 | * 1. It ensures that a team called 'Alumni' exists.
28 | * 2. It removes all 'Alumni' team from all the repositories.
29 | * 3. It populates 'Alumni' team with organization members who:
30 | * a. do not have 'KEEP:' in their inline comment AND
31 | * b. have not been added to the organization recently (if passed through NEW_MEMBERS) AND
32 | * c. have not performed any activity in the past X months.
33 | * 4. It removes repository collaborators who:
34 | * a. do not have 'KEEP:' in their inline comment AND
35 | * b. have not been added to the repository recently (if passed through NEW_REPOSITORY_COLLABORATORS) AND
36 | * c. have not performed any activity on the repository they're a collaborator of in the past X months.
37 | * 5. It removes team members who:
38 | * a. do not have 'KEEP:' in their inline comment AND
39 | * b. have not been added to the team recently (if passed through NEW_TEAM_MEMBERS) AND
40 | * c. have not performed any activity on any repository the team they're a member of has access to in the past X months.
41 | */
42 | async function run(): Promise {
43 | const newMembers = JSON.parse(process.env.NEW_MEMBERS || '[]')
44 | const newRepositoryCollaborators = JSON.parse(
45 | process.env.NEW_REPOSITORY_COLLABORATORS || '{}'
46 | )
47 | const newTeamMembers = JSON.parse(process.env.NEW_TEAM_MEMBERS || '{}')
48 | const cutoffInMonths = parseInt(process.env.CUTOFF_IN_MONTHS || '12')
49 |
50 | const github = await GitHub.getGitHub()
51 |
52 | const githubRepositories = await github.listRepositories()
53 |
54 | const since = new Date()
55 | since.setMonth(since.getMonth() - cutoffInMonths)
56 |
57 | const githubRepositoryActivities =
58 | await github.listRepositoryActivities(since)
59 | const githubRepositoryIssues = await github.listRepositoryIssues(since)
60 | const githubRepositoryPullRequestReviewComments =
61 | await github.listRepositoryPullRequestReviewComments(since)
62 | const githubRepositoryIssueComments =
63 | await github.listRepositoryIssueComments(since)
64 | const githubRepositoryCommitComments =
65 | await github.listRepositoryCommitComments(since)
66 |
67 | const activeActorsByRepository = [
68 | ...githubRepositoryActivities.map(({repository, activity}) => ({
69 | repository: repository.name,
70 | actor: activity.actor?.login
71 | })),
72 | ...githubRepositoryIssues.map(({repository, issue}) => ({
73 | repository: repository.name,
74 | actor: issue.user?.login
75 | })),
76 | ...githubRepositoryPullRequestReviewComments.map(
77 | ({repository, comment}) => ({
78 | repository: repository.name,
79 | actor: comment.user?.login
80 | })
81 | ),
82 | ...githubRepositoryIssueComments.map(({repository, comment}) => ({
83 | repository: repository.name,
84 | actor: comment.user?.login
85 | })),
86 | ...githubRepositoryCommitComments.map(({repository, comment}) => ({
87 | repository: repository.name,
88 | actor: comment.user?.login
89 | }))
90 | ]
91 | .filter(({actor}) => actor !== undefined)
92 | .reduce>((acc, {repository, actor}) => {
93 | acc[repository] = acc[repository] ?? []
94 | acc[repository].push(actor!)
95 | return acc
96 | }, {})
97 | const activeActors = Array.from(
98 | new Set(Object.values(activeActorsByRepository).flat())
99 | )
100 | const archivedRepositories = githubRepositories
101 | .filter(repository => repository.archived)
102 | .map(repository => repository.name)
103 |
104 | const config = Config.FromPath()
105 |
106 | // alumni is a team for all the members who should get credit for their work
107 | // but do not need any special access anymore
108 | // first, ensure that the team exists
109 | const alumniTeam = new Team('Alumni')
110 | config.addResource(alumniTeam)
111 |
112 | // then, ensure that the team doesn't have any special access anywhere
113 | const repositoryTeams = config.getResources(RepositoryTeam)
114 | for (const repositoryTeam of repositoryTeams) {
115 | if (repositoryTeam.team === alumniTeam.name) {
116 | config.removeResource(repositoryTeam)
117 | }
118 | }
119 |
120 | // add members that have been inactive to the alumni team
121 | const members = getResources(config, Member)
122 | for (const member of members) {
123 | const isNew = newMembers.includes(member.username)
124 | if (!isNew) {
125 | const isActive = activeActors.includes(member.username)
126 | if (!isActive) {
127 | console.log(`Adding ${member.username} to the ${alumniTeam.name} team`)
128 | const teamMember = new TeamMember(
129 | alumniTeam.name,
130 | member.username,
131 | Role.Member
132 | )
133 | config.addResource(teamMember)
134 | }
135 | }
136 | }
137 |
138 | // remove repository collaborators that have been inactive
139 | const repositoryCollaborators = getResources(config, RepositoryCollaborator)
140 | for (const repositoryCollaborator of repositoryCollaborators) {
141 | const isNew = newRepositoryCollaborators[
142 | repositoryCollaborator.username
143 | ]?.includes(repositoryCollaborator.repository)
144 | if (!isNew) {
145 | const isCollaboratorActive = activeActorsByRepository[
146 | repositoryCollaborator.repository
147 | ]?.includes(repositoryCollaborator.username)
148 | const isRepositoryArchived = archivedRepositories.includes(
149 | repositoryCollaborator.repository
150 | )
151 | if (!isCollaboratorActive && !isRepositoryArchived) {
152 | console.log(
153 | `Removing ${repositoryCollaborator.username} from ${repositoryCollaborator.repository} repository`
154 | )
155 | config.removeResource(repositoryCollaborator)
156 | }
157 | }
158 | }
159 |
160 | // remove team members that have been inactive (look at all the team repositories)
161 | const teamMembers = getResources(config, TeamMember).filter(
162 | teamMember => teamMember.team !== alumniTeam.name
163 | )
164 | for (const teamMember of teamMembers) {
165 | const isNew = newTeamMembers[teamMember.username]?.includes(teamMember.team)
166 | if (!isNew) {
167 | const repositories = repositoryTeams
168 | .filter(repositoryTeam => repositoryTeam.team === teamMember.team)
169 | .map(repositoryTeam => repositoryTeam.repository)
170 | const isActive = repositories.some(repository =>
171 | activeActorsByRepository[repository]?.includes(teamMember.username)
172 | )
173 | if (!isActive) {
174 | console.log(
175 | `Removing ${teamMember.username} from ${teamMember.team} team`
176 | )
177 | config.removeResource(teamMember)
178 | }
179 | }
180 | }
181 |
182 | config.save()
183 | }
184 |
185 | run()
186 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/add-collaborator-to-all-repos.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository} from '../../resources/repository.js'
3 | import * as core from '@actions/core'
4 | import {
5 | Permission,
6 | RepositoryCollaborator
7 | } from '../../resources/repository-collaborator.js'
8 |
9 | export async function runAddCollaboratorToAllRepos(
10 | username: string,
11 | permission: Permission,
12 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true
13 | ): Promise {
14 | const config = Config.FromPath()
15 |
16 | await addCollaboratorToAllRepos(
17 | config,
18 | username,
19 | permission,
20 | repositoryFilter
21 | )
22 |
23 | config.save()
24 | }
25 |
26 | export async function addCollaboratorToAllRepos(
27 | config: Config,
28 | username: string,
29 | permission: Permission,
30 | repositoryFilter: (repository: Repository) => boolean = () => true
31 | ): Promise {
32 | const collaborators = config
33 | .getResources(RepositoryCollaborator)
34 | .filter(c => c.username === username)
35 |
36 | const repositories = config
37 | .getResources(Repository)
38 | .filter(r => !r.archived)
39 | .filter(repositoryFilter)
40 | .filter(r => !collaborators.some(c => c.repository === r.name))
41 |
42 | for (const repository of repositories) {
43 | const collaborator = new RepositoryCollaborator(
44 | repository.name,
45 | username,
46 | permission
47 | )
48 | core.info(
49 | `Adding ${collaborator.username} as a collaborator with ${collaborator.permission} access to ${collaborator.repository} repository`
50 | )
51 | config.addResource(collaborator)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/add-file-to-all-repos.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository} from '../../resources/repository.js'
3 | import {RepositoryFile} from '../../resources/repository-file.js'
4 | import * as core from '@actions/core'
5 |
6 | export async function runAddFileToAllRepos(
7 | name: string,
8 | content: string = name,
9 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true
10 | ): Promise {
11 | const config = Config.FromPath()
12 |
13 | await addFileToAllRepos(config, name, content, repositoryFilter)
14 |
15 | config.save()
16 | }
17 |
18 | export async function addFileToAllRepos(
19 | config: Config,
20 | name: string,
21 | content: string = name,
22 | repositoryFilter: (repository: Repository) => boolean = () => true
23 | ): Promise {
24 | const repositories = config
25 | .getResources(Repository)
26 | .filter(r => !r.archived)
27 | .filter(repositoryFilter)
28 |
29 | for (const repository of repositories) {
30 | const file = new RepositoryFile(repository.name, name)
31 | file.content = content
32 | if (!config.someResource(file)) {
33 | core.info(`Adding ${file.file} file to ${file.repository} repository`)
34 | config.addResource(file)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/add-label-to-all-repos.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository} from '../../resources/repository.js'
3 | import {RepositoryLabel} from '../../resources/repository-label.js'
4 | import * as core from '@actions/core'
5 |
6 | export async function runAddLabelToAllRepos(
7 | name: string,
8 | color: string | undefined = undefined,
9 | description: string | undefined = undefined,
10 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true
11 | ): Promise {
12 | const config = Config.FromPath()
13 |
14 | await addLabelToAllRepos(config, name, color, description, repositoryFilter)
15 |
16 | config.save()
17 | }
18 |
19 | export async function addLabelToAllRepos(
20 | config: Config,
21 | name: string,
22 | color: string | undefined = undefined,
23 | description: string | undefined = undefined,
24 | repositoryFilter: (repository: Repository) => boolean = () => true
25 | ): Promise {
26 | const repositories = config
27 | .getResources(Repository)
28 | .filter(r => !r.archived)
29 | .filter(repositoryFilter)
30 |
31 | for (const repository of repositories) {
32 | const label = new RepositoryLabel(repository.name, name)
33 | label.color = color
34 | label.description = description
35 |
36 | if (!config.someResource(label)) {
37 | core.info(`Adding ${label.name} file to ${label.repository} repository`)
38 | config.addResource(label)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/describe-access-changes.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {State} from '../../terraform/state.js'
3 | import {RepositoryCollaborator} from '../../resources/repository-collaborator.js'
4 | import {Member} from '../../resources/member.js'
5 | import {TeamMember} from '../../resources/team-member.js'
6 | import {RepositoryTeam} from '../../resources/repository-team.js'
7 | import diff from 'deep-diff'
8 | import * as core from '@actions/core'
9 | import {Repository} from '../../resources/repository.js'
10 |
11 | type AccessSummary = Record<
12 | string,
13 | {
14 | role?: string
15 | repositories: Record
16 | }
17 | >
18 |
19 | function getAccessSummaryFrom(source: State | Config): AccessSummary {
20 | const members = source.getResources(Member)
21 | const teamMembers = source.getResources(TeamMember)
22 | const teamRepositories = source.getResources(RepositoryTeam)
23 | const repositoryCollaborators = source.getResources(RepositoryCollaborator)
24 |
25 | const archivedRepositories = source
26 | .getResources(Repository)
27 | .filter(repository => repository.archived)
28 | .map(repository => repository.name.toLowerCase())
29 |
30 | const usernames = new Set([
31 | ...members.map(member => member.username.toLowerCase()),
32 | ...repositoryCollaborators.map(collaborator =>
33 | collaborator.username.toLowerCase()
34 | )
35 | ])
36 |
37 | const accessSummary: AccessSummary = {}
38 | const permissions = ['admin', 'maintain', 'push', 'triage', 'pull']
39 |
40 | for (const username of usernames) {
41 | const role = members.find(
42 | member => member.username.toLowerCase() === username
43 | )?.role
44 | const teams = teamMembers
45 | .filter(teamMember => teamMember.username.toLowerCase() === username)
46 | .map(teamMember => teamMember.team.toLowerCase())
47 | const repositoryCollaborator = repositoryCollaborators
48 | .filter(collaborator => collaborator.username.toLowerCase() === username)
49 | .filter(
50 | collaborator =>
51 | !archivedRepositories.includes(collaborator.repository.toLowerCase())
52 | )
53 | const teamRepository = teamRepositories
54 | .filter(repository => teams.includes(repository.team.toLowerCase()))
55 | .filter(
56 | repository =>
57 | !archivedRepositories.includes(repository.repository.toLowerCase())
58 | )
59 |
60 | const repositories: Record = {}
61 |
62 | for (const rc of repositoryCollaborator) {
63 | const repository = rc.repository.toLowerCase()
64 | repositories[repository] = repositories[repository] ?? {}
65 | if (
66 | !repositories[repository].permission ||
67 | permissions.indexOf(rc.permission) <
68 | permissions.indexOf(repositories[repository].permission)
69 | ) {
70 | repositories[repository].permission = rc.permission
71 | }
72 | }
73 |
74 | for (const tr of teamRepository) {
75 | const repository = tr.repository.toLowerCase()
76 | repositories[repository] = repositories[repository] ?? {}
77 | if (
78 | !repositories[repository].permission ||
79 | permissions.indexOf(tr.permission) <
80 | permissions.indexOf(repositories[repository].permission)
81 | ) {
82 | repositories[repository].permission = tr.permission
83 | }
84 | }
85 |
86 | if (role !== undefined || Object.keys(repositories).length > 0) {
87 | accessSummary[username] = {
88 | role,
89 | repositories
90 | }
91 | }
92 | }
93 |
94 | return deepSort(accessSummary)
95 | }
96 |
97 | // deep sort object
98 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
99 | function deepSort(obj: any): any {
100 | if (Array.isArray(obj)) {
101 | return obj.map(deepSort)
102 | } else if (typeof obj === 'object') {
103 | const sorted: Record = {}
104 | for (const key of Object.keys(obj).sort()) {
105 | sorted[key] = deepSort(obj[key])
106 | }
107 | return sorted
108 | } else {
109 | return obj
110 | }
111 | }
112 |
113 | export async function runDescribeAccessChanges(): Promise {
114 | const state = await State.New()
115 | const config = Config.FromPath()
116 |
117 | return await describeAccessChanges(state, config)
118 | }
119 |
120 | export async function describeAccessChanges(
121 | state: State,
122 | config: Config
123 | ): Promise {
124 | const before = getAccessSummaryFrom(state)
125 | const after = getAccessSummaryFrom(config)
126 |
127 | core.info(JSON.stringify({before, after}, null, 2))
128 |
129 | const changes = diff(before, after) || []
130 |
131 | core.debug(JSON.stringify(changes, null, 2))
132 |
133 | const changesByUser: Record = {}
134 | for (const change of changes) {
135 | if (change.path === undefined) {
136 | throw new Error(`Change ${change.kind} has no path`)
137 | }
138 | const path = change.path
139 | changesByUser[path[0]] = changesByUser[path[0]] || []
140 | changesByUser[path[0]].push(change)
141 | }
142 |
143 | // iterate over changesByUser and build a description
144 | const lines = []
145 | for (const [username, userChanges] of Object.entries(changesByUser)) {
146 | lines.push(`User ${username}:`)
147 | for (const change of userChanges) {
148 | if (change.path === undefined) {
149 | throw new Error(`Change ${change.kind} has no path`)
150 | }
151 | const path = change.path
152 | switch (change.kind) {
153 | case 'E':
154 | if (path[1] === 'role') {
155 | if (change.lhs === undefined) {
156 | lines.push(
157 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)`
158 | )
159 | } else if (change.rhs === undefined) {
160 | lines.push(` - will leave the organization`)
161 | } else {
162 | lines.push(
163 | ` - will have the role in the organization change from ${change.lhs} to ${change.rhs}`
164 | )
165 | }
166 | } else {
167 | lines.push(
168 | ` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}`
169 | )
170 | }
171 | break
172 | case 'N':
173 | if (path.length === 1) {
174 | if (change.rhs.role) {
175 | lines.push(
176 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)`
177 | )
178 | }
179 | if (change.rhs.repositories) {
180 | const repositories = change.rhs.repositories as unknown as Record<
181 | string,
182 | {permission: string}
183 | >
184 | for (const [repository, {permission}] of Object.entries(
185 | repositories
186 | )) {
187 | lines.push(
188 | ` - will gain ${permission} permission to ${repository}`
189 | )
190 | }
191 | }
192 | } else {
193 | lines.push(
194 | ` - will gain ${change.rhs.permission} permission to ${path[2]}`
195 | )
196 | }
197 | break
198 | case 'D':
199 | if (path.length === 1) {
200 | if (change.lhs.role) {
201 | lines.push(` - will leave the organization`)
202 | }
203 | if (change.lhs.repositories) {
204 | const repositories = change.lhs.repositories as unknown as Record<
205 | string,
206 | {permission: string}
207 | >
208 | for (const [repository, {permission}] of Object.entries(
209 | repositories
210 | )) {
211 | lines.push(
212 | ` - will lose ${permission} permission to ${repository}`
213 | )
214 | }
215 | }
216 | } else {
217 | lines.push(
218 | ` - will lose ${change.lhs.permission} permission to ${path[2]}`
219 | )
220 | }
221 | break
222 | }
223 | }
224 | }
225 |
226 | return changes.length > 0
227 | ? lines.join('\n')
228 | : 'There will be no access changes'
229 | }
230 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/format.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 |
3 | export async function runFormat(): Promise {
4 | const config = Config.FromPath()
5 | config.save()
6 | }
7 |
8 | export async function format(_config: Config): Promise {}
9 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/protect-default-branches.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository, Visibility} from '../../resources/repository.js'
3 | import {RepositoryBranchProtectionRule} from '../../resources/repository-branch-protection-rule.js'
4 |
5 | export async function runProtectDefaultBranches(
6 | includePrivate = false
7 | ): Promise {
8 | const config = Config.FromPath()
9 |
10 | await protectDefaultBranches(config, includePrivate)
11 |
12 | config.save()
13 | }
14 |
15 | export async function protectDefaultBranches(
16 | config: Config,
17 | includePrivate = false
18 | ): Promise {
19 | const repositories = config.getResources(Repository).filter(r => !r.archived)
20 |
21 | for (const repository of repositories) {
22 | if (includePrivate || repository.visibility !== Visibility.Private) {
23 | const rule = new RepositoryBranchProtectionRule(
24 | repository.name,
25 | repository.default_branch ?? 'main'
26 | )
27 | if (!config.someResource(rule)) {
28 | console.log(
29 | `Adding branch protection rule for ${rule.pattern} to ${rule.repository} repository`
30 | )
31 | config.addResource(rule)
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/set-property-in-all-repos.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository} from '../../resources/repository.js'
3 | import * as core from '@actions/core'
4 |
5 | export async function runSetPropertyInAllRepos(
6 | name: keyof Repository,
7 | value: unknown,
8 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true
9 | ): Promise {
10 | const config = Config.FromPath()
11 |
12 | await setPropertyInAllRepos(config, name, value, repositoryFilter)
13 |
14 | config.save()
15 | }
16 |
17 | export async function setPropertyInAllRepos(
18 | config: Config,
19 | name: keyof Repository,
20 | value: unknown,
21 | repositoryFilter: (repository: Repository) => boolean = () => true
22 | ): Promise {
23 | const repositories = config
24 | .getResources(Repository)
25 | .filter(r => !r.archived)
26 | .filter(repositoryFilter)
27 |
28 | for (const repository of repositories) {
29 | const v = repository[name]
30 | if (v !== value) {
31 | // @ts-expect-error -- We expect the property to be writable
32 | repository[name] = value
33 | core.info(
34 | `Setting ${name} property to ${value} for ${repository.name} repository`
35 | )
36 | config.addResource(repository)
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/scripts/src/actions/shared/toggle-archived-repos.ts:
--------------------------------------------------------------------------------
1 | import {Config} from '../../yaml/config.js'
2 | import {Repository} from '../../resources/repository.js'
3 | import {State} from '../../terraform/state.js'
4 |
5 | export async function runToggleArchivedRepos(): Promise {
6 | const state = await State.New()
7 | const config = Config.FromPath()
8 |
9 | await toggleArchivedRepos(state, config)
10 |
11 | config.save()
12 | }
13 |
14 | export async function toggleArchivedRepos(
15 | state: State,
16 | config: Config
17 | ): Promise {
18 | const resources = state.getAllResources()
19 | const stateRepositories = state.getResources(Repository)
20 | const configRepositories = config.getResources(Repository)
21 |
22 | for (const configRepository of configRepositories) {
23 | if (configRepository.archived) {
24 | config.removeResource(configRepository)
25 | const repository = new Repository(configRepository.name)
26 | repository.archived = true
27 | config.addResource(repository)
28 | } else {
29 | const stateRepository = stateRepositories.find(
30 | r => r.name === configRepository.name
31 | )
32 | if (stateRepository !== undefined && stateRepository.archived) {
33 | stateRepository.archived = false
34 | config.addResource(stateRepository)
35 | for (const resource of resources) {
36 | if (
37 | 'repository' in resource &&
38 | resource.repository === stateRepository.name
39 | ) {
40 | config.addResource(resource)
41 | }
42 | }
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/scripts/src/actions/sync-labels.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 | import {Octokit} from '@octokit/rest'
3 | import {GitHub} from '../github.js'
4 | import env from '../env.js'
5 | import * as core from '@actions/core'
6 | import type {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
7 |
8 | type Endpoints = InstanceType
9 |
10 | type Labels = GetResponseDataTypeFromEndpointMethod<
11 | Endpoints['issues']['getLabel']
12 | >[]
13 |
14 | async function getLabels(repo: string): Promise {
15 | // initialize GitHub client
16 | const github = await GitHub.getGitHub()
17 |
18 | // use the GitHub client to fetch the list of labels from js-libp2p
19 | const labels = await github.client.paginate(
20 | github.client.issues.listLabelsForRepo,
21 | {
22 | owner: env.GITHUB_ORG,
23 | repo
24 | }
25 | )
26 |
27 | return labels
28 | }
29 |
30 | async function addLabel(
31 | repo: string,
32 | name: string,
33 | color: string,
34 | description: string | undefined
35 | ): Promise {
36 | // initialize GitHub client
37 | const github = await GitHub.getGitHub()
38 |
39 | await github.client.issues.createLabel({
40 | owner: env.GITHUB_ORG,
41 | repo,
42 | name,
43 | color,
44 | description
45 | })
46 | }
47 |
48 | async function removeLabel(repo: string, name: string): Promise {
49 | // initialize GitHub client
50 | const github = await GitHub.getGitHub()
51 |
52 | await github.client.issues.deleteLabel({
53 | owner: env.GITHUB_ORG,
54 | repo,
55 | name
56 | })
57 | }
58 |
59 | async function sync(): Promise {
60 | const sourceRepo = process.env.SOURCE_REPOSITORY
61 | const targetRepos = process.env.TARGET_REPOSITORIES?.split(',')?.map(r =>
62 | r.trim()
63 | )
64 | const addLabels = process.env.ADD_LABELS === 'true'
65 | const removeLabels = process.env.REMOVE_LABELS === 'true'
66 |
67 | if (!sourceRepo) {
68 | throw new Error('SOURCE_REPOSITORY environment variable not set')
69 | }
70 |
71 | if (!targetRepos) {
72 | throw new Error('TARGET_REPOSITORIES environment variable not set')
73 | }
74 |
75 | const sourceLabels = await getLabels(sourceRepo)
76 | core.info(
77 | `Found the following labels in ${sourceRepo}: ${sourceLabels
78 | .map(l => l.name)
79 | .join(', ')}`
80 | )
81 |
82 | for (const repo of targetRepos) {
83 | const targetLabels = await getLabels(repo)
84 | core.info(
85 | `Found the following labels in ${repo}: ${targetLabels
86 | .map(l => l.name)
87 | .join(', ')}`
88 | )
89 |
90 | if (removeLabels) {
91 | for (const label of targetLabels) {
92 | if (!sourceLabels.find(l => l.name === label.name)) {
93 | core.info(`Removing ${label.name} label from ${repo} repository`)
94 | await removeLabel(repo, label.name)
95 | }
96 | }
97 | }
98 |
99 | if (addLabels) {
100 | for (const label of sourceLabels) {
101 | if (!targetLabels.some(l => l.name === label.name)) {
102 | core.info(`Adding ${label.name} label to ${repo} repository`)
103 | await addLabel(
104 | repo,
105 | label.name,
106 | label.color,
107 | label.description || undefined
108 | )
109 | }
110 | }
111 | }
112 | }
113 | }
114 |
115 | sync()
116 |
--------------------------------------------------------------------------------
/scripts/src/actions/update-pull-requests.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {context} from '@actions/github'
3 |
4 | async function updatePullRequests(): Promise {
5 | const github = await GitHub.getGitHub()
6 |
7 | const pulls = await github.client.paginate(github.client.pulls.list, {
8 | ...context.repo,
9 | state: 'open'
10 | })
11 |
12 | for (const pull of pulls) {
13 | if (pull.draft === true) {
14 | // skip draft pull requests
15 | continue
16 | }
17 |
18 | if (pull.user?.type === 'Bot') {
19 | // skip bot pull requests
20 | continue
21 | }
22 |
23 | // replace process.env.GITHUB_REF_NAME with context.refName if it becomes available https://github.com/actions/toolkit/pull/935
24 | if (pull.base.ref !== (process.env.GITHUB_REF_NAME as string)) {
25 | // skip pull requests that are not on the target branch
26 | continue
27 | }
28 |
29 | if (pull.base.sha === context.sha) {
30 | // skip pull requests that are already up to date
31 | continue
32 | }
33 |
34 | try {
35 | await github.client.pulls.updateBranch({
36 | ...context.repo,
37 | pull_number: pull.number
38 | })
39 | } catch (error) {
40 | // we might be unable to update the pull request if it there is a conflict
41 | console.error(error)
42 | }
43 | }
44 | }
45 |
46 | updatePullRequests()
47 |
--------------------------------------------------------------------------------
/scripts/src/env.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | TF_EXEC: process.env.TF_EXEC || 'true',
3 | TF_LOCK: process.env.TF_LOCK || 'true',
4 | TF_WORKING_DIR: process.env.TF_WORKING_DIR || '../terraform',
5 | FILES_DIR: process.env.FILES_DIR || '../files',
6 | GITHUB_DIR: process.env.GITHUB_DIR || '../github',
7 | GITHUB_APP_ID: process.env.GITHUB_APP_ID || '',
8 | GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID || '',
9 | GITHUB_APP_PEM_FILE: process.env.GITHUB_APP_PEM_FILE || '',
10 | GITHUB_ORG: process.env.TF_WORKSPACE || 'default'
11 | }
12 |
--------------------------------------------------------------------------------
/scripts/src/main.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 | import {runSync} from './sync.js'
3 | import {runToggleArchivedRepos} from './actions/shared/toggle-archived-repos.js'
4 |
5 | async function run(): Promise {
6 | await runSync()
7 | await runToggleArchivedRepos()
8 | }
9 |
10 | run()
11 |
--------------------------------------------------------------------------------
/scripts/src/resources/member.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {Id, StateSchema} from '../terraform/schema.js'
3 | import env from '../env.js'
4 | import {Path, ConfigSchema} from '../yaml/schema.js'
5 | import {Resource} from './resource.js'
6 |
7 | export enum Role {
8 | Admin = 'admin',
9 | Member = 'member'
10 | }
11 |
12 | export type MemberSchema = {
13 | type: typeof Member.StateType
14 | values: {
15 | username: string
16 | role: string
17 | }
18 | }
19 |
20 | export class Member extends String implements Resource {
21 | static StateType = 'github_membership' as const
22 | static async FromGitHub(_members: Member[]): Promise<[Id, Member][]> {
23 | const github = await GitHub.getGitHub()
24 | const invitations = await github.listInvitations()
25 | const members = await github.listMembers()
26 | const result: [Id, Member][] = []
27 | for (const invitation of invitations) {
28 | if (invitation.role === 'billing_manager') {
29 | throw new Error(`Member role 'billing_manager' is not supported.`)
30 | }
31 | if (invitation.login === null) {
32 | throw new Error(`Invitation ${invitation.id} has no login`)
33 | }
34 | const role = invitation.role === 'admin' ? Role.Admin : Role.Member
35 | result.push([
36 | `${env.GITHUB_ORG}:${invitation.login}`,
37 | new Member(invitation.login, role)
38 | ])
39 | }
40 | for (const member of members) {
41 | if (member.role === 'billing_manager') {
42 | throw new Error(`Member role 'billing_manager' is not supported.`)
43 | }
44 | if (member.user === null) {
45 | throw new Error(`Member ${member.url} has no associated user`)
46 | }
47 | result.push([
48 | `${env.GITHUB_ORG}:${member.user.login}`,
49 | new Member(member.user.login, member.role as Role)
50 | ])
51 | }
52 | return result
53 | }
54 | static FromState(state: StateSchema): Member[] {
55 | const members: Member[] = []
56 | if (state.values?.root_module?.resources !== undefined) {
57 | for (const resource of state.values.root_module.resources) {
58 | if (resource.type === Member.StateType && resource.mode === 'managed') {
59 | members.push(
60 | new Member(resource.values.username, resource.values.role as Role)
61 | )
62 | }
63 | }
64 | }
65 | return members
66 | }
67 | static FromConfig(config: ConfigSchema): Member[] {
68 | const members: Member[] = []
69 | if (config.members !== undefined) {
70 | for (const [role, usernames] of Object.entries(config.members)) {
71 | for (const username of usernames ?? []) {
72 | members.push(new Member(username, role as Role))
73 | }
74 | }
75 | }
76 | return members
77 | }
78 |
79 | constructor(username: string, role: Role) {
80 | super(username)
81 | this._username = username
82 | this._role = role
83 | }
84 |
85 | private _username: string
86 | get username(): string {
87 | return this._username
88 | }
89 | private _role: Role
90 | get role(): Role {
91 | return this._role
92 | }
93 |
94 | getSchemaPath(schema: ConfigSchema): Path {
95 | const members = schema.members?.[this.role] ?? []
96 | const index = members.indexOf(this.username)
97 | return ['members', this.role, index === -1 ? members.length : index]
98 | }
99 |
100 | getStateAddress(): string {
101 | return `${Member.StateType}.this["${this.username}"]`
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository-branch-protection-rule.ts:
--------------------------------------------------------------------------------
1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer'
2 | import {GitHub} from '../github.js'
3 | import {Id, StateSchema} from '../terraform/schema.js'
4 | import {Path, ConfigSchema} from '../yaml/schema.js'
5 | import {Repository} from './repository.js'
6 | import {Resource} from './resource.js'
7 |
8 | @Exclude()
9 | class RequiredPullRequestReviews {
10 | @Expose() dismiss_stale_reviews?: boolean
11 | @Expose() dismissal_restrictions?: string[]
12 | @Expose() pull_request_bypassers?: string[]
13 | @Expose() require_code_owner_reviews?: boolean
14 | @Expose() require_last_push_approval?: boolean
15 | @Expose() required_approving_review_count?: number
16 | @Expose() restrict_dismissals?: boolean
17 | }
18 |
19 | @Exclude()
20 | class RequiredStatusChecks {
21 | @Expose() contexts?: string[]
22 | @Expose() strict?: boolean
23 | }
24 |
25 | @Exclude()
26 | class RestrictPushes {
27 | @Expose() blocks_creations?: boolean
28 | @Expose() push_allowances?: string[]
29 | }
30 |
31 | @Exclude()
32 | export class RepositoryBranchProtectionRule implements Resource {
33 | static StateType = 'github_branch_protection' as const
34 | static async FromGitHub(
35 | _rules: RepositoryBranchProtectionRule[]
36 | ): Promise<[Id, RepositoryBranchProtectionRule][]> {
37 | const github = await GitHub.getGitHub()
38 | const rules = await github.listRepositoryBranchProtectionRules()
39 | const result: [Id, RepositoryBranchProtectionRule][] = []
40 | for (const rule of rules) {
41 | result.push([
42 | `${rule.repository.name}:${rule.branchProtectionRule.pattern}`,
43 | new RepositoryBranchProtectionRule(
44 | rule.repository.name,
45 | rule.branchProtectionRule.pattern
46 | )
47 | ])
48 | }
49 | return result
50 | }
51 | static FromState(state: StateSchema): RepositoryBranchProtectionRule[] {
52 | const rules: RepositoryBranchProtectionRule[] = []
53 | if (state.values?.root_module?.resources !== undefined) {
54 | for (const resource of state.values.root_module.resources) {
55 | if (
56 | resource.type === RepositoryBranchProtectionRule.StateType &&
57 | resource.mode === 'managed'
58 | ) {
59 | const repositoryIndex: string = resource.index.split(':')[0]
60 | const repository = state.values.root_module.resources.find(
61 | r =>
62 | r.mode === 'managed' &&
63 | r.type === Repository.StateType &&
64 | r.index === repositoryIndex
65 | )
66 | const required_pull_request_reviews =
67 | resource.values.required_pull_request_reviews?.at(0)
68 | const required_status_checks =
69 | resource.values.required_status_checks?.at(0)
70 | rules.push(
71 | plainToClassFromExist(
72 | new RepositoryBranchProtectionRule(
73 | repository !== undefined &&
74 | repository.type === Repository.StateType
75 | ? repository.values.name
76 | : repositoryIndex,
77 | resource.values.pattern
78 | ),
79 | {
80 | ...resource.values,
81 | required_pull_request_reviews,
82 | required_status_checks
83 | }
84 | )
85 | )
86 | }
87 | }
88 | }
89 | return rules
90 | }
91 | static FromConfig(config: ConfigSchema): RepositoryBranchProtectionRule[] {
92 | const rules: RepositoryBranchProtectionRule[] = []
93 | if (config.repositories !== undefined) {
94 | for (const [repository_name, repository] of Object.entries(
95 | config.repositories
96 | )) {
97 | if (repository.branch_protection !== undefined) {
98 | for (const [pattern, rule] of Object.entries(
99 | repository.branch_protection
100 | )) {
101 | rules.push(
102 | plainToClassFromExist(
103 | new RepositoryBranchProtectionRule(repository_name, pattern),
104 | rule
105 | )
106 | )
107 | }
108 | }
109 | }
110 | }
111 | return rules
112 | }
113 |
114 | constructor(repository: string, pattern: string) {
115 | this._repository = repository
116 | this._pattern = pattern
117 | }
118 |
119 | private _repository: string
120 | get repository(): string {
121 | return this._repository
122 | }
123 | private _pattern: string
124 | get pattern(): string {
125 | return this._pattern
126 | }
127 |
128 | @Expose() allows_deletions?: boolean
129 | @Expose() allows_force_pushes?: boolean
130 | @Expose() enforce_admins?: boolean
131 | @Expose() force_push_bypassers?: string[]
132 | @Expose() lock_branch?: boolean
133 | @Expose() require_conversation_resolution?: boolean
134 | @Expose() require_signed_commits?: boolean
135 | @Expose() required_linear_history?: boolean
136 | @Expose()
137 | @Type(() => RequiredPullRequestReviews)
138 | required_pull_request_reviews?: RequiredPullRequestReviews
139 | @Expose()
140 | @Type(() => RequiredStatusChecks)
141 | required_status_checks?: RequiredStatusChecks
142 | @Expose()
143 | @Type(() => RestrictPushes)
144 | restrict_pushes?: RestrictPushes
145 |
146 | getSchemaPath(_schema: ConfigSchema): Path {
147 | return ['repositories', this.repository, 'branch_protection', this.pattern]
148 | }
149 |
150 | getStateAddress(): string {
151 | return `${RepositoryBranchProtectionRule.StateType}.this["${this.repository}:${this.pattern}"]`
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository-collaborator.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {Id, StateSchema} from '../terraform/schema.js'
3 | import {Path, ConfigSchema} from '../yaml/schema.js'
4 | import {Resource} from './resource.js'
5 |
6 | export enum Permission {
7 | Admin = 'admin',
8 | Maintain = 'maintain',
9 | Push = 'push',
10 | Triage = 'triage',
11 | Pull = 'pull'
12 | }
13 |
14 | export class RepositoryCollaborator extends String implements Resource {
15 | static StateType = 'github_repository_collaborator' as const
16 | static async FromGitHub(
17 | _collaborators: RepositoryCollaborator[]
18 | ): Promise<[Id, RepositoryCollaborator][]> {
19 | const github = await GitHub.getGitHub()
20 | const invitations = await github.listRepositoryInvitations()
21 | const collaborators = await github.listRepositoryCollaborators()
22 | const result: [Id, RepositoryCollaborator][] = []
23 | for (const invitation of invitations) {
24 | if (invitation.invitee === null) {
25 | throw new Error(`Invitation ${invitation.id} has no invitee`)
26 | }
27 | result.push([
28 | `${invitation.repository.name}:${invitation.invitee.login}`,
29 | new RepositoryCollaborator(
30 | invitation.repository.name,
31 | invitation.invitee.login,
32 | invitation.permissions as Permission
33 | )
34 | ])
35 | }
36 | for (const collaborator of collaborators) {
37 | let permission: Permission | undefined
38 | if (collaborator.collaborator.permissions?.admin) {
39 | permission = Permission.Triage
40 | } else if (collaborator.collaborator.permissions?.maintain) {
41 | permission = Permission.Push
42 | } else if (collaborator.collaborator.permissions?.push) {
43 | permission = Permission.Maintain
44 | } else if (collaborator.collaborator.permissions?.triage) {
45 | permission = Permission.Admin
46 | } else if (collaborator.collaborator.permissions?.pull) {
47 | permission = Permission.Pull
48 | }
49 | if (permission === undefined) {
50 | throw new Error(
51 | `Unknown permission for ${collaborator.collaborator.login}`
52 | )
53 | }
54 | result.push([
55 | `${collaborator.repository.name}:${collaborator.collaborator.login}`,
56 | new RepositoryCollaborator(
57 | collaborator.repository.name,
58 | collaborator.collaborator.login,
59 | permission
60 | )
61 | ])
62 | }
63 | return result
64 | }
65 | static FromState(state: StateSchema): RepositoryCollaborator[] {
66 | const collaborators: RepositoryCollaborator[] = []
67 | if (state.values?.root_module?.resources !== undefined) {
68 | for (const resource of state.values.root_module.resources) {
69 | if (
70 | resource.type === RepositoryCollaborator.StateType &&
71 | resource.mode === 'managed'
72 | ) {
73 | collaborators.push(
74 | new RepositoryCollaborator(
75 | resource.values.repository,
76 | resource.values.username,
77 | resource.values.permission as Permission
78 | )
79 | )
80 | }
81 | }
82 | }
83 | return collaborators
84 | }
85 | static FromConfig(config: ConfigSchema): RepositoryCollaborator[] {
86 | const collaborators: RepositoryCollaborator[] = []
87 | if (config.repositories !== undefined) {
88 | for (const [repository_name, repository] of Object.entries(
89 | config.repositories
90 | )) {
91 | if (repository.collaborators !== undefined) {
92 | for (const [permission, usernames] of Object.entries(
93 | repository.collaborators
94 | )) {
95 | for (const username of usernames ?? []) {
96 | collaborators.push(
97 | new RepositoryCollaborator(
98 | repository_name,
99 | username,
100 | permission as Permission
101 | )
102 | )
103 | }
104 | }
105 | }
106 | }
107 | }
108 | return collaborators
109 | }
110 | constructor(repository: string, username: string, permission: Permission) {
111 | super(username)
112 | this._repository = repository
113 | this._username = username
114 | this._permission = permission
115 | }
116 |
117 | private _repository: string
118 | get repository(): string {
119 | return this._repository
120 | }
121 | private _username: string
122 | get username(): string {
123 | return this._username
124 | }
125 | private _permission: Permission
126 | get permission(): Permission {
127 | return this._permission
128 | }
129 |
130 | getSchemaPath(schema: ConfigSchema): Path {
131 | const collaborators =
132 | schema.repositories?.[this.repository]?.collaborators?.[
133 | this.permission
134 | ] || []
135 | const index = collaborators.indexOf(this.username)
136 | return [
137 | 'repositories',
138 | this.repository,
139 | 'collaborators',
140 | this.permission,
141 | index === -1 ? collaborators.length : index
142 | ]
143 | }
144 |
145 | getStateAddress(): string {
146 | return `${RepositoryCollaborator.StateType}.this["${this.repository}:${this.username}"]`
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository-file.ts:
--------------------------------------------------------------------------------
1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer'
2 | import {Path, ConfigSchema} from '../yaml/schema.js'
3 | import {Resource} from './resource.js'
4 | import {GitHub} from '../github.js'
5 | import {Id, StateSchema} from '../terraform/schema.js'
6 | import env from '../env.js'
7 | import * as fs from 'fs'
8 | import * as path from 'path'
9 |
10 | export function findFileByContent(
11 | dirPath: string,
12 | content: string
13 | ): string | undefined {
14 | const files = fs.readdirSync(dirPath)
15 | for (const file of files) {
16 | const filePath = path.join(dirPath, file)
17 | const fileStats = fs.lstatSync(filePath)
18 | if (fileStats.isFile()) {
19 | const fileContent = fs.readFileSync(filePath).toString()
20 | if (fileContent === content) {
21 | return filePath
22 | }
23 | } else if (fileStats.isDirectory()) {
24 | const otherFilePath = findFileByContent(filePath, content)
25 | if (otherFilePath) {
26 | return otherFilePath
27 | }
28 | }
29 | }
30 | return undefined
31 | }
32 |
33 | @Exclude()
34 | export class RepositoryFile implements Resource {
35 | static StateType = 'github_repository_file' as const
36 | static async FromGitHub(
37 | files: RepositoryFile[]
38 | ): Promise<[Id, RepositoryFile][]> {
39 | const github = await GitHub.getGitHub()
40 | const result: [Id, RepositoryFile][] = []
41 | for (const file of files) {
42 | const remoteFile = await github.getRepositoryFile(
43 | file.repository,
44 | file.file
45 | )
46 | if (remoteFile !== undefined) {
47 | result.push([`${file.repository}/${file.file}:${remoteFile.ref}`, file])
48 | }
49 | }
50 | return result
51 | }
52 | static FromState(state: StateSchema): RepositoryFile[] {
53 | const files: RepositoryFile[] = []
54 | if (state.values?.root_module?.resources !== undefined) {
55 | for (const resource of state.values.root_module.resources) {
56 | if (
57 | resource.type === RepositoryFile.StateType &&
58 | resource.mode === 'managed'
59 | ) {
60 | const content =
61 | findFileByContent(env.FILES_DIR, resource.values.content)?.slice(
62 | env.FILES_DIR.length + 1
63 | ) || resource.values.content
64 | files.push(
65 | plainToClassFromExist(
66 | new RepositoryFile(
67 | resource.values.repository,
68 | resource.values.file
69 | ),
70 | {...resource.values, content}
71 | )
72 | )
73 | }
74 | }
75 | }
76 | return files
77 | }
78 | static FromConfig(config: ConfigSchema): RepositoryFile[] {
79 | const files: RepositoryFile[] = []
80 | if (config.repositories !== undefined) {
81 | for (const [repository_name, repository] of Object.entries(
82 | config.repositories
83 | )) {
84 | if (repository.files !== undefined) {
85 | for (const [file_name, file] of Object.entries(repository.files)) {
86 | files.push(
87 | plainToClassFromExist(
88 | new RepositoryFile(repository_name, file_name),
89 | file
90 | )
91 | )
92 | }
93 | }
94 | }
95 | }
96 | return files
97 | }
98 |
99 | constructor(repository: string, name: string) {
100 | this._repository = repository
101 | this._file = name
102 | }
103 |
104 | private _repository: string
105 | get repository(): string {
106 | return this._repository
107 | }
108 | private _file: string
109 | get file(): string {
110 | return this._file
111 | }
112 |
113 | @Expose() content?: string
114 | @Expose() overwrite_on_create?: boolean
115 |
116 | getSchemaPath(_schema: ConfigSchema): Path {
117 | return ['repositories', this.repository, 'files', this.file]
118 | }
119 |
120 | getStateAddress(): string {
121 | return `${RepositoryFile.StateType}.this["${this.repository}/${this.file}"]`
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository-label.ts:
--------------------------------------------------------------------------------
1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer'
2 | import {Path, ConfigSchema} from '../yaml/schema.js'
3 | import {Resource} from './resource.js'
4 | import {GitHub} from '../github.js'
5 | import {Id, StateSchema} from '../terraform/schema.js'
6 |
7 | @Exclude()
8 | export class RepositoryLabel implements Resource {
9 | static StateType = 'github_issue_labels' as const
10 | static async FromGitHub(
11 | _labels: RepositoryLabel[]
12 | ): Promise<[Id, RepositoryLabel][]> {
13 | const github = await GitHub.getGitHub()
14 | const labels = await github.listRepositoryLabels()
15 | const result: [Id, RepositoryLabel][] = []
16 | for (const label of labels) {
17 | result.push([
18 | label.repository.name,
19 | new RepositoryLabel(label.repository.name, label.label.name)
20 | ])
21 | }
22 | return result
23 | }
24 | static FromState(state: StateSchema): RepositoryLabel[] {
25 | const labels: RepositoryLabel[] = []
26 | if (state.values?.root_module?.resources !== undefined) {
27 | for (const resource of state.values.root_module.resources) {
28 | if (
29 | resource.type === RepositoryLabel.StateType &&
30 | resource.mode === 'managed'
31 | ) {
32 | for (const label of resource.values.label ?? []) {
33 | labels.push(
34 | plainToClassFromExist(
35 | new RepositoryLabel(resource.values.repository, label.name),
36 | label
37 | )
38 | )
39 | }
40 | }
41 | }
42 | }
43 | return labels
44 | }
45 | static FromConfig(config: ConfigSchema): RepositoryLabel[] {
46 | const labels: RepositoryLabel[] = []
47 | if (config.repositories !== undefined) {
48 | for (const [repository_name, repository] of Object.entries(
49 | config.repositories
50 | )) {
51 | if (repository.labels !== undefined) {
52 | for (const [name, label] of Object.entries(repository.labels)) {
53 | labels.push(
54 | plainToClassFromExist(
55 | new RepositoryLabel(repository_name, name),
56 | label
57 | )
58 | )
59 | }
60 | }
61 | }
62 | }
63 | return labels
64 | }
65 | constructor(repository: string, name: string) {
66 | this._repository = repository
67 | this._name = name
68 | }
69 |
70 | private _repository: string
71 | get repository(): string {
72 | return this._repository
73 | }
74 | private _name: string
75 | get name(): string {
76 | return this._name
77 | }
78 |
79 | @Expose() color?: string
80 | @Expose() description?: string
81 |
82 | getSchemaPath(_schema: ConfigSchema): Path {
83 | return ['repositories', this.repository, 'labels', this.name]
84 | }
85 |
86 | getStateAddress(): string {
87 | return `${RepositoryLabel.StateType}.this["${this.repository}"]`
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository-team.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {Id, StateSchema} from '../terraform/schema.js'
3 | import {Path, ConfigSchema} from '../yaml/schema.js'
4 | import {Resource} from './resource.js'
5 | import {Team} from './team.js'
6 |
7 | export enum Permission {
8 | Admin = 'admin',
9 | Maintain = 'maintain',
10 | Push = 'push',
11 | Triage = 'triage',
12 | Pull = 'pull'
13 | }
14 |
15 | export class RepositoryTeam extends String implements Resource {
16 | static StateType = 'github_team_repository' as const
17 | static async FromGitHub(
18 | _teams: RepositoryTeam[]
19 | ): Promise<[Id, RepositoryTeam][]> {
20 | const github = await GitHub.getGitHub()
21 | const teams = await github.listTeamRepositories()
22 | const result: [Id, RepositoryTeam][] = []
23 | for (const team of teams) {
24 | result.push([
25 | `${team.team.id}:${team.repository.name}`,
26 | new RepositoryTeam(
27 | team.repository.name,
28 | team.team.name,
29 | team.team.permission as Permission
30 | )
31 | ])
32 | }
33 | return result
34 | }
35 | static FromState(state: StateSchema): RepositoryTeam[] {
36 | const teams: RepositoryTeam[] = []
37 | if (state.values?.root_module?.resources !== undefined) {
38 | for (const resource of state.values.root_module.resources) {
39 | if (
40 | resource.type === RepositoryTeam.StateType &&
41 | resource.mode === 'managed'
42 | ) {
43 | const teamIndex = resource.index.split(`:`).slice(0, -1).join(`:`)
44 | const team = state.values.root_module.resources.find(
45 | r =>
46 | r.mode === 'managed' &&
47 | r.type === Team.StateType &&
48 | r.index === teamIndex
49 | )
50 | teams.push(
51 | new RepositoryTeam(
52 | resource.values.repository,
53 | team !== undefined && team.type === Team.StateType
54 | ? team.values.name
55 | : teamIndex,
56 | resource.values.permission as Permission
57 | )
58 | )
59 | }
60 | }
61 | }
62 | return teams
63 | }
64 | static FromConfig(config: ConfigSchema): RepositoryTeam[] {
65 | const teams: RepositoryTeam[] = []
66 | if (config.repositories !== undefined) {
67 | for (const [repository_name, repository] of Object.entries(
68 | config.repositories
69 | )) {
70 | if (repository.teams !== undefined) {
71 | for (const [permission, team_names] of Object.entries(
72 | repository.teams
73 | )) {
74 | for (const team_name of team_names ?? []) {
75 | teams.push(
76 | new RepositoryTeam(
77 | repository_name,
78 | team_name,
79 | permission as Permission
80 | )
81 | )
82 | }
83 | }
84 | }
85 | }
86 | }
87 | return teams
88 | }
89 | constructor(repository: string, team: string, permission: Permission) {
90 | super(team)
91 | this._repository = repository
92 | this._team = team
93 | this._permission = permission
94 | }
95 |
96 | private _repository: string
97 | get repository(): string {
98 | return this._repository
99 | }
100 | private _team: string
101 | get team(): string {
102 | return this._team
103 | }
104 | private _permission: Permission
105 | get permission(): Permission {
106 | return this._permission
107 | }
108 |
109 | getSchemaPath(schema: ConfigSchema): Path {
110 | const teams =
111 | schema.repositories?.[this.repository]?.teams?.[this.permission] || []
112 | const index = teams.indexOf(this.team)
113 | return [
114 | 'repositories',
115 | this.repository,
116 | 'teams',
117 | this.permission,
118 | index === -1 ? teams.length : index
119 | ]
120 | }
121 |
122 | getStateAddress(): string {
123 | return `${RepositoryTeam.StateType}.this["${this.team}:${this.repository}"]`
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/scripts/src/resources/repository.ts:
--------------------------------------------------------------------------------
1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer'
2 | import {GitHub} from '../github.js'
3 | import {Id, StateSchema} from '../terraform/schema.js'
4 | import {Path, ConfigSchema} from '../yaml/schema.js'
5 | import {Resource} from './resource.js'
6 |
7 | @Exclude()
8 | class PageSource {
9 | @Expose() branch?: string
10 | @Expose() path?: string
11 | }
12 |
13 | @Exclude()
14 | class Pages {
15 | @Expose() source?: PageSource
16 | @Expose() build_type?: 'legacy' | 'workflow'
17 | @Expose() cname?: string
18 | }
19 |
20 | @Exclude()
21 | class Template {
22 | @Expose() owner?: string
23 | @Expose() repository?: string
24 | @Expose() include_all_branches?: boolean
25 | }
26 |
27 | export enum Visibility {
28 | Private = 'private',
29 | Public = 'public'
30 | }
31 |
32 | @Exclude()
33 | export class Repository implements Resource {
34 | static StateType = 'github_repository' as const
35 | static async FromGitHub(
36 | _repositories: Repository[]
37 | ): Promise<[Id, Repository][]> {
38 | const github = await GitHub.getGitHub()
39 | const repositories = await github.listRepositories()
40 | const result: [Id, Repository][] = []
41 | for (const repository of repositories) {
42 | result.push([repository.name, new Repository(repository.name)])
43 | }
44 | return result
45 | }
46 | static FromState(state: StateSchema): Repository[] {
47 | const repositories: Repository[] = []
48 | if (state.values?.root_module?.resources !== undefined) {
49 | for (const resource of state.values.root_module.resources) {
50 | if (
51 | resource.type === Repository.StateType &&
52 | resource.mode === 'managed'
53 | ) {
54 | const pages = {
55 | ...resource.values.pages?.at(0),
56 | source: {...resource.values.pages?.at(0)?.source?.at(0)}
57 | }
58 | const template = resource.values.template?.at(0)
59 | const security_and_analysis =
60 | resource.values.security_and_analysis?.at(0)
61 | const advanced_security =
62 | security_and_analysis?.advanced_security?.at(0)?.status ===
63 | 'enabled'
64 | const secret_scanning =
65 | security_and_analysis?.secret_scanning?.at(0)?.status === 'enabled'
66 | const secret_scanning_push_protection =
67 | security_and_analysis?.secret_scanning_push_protection?.at(0)
68 | ?.status === 'enabled'
69 | repositories.push(
70 | plainToClassFromExist(new Repository(resource.values.name), {
71 | ...resource.values,
72 | pages,
73 | template,
74 | advanced_security,
75 | secret_scanning,
76 | secret_scanning_push_protection
77 | })
78 | )
79 | }
80 | }
81 | }
82 | return repositories
83 | }
84 | static FromConfig(config: ConfigSchema): Repository[] {
85 | const repositories: Repository[] = []
86 | if (config.repositories !== undefined) {
87 | for (const [name, repository] of Object.entries(config.repositories)) {
88 | repositories.push(
89 | plainToClassFromExist(new Repository(name), repository)
90 | )
91 | }
92 | }
93 | return repositories
94 | }
95 |
96 | constructor(name: string) {
97 | this._name = name
98 | }
99 |
100 | private _name: string
101 | get name(): string {
102 | return this._name
103 | }
104 |
105 | @Expose() allow_auto_merge?: boolean
106 | @Expose() allow_merge_commit?: boolean
107 | @Expose() allow_rebase_merge?: boolean
108 | @Expose() allow_squash_merge?: boolean
109 | @Expose() allow_update_branch?: boolean
110 | @Expose() archive_on_destroy?: boolean
111 | @Expose() archived?: boolean
112 | @Expose() auto_init?: boolean
113 | @Expose() default_branch?: string
114 | @Expose() delete_branch_on_merge?: boolean
115 | @Expose() description?: string
116 | @Expose() gitignore_template?: string
117 | @Expose() has_discussions?: boolean
118 | @Expose() has_downloads?: boolean
119 | @Expose() has_issues?: boolean
120 | @Expose() has_projects?: boolean
121 | @Expose() has_wiki?: boolean
122 | @Expose() homepage_url?: string
123 | @Expose() ignore_vulnerability_alerts_during_read?: boolean
124 | @Expose() is_template?: boolean
125 | @Expose() license_template?: string
126 | @Expose() merge_commit_message?: string
127 | @Expose() merge_commit_title?: string
128 | @Expose()
129 | @Type(() => Pages)
130 | pages?: Pages
131 | // security_and_analysis
132 | @Expose() advanced_security?: boolean
133 | @Expose() secret_scanning?: boolean
134 | @Expose() secret_scanning_push_protection?: boolean
135 | @Expose() squash_merge_commit_message?: string
136 | @Expose() squash_merge_commit_title?: string
137 | @Expose()
138 | @Type(() => Template)
139 | template?: Template
140 | @Expose() topics?: string[]
141 | @Expose() visibility?: Visibility
142 | @Expose() vulnerability_alerts?: boolean
143 | @Expose() web_commit_signoff_required?: boolean
144 |
145 | getSchemaPath(_schema: ConfigSchema): Path {
146 | return ['repositories', this.name]
147 | }
148 |
149 | getStateAddress(): string {
150 | return `${Repository.StateType}.this["${this.name}"]`
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/scripts/src/resources/resource.ts:
--------------------------------------------------------------------------------
1 | import {instanceToPlain} from 'class-transformer'
2 | import {Id, StateSchema} from '../terraform/schema.js'
3 | import {Path, ConfigSchema} from '../yaml/schema.js'
4 | import {Member} from './member.js'
5 | import {Repository} from './repository.js'
6 | import {RepositoryBranchProtectionRule} from './repository-branch-protection-rule.js'
7 | import {RepositoryCollaborator} from './repository-collaborator.js'
8 | import {RepositoryFile} from './repository-file.js'
9 | import {RepositoryLabel} from './repository-label.js'
10 | import {RepositoryTeam} from './repository-team.js'
11 | import {Team} from './team.js'
12 | import {TeamMember} from './team-member.js'
13 |
14 | export interface Resource {
15 | // returns YAML config path under which the resource can be found
16 | // e.g. ['members', 'admin', ]
17 | getSchemaPath(schema: ConfigSchema): Path
18 | // returns Terraform state path under which the resource can be found
19 | // e.g. github_membership.this["galargh"]
20 | getStateAddress(): string
21 | }
22 |
23 | export interface ResourceConstructor {
24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
25 | new (...args: any[]): T
26 | // extracts all resources of specific type from the given YAML config
27 | FromConfig(config: ConfigSchema): T[]
28 | // extracts all resources of specific type from the given Terraform state
29 | FromState(state: StateSchema): T[]
30 | // retrieves all resources of specific type from GitHub API
31 | // it takes a list of resources of the same type as an argument
32 | // an implementation can choose to ignore it or use it to only check if given resources still exist
33 | // this is the case with repository files for example where we don't want to manage ALL the files thorugh GitHub Management
34 | FromGitHub(resources: T[]): Promise<[Id, Resource][]>
35 | StateType: string
36 | }
37 |
38 | export const ResourceConstructors: ResourceConstructor[] = [
39 | Member,
40 | RepositoryBranchProtectionRule,
41 | RepositoryCollaborator,
42 | RepositoryFile,
43 | RepositoryLabel,
44 | RepositoryTeam,
45 | Repository,
46 | TeamMember,
47 | Team
48 | ]
49 |
50 | export function resourceToPlain(
51 | resource: T | undefined
52 | ): string | Record | undefined {
53 | if (resource !== undefined) {
54 | if (resource instanceof String) {
55 | return resource.toString()
56 | } else {
57 | return instanceToPlain(resource, {exposeUnsetFields: false})
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/scripts/src/resources/team-member.ts:
--------------------------------------------------------------------------------
1 | import {GitHub} from '../github.js'
2 | import {Id, StateSchema} from '../terraform/schema.js'
3 | import {Path, ConfigSchema} from '../yaml/schema.js'
4 | import {Resource} from './resource.js'
5 | import {Team} from './team.js'
6 |
7 | export enum Role {
8 | Maintainer = 'maintainer',
9 | Member = 'member'
10 | }
11 |
12 | export class TeamMember extends String implements Resource {
13 | static StateType = 'github_team_membership' as const
14 | static async FromGitHub(_members: TeamMember[]): Promise<[Id, TeamMember][]> {
15 | const github = await GitHub.getGitHub()
16 | const invitations = await github.listTeamInvitations()
17 | const members = await github.listTeamMembers()
18 | const result: [Id, TeamMember][] = []
19 | for (const invitation of invitations) {
20 | if (invitation.invitation.login === null) {
21 | throw new Error(`Invitation ${invitation.invitation.id} has no login`)
22 | }
23 | const member = _members.find(
24 | m =>
25 | m.team === invitation.team.name &&
26 | m.username === invitation.invitation.login
27 | )
28 | result.push([
29 | `${invitation.team.id}:${invitation.invitation.login}`,
30 | new TeamMember(
31 | invitation.team.name,
32 | invitation.invitation.login,
33 | member?.role || Role.Member
34 | )
35 | ])
36 | }
37 | for (const member of members) {
38 | result.push([
39 | `${member.team.id}:${member.member.login}`,
40 | new TeamMember(
41 | member.team.name,
42 | member.member.login,
43 | member.membership.role as Role
44 | )
45 | ])
46 | }
47 | return result
48 | }
49 | static FromState(state: StateSchema): TeamMember[] {
50 | const members: TeamMember[] = []
51 | if (state.values?.root_module?.resources !== undefined) {
52 | for (const resource of state.values.root_module.resources) {
53 | if (
54 | resource.type === TeamMember.StateType &&
55 | resource.mode === 'managed'
56 | ) {
57 | const teamIndex = resource.index.split(`:`).slice(0, -1).join(`:`)
58 | const team = state.values.root_module.resources.find(
59 | r =>
60 | r.mode === 'managed' &&
61 | r.type === Team.StateType &&
62 | r.index === teamIndex
63 | )
64 | members.push(
65 | new TeamMember(
66 | team !== undefined && team.type === Team.StateType
67 | ? team.values.name
68 | : teamIndex,
69 | resource.values.username,
70 | resource.values.role as Role
71 | )
72 | )
73 | }
74 | }
75 | }
76 | return members
77 | }
78 | static FromConfig(config: ConfigSchema): TeamMember[] {
79 | const members: TeamMember[] = []
80 | if (config.teams !== undefined) {
81 | for (const [team_name, team] of Object.entries(config.teams)) {
82 | if (team.members !== undefined) {
83 | for (const [role, usernames] of Object.entries(team.members)) {
84 | for (const username of usernames ?? []) {
85 | members.push(new TeamMember(team_name, username, role as Role))
86 | }
87 | }
88 | }
89 | }
90 | }
91 | return members
92 | }
93 |
94 | constructor(team: string, username: string, role: Role) {
95 | super(username)
96 | this._team = team
97 | this._username = username
98 | this._role = role
99 | }
100 |
101 | private _team: string
102 | get team(): string {
103 | return this._team
104 | }
105 | private _username: string
106 | get username(): string {
107 | return this._username
108 | }
109 | private _role: Role
110 | get role(): Role {
111 | return this._role
112 | }
113 |
114 | getSchemaPath(schema: ConfigSchema): Path {
115 | const members = schema.teams?.[this.team]?.members?.[this.role] || []
116 | const index = members.indexOf(this.username)
117 | return [
118 | 'teams',
119 | this.team,
120 | 'members',
121 | this.role,
122 | index === -1 ? members.length : index
123 | ]
124 | }
125 |
126 | getStateAddress(): string {
127 | return `${TeamMember.StateType}.this["${this.team}:${this.username}"]`
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/scripts/src/resources/team.ts:
--------------------------------------------------------------------------------
1 | import {Resource} from './resource.js'
2 | import {Path, ConfigSchema} from '../yaml/schema.js'
3 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer'
4 | import {GitHub} from '../github.js'
5 | import {Id, StateSchema} from '../terraform/schema.js'
6 |
7 | export enum Privacy {
8 | PUBLIC = 'closed',
9 | PRIVATE = 'secret'
10 | }
11 |
12 | @Exclude()
13 | export class Team implements Resource {
14 | static StateType = 'github_team' as const
15 | static async FromGitHub(_teams: Team[]): Promise<[Id, Team][]> {
16 | const github = await GitHub.getGitHub()
17 | const teams = await github.listTeams()
18 | const result: [Id, Team][] = []
19 | for (const team of teams) {
20 | result.push([`${team.id}`, new Team(team.name)])
21 | }
22 | return result
23 | }
24 | static FromState(state: StateSchema): Team[] {
25 | const teams: Team[] = []
26 | if (state.values?.root_module?.resources !== undefined) {
27 | for (const resource of state.values.root_module.resources) {
28 | if (resource.type === Team.StateType && resource.mode === 'managed') {
29 | let parent_team_id = resource.values.parent_team_id
30 | if (parent_team_id !== undefined) {
31 | const parentTeam = state.values.root_module.resources.find(
32 | r =>
33 | r.type === Team.StateType &&
34 | r.mode === 'managed' &&
35 | `${r.values.id}` === `${parent_team_id}`
36 | )
37 | parent_team_id =
38 | parentTeam !== undefined && parentTeam.type === Team.StateType
39 | ? parentTeam.values.name
40 | : undefined
41 | }
42 | teams.push(
43 | plainToClassFromExist(new Team(resource.values.name), {
44 | ...resource.values,
45 | parent_team_id
46 | })
47 | )
48 | }
49 | }
50 | }
51 | return teams
52 | }
53 | static FromConfig(config: ConfigSchema): Team[] {
54 | const teams: Team[] = []
55 | if (config.teams !== undefined) {
56 | for (const [name, team] of Object.entries(config.teams)) {
57 | teams.push(plainToClassFromExist(new Team(name), team))
58 | }
59 | }
60 | return teams
61 | }
62 |
63 | constructor(name: string) {
64 | this._name = name
65 | }
66 |
67 | private _name: string
68 | get name(): string {
69 | return this._name
70 | }
71 |
72 | @Expose() create_default_maintainer?: boolean
73 | @Expose() description?: string
74 | @Expose() parent_team_id?: string
75 | @Expose() privacy?: Privacy
76 |
77 | getSchemaPath(_schema: ConfigSchema): Path {
78 | return ['teams', this.name]
79 | }
80 |
81 | getStateAddress(): string {
82 | return `${Team.StateType}.this["${this.name}"]`
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/scripts/src/sync.ts:
--------------------------------------------------------------------------------
1 | import {Resource, ResourceConstructors} from './resources/resource.js'
2 | import {State} from './terraform/state.js'
3 | import {Id} from './terraform/schema.js'
4 | import {Config} from './yaml/config.js'
5 |
6 | export async function runSync(): Promise {
7 | const state = await State.New()
8 | const config = Config.FromPath()
9 |
10 | await sync(state, config)
11 |
12 | config.save()
13 | }
14 |
15 | export async function sync(state: State, config: Config): Promise {
16 | const resources: [Id, Resource][] = []
17 | for (const resourceClass of ResourceConstructors) {
18 | const isIgnored = await state.isIgnored(resourceClass)
19 | if (!isIgnored) {
20 | const oldResources = config.getResources(resourceClass)
21 | const newResources = await resourceClass.FromGitHub(oldResources)
22 | resources.push(...newResources)
23 | }
24 | }
25 |
26 | await state.sync(resources)
27 | await state.refresh()
28 |
29 | const syncedResources = state.getAllResources()
30 | config.sync(syncedResources)
31 | }
32 |
--------------------------------------------------------------------------------
/scripts/src/terraform/locals.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import HCL from 'hcl2-parser'
3 | import env from '../env.js'
4 |
5 | type LocalsSchema = {
6 | resource_types: string[]
7 | ignore: {
8 | repositories: string[]
9 | teams: string[]
10 | users: string[]
11 | }
12 | }
13 |
14 | export class Locals {
15 | static locals: LocalsSchema
16 | static getLocals(): LocalsSchema {
17 | if (Locals.locals === undefined) {
18 | const locals: LocalsSchema = {
19 | resource_types: [],
20 | ignore: {
21 | repositories: [],
22 | teams: [],
23 | users: []
24 | }
25 | }
26 | for (const path of [
27 | `${env.TF_WORKING_DIR}/locals.tf`,
28 | `${env.TF_WORKING_DIR}/locals_override.tf`
29 | ]) {
30 | if (fs.existsSync(path)) {
31 | const hcl =
32 | HCL.parseToObject(fs.readFileSync(path))?.at(0)?.locals?.at(0) ?? {}
33 | locals.resource_types = hcl.resource_types ?? locals.resource_types
34 | locals.ignore.repositories =
35 | hcl.ignore?.repositories ?? locals.ignore.repositories
36 | locals.ignore.teams = hcl.ignore?.teams ?? locals.ignore.teams
37 | locals.ignore.users = hcl.ignore?.users ?? locals.ignore.users
38 | }
39 | }
40 | this.locals = locals
41 | }
42 | return Locals.locals
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/scripts/src/terraform/schema.ts:
--------------------------------------------------------------------------------
1 | type ResourceSchema = {
2 | mode: string
3 | index: string
4 | address: string
5 | } & (
6 | | {
7 | type: 'github_membership'
8 | values: {username: string; role: 'admin' | 'member'}
9 | }
10 | | {
11 | type: 'github_branch_protection'
12 | values: {
13 | pattern: string
14 | required_pull_request_reviews?: object[]
15 | required_status_checks?: object[]
16 | }
17 | }
18 | | {
19 | type: 'github_repository_collaborator'
20 | values: {
21 | permission: 'admin' | 'maintain' | 'push' | 'triage' | 'pull'
22 | repository: string
23 | username: string
24 | }
25 | }
26 | | {
27 | type: 'github_repository_file'
28 | values: {
29 | content: string
30 | file: string
31 | repository: string
32 | }
33 | }
34 | | {
35 | type: 'github_issue_labels'
36 | values: {
37 | repository: string
38 | label?: {
39 | name: string
40 | color: string
41 | description: string
42 | }[]
43 | }
44 | }
45 | | {
46 | type: 'github_team_repository'
47 | values: {
48 | repository: string
49 | permission: 'admin' | 'maintain' | 'push' | 'triage' | 'pull'
50 | }
51 | }
52 | | {
53 | type: 'github_repository'
54 | values: {
55 | name: string
56 | pages?: {
57 | source?: object[]
58 | }[]
59 | security_and_analysis?: {
60 | advanced_security?: {
61 | status: string
62 | }[]
63 | secret_scanning?: {
64 | status: string
65 | }[]
66 | secret_scanning_push_protection?: {
67 | status: string
68 | }[]
69 | }[]
70 | template?: object[]
71 | }
72 | }
73 | | {
74 | type: 'github_team_membership'
75 | values: {
76 | username: string
77 | role: 'maintainer' | 'member'
78 | }
79 | }
80 | | {
81 | type: 'github_team'
82 | values: {
83 | name: string
84 | id: string
85 | parent_team_id?: string
86 | }
87 | }
88 | )
89 |
90 | export type StateSchema = {
91 | values?: {
92 | root_module?: {
93 | resources?: ResourceSchema[]
94 | }
95 | }
96 | }
97 | export type Id = string
98 |
--------------------------------------------------------------------------------
/scripts/src/terraform/state.ts:
--------------------------------------------------------------------------------
1 | import {Id, StateSchema} from './schema.js'
2 | import {
3 | Resource,
4 | ResourceConstructors,
5 | ResourceConstructor
6 | } from '../resources/resource.js'
7 | import env from '../env.js'
8 | import * as cli from '@actions/exec'
9 | import * as fs from 'fs'
10 | import * as core from '@actions/core'
11 | import HCL from 'hcl2-parser'
12 |
13 | export async function loadState(): Promise {
14 | let source = ''
15 | if (env.TF_EXEC === 'true') {
16 | core.info('Loading state from Terraform state file')
17 | await cli.exec('terraform show -json', undefined, {
18 | cwd: env.TF_WORKING_DIR,
19 | listeners: {
20 | stdout: data => {
21 | source += data.toString()
22 | }
23 | },
24 | silent: true
25 | })
26 | } else {
27 | source = fs
28 | .readFileSync(`${env.TF_WORKING_DIR}/terraform.tfstate`)
29 | .toString()
30 | }
31 | return source
32 | }
33 |
34 | type HCLObject = {
35 | resource?: {
36 | [key: string]: {
37 | this?: {
38 | lifecycle?: {
39 | ignore_changes?: string[]
40 | }[]
41 | }[]
42 | }
43 | }
44 | }[]
45 |
46 | export class State {
47 | static async New(): Promise {
48 | const state = await import('./state.js')
49 | return new State(await state.loadState())
50 | }
51 |
52 | private _ignoredProperties: Record = {}
53 | private _state: StateSchema
54 |
55 | private updateIgnoredPropertiesFrom(path: string): void {
56 | if (fs.existsSync(path)) {
57 | const hcl: HCLObject | undefined = HCL.parseToObject(
58 | fs.readFileSync(path)
59 | )
60 | for (const [name, resource] of Object.entries(
61 | hcl?.at(0)?.resource ?? {}
62 | )) {
63 | const properties = resource?.this
64 | ?.at(0)
65 | ?.lifecycle?.at(0)?.ignore_changes
66 | if (properties !== undefined) {
67 | this._ignoredProperties[name] = properties.map((v: string) =>
68 | v.substring(2, v.length - 1)
69 | ) // '${v}' -> 'v'
70 | }
71 | }
72 | }
73 | }
74 |
75 | private getState(source: string): StateSchema {
76 | const state: StateSchema = JSON.parse(source, (_k, v) => v ?? undefined)
77 | if (state.values?.root_module?.resources !== undefined) {
78 | state.values.root_module.resources = state.values.root_module.resources
79 | .filter(r => r.mode === 'managed')
80 | // .filter(r => !this._ignoredTypes.includes(r.type))
81 | .map(r => {
82 | // TODO: remove nested values
83 | r.values = Object.fromEntries(
84 | Object.entries(r.values).filter(
85 | ([k, _v]) => !this._ignoredProperties[r.type]?.includes(k)
86 | )
87 | ) as typeof r.values
88 | return r
89 | })
90 | }
91 | return state
92 | }
93 |
94 | constructor(source: string) {
95 | this.updateIgnoredPropertiesFrom(`${env.TF_WORKING_DIR}/resources.tf`)
96 | this.updateIgnoredPropertiesFrom(
97 | `${env.TF_WORKING_DIR}/resources_override.tf`
98 | )
99 | this._state = this.getState(source)
100 | }
101 |
102 | async reset(): Promise {
103 | const state = await import('./state.js')
104 | this._state = this.getState(await state.loadState())
105 | }
106 |
107 | async refresh(): Promise {
108 | if (env.TF_EXEC === 'true') {
109 | await cli.exec(
110 | `terraform apply -refresh-only -auto-approve -lock=${env.TF_LOCK}`,
111 | undefined,
112 | {
113 | cwd: env.TF_WORKING_DIR
114 | }
115 | )
116 | }
117 | await this.reset()
118 | }
119 |
120 | getAllAddresses(): string[] {
121 | const addresses = []
122 | for (const resourceClass of ResourceConstructors) {
123 | const classAddresses = this.getAddresses(resourceClass)
124 | addresses.push(...classAddresses)
125 | }
126 | return addresses
127 | }
128 |
129 | getAddresses(
130 | resourceClass: ResourceConstructor
131 | ): string[] {
132 | if (ResourceConstructors.includes(resourceClass)) {
133 | return (this._state?.values?.root_module?.resources ?? [])
134 | .filter(r => r.type === resourceClass.StateType)
135 | .map(r => r.address)
136 | } else {
137 | throw new Error(`${resourceClass.name} is not supported`)
138 | }
139 | }
140 |
141 | getAllResources(): Resource[] {
142 | const resources = []
143 | for (const resourceClass of ResourceConstructors) {
144 | const classResources = this.getResources(resourceClass)
145 | resources.push(...classResources)
146 | }
147 | return resources
148 | }
149 |
150 | getResources(resourceClass: ResourceConstructor): T[] {
151 | if (ResourceConstructors.includes(resourceClass)) {
152 | return resourceClass.FromState(this._state)
153 | } else {
154 | throw new Error(`${resourceClass.name} is not supported`)
155 | }
156 | }
157 |
158 | async isIgnored(
159 | resourceClass: ResourceConstructor
160 | ): Promise {
161 | const {Locals} = await import('./locals.js')
162 | return !Locals.getLocals().resource_types.includes(resourceClass.StateType)
163 | }
164 |
165 | async addResourceAt(id: Id, address: string): Promise {
166 | if (env.TF_EXEC === 'true') {
167 | const [, key, value] = address.match(/^([^.]+)\.this\["([^"]+)"\]$/)!
168 | const resources = JSON.stringify({
169 | [key]: {
170 | [value]: {}
171 | }
172 | }).replaceAll('"', '\\"')
173 | const addr = address.replaceAll('"', '\\"')
174 | await cli.exec(
175 | `terraform import -lock=${env.TF_LOCK} -var "resources=${resources}" "${addr}" "${id}"`,
176 | undefined,
177 | {cwd: env.TF_WORKING_DIR}
178 | )
179 | }
180 | }
181 |
182 | async removeResourceAt(address: string): Promise {
183 | if (env.TF_EXEC === 'true') {
184 | await cli.exec(
185 | `terraform state rm -lock=${env.TF_LOCK} "${address.replaceAll(
186 | '"',
187 | '\\"'
188 | )}"`,
189 | undefined,
190 | {cwd: env.TF_WORKING_DIR}
191 | )
192 | }
193 | }
194 |
195 | async sync(resources: [Id, Resource][]): Promise {
196 | const addresses = new Set(this.getAllAddresses())
197 | const addressToId = new Map()
198 | for (const [id, resource] of resources) {
199 | addressToId.set(resource.getStateAddress().toLowerCase(), id)
200 | }
201 | for (const address of addresses) {
202 | if (!addressToId.has(address)) {
203 | await this.removeResourceAt(address)
204 | }
205 | }
206 | for (const [address, id] of addressToId) {
207 | if (!addresses.has(address)) {
208 | await this.addResourceAt(id, address)
209 | }
210 | }
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/scripts/src/utils.ts:
--------------------------------------------------------------------------------
1 | import * as YAML from 'yaml'
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | export function jsonEquals(a: any, b: any): boolean {
5 | return JSON.stringify(a) === JSON.stringify(b)
6 | }
7 |
8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
9 | export function yamlify(value: any): YAML.ParsedNode {
10 | const node = YAML.parseDocument(YAML.stringify(value)).contents
11 | if (node === null) {
12 | throw new Error(
13 | `Failed to parse YAML to a non-null value: ${YAML.stringify(value)}`
14 | )
15 | }
16 | return node
17 | }
18 |
19 | export function globToRegex(globPattern: string): RegExp {
20 | const regexPattern = globPattern
21 | .split('')
22 | .map(char => {
23 | if (char === '*') {
24 | return '.*'
25 | } else if (char === '?') {
26 | return '.'
27 | } else if (
28 | ['.', '\\', '+', '(', ')', '[', ']', '{', '}', '|', '^', '$'].includes(
29 | char
30 | )
31 | ) {
32 | return `\\${char}`
33 | } else {
34 | return char
35 | }
36 | })
37 | .join('')
38 |
39 | return new RegExp(`^${regexPattern}$`)
40 | }
41 |
--------------------------------------------------------------------------------
/scripts/src/yaml/config.ts:
--------------------------------------------------------------------------------
1 | import * as YAML from 'yaml'
2 | import {ConfigSchema, pathToYAML} from './schema.js'
3 | import {
4 | Resource,
5 | ResourceConstructor,
6 | ResourceConstructors,
7 | resourceToPlain
8 | } from '../resources/resource.js'
9 | import diff from 'deep-diff'
10 | import env from '../env.js'
11 | import * as fs from 'fs'
12 | import {jsonEquals, yamlify} from '../utils.js'
13 |
14 | export class Config {
15 | static FromPath(path = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml`): Config {
16 | const source = fs.readFileSync(path, 'utf8')
17 | return new Config(source)
18 | }
19 |
20 | constructor(source: string) {
21 | this._document = YAML.parseDocument(source)
22 | }
23 |
24 | private _document: YAML.Document
25 | get document(): YAML.Document {
26 | return this._document
27 | }
28 |
29 | get(): ConfigSchema {
30 | return this._document.toJSON()
31 | }
32 |
33 | format(): void {
34 | const schema = this.get()
35 | const resources = this.getAllResources()
36 | const resourcePaths = resources.map(r => r.getSchemaPath(schema).join('.'))
37 | let again = true
38 | while (again) {
39 | again = false
40 | YAML.visit(this._document, {
41 | Scalar(_, node) {
42 | if (node.value === undefined || node.value === null) {
43 | again = true
44 | return YAML.visit.REMOVE
45 | }
46 | },
47 | Pair(_, node, path) {
48 | const resourcePath = [...path, node]
49 | .filter(p => YAML.isPair(p))
50 | .map(p => (p as YAML.Pair).key)
51 | .map(k => String(k))
52 | .join('.')
53 | if (!resourcePaths.includes(resourcePath)) {
54 | const isEmpty = node.value === null || node.value === undefined
55 | const isEmptyScalar =
56 | YAML.isScalar(node.value) &&
57 | (node.value.value === undefined ||
58 | node.value.value === null ||
59 | node.value.value === '')
60 | const isEmptyCollection =
61 | YAML.isCollection(node.value) && node.value.items.length === 0
62 | if (isEmpty || isEmptyScalar || isEmptyCollection) {
63 | again = true
64 | return YAML.visit.REMOVE
65 | }
66 | }
67 | }
68 | })
69 | }
70 | YAML.visit(this._document, {
71 | Map(_, {items}) {
72 | items.sort(
73 | (a: YAML.Pair, b: YAML.Pair) => {
74 | return JSON.stringify(a.key).localeCompare(JSON.stringify(b.key))
75 | }
76 | )
77 | },
78 | Seq(_, {items}) {
79 | items.sort((a: unknown, b: unknown) => {
80 | return JSON.stringify(a).localeCompare(JSON.stringify(b))
81 | })
82 | }
83 | })
84 | }
85 |
86 | toString(): string {
87 | return this._document.toString({
88 | collectionStyle: 'block',
89 | singleQuote: false,
90 | lineWidth: 0
91 | })
92 | }
93 |
94 | save(path = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml`): void {
95 | this.format()
96 | fs.writeFileSync(path, this.toString())
97 | }
98 |
99 | getAllResources(): Resource[] {
100 | const resources = []
101 | for (const resourceClass of ResourceConstructors) {
102 | const classResources = this.getResources(resourceClass)
103 | resources.push(...classResources)
104 | }
105 | return resources
106 | }
107 |
108 | getResources(resourceClass: ResourceConstructor): T[] {
109 | if (ResourceConstructors.includes(resourceClass)) {
110 | return resourceClass.FromConfig(this.get())
111 | } else {
112 | throw new Error(`${resourceClass.name} is not supported`)
113 | }
114 | }
115 |
116 | findResource(resource: T): T | undefined {
117 | const schema = this.get()
118 | return this.getResources(
119 | resource.constructor as ResourceConstructor
120 | ).find(r =>
121 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema))
122 | )
123 | }
124 |
125 | someResource(resource: T): boolean {
126 | return this.findResource(resource) !== undefined
127 | }
128 |
129 | // updates the resource if it already exists, otherwise adds it
130 | addResource(
131 | resource: T,
132 | canDeleteProperties = false
133 | ): void {
134 | const oldResource = this.findResource(resource)
135 | const path = resource.getSchemaPath(this.get())
136 | const newValue = resourceToPlain(resource)
137 | const oldValue = resourceToPlain(oldResource)
138 | const diffs = diff(oldValue, newValue)
139 | for (const d of diffs || []) {
140 | if (d.kind === 'N') {
141 | this._document.addIn(
142 | pathToYAML([...path, ...(d.path || [])]),
143 | yamlify(d.rhs)
144 | )
145 | } else if (d.kind === 'E') {
146 | this._document.setIn(
147 | pathToYAML([...path, ...(d.path || [])]),
148 | yamlify(d.rhs)
149 | )
150 | const node = this._document.getIn(
151 | [...path, ...(d.path || [])],
152 | true
153 | ) as {comment?: string; commentBefore?: string}
154 | delete node.comment
155 | delete node.commentBefore
156 | } else if (d.kind === 'D' && canDeleteProperties) {
157 | this._document.deleteIn(pathToYAML([...path, ...(d.path || [])]))
158 | } else if (d.kind === 'A') {
159 | if (d.item.kind === 'N') {
160 | this._document.addIn(
161 | pathToYAML([...path, ...(d.path || []), d.index]),
162 | yamlify(d.item.rhs)
163 | )
164 | } else if (d.item.kind === 'E') {
165 | this._document.setIn(
166 | pathToYAML([...path, ...(d.path || []), d.index]),
167 | yamlify(d.item.rhs)
168 | )
169 | const node = this._document.getIn(
170 | [...path, ...(d.path || []), d.index],
171 | true
172 | ) as {comment?: string; commentBefore?: string}
173 | delete node.comment
174 | delete node.commentBefore
175 | } else if (d.item.kind === 'D') {
176 | this._document.setIn(
177 | pathToYAML([...path, ...(d.path || []), d.index]),
178 | undefined
179 | )
180 | } else {
181 | throw new Error('Nested arrays are not supported')
182 | }
183 | }
184 | }
185 | }
186 |
187 | removeResource(resource: T): void {
188 | if (this.someResource(resource)) {
189 | const path = resource.getSchemaPath(this.get())
190 | this._document.deleteIn(path)
191 | }
192 | }
193 |
194 | sync(resources: Resource[]): void {
195 | const oldResources = []
196 | for (const resource of ResourceConstructors) {
197 | oldResources.push(...this.getResources(resource))
198 | }
199 | const schema = this.get()
200 | for (const resource of oldResources) {
201 | if (
202 | !resources.some(r =>
203 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema))
204 | )
205 | ) {
206 | this.removeResource(resource)
207 | }
208 | }
209 | for (const resource of resources) {
210 | this.addResource(resource, true)
211 | }
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/scripts/src/yaml/schema.ts:
--------------------------------------------------------------------------------
1 | import {Role as MemberRole} from '../resources/member.js'
2 | import {Repository} from '../resources/repository.js'
3 | import {RepositoryFile} from '../resources/repository-file.js'
4 | import {Permission as RepositoryCollaboratorPermission} from '../resources/repository-collaborator.js'
5 | import {Permission as RepositoryTeamPermission} from '../resources/repository-team.js'
6 | import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule.js'
7 | import {RepositoryLabel} from '../resources/repository-label.js'
8 | import {Role as TeamRole} from '../resources/team-member.js'
9 | import {Team} from '../resources/team.js'
10 | import * as YAML from 'yaml'
11 | import {yamlify} from '../utils.js'
12 |
13 | type TeamMember = string
14 | type RepositoryCollaborator = string
15 | type RepositoryTeam = string
16 | type Member = string
17 |
18 | interface RepositoryExtension {
19 | files?: Record
20 | collaborators?: {
21 | [permission in RepositoryCollaboratorPermission]?: RepositoryCollaborator[]
22 | }
23 | teams?: {
24 | [permission in RepositoryTeamPermission]?: RepositoryTeam[]
25 | }
26 | branch_protection?: Record
27 | labels?: Record
28 | }
29 |
30 | interface TeamExtension {
31 | members?: {
32 | [role in TeamRole]?: TeamMember[]
33 | }
34 | }
35 |
36 | export type Path = (string | number)[]
37 |
38 | export class ConfigSchema {
39 | members?: {
40 | [role in MemberRole]?: Member[]
41 | }
42 | repositories?: Record
43 | teams?: Record
44 | }
45 |
46 | export function pathToYAML(path: Path): (YAML.ParsedNode | number)[] {
47 | return path.map(e => (typeof e === 'number' ? e : yamlify(e)))
48 | }
49 |
--------------------------------------------------------------------------------
/scripts/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["__tests__/**/*.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
4 | "module": "nodenext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
5 | "outDir": "./lib", /* Redirect output structure to the directory. */
6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
7 | "strict": true, /* Enable all strict type-checking options. */
8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
10 | "experimentalDecorators": true,
11 | "lib": ["es2023"],
12 | "moduleResolution": "node16",
13 | },
14 | "exclude": ["node_modules"]
15 | }
16 |
--------------------------------------------------------------------------------
/terraform/bootstrap/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.5.0"
6 | constraints = "4.5.0"
7 | hashes = [
8 | "h1:PR5m6lcJZzSIYqfhnMd0YWTN+On2XGgfYV5AKIvOvBo=",
9 | "zh:0573de96ba316d808be9f8d6fc8e8e68e0e6b614ed4d707bd236c4f7b46ac8b1",
10 | "zh:37560469042f5f43fdb961eb6e6b0a8f95057df68af2c1168d5b8c66ddcb1512",
11 | "zh:44bb4f6bc1f58e19b8bf7041f981a2549a351762d17dd39654eb24d1fa7991c7",
12 | "zh:53af6557b68e547ac5c02cfd0e47ef63c8e9edfacf46921ccc97d73c0cd362c9",
13 | "zh:578a583f69a8e5947d66b2b9d6969690043b6887f6b574263be7ef05f82a82ad",
14 | "zh:6c2d42f30db198a4e7badd7f8037ef9bd951cfd6cf40328c6a7eed96801a374e",
15 | "zh:758f3fc4d833dbdda57a4db743cbbddc8fd8c0492df47771b848447ba7876ce5",
16 | "zh:78241bd45e2f6102055787b3697849fee7e9c28a744ba59cad956639c1aca07b",
17 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
18 | "zh:a3a7f4699c097c7b8364d05a5df9f3bd5d005fd5736c28ec5dc8f8c0ee340512",
19 | "zh:bf875483bf2ad6cfb4029813328cdcd9ea40f50b9f1c265f4e742fe8cc456157",
20 | "zh:f4722596e8b5f012013f87bf4d2b7d302c248a04a144de4563b3e3f754a30c51",
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/terraform/bootstrap/aws.tf:
--------------------------------------------------------------------------------
1 | # terraform init
2 | # export AWS_ACCESS_KEY_ID=
3 | # export AWS_SECRET_ACCESS_KEY=
4 | # export AWS_REGION=
5 | # export TF_VAR_name=
6 | # terraform apply
7 |
8 | terraform {
9 | required_providers {
10 | aws = {
11 | version = "4.5.0"
12 | }
13 | }
14 |
15 | required_version = "~> 1.12.0"
16 | }
17 |
18 | provider "aws" {}
19 |
20 | variable "name" {
21 | description = "The name to use for S3 bucket, DynamoDB table and IAM users."
22 | type = string
23 | }
24 |
25 | resource "aws_s3_bucket" "this" {
26 | bucket = var.name
27 |
28 | tags = {
29 | Name = "GitHub Management"
30 | Url = "https://github.com/pl-strflt/github-mgmt-template"
31 | }
32 | }
33 |
34 | resource "aws_s3_bucket_ownership_controls" "this" {
35 | bucket = aws_s3_bucket.this.id
36 |
37 | rule {
38 | object_ownership = "BucketOwnerPreferred"
39 | }
40 | }
41 |
42 | resource "aws_s3_bucket_acl" "this" {
43 | depends_on = [ aws_s3_bucket_ownership_controls.this ]
44 |
45 | bucket = aws_s3_bucket.this.id
46 | acl = "private"
47 | }
48 |
49 | resource "aws_dynamodb_table" "this" {
50 | name = var.name
51 | billing_mode = "PAY_PER_REQUEST"
52 | hash_key = "LockID"
53 |
54 | attribute {
55 | name = "LockID"
56 | type = "S"
57 | }
58 |
59 | tags = {
60 | Name = "GitHub Management"
61 | Url = "https://github.com/pl-strflt/github-mgmt-template"
62 | }
63 | }
64 |
65 | resource "aws_iam_user" "ro" {
66 | name = "${var.name}-ro"
67 |
68 | tags = {
69 | Name = "GitHub Management"
70 | Url = "https://github.com/pl-strflt/github-mgmt-template"
71 | }
72 | }
73 |
74 | resource "aws_iam_user" "rw" {
75 | name = "${var.name}-rw"
76 |
77 | tags = {
78 | Name = "GitHub Management"
79 | Url = "https://github.com/pl-strflt/github-mgmt-template"
80 | }
81 | }
82 |
83 | data "aws_iam_policy_document" "ro" {
84 | statement {
85 | actions = ["s3:ListBucket"]
86 | resources = ["${aws_s3_bucket.this.arn}"]
87 | effect = "Allow"
88 | }
89 |
90 | statement {
91 | actions = ["s3:GetObject"]
92 | resources = ["${aws_s3_bucket.this.arn}/*"]
93 | effect = "Allow"
94 | }
95 |
96 | statement {
97 | actions = ["dynamodb:GetItem"]
98 | resources = ["${aws_dynamodb_table.this.arn}"]
99 | effect = "Allow"
100 | }
101 | }
102 |
103 | data "aws_iam_policy_document" "rw" {
104 | statement {
105 | actions = ["s3:ListBucket"]
106 | resources = ["${aws_s3_bucket.this.arn}"]
107 | effect = "Allow"
108 | }
109 |
110 | statement {
111 | actions = [
112 | "s3:GetObject",
113 | "s3:PutObject",
114 | "s3:DeleteObject",
115 | ]
116 |
117 | resources = ["${aws_s3_bucket.this.arn}/*"]
118 | effect = "Allow"
119 | }
120 |
121 | statement {
122 | actions = [
123 | "dynamodb:GetItem",
124 | "dynamodb:PutItem",
125 | "dynamodb:DeleteItem",
126 | ]
127 |
128 | resources = ["${aws_dynamodb_table.this.arn}"]
129 | effect = "Allow"
130 | }
131 | }
132 |
133 | resource "aws_iam_user_policy" "ro" {
134 | name = "${var.name}-ro"
135 | user = aws_iam_user.ro.name
136 |
137 | policy = data.aws_iam_policy_document.ro.json
138 | }
139 |
140 | resource "aws_iam_user_policy" "rw" {
141 | name = "${var.name}-rw"
142 | user = aws_iam_user.rw.name
143 |
144 | policy = data.aws_iam_policy_document.rw.json
145 | }
146 |
--------------------------------------------------------------------------------
/terraform/data.tf:
--------------------------------------------------------------------------------
1 | data "github_organization_teams" "this" {
2 | count = length(setintersection(
3 | toset(["github_team"]),
4 | toset(local.resource_types)
5 | )) == 0 ? 0 : 1
6 | }
7 |
--------------------------------------------------------------------------------
/terraform/locals_override.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | resource_types = [
3 | "github_repository",
4 | "github_branch_protection"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/terraform/providers.tf:
--------------------------------------------------------------------------------
1 | provider "github" {
2 | owner = local.organization
3 | write_delay_ms = var.write_delay_ms
4 | app_auth {}
5 | }
6 |
--------------------------------------------------------------------------------
/terraform/resources.tf:
--------------------------------------------------------------------------------
1 | resource "github_membership" "this" {
2 | for_each = try(var.resources.github_membership, local.resources.github_membership)
3 |
4 | username = each.value.username
5 | role = each.value.role
6 | # downgrade_on_destroy = try(each.value.downgrade_on_destroy, null)
7 |
8 | lifecycle {
9 | ignore_changes = []
10 | prevent_destroy = true
11 | }
12 | }
13 |
14 | resource "github_repository" "this" {
15 | for_each = try(var.resources.github_repository, local.resources.github_repository)
16 |
17 | name = each.value.name
18 | allow_auto_merge = try(each.value.allow_auto_merge, null)
19 | allow_merge_commit = try(each.value.allow_merge_commit, null)
20 | allow_rebase_merge = try(each.value.allow_rebase_merge, null)
21 | allow_squash_merge = try(each.value.allow_squash_merge, null)
22 | allow_update_branch = try(each.value.allow_update_branch, null)
23 | archive_on_destroy = try(each.value.archive_on_destroy, null)
24 | archived = try(each.value.archived, null)
25 | auto_init = try(each.value.auto_init, null)
26 | default_branch = try(each.value.default_branch, null)
27 | delete_branch_on_merge = try(each.value.delete_branch_on_merge, null)
28 | description = try(each.value.description, null)
29 | gitignore_template = try(each.value.gitignore_template, null)
30 | has_discussions = try(each.value.has_discussions, null)
31 | has_downloads = try(each.value.has_downloads, null)
32 | has_issues = try(each.value.has_issues, null)
33 | has_projects = try(each.value.has_projects, null)
34 | has_wiki = try(each.value.has_wiki, null)
35 | homepage_url = try(each.value.homepage_url, null)
36 | ignore_vulnerability_alerts_during_read = try(each.value.ignore_vulnerability_alerts_during_read, null)
37 | is_template = try(each.value.is_template, null)
38 | license_template = try(each.value.license_template, null)
39 | merge_commit_message = try(each.value.merge_commit_message, null)
40 | merge_commit_title = try(each.value.merge_commit_title, null)
41 | # private = try(each.value.private, null)
42 | squash_merge_commit_message = try(each.value.squash_merge_commit_message, null)
43 | squash_merge_commit_title = try(each.value.squash_merge_commit_title, null)
44 | topics = try(each.value.topics, null)
45 | visibility = try(each.value.visibility, null)
46 | vulnerability_alerts = try(each.value.vulnerability_alerts, null)
47 | web_commit_signoff_required = try(each.value.web_commit_signoff_required, null)
48 |
49 | dynamic "security_and_analysis" {
50 | for_each = try(each.value.security_and_analysis, [])
51 |
52 | content {
53 | dynamic "advanced_security" {
54 | for_each = security_and_analysis.value["advanced_security"]
55 | content {
56 | status = advanced_security.value["status"]
57 | }
58 | }
59 | dynamic "secret_scanning" {
60 | for_each = security_and_analysis.value["secret_scanning"]
61 | content {
62 | status = secret_scanning.value["status"]
63 | }
64 | }
65 | dynamic "secret_scanning_push_protection" {
66 | for_each = security_and_analysis.value["secret_scanning_push_protection"]
67 | content {
68 | status = secret_scanning_push_protection.value["status"]
69 | }
70 | }
71 | }
72 | }
73 |
74 | dynamic "pages" {
75 | for_each = try(each.value.pages, [])
76 | content {
77 | build_type = try(pages.value["build_type"], null)
78 | cname = try(pages.value["cname"], null)
79 | dynamic "source" {
80 | for_each = pages.value["source"]
81 | content {
82 | branch = source.value["branch"]
83 | path = try(source.value["path"], null)
84 | }
85 | }
86 | }
87 | }
88 | dynamic "template" {
89 | for_each = try(each.value.template, [])
90 | content {
91 | owner = template.value["owner"]
92 | repository = template.value["repository"]
93 | include_all_branches = try(template.value["include_all_branches"], null)
94 | }
95 | }
96 |
97 | lifecycle {
98 | ignore_changes = []
99 | prevent_destroy = true
100 | }
101 | }
102 |
103 | resource "github_repository_collaborator" "this" {
104 | for_each = try(var.resources.github_repository_collaborator, local.resources.github_repository_collaborator)
105 |
106 | depends_on = [github_repository.this]
107 |
108 | repository = each.value.repository
109 | username = each.value.username
110 | permission = each.value.permission
111 | # permission_diff_suppression = try(each.value.permission_diff_suppression, null)
112 |
113 | lifecycle {
114 | ignore_changes = []
115 | }
116 | }
117 |
118 | resource "github_branch_protection" "this" {
119 | for_each = try(var.resources.github_branch_protection, local.resources.github_branch_protection)
120 |
121 | pattern = each.value.pattern
122 |
123 | repository_id = lookup(each.value, "repository_id", lookup(lookup(github_repository.this, lower(lookup(each.value, "repository", "")), {}), "node_id", null))
124 |
125 | allows_deletions = try(each.value.allows_deletions, null)
126 | allows_force_pushes = try(each.value.allows_force_pushes, null)
127 | enforce_admins = try(each.value.enforce_admins, null)
128 | force_push_bypassers = try(each.value.force_push_bypassers, null)
129 | lock_branch = try(each.value.lock_branch, null)
130 | require_conversation_resolution = try(each.value.require_conversation_resolution, null)
131 | require_signed_commits = try(each.value.require_signed_commits, null)
132 | required_linear_history = try(each.value.required_linear_history, null)
133 |
134 | dynamic "required_pull_request_reviews" {
135 | for_each = try(each.value.required_pull_request_reviews, [])
136 | content {
137 | dismiss_stale_reviews = try(required_pull_request_reviews.value["dismiss_stale_reviews"], null)
138 | dismissal_restrictions = try(required_pull_request_reviews.value["dismissal_restrictions"], null)
139 | pull_request_bypassers = try(required_pull_request_reviews.value["pull_request_bypassers"], null)
140 | require_code_owner_reviews = try(required_pull_request_reviews.value["require_code_owner_reviews"], null)
141 | require_last_push_approval = try(required_pull_request_reviews.value["require_last_push_approval"], null)
142 | required_approving_review_count = try(required_pull_request_reviews.value["required_approving_review_count"], null)
143 | restrict_dismissals = try(required_pull_request_reviews.value["restrict_dismissals"], null)
144 | }
145 | }
146 | dynamic "required_status_checks" {
147 | for_each = try(each.value.required_status_checks, null)
148 | content {
149 | contexts = try(required_status_checks.value["contexts"], null)
150 | strict = try(required_status_checks.value["strict"], null)
151 | }
152 | }
153 | dynamic "restrict_pushes" {
154 | for_each = try(each.value.restrict_pushes, [])
155 | content {
156 | blocks_creations = try(restrict_pushes.value["blocks_creations"], null)
157 | push_allowances = try(restrict_pushes.value["push_allowances"], null)
158 | }
159 | }
160 | }
161 |
162 | resource "github_team" "this" {
163 | for_each = try(var.resources.github_team, local.resources.github_team)
164 |
165 | name = each.value.name
166 |
167 | parent_team_id = try(try(element(data.github_organization_teams.this[0].teams, index(data.github_organization_teams.this[0].teams.*.name, each.value.parent_team_id)).id, each.value.parent_team_id), null)
168 |
169 | # create_default_maintainer = try(each.value.create_default_maintainer, null)
170 | description = try(each.value.description, null)
171 | # ldap_dn = try(each.value.ldap_dn, null)
172 | privacy = try(each.value.privacy, null)
173 |
174 | lifecycle {
175 | ignore_changes = []
176 | }
177 | }
178 |
179 | resource "github_team_repository" "this" {
180 | for_each = try(var.resources.github_team_repository, local.resources.github_team_repository)
181 |
182 | depends_on = [github_repository.this]
183 |
184 | repository = each.value.repository
185 | permission = each.value.permission
186 |
187 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null))
188 |
189 | lifecycle {
190 | ignore_changes = []
191 | }
192 | }
193 |
194 | resource "github_team_membership" "this" {
195 | for_each = try(var.resources.github_team_membership, local.resources.github_team_membership)
196 |
197 | username = each.value.username
198 | role = each.value.role
199 |
200 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null))
201 |
202 | lifecycle {
203 | ignore_changes = []
204 | }
205 | }
206 |
207 | resource "github_repository_file" "this" {
208 | for_each = try(var.resources.github_repository_file, local.resources.github_repository_file)
209 |
210 | repository = each.value.repository
211 | file = each.value.file
212 | content = each.value.content
213 | # autocreate_branch = try(each.value.autocreate_branch, null)
214 | # autocreate_branch_source_branch = try(each.value.autocreate_branch_source_branch, null)
215 | # autocreate_branch_source_sha = try(each.value.autocreate_branch_source_sha, null)
216 | # Since 5.25.0 the branch attribute defaults to the default branch of the repository
217 | # branch = try(each.value.branch, null)
218 | branch = lookup(each.value, "branch", lookup(lookup(github_repository.this, each.value.repository, {}), "default_branch", null))
219 | overwrite_on_create = try(each.value.overwrite_on_create, true)
220 | # Keep the defaults from 4.x
221 | commit_author = try(each.value.commit_author, "GitHub")
222 | commit_email = try(each.value.commit_email, "noreply@github.com")
223 | commit_message = try(each.value.commit_message, "chore: Update ${each.value.file} [skip ci]")
224 |
225 | lifecycle {
226 | ignore_changes = []
227 | }
228 | }
229 |
230 | resource "github_issue_labels" "this" {
231 | for_each = try(var.resources.github_issue_labels, local.resources.github_issue_labels)
232 |
233 | depends_on = [github_repository.this]
234 |
235 | repository = each.value.repository
236 |
237 | dynamic "label" {
238 | for_each = try(each.value.label, [])
239 | content {
240 | color = try(label.value["color"], "7B42BC")
241 | description = try(label.value["description"], "")
242 | name = label.value["name"]
243 | }
244 | }
245 |
246 | lifecycle {
247 | ignore_changes = []
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/terraform/resources_override.tf:
--------------------------------------------------------------------------------
1 | resource "github_repository" "this" {
2 | lifecycle {
3 | ignore_changes = [
4 | allow_auto_merge,
5 | allow_merge_commit,
6 | allow_rebase_merge,
7 | allow_squash_merge,
8 | archive_on_destroy,
9 | archived,
10 | auto_init,
11 | default_branch,
12 | delete_branch_on_merge,
13 | description,
14 | gitignore_template,
15 | has_downloads,
16 | has_issues,
17 | has_projects,
18 | has_wiki,
19 | homepage_url,
20 | ignore_vulnerability_alerts_during_read,
21 | is_template,
22 | license_template,
23 | pages,
24 | template,
25 | topics,
26 | visibility,
27 | vulnerability_alerts,
28 | web_commit_signoff_required,
29 | ]
30 | }
31 | }
32 |
33 | resource "github_branch_protection" "this" {
34 | lifecycle {
35 | ignore_changes = [
36 | allows_deletions,
37 | allows_force_pushes,
38 | enforce_admins,
39 | force_push_bypassers,
40 | require_conversation_resolution,
41 | require_signed_commits,
42 | required_linear_history,
43 | # required_pull_request_reviews,
44 | # required_status_checks,
45 | restrict_pushes,
46 | ]
47 | }
48 | }
49 |
50 | resource "github_team" "this" {
51 | lifecycle {
52 | ignore_changes = [
53 | description,
54 | parent_team_id,
55 | privacy,
56 | ]
57 | }
58 | }
59 |
60 | resource "github_repository_file" "this" {
61 | lifecycle {
62 | ignore_changes = [
63 | overwrite_on_create,
64 | ]
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/terraform/terraform.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | github = {
4 | source = "integrations/github"
5 | version = "~> 6.6.0"
6 | }
7 | }
8 |
9 | # https://github.com/hashicorp/terraform/issues/32329
10 | required_version = "~> 1.12.0"
11 | }
12 |
--------------------------------------------------------------------------------
/terraform/terraform_override.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | backend "s3" {
3 | region = "us-east-1"
4 | bucket = "github-mgmt"
5 | key = "terraform.tfstate"
6 | workspace_key_prefix = "org"
7 | dynamodb_table = "github-mgmt"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/terraform/variables.tf:
--------------------------------------------------------------------------------
1 | variable "write_delay_ms" {
2 | description = "Amount of time in milliseconds to sleep in between writes to GitHub API."
3 | type = number
4 | default = 1000
5 | }
6 |
7 | variable "resources" {
8 | description = "Resources to import."
9 | type = object({
10 | github_membership = optional(map(any), {})
11 | github_repository = optional(map(any), {})
12 | github_repository_collaborator = optional(map(any), {})
13 | github_branch_protection = optional(map(any), {})
14 | github_team = optional(map(any), {})
15 | github_team_repository = optional(map(any), {})
16 | github_team_membership = optional(map(any), {})
17 | github_repository_file = optional(map(any), {})
18 | github_issue_labels = optional(map(any), {})
19 | })
20 | default = null
21 | }
22 |
--------------------------------------------------------------------------------