diff --git a/.github/actions/graformer/action.yaml b/.github/actions/graformer/action.yaml index 100fe39..2eab419 100644 --- a/.github/actions/graformer/action.yaml +++ b/.github/actions/graformer/action.yaml @@ -120,6 +120,8 @@ runs: return `${resourceChange.type} :: ${match[1]}/${actualChange.pattern}`; }else if(resourceChange.type === 'github_repository_environment'){ return `${resourceChange.type} :: ${actualChange.repository}/${actualChange.environment}`; + }else if(resourceChange.type === 'github_repository_custom_property'){ + return `${resourceChange.type} :: ${actualChange.repository}/${actualChange.property_name}`; } else { return `unknown type ${resourceChange.type}. resource address: ${resourceChange.address}` } diff --git a/.schemas/repository-config.schema.json b/.schemas/repository-config.schema.json index 24cccf1..5007703 100644 --- a/.schemas/repository-config.schema.json +++ b/.schemas/repository-config.schema.json @@ -421,6 +421,12 @@ }, "type": "array" }, + "custom_properties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "high_integrity": { "$ref": "#/$defs/HighIntegrityConfig" } diff --git a/DEVELOPERS_GUIDE.md b/DEVELOPERS_GUIDE.md index 0b687cc..b4309d6 100644 --- a/DEVELOPERS_GUIDE.md +++ b/DEVELOPERS_GUIDE.md @@ -102,6 +102,8 @@ These are the primary configuration options for each repository. - **`branch_protections_v4`**: *(optional, object[] [BranchProtectionV4](#branch-protection-configuration-v4))* Configuration for branch protection rules. +- **`custom_properties`**: *(optional, map[string]string)* A map of GitHub organization custom property names to their string values. See [Custom Properties](#custom-properties). + - **`high_integrity`**: *(optional, object [HighIntegrity](#high-integrity-configuration))* Expansion directives for high-integrity repositories. This field is consumed by the `expand` command and is **not** passed to Terraform — it is removed from the output after expansion. ## High Integrity Configuration @@ -124,6 +126,20 @@ high_integrity: enabled: true ``` +## Custom Properties + +[GitHub organization custom properties](https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization) allow organizations to attach structured metadata to repositories — such as lifecycle stage, team ownership, compliance classification, or any other organization-defined attribute. They are defined once at the organization level and can then be set per repository. + +The `custom_properties` field is a map of property names to their string values. Custom properties must be defined at the organization level before they can be set on a repository. Only `string`-type custom properties are supported. + +Example: + +```yaml +custom_properties: + oss_lifecycle: active + team: platform +``` + ## Template Configuration Options for configuring a repository from a template. diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index ee4d003..bc4f5f2 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -32,8 +32,9 @@ You must have: | Administration | Read & Write | | Checks | Read & Write | | Contents | Read & Write | +| Custom Properties | Read & Write | | Dependabot Alerts | Read & Write | -| Metadata | Read-only | +| Metadata | Read-only | | Pages | Read & Write | | Pull Requests | Read & Write | diff --git a/feature/github-repo-importer/pkg/github/custom_properties.go b/feature/github-repo-importer/pkg/github/custom_properties.go new file mode 100644 index 0000000..05a948e --- /dev/null +++ b/feature/github-repo-importer/pkg/github/custom_properties.go @@ -0,0 +1,41 @@ +package github + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-github/v67/github" + "github.com/gr-oss-devops/github-repo-importer/pkg/file" +) + +func FetchCustomProperties(client *github.Client, owner, repo string, dumpManager *file.DumpManager) (map[string]string, error) { + customPropertyValues, r, err := client.Repositories.GetAllCustomPropertyValues(context.Background(), owner, repo) + if err != nil { + if r != nil && r.StatusCode == http.StatusForbidden { + fmt.Printf("skipping custom properties due to insufficient permissions: %v\n", err) + return nil, nil + } + return nil, fmt.Errorf("failed to get custom properties: %w", err) + } + + if err := dumpManager.WriteJSONFile("custom_properties.json", customPropertyValues); err != nil { + fmt.Printf("failed to write custom_properties.json: %v\n", err) + } + + result := make(map[string]string) + for _, prop := range customPropertyValues { + if prop.Value == nil { + continue + } + if v, ok := prop.Value.(string); ok { + result[prop.PropertyName] = v + } + } + + if len(result) == 0 { + return nil, nil + } + + return result, nil +} diff --git a/feature/github-repo-importer/pkg/github/github.go b/feature/github-repo-importer/pkg/github/github.go index 7c251bf..fa363f7 100644 --- a/feature/github-repo-importer/pkg/github/github.go +++ b/feature/github-repo-importer/pkg/github/github.go @@ -157,6 +157,11 @@ func ImportRepo(repoName string) (*Repository, error) { fmt.Printf("failed to resolve rulesets: %v\n", err) } + customProperties, err := FetchCustomProperties(v3client, repoNameSplit[0], repoNameSplit[1], dumpManager) + if err != nil { + fmt.Printf("failed to fetch custom properties: %v\n", err) + } + return &Repository{ Name: repo.GetName(), Owner: repo.GetOwner().GetLogin(), @@ -200,6 +205,7 @@ func ImportRepo(repoName string) (*Repository, error) { Rulesets: resolvedRulesets, VulnerabilityAlertsEnabled: &vulnerabilityAlertsEnabled, BranchProtectionsV4: resolveBranchProtectionsFromGraphQL(&branchProtectionRulesGraphQLQuery), + CustomProperties: customProperties, }, nil } diff --git a/feature/github-repo-importer/pkg/github/repositories.go b/feature/github-repo-importer/pkg/github/repositories.go index bc1c878..16ca5a0 100644 --- a/feature/github-repo-importer/pkg/github/repositories.go +++ b/feature/github-repo-importer/pkg/github/repositories.go @@ -45,6 +45,7 @@ type Repository struct { Rulesets []Ruleset `yaml:"rulesets,omitempty"` VulnerabilityAlertsEnabled *bool `yaml:"vulnerability_alerts_enabled,omitempty"` BranchProtectionsV4 []*BranchProtectionV4 `yaml:"branch_protections_v4,omitempty"` + CustomProperties map[string]string `yaml:"custom_properties,omitempty"` } type RepositoryTemplate struct { diff --git a/feature/github-repo-provisioning/main.tf b/feature/github-repo-provisioning/main.tf index 7ed838a..c7ab166 100644 --- a/feature/github-repo-provisioning/main.tf +++ b/feature/github-repo-provisioning/main.tf @@ -547,3 +547,57 @@ resource "github_repository_ruleset" "ruleset" { } } } + +locals { + new_custom_properties_flattened = flatten([ + for repo, config in local.new_repos : [ + for prop_name, prop_value in try(config.custom_properties, {}) : { + repository = repo + property_name = prop_name + property_value = prop_value + } + ] + ]) + + new_custom_properties_map = { + for item in local.new_custom_properties_flattened : + "${item.repository}/${item.property_name}" => item + } + + generated_custom_properties_flattened = flatten([ + for repo, config in local.generated_repos : [ + for prop_name, prop_value in try(config.custom_properties, {}) : { + repository = repo + property_name = prop_name + property_value = prop_value + } + ] + ]) + + generated_custom_properties_map = { + for item in local.generated_custom_properties_flattened : + "${item.repository}/${item.property_name}" => item + } + + all_custom_properties_map = merge(local.new_custom_properties_map, local.generated_custom_properties_map) +} + +import { + for_each = local.generated_custom_properties_map + to = github_repository_custom_property.custom_property[each.key] + id = format("%s:%s:%s", var.owner, each.value.repository, each.value.property_name) +} + +resource "github_repository_custom_property" "custom_property" { + depends_on = [module.repository] + for_each = local.all_custom_properties_map + + repository = each.value.repository + property_name = each.value.property_name + property_type = "string" + property_value = [each.value.property_value] + + lifecycle { + ignore_changes = [property_type] # At this moment the provider does not support updates to the property_type field, so we set it to a default value and ignore changes to it + } +}