diff --git a/.github/actions/tf-setup/action.yml b/.github/actions/tf-setup/action.yml new file mode 100644 index 0000000..62815d7 --- /dev/null +++ b/.github/actions/tf-setup/action.yml @@ -0,0 +1,46 @@ +name: Set up TF +description: Set up terraform + +inputs: + working-directory: + description: Working directory + required: false + default: terraform + wrapper: + description: Whether to use the terraform wrapper + required: false + default: 'true' + upgrade: + description: Whether to upgrade terraform + required: false + default: 'false' + version: + description: TF GitHub Provider version + required: false + default: '5.25.2-rc9' + +runs: + using: composite + steps: + - name: Setup Terraform + uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 + with: + terraform_version: 1.2.9 + terraform_wrapper: ${{ inputs.wrapper }} + - name: Setup Custom Terraform Provider + env: + VERSION: ${{ inputs.version }} + run: | + curl -LO https://github.com/galargh/terraform-provider-github/releases/download/v${VERSION}/terraform-provider-github_${VERSION}_linux_amd64.zip + mkdir -p ~/.terraform.d/plugins/registry.terraform.io/integrations/github/${VERSION}/linux_amd64/ + unzip terraform-provider-github_${VERSION}_linux_amd64.zip -d ~/.terraform.d/plugins/registry.terraform.io/integrations/github/${VERSION}/linux_amd64/ + rm terraform-provider-github_${VERSION}_linux_amd64.zip + shell: bash + working-directory: ${{ inputs.working-directory }} + - name: Init Terraform + env: + UPGRADE: ${{ inputs.upgrade }} + run: | + terraform init -upgrade=${{ env.UPGRADE }} + shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/apply.yml b/.github/workflows/apply.yml index eaf28f9..7da961b 100644 --- a/.github/workflows/apply.yml +++ b/.github/workflows/apply.yml @@ -66,12 +66,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 - with: - terraform_version: 1.2.9 - - name: Initialize terraform - run: terraform init + - name: Setup Terraform + uses: ./.github/actions/tf-setup - name: Terraform Plan Download env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index 15e6163..4e21c76 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -70,14 +70,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 + - name: Setup Terraform + uses: ./.github/actions/tf-setup with: - terraform_version: 1.2.9 - terraform_wrapper: false - - name: Initialize terraform - run: terraform init -upgrade - working-directory: terraform + wrapper: false + upgrade: true - name: Select terraform workspace run: | terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}" diff --git a/.github/workflows/fix.yml b/.github/workflows/fix.yml index 6c5ad77..067f3af 100644 --- a/.github/workflows/fix.yml +++ b/.github/workflows/fix.yml @@ -93,13 +93,8 @@ jobs: # only checking out github directory from the PR git fetch origin "pull/${NUMBER}/head" rm -rf github && git checkout "${SHA}" -- github - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 - with: - terraform_version: 1.2.9 - - name: Initialize terraform - run: terraform init - working-directory: terraform + - name: Setup Terraform + uses: ./.github/actions/tf-setup - name: Initialize scripts run: npm ci && npm run build working-directory: scripts diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml index 94ecd13..7ff184c 100644 --- a/.github/workflows/plan.yml +++ b/.github/workflows/plan.yml @@ -79,13 +79,10 @@ jobs: run: | git fetch origin "pull/${NUMBER}/head" rm -rf github && git checkout "${SHA}" -- github - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 + - name: Setup Terraform + uses: ./.github/actions/tf-setup with: - terraform_version: 1.2.9 - - name: Initialize terraform - run: terraform init - working-directory: terraform + working-directory: terraform - name: Plan terraform run: terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color working-directory: terraform @@ -118,14 +115,10 @@ jobs: run: | git fetch origin "pull/${NUMBER}/head" rm -rf github && git checkout "${SHA}" -- github - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 + - name: Setup Terraform + uses: ./.github/actions/tf-setup with: - terraform_version: 1.2.9 - terraform_wrapper: false - - name: Initialize terraform - run: terraform init - working-directory: terraform + wrapper: false - name: Download terraform plans uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index c3dca9a..8ed1c35 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -64,14 +64,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Setup terraform - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 + - name: Setup Terraform + uses: ./.github/actions/tf-setup with: - terraform_version: 1.2.9 - terraform_wrapper: false - - name: Initialize terraform - run: terraform init -upgrade - working-directory: terraform + wrapper: false + upgrade: true - name: Select terraform workspace run: | terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6bd77..d168dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - workflows: not to use deprecated GitHub Actions expressions anymore - tf: to prevent destroy of membership and repository resources - apply: find sha for plan using proper credentials +- made issue label support more efficient for large orgs ### Fixed - links to supported resources in HOWTOs diff --git a/docs/ABOUT.md b/docs/ABOUT.md index 0b41101..98d8d42 100644 --- a/docs/ABOUT.md +++ b/docs/ABOUT.md @@ -47,7 +47,7 @@ Running the `Sync` GitHub Action workflows refreshes the underlying terraform st - [github_team_repository](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) - [github_team_membership](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_membership) - [github_repository_file](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file) -- [github_issue_label](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_label) +- [github_issue_labels](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_labels) # Config Fix Rules diff --git a/docs/EXAMPLE.yml b/docs/EXAMPLE.yml index aa0be52..b4dfd51 100644 --- a/docs/EXAMPLE.yml +++ b/docs/EXAMPLE.yml @@ -28,7 +28,9 @@ repositories: # This group defines repositories (https://registry.terraform.io/p master: # This key accepts only EXACT branch names, unlike the terraform resource which accepts any pattern allows_deletions: false allows_force_pushes: false + blocks_creations: false enforce_admins: false + lock_branch: false require_conversation_resolution: false require_signed_commits: false required_linear_history: false @@ -63,6 +65,8 @@ repositories: # This group defines repositories (https://registry.terraform.io/p allow_merge_commit: true allow_rebase_merge: true allow_squash_merge: true + allow_update_branch: true + has_discussions: true has_downloads: true has_issues: true has_projects: true @@ -81,6 +85,13 @@ repositories: # This group defines repositories (https://registry.terraform.io/p gitignore_template: Terraform # This field accepts a name of a template from https://github.com/github/gitignore without extension ignore_vulnerability_alerts_during_read: false license_template: mit # This field accepts a name of a template from https://github.com/github/choosealicense.com/tree/gh-pages/_licenses without extension + merge_commit_message: PR_TITLE + merge_commit_title: MERGE_MESSAGE + advanced_security: false + secret_scanning: false + secret_scanning_push_protection: false + squash_merge_commit_message: PR_TITLE + squash_merge_commit_title: MERGE_MESSAGE pages: cname: "" source: @@ -91,3 +102,10 @@ repositories: # This group defines repositories (https://registry.terraform.io/p repository: github-mgmt-template topics: - github + labels: # This group defines repository labels (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_labels) + bug: + color: d73a4a + description: Something isn't working + documentation: + color: 0075ca + description: Improvements or additions to documentation diff --git a/github/.schema.json b/github/.schema.json index 42ac15e..624afd3 100644 --- a/github/.schema.json +++ b/github/.schema.json @@ -27,6 +27,9 @@ "additionalProperties": { "additionalProperties": false, "properties": { + "advanced_security": { + "type": "boolean" + }, "allow_auto_merge": { "type": "boolean" }, @@ -39,6 +42,9 @@ "allow_squash_merge": { "type": "boolean" }, + "allow_update_branch": { + "type": "boolean" + }, "archive_on_destroy": { "type": "boolean" }, @@ -108,6 +114,9 @@ "gitignore_template": { "type": "string" }, + "has_discussions": { + "type": "boolean" + }, "has_downloads": { "type": "boolean" }, @@ -129,9 +138,21 @@ "is_template": { "type": "boolean" }, + "labels": { + "additionalProperties": { + "$ref": "#/definitions/RepositoryLabel" + }, + "type": "object" + }, "license_template": { "type": "string" }, + "merge_commit_message": { + "type": "string" + }, + "merge_commit_title": { + "type": "string" + }, "pages": { "additionalProperties": false, "properties": { @@ -153,6 +174,18 @@ }, "type": "object" }, + "secret_scanning": { + "type": "boolean" + }, + "secret_scanning_push_protection": { + "type": "boolean" + }, + "squash_merge_commit_message": { + "type": "string" + }, + "squash_merge_commit_title": { + "type": "string" + }, "teams": { "additionalProperties": false, "properties": { @@ -276,9 +309,15 @@ "allows_force_pushes": { "type": "boolean" }, + "blocks_creations": { + "type": "boolean" + }, "enforce_admins": { "type": "boolean" }, + "lock_branch": { + "type": "boolean" + }, "push_restrictions": { "items": { "type": "string" @@ -354,6 +393,18 @@ }, "type": "object" }, + "RepositoryLabel": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "type": "object" + }, "Visibility": { "enum": [ "private", diff --git a/scripts/__tests__/__resources__/terraform/locals_override.tf b/scripts/__tests__/__resources__/terraform/locals_override.tf index 2a153cb..18a5846 100644 --- a/scripts/__tests__/__resources__/terraform/locals_override.tf +++ b/scripts/__tests__/__resources__/terraform/locals_override.tf @@ -8,6 +8,6 @@ locals { "github_team", "github_branch_protection", "github_repository_file", - "github_issue_label" + "github_issue_labels" ] } diff --git a/scripts/__tests__/__resources__/terraform/resources.tf b/scripts/__tests__/__resources__/terraform/resources.tf index 5d7256c..8b48094 100644 --- a/scripts/__tests__/__resources__/terraform/resources.tf +++ b/scripts/__tests__/__resources__/terraform/resources.tf @@ -38,7 +38,7 @@ resource "github_repository_file" "this" { ignore_changes = [] } } -resource "github_issue_label" "this" { +resource "github_issue_labels" "this" { lifecycle { ignore_changes = [] } diff --git a/scripts/__tests__/__resources__/terraform/terraform.tfstate b/scripts/__tests__/__resources__/terraform/terraform.tfstate index b7a3dd2..eba24f1 100644 --- a/scripts/__tests__/__resources__/terraform/terraform.tfstate +++ b/scripts/__tests__/__resources__/terraform/terraform.tfstate @@ -2055,39 +2055,29 @@ ] }, { - "address": "github_issue_label.this[\"github-action-releaser:topic/ci\"]", + "address": "github_issue_labels.this[\"github-action-releaser\"]", "mode": "managed", - "type": "github_issue_label", + "type": "github_issue_labels", "name": "this", - "index": "github-action-releaser:topic/ci", - "provider_name": "registry.terraform.io/integrations/github", - "schema_version": 0, - "values": { - "repository": "github-action-releaser", - "name": "topic/ci", - "color": "#57cc2c", - "description": "Topic CI", - "url": "https://github.com/pl-strflt/github-action-releaser/labels/topic%2Fci" - }, - "sensitive_values": {}, - "depends_on": [ - "github_repository.this" - ] - }, - { - "address": "github_issue_label.this[\"github-action-releaser:topic dx\"]", - "mode": "managed", - "type": "github_issue_label", - "name": "this", - "index": "github-action-releaser:topic dx", + "index": "github-action-releaser", "provider_name": "registry.terraform.io/integrations/github", "schema_version": 0, "values": { "repository": "github-action-releaser", - "name": "topic dx", - "color": "#57cc2c", - "description": "Topic DX", - "url": "https://github.com/pl-strflt/github-action-releaser/labels/topic%20dx" + "label": [ + { + "name": "topic/ci", + "color": "#57cc2c", + "description": "Topic CI", + "url": "https://github.com/pl-strflt/github-action-releaser/labels/topic%2Fci" + }, + { + "name": "topic dx", + "color": "#57cc2c", + "description": "Topic DX", + "url": "https://github.com/pl-strflt/github-action-releaser/labels/topic%20dx" + } + ] }, "sensitive_values": {}, "depends_on": [ @@ -2095,19 +2085,23 @@ ] }, { - "address": "github_issue_label.this[\"projects-status-history:stale\"]", + "address": "github_issue_labels.this[\"projects-status-history\"]", "mode": "managed", - "type": "github_issue_label", + "type": "github_issue_labels", "name": "this", "index": "projects-status-history:stale", "provider_name": "registry.terraform.io/integrations/github", "schema_version": 0, "values": { "repository": "projects-status-history", - "name": "stale", - "color": "#57cc2c", - "description": "Stale", - "url": "https://github.com/pl-strflt/projects-status-history/labels/stale" + "label": [ + { + "name": "stale", + "color": "#57cc2c", + "description": "Stale", + "url": "https://github.com/pl-strflt/projects-status-history/labels/stale" + } + ] }, "sensitive_values": {}, "depends_on": [ diff --git a/scripts/__tests__/terraform/state.test.ts b/scripts/__tests__/terraform/state.test.ts index 37162e7..9b00462 100644 --- a/scripts/__tests__/terraform/state.test.ts +++ b/scripts/__tests__/terraform/state.test.ts @@ -56,7 +56,7 @@ test('can add and remove resources through sync', async () => { await config.sync(desiredResources) expect(addResourceSpy).not.toHaveBeenCalled() - expect(removeResourceAtSpy).toHaveBeenCalledTimes(resources.length) + expect(removeResourceAtSpy).toHaveBeenCalledTimes(global.UniqueResourcesCount) addResourceSpy.mockReset() removeResourceAtSpy.mockReset() diff --git a/scripts/__tests__/yaml/config.test.ts b/scripts/__tests__/yaml/config.test.ts index a220757..3518fe7 100644 --- a/scripts/__tests__/yaml/config.test.ts +++ b/scripts/__tests__/yaml/config.test.ts @@ -12,6 +12,7 @@ import {RepositoryFile} from '../../src/resources/repository-file' import {randomUUID} from 'crypto' import {Team, Privacy as TeamPrivacy} from '../../src/resources/team' import {RepositoryBranchProtectionRule} from '../../src/resources/repository-branch-protection-rule' +import {RepositoryLabel} from '../../src/resources/repository-label' test('can retrieve resources from YAML schema', async () => { const config = Config.FromPath() @@ -84,6 +85,23 @@ test('can remove repositories, including their sub-resources', async () => { expect(config.getAllResources()).toHaveLength(count) }) +test('cannot remove labels without removing repositories', async () => { + const config = Config.FromPath() + + const labels = config.getResources(RepositoryLabel) + + for (const [index, label] of labels.entries()) { + config.removeResource(label) + expect(config.someResource(label)).toBeFalsy() + const newLabels = config.getResources(RepositoryLabel) + expect(newLabels).toHaveLength(labels.length - index - 1) + } + + expect(config.getAllResources()).toHaveLength( + global.ResourcesCount - labels.length + ) +}) + test('can add members', async () => { const config = Config.FromPath() diff --git a/scripts/jest.d.ts b/scripts/jest.d.ts index e745ca9..847f056 100644 --- a/scripts/jest.d.ts +++ b/scripts/jest.d.ts @@ -1,6 +1,8 @@ declare global { var ResourceCounts: Record var ResourcesCount: number + var UniqueResourceCounts: Record + var UniqueResourcesCount: number } export {} diff --git a/scripts/jest.setup.ts b/scripts/jest.setup.ts index 255d544..84b51f2 100644 --- a/scripts/jest.setup.ts +++ b/scripts/jest.setup.ts @@ -73,3 +73,18 @@ global.ResourcesCount = Object.values(global.ResourceCounts).reduce( (a, b) => a + b, 0 ) +global.UniqueResourceCounts = { + [Member.name]: 2, + [Repository.name]: 7, + [Team.name]: 2, + [RepositoryCollaborator.name]: 1, + [RepositoryBranchProtectionRule.name]: 1, + [RepositoryTeam.name]: 7, + [TeamMember.name]: 2, + [RepositoryFile.name]: 1, + [RepositoryLabel.name]: 2 +} +global.UniqueResourcesCount = Object.values(global.UniqueResourceCounts).reduce( + (a, b) => a + b, + 0 +) diff --git a/scripts/src/actions/remove-inactive-members.ts b/scripts/src/actions/remove-inactive-members.ts index 57be64f..919b0f5 100644 --- a/scripts/src/actions/remove-inactive-members.ts +++ b/scripts/src/actions/remove-inactive-members.ts @@ -31,7 +31,7 @@ function getResources( const schema = config.get() return config.getResources(resourceClass).filter(resource => { const node = config.document.getIn( - resource.getSchemaPath(schema), + resource.getSchemaPath(schema).get(), true ) as NodeBase return !node.comment?.includes('KEEP:') diff --git a/scripts/src/resources/member.ts b/scripts/src/resources/member.ts index 7327b50..52ac73a 100644 --- a/scripts/src/resources/member.ts +++ b/scripts/src/resources/member.ts @@ -80,7 +80,7 @@ export class Member extends String implements Resource { getSchemaPath(schema: ConfigSchema): Path { const members = schema.members?.[this.role] ?? [] const index = members.indexOf(this.username) - return ['members', this.role, index === -1 ? members.length : index] + return new Path('members', this.role, index === -1 ? members.length : index) } getStateAddress(): string { diff --git a/scripts/src/resources/repository-branch-protection-rule.ts b/scripts/src/resources/repository-branch-protection-rule.ts index 670d8c9..959c492 100644 --- a/scripts/src/resources/repository-branch-protection-rule.ts +++ b/scripts/src/resources/repository-branch-protection-rule.ts @@ -132,7 +132,12 @@ export class RepositoryBranchProtectionRule implements Resource { required_status_checks?: RequiredStatusChecks getSchemaPath(_schema: ConfigSchema): Path { - return ['repositories', this.repository, 'branch_protection', this.pattern] + return new Path( + 'repositories', + this.repository, + 'branch_protection', + this.pattern + ) } getStateAddress(): string { diff --git a/scripts/src/resources/repository-collaborator.ts b/scripts/src/resources/repository-collaborator.ts index 3fb1117..ae2b641 100644 --- a/scripts/src/resources/repository-collaborator.ts +++ b/scripts/src/resources/repository-collaborator.ts @@ -130,13 +130,13 @@ export class RepositoryCollaborator extends String implements Resource { this.permission ] || [] const index = collaborators.indexOf(this.username) - return [ + return new Path( 'repositories', this.repository, 'collaborators', this.permission, index === -1 ? collaborators.length : index - ] + ) } getStateAddress(): string { diff --git a/scripts/src/resources/repository-file.ts b/scripts/src/resources/repository-file.ts index a6e6461..3c7f2ce 100644 --- a/scripts/src/resources/repository-file.ts +++ b/scripts/src/resources/repository-file.ts @@ -114,7 +114,7 @@ export class RepositoryFile implements Resource { @Expose() overwrite_on_create?: boolean getSchemaPath(_schema: ConfigSchema): Path { - return ['repositories', this.repository, 'files', this.file] + return new Path('repositories', this.repository, 'files', this.file) } getStateAddress(): string { diff --git a/scripts/src/resources/repository-label.ts b/scripts/src/resources/repository-label.ts index d601d94..67abc8a 100644 --- a/scripts/src/resources/repository-label.ts +++ b/scripts/src/resources/repository-label.ts @@ -6,7 +6,7 @@ import {Id, StateSchema} from '../terraform/schema' @Exclude() export class RepositoryLabel implements Resource { - static StateType: string = 'github_issue_label' + static StateType: string = 'github_issue_labels' static async FromGitHub( _labels: RepositoryLabel[] ): Promise<[Id, RepositoryLabel][]> { @@ -15,8 +15,11 @@ export class RepositoryLabel implements Resource { const result: [Id, RepositoryLabel][] = [] for (const label of labels) { result.push([ - `${label.repository.name}:${label.label.name}`, - new RepositoryLabel(label.repository.name, label.label.name) + label.repository.name, + plainToClassFromExist( + new RepositoryLabel(label.repository.name, label.label.name), + label.label + ) ]) } return result @@ -29,15 +32,14 @@ export class RepositoryLabel implements Resource { resource.type === RepositoryLabel.StateType && resource.mode === 'managed' ) { - labels.push( - plainToClassFromExist( - new RepositoryLabel( - resource.values.repository, - resource.values.name - ), - resource.values + for (const label of resource.values.label) { + labels.push( + plainToClassFromExist( + new RepositoryLabel(resource.values.repository, label.name), + label + ) ) - ) + } } } } @@ -72,6 +74,7 @@ export class RepositoryLabel implements Resource { get repository(): string { return this._repository } + private _name: string get name(): string { return this._name @@ -81,10 +84,10 @@ export class RepositoryLabel implements Resource { @Expose() description?: string getSchemaPath(_schema: ConfigSchema): Path { - return ['repositories', this.repository, 'labels', this.name] + return new Path('repositories', this.repository, 'labels', this._name) } getStateAddress(): string { - return `${RepositoryLabel.StateType}.this["${this.repository}:${this.name}"]` + return `${RepositoryLabel.StateType}.this["${this.repository}"]` } } diff --git a/scripts/src/resources/repository-team.ts b/scripts/src/resources/repository-team.ts index 75eaaa1..a13b2bf 100644 --- a/scripts/src/resources/repository-team.ts +++ b/scripts/src/resources/repository-team.ts @@ -108,13 +108,13 @@ export class RepositoryTeam extends String implements Resource { const teams = schema.repositories?.[this.repository]?.teams?.[this.permission] || [] const index = teams.indexOf(this.team) - return [ + return new Path( 'repositories', this.repository, 'teams', this.permission, index === -1 ? teams.length : index - ] + ) } getStateAddress(): string { diff --git a/scripts/src/resources/repository.ts b/scripts/src/resources/repository.ts index d7578bb..aac5da6 100644 --- a/scripts/src/resources/repository.ts +++ b/scripts/src/resources/repository.ts @@ -140,7 +140,7 @@ export class Repository implements Resource { @Expose() vulnerability_alerts?: boolean getSchemaPath(_schema: ConfigSchema): Path { - return ['repositories', this.name] + return new Path('repositories', this.name) } getStateAddress(): string { diff --git a/scripts/src/resources/resource.ts b/scripts/src/resources/resource.ts index a0c90e4..b897c23 100644 --- a/scripts/src/resources/resource.ts +++ b/scripts/src/resources/resource.ts @@ -12,8 +12,8 @@ import {Team} from './team' import {TeamMember} from './team-member' export interface Resource { - // returns YAML config path under which the resource can be found - // e.g. ['members', 'admin', ] + // returns an unique YAML config path under which the resource can be found + // e.g. ['members', 'admin', 'galargh'] getSchemaPath(schema: ConfigSchema): Path // returns Terraform state path under which the resource can be found // e.g. github_membership.this["galargh"] diff --git a/scripts/src/resources/team-member.ts b/scripts/src/resources/team-member.ts index 3e050c2..132fe71 100644 --- a/scripts/src/resources/team-member.ts +++ b/scripts/src/resources/team-member.ts @@ -109,13 +109,13 @@ export class TeamMember extends String implements Resource { getSchemaPath(schema: ConfigSchema): Path { const members = schema.teams?.[this.team]?.members?.[this.role] || [] const index = members.indexOf(this.username) - return [ + return new Path( 'teams', this.team, 'members', this.role, index === -1 ? members.length : index - ] + ) } getStateAddress(): string { diff --git a/scripts/src/resources/team.ts b/scripts/src/resources/team.ts index ce545fa..f7f980c 100644 --- a/scripts/src/resources/team.ts +++ b/scripts/src/resources/team.ts @@ -70,8 +70,8 @@ export class Team implements Resource { @Expose() parent_team_id?: string @Expose() privacy?: Privacy - getSchemaPath(schema: ConfigSchema): Path { - return ['teams', this.name] + getSchemaPath(_schema: ConfigSchema): Path { + return new Path('teams', this.name) } getStateAddress(): string { diff --git a/scripts/src/terraform/schema.ts b/scripts/src/terraform/schema.ts index 221cdaf..2b8b531 100644 --- a/scripts/src/terraform/schema.ts +++ b/scripts/src/terraform/schema.ts @@ -1,2 +1,3 @@ export type StateSchema = any export type Id = string +export type Address = string diff --git a/scripts/src/terraform/state.ts b/scripts/src/terraform/state.ts index 98c98f5..5fa4366 100644 --- a/scripts/src/terraform/state.ts +++ b/scripts/src/terraform/state.ts @@ -1,4 +1,4 @@ -import {Id, StateSchema} from './schema' +import {Address, Id, StateSchema} from './schema' import { Resource, ResourceConstructors, @@ -168,7 +168,7 @@ export class State { await this.addResourceAt(id, resource.getStateAddress().toLowerCase()) } - async addResourceAt(id: Id, address: string) { + async addResourceAt(id: Id, address: Address) { if (env.TF_EXEC === 'true') { await cli.exec( `terraform import -lock=${env.TF_LOCK} "${address.replaceAll( @@ -185,7 +185,7 @@ export class State { await this.removeResourceAt(resource.getStateAddress().toLowerCase()) } - async removeResourceAt(address: string) { + async removeResourceAt(address: Address) { if (env.TF_EXEC === 'true') { await cli.exec( `terraform state rm -lock=${env.TF_LOCK} "${address.replaceAll( @@ -209,7 +209,15 @@ export class State { await this.removeResourceAt(address) } } - for (const [id, resource] of resources) { + const firsts = resources.filter(([_, resource], index, self) => { + const address = resource.getStateAddress().toLowerCase() + return ( + self.findIndex( + ([_, r]) => r.getStateAddress().toLowerCase() === address + ) === index + ) + }) + for (const [id, resource] of firsts) { if ( !addresses.some(a => a === resource.getStateAddress().toLowerCase()) ) { diff --git a/scripts/src/yaml/config.ts b/scripts/src/yaml/config.ts index 1730b3b..cb2e3d0 100644 --- a/scripts/src/yaml/config.ts +++ b/scripts/src/yaml/config.ts @@ -1,5 +1,5 @@ import * as YAML from 'yaml' -import {ConfigSchema, pathToYAML} from './schema' +import {ConfigSchema, Path} from './schema' import { Resource, ResourceConstructor, @@ -9,7 +9,7 @@ import { import {diff} from 'deep-diff' import env from '../env' import * as fs from 'fs' -import {jsonEquals, yamlify} from '../utils' +import {yamlify} from '../utils' export class Config { static FromPath( @@ -35,7 +35,7 @@ export class Config { format(): void { const schema = this.get() const resources = this.getAllResources() - const resourcePaths = resources.map(r => r.getSchemaPath(schema).join('.')) + const resourcePaths = resources.map(r => r.getSchemaPath(schema)) let again = true while (again) { again = false @@ -47,11 +47,11 @@ export class Config { } }, Pair(_, node, path) { - const resourcePath = [...path, node] + const resourcePathParts = [...path, node] .filter((p: any) => YAML.isPair(p)) .map((p: any) => p.key.toString()) - .join('.') - if (!resourcePaths.includes(resourcePath)) { + const resourcePath = new Path(...resourcePathParts) + if (!resourcePaths.some(p => p.equals(resourcePath))) { const isEmpty = node.value === null || node.value === undefined const isEmptyScalar = YAML.isScalar(node.value) && @@ -117,9 +117,7 @@ export class Config { const schema = this.get() return this.getResources( resource.constructor as ResourceConstructor - ).find(r => - jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) - ) + ).find(r => r.getSchemaPath(schema).equals(resource.getSchemaPath(schema))) } someResource(resource: T): boolean { @@ -139,46 +137,54 @@ export class Config { for (const d of diffs || []) { if (d.kind === 'N') { this._document.addIn( - pathToYAML([...path, ...(d.path || [])]), + path.extend(...(d.path || [])).toYAML(), yamlify(d.rhs) ) } else if (d.kind === 'E') { this._document.setIn( - pathToYAML([...path, ...(d.path || [])]), + path.extend(...(d.path || [])).toYAML(), yamlify(d.rhs) ) - delete (this._document.getIn([...path, ...(d.path || [])], true) as any) - .comment - delete (this._document.getIn([...path, ...(d.path || [])], true) as any) - .commentBefore + delete ( + this._document.getIn( + path.extend(...(d.path || [])).get(), + true + ) as any + ).comment + delete ( + this._document.getIn( + path.extend(...(d.path || [])).get(), + true + ) as any + ).commentBefore } else if (d.kind === 'D' && canDeleteProperties) { - this._document.deleteIn(pathToYAML([...path, ...(d.path || [])])) + this._document.deleteIn(path.extend(...(d.path || [])).toYAML()) } else if (d.kind === 'A') { if (d.item.kind === 'N') { this._document.addIn( - pathToYAML([...path, ...(d.path || []), d.index]), + path.extend(...(d.path || []), d.index).toYAML(), yamlify(d.item.rhs) ) } else if (d.item.kind === 'E') { this._document.setIn( - pathToYAML([...path, ...(d.path || []), d.index]), + path.extend(...(d.path || []), d.index).toYAML(), yamlify(d.item.rhs) ) delete ( this._document.getIn( - [...path, ...(d.path || []), d.index], + path.extend(...(d.path || []), d.index).toYAML(), true ) as any ).comment delete ( this._document.getIn( - [...path, ...(d.path || []), d.index], + path.extend(...(d.path || []), d.index).toYAML(), true ) as any ).commentBefore } else if (d.item.kind === 'D') { this._document.setIn( - pathToYAML([...path, ...(d.path || []), d.index]), + path.extend(...(d.path || []), d.index).toYAML(), undefined ) } else { @@ -191,7 +197,7 @@ export class Config { removeResource(resource: T): void { if (this.someResource(resource)) { const path = resource.getSchemaPath(this.get()) - this._document.deleteIn(path) + this._document.deleteIn(path.get()) } } @@ -203,8 +209,10 @@ export class Config { const schema = this.get() for (const resource of oldResources) { if ( - !resources.some(r => - jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) + !resources.some( + r => + r.getStateAddress() === resource.getStateAddress() && + r.getSchemaPath(schema).equals(resource.getSchemaPath(schema)) ) ) { this.removeResource(resource) diff --git a/scripts/src/yaml/schema.ts b/scripts/src/yaml/schema.ts index b71eb97..63c8139 100644 --- a/scripts/src/yaml/schema.ts +++ b/scripts/src/yaml/schema.ts @@ -4,11 +4,11 @@ import {RepositoryFile} from '../resources/repository-file' import {Permission as RepositoryCollaboratorPermission} from '../resources/repository-collaborator' import {Permission as RepositoryTeamPermission} from '../resources/repository-team' import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule' -import {RepositoryLabel} from '../resources/repository-label' import {Role as TeamRole} from '../resources/team-member' import {Team} from '../resources/team' import * as YAML from 'yaml' import {yamlify} from '../utils' +import {RepositoryLabel} from '../resources/repository-label' type TeamMember = string type RepositoryCollaborator = string @@ -33,7 +33,33 @@ interface TeamExtension { } } -export type Path = (string | number)[] +export class Path { + constructor(...path: (string | number)[]) { + this._path = path + } + + private _path: (string | number)[] + + get(): (string | number)[] { + return this._path + } + + toYAML(): (YAML.ParsedNode | number)[] { + return this._path.map(e => (typeof e === 'number' ? e : yamlify(e))) + } + + toString(): string { + return this._path.join('.') + } + + equals(other: Path): boolean { + return this.toString() === other.toString() + } + + extend(...path: (string | number)[]): Path { + return new Path(...this._path, ...path) + } +} export class ConfigSchema { members?: { @@ -42,7 +68,3 @@ export class ConfigSchema { repositories?: Record teams?: Record } - -export function pathToYAML(path: Path): (YAML.ParsedNode | number)[] { - return path.map(e => (typeof e === 'number' ? e : yamlify(e))) -} diff --git a/terraform/locals.tf b/terraform/locals.tf index 7d3d936..30fa36e 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -1,5 +1,6 @@ locals { - organization = terraform.workspace - config = yamldecode(file("${path.module}/../github/${local.organization}.yml")) - resource_types = [] + organization = terraform.workspace + config = yamldecode(file("${path.module}/../github/${local.organization}.yml")) + resource_types = [] + advanced_security = false } diff --git a/terraform/resources.tf b/terraform/resources.tf index 540f515..02835ea 100644 --- a/terraform/resources.tf +++ b/terraform/resources.tf @@ -54,23 +54,27 @@ resource "github_repository" "this" { visibility = try(each.value.visibility, null) vulnerability_alerts = try(each.value.vulnerability_alerts, null) - security_and_analysis { - dynamic "advanced_security" { - for_each = try(each.value.visibility == "public" ? [] : [each.value.advanced_security ? "enabled" : "disabled"], []) - content { - status = advanced_security.value + dynamic "security_and_analysis" { + for_each = try(each.value.visibility == "public" || local.advanced_security ? [{}] : [], []) + + content { + dynamic "advanced_security" { + for_each = try(each.value.visibility == "public" || !local.advanced_security ? [] : [each.value.advanced_security ? "enabled" : "disabled"], []) + content { + status = advanced_security.value + } } - } - dynamic "secret_scanning" { - for_each = try([each.value.secret_scanning ? "enabled" : "disabled"], []) - content { - status = secret_scanning.value + dynamic "secret_scanning" { + for_each = try(each.value.visibility == "private" ? [] : [each.value.secret_scanning ? "enabled" : "disabled"], []) + content { + status = secret_scanning.value + } } - } - dynamic "secret_scanning_push_protection" { - for_each = try([each.value.secret_scanning_push_protection ? "enabled" : "disabled"], []) - content { - status = secret_scanning_push_protection.value + dynamic "secret_scanning_push_protection" { + for_each = try(each.value.visibility == "private" ? [] : [each.value.secret_scanning_push_protection ? "enabled" : "disabled"], []) + content { + status = secret_scanning_push_protection.value + } } } } @@ -262,32 +266,34 @@ resource "github_repository_file" "this" { branch = github_repository.this[each.value.repository_key].default_branch overwrite_on_create = try(each.value.overwrite_on_create, null) # Keep the defaults from 4.x - commit_author = "GitHub" - commit_email = "noreply@github.com" - commit_message = "chore: Update ${each.value.file} [skip ci]" + commit_author = "GitHub" + commit_email = "noreply@github.com" + commit_message = "chore: Update ${each.value.file} [skip ci]" lifecycle { ignore_changes = [] } } -resource "github_issue_label" "this" { - for_each = merge([ - for repository, repository_config in lookup(local.config, "repositories", {}) : - { - for label, config in lookup(repository_config, "labels", {}) : lower("${repository}:${label}") => merge(config, { - repository = repository - label = label - }) - } - ]...) +resource "github_issue_labels" "this" { + for_each = { + for repository, config in lookup(local.config, "repositories", {}) : lower(repository) => merge(config, { + name = repository + }) + } depends_on = [github_repository.this] - repository = each.value.repository - name = each.value.label - color = try(each.value.color, null) - description = try(each.value.description, null) + repository = each.value.name + + dynamic "label" { + for_each = lookup(each.value, "labels", {}) + content { + name = label.key + color = try(label.value.color, "7B42BC") + description = try(label.value.description, "") + } + } lifecycle { ignore_changes = [] diff --git a/terraform/terraform.tf b/terraform/terraform.tf index a3c809c..67e04a2 100644 --- a/terraform/terraform.tf +++ b/terraform/terraform.tf @@ -1,8 +1,8 @@ terraform { required_providers { github = { - source = "integrations/github" - version = "5.25.0" + source = "registry.terraform.io/integrations/github" + version = "5.25.2-rc9" } }