Skip to content

Commit 6892990

Browse files
committed
feat: add github_repository_custom_properties resource (batch)
New resource for managing multiple custom property values on a repository in a single resource block with in-place updates. Complements the existing singular github_repository_custom_property resource.
1 parent 636eff4 commit 6892990

3 files changed

Lines changed: 496 additions & 0 deletions

File tree

github/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ func Provider() *schema.Provider {
192192
"github_repository_collaborator": resourceGithubRepositoryCollaborator(),
193193
"github_repository_collaborators": resourceGithubRepositoryCollaborators(),
194194
"github_repository_custom_property": resourceGithubRepositoryCustomProperty(),
195+
"github_repository_custom_properties": resourceGithubRepositoryCustomProperties(),
195196
"github_repository_deploy_key": resourceGithubRepositoryDeployKey(),
196197
"github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(),
197198
"github_repository_environment": resourceGithubRepositoryEnvironment(),
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"strings"
8+
9+
"github.com/google/go-github/v83/github"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
11+
)
12+
13+
func resourceGithubRepositoryCustomProperties() *schema.Resource {
14+
return &schema.Resource{
15+
Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.",
16+
Create: resourceGithubRepositoryCustomPropertiesCreateOrUpdate,
17+
Read: resourceGithubRepositoryCustomPropertiesRead,
18+
Update: resourceGithubRepositoryCustomPropertiesCreateOrUpdate,
19+
Delete: resourceGithubRepositoryCustomPropertiesDelete,
20+
Importer: &schema.ResourceImporter{
21+
StateContext: resourceGithubRepositoryCustomPropertiesImport,
22+
},
23+
24+
Schema: map[string]*schema.Schema{
25+
"repository_name": {
26+
Type: schema.TypeString,
27+
Required: true,
28+
ForceNew: true,
29+
Description: "Name of the repository.",
30+
},
31+
"property": {
32+
Type: schema.TypeSet,
33+
Required: true,
34+
MinItems: 1,
35+
Description: "Set of custom property values for this repository.",
36+
Elem: &schema.Resource{
37+
Schema: map[string]*schema.Schema{
38+
"name": {
39+
Type: schema.TypeString,
40+
Required: true,
41+
Description: "Name of the custom property (must be defined at the organization level).",
42+
},
43+
"value": {
44+
Type: schema.TypeSet,
45+
Required: true,
46+
MinItems: 1,
47+
Description: "Value(s) of the custom property. For multi_select properties, multiple values can be specified.",
48+
Elem: &schema.Schema{
49+
Type: schema.TypeString,
50+
},
51+
},
52+
},
53+
},
54+
Set: resourceGithubRepositoryCustomPropertiesHash,
55+
},
56+
},
57+
}
58+
}
59+
60+
// resourceGithubRepositoryCustomPropertiesHash creates a hash for a property block
61+
// using only the property name, so that value changes are detected as in-place
62+
// updates rather than remove+add within the set.
63+
func resourceGithubRepositoryCustomPropertiesHash(v any) int {
64+
raw := v.(map[string]any)
65+
name := raw["name"].(string)
66+
return schema.HashString(name)
67+
}
68+
69+
func resourceGithubRepositoryCustomPropertiesCreateOrUpdate(d *schema.ResourceData, meta any) error {
70+
if err := checkOrganization(meta); err != nil {
71+
return err
72+
}
73+
74+
client := meta.(*Owner).v3client
75+
ctx := context.Background()
76+
owner := meta.(*Owner).name
77+
repoName := d.Get("repository_name").(string)
78+
properties := d.Get("property").(*schema.Set).List()
79+
80+
// Get all organization custom property definitions to determine types
81+
orgProperties, _, err := client.Organizations.GetAllCustomProperties(ctx, owner)
82+
if err != nil {
83+
return fmt.Errorf("error reading organization custom property definitions: %w", err)
84+
}
85+
86+
// Create a map of property names to their types
87+
propertyTypes := make(map[string]github.PropertyValueType)
88+
for _, prop := range orgProperties {
89+
if prop.PropertyName != nil {
90+
propertyTypes[*prop.PropertyName] = prop.ValueType
91+
}
92+
}
93+
94+
// Build custom property values for this repository
95+
customProperties := make([]*github.CustomPropertyValue, 0, len(properties))
96+
97+
for _, propBlock := range properties {
98+
propMap := propBlock.(map[string]any)
99+
propertyName := propMap["name"].(string)
100+
propertyValues := expandStringList(propMap["value"].(*schema.Set).List())
101+
102+
propertyType, ok := propertyTypes[propertyName]
103+
if !ok {
104+
return fmt.Errorf("custom property %q is not defined at the organization level", propertyName)
105+
}
106+
107+
customProperty := &github.CustomPropertyValue{
108+
PropertyName: propertyName,
109+
}
110+
111+
switch propertyType {
112+
case github.PropertyValueTypeMultiSelect:
113+
customProperty.Value = propertyValues
114+
case github.PropertyValueTypeString, github.PropertyValueTypeSingleSelect,
115+
github.PropertyValueTypeTrueFalse, github.PropertyValueTypeURL:
116+
if len(propertyValues) > 0 {
117+
customProperty.Value = propertyValues[0]
118+
}
119+
default:
120+
return fmt.Errorf("unsupported property type %q for property %q", propertyType, propertyName)
121+
}
122+
123+
customProperties = append(customProperties, customProperty)
124+
}
125+
126+
_, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties)
127+
if err != nil {
128+
return fmt.Errorf("error setting custom properties for repository %s/%s: %w", owner, repoName, err)
129+
}
130+
131+
d.SetId(buildTwoPartID(owner, repoName))
132+
133+
return resourceGithubRepositoryCustomPropertiesRead(d, meta)
134+
}
135+
136+
func resourceGithubRepositoryCustomPropertiesRead(d *schema.ResourceData, meta any) error {
137+
if err := checkOrganization(meta); err != nil {
138+
return err
139+
}
140+
141+
client := meta.(*Owner).v3client
142+
ctx := context.Background()
143+
144+
owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository")
145+
if err != nil {
146+
return err
147+
}
148+
149+
// Get current properties from state to know which ones we're managing.
150+
// On import this will be empty, which is handled below.
151+
propertiesFromState := d.Get("property").(*schema.Set).List()
152+
managedPropertyNames := make(map[string]bool)
153+
for _, propBlock := range propertiesFromState {
154+
propMap := propBlock.(map[string]any)
155+
managedPropertyNames[propMap["name"].(string)] = true
156+
}
157+
158+
isImport := len(managedPropertyNames) == 0
159+
160+
// Read actual properties from GitHub
161+
allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName)
162+
if err != nil {
163+
return fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err)
164+
}
165+
166+
// Build the property set — either all properties (import) or only managed ones
167+
managedProperties := make([]any, 0)
168+
for _, prop := range allCustomProperties {
169+
if !isImport && !managedPropertyNames[prop.PropertyName] {
170+
continue
171+
}
172+
173+
// Skip properties with nil/null values (unset)
174+
if prop.Value == nil {
175+
continue
176+
}
177+
178+
propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(prop)
179+
if err != nil {
180+
return fmt.Errorf("error parsing property %q for repository %s/%s: %w", prop.PropertyName, owner, repoName, err)
181+
}
182+
183+
if len(propertyValue) == 0 {
184+
continue
185+
}
186+
187+
managedProperties = append(managedProperties, map[string]any{
188+
"name": prop.PropertyName,
189+
"value": propertyValue,
190+
})
191+
}
192+
193+
// If no properties exist, remove resource from state
194+
if len(managedProperties) == 0 {
195+
log.Printf("[WARN] No custom properties found for %s/%s, removing from state", owner, repoName)
196+
d.SetId("")
197+
return nil
198+
}
199+
200+
_ = d.Set("repository_name", repoName)
201+
_ = d.Set("property", managedProperties)
202+
203+
return nil
204+
}
205+
206+
func resourceGithubRepositoryCustomPropertiesDelete(d *schema.ResourceData, meta any) error {
207+
if err := checkOrganization(meta); err != nil {
208+
return err
209+
}
210+
211+
client := meta.(*Owner).v3client
212+
ctx := context.Background()
213+
214+
owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository")
215+
if err != nil {
216+
return err
217+
}
218+
219+
properties := d.Get("property").(*schema.Set).List()
220+
if len(properties) == 0 {
221+
return nil
222+
}
223+
224+
// Set all managed properties to nil (removes them)
225+
customProperties := make([]*github.CustomPropertyValue, 0, len(properties))
226+
for _, propBlock := range properties {
227+
propMap := propBlock.(map[string]any)
228+
customProperties = append(customProperties, &github.CustomPropertyValue{
229+
PropertyName: propMap["name"].(string),
230+
Value: nil,
231+
})
232+
}
233+
234+
_, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties)
235+
if err != nil {
236+
return fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err)
237+
}
238+
239+
return nil
240+
}
241+
242+
func resourceGithubRepositoryCustomPropertiesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) {
243+
// Import ID format: owner/repo (using standard two-part ID)
244+
// On import, Read will detect empty state and import ALL properties
245+
parts := strings.SplitN(d.Id(), "/", 2)
246+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
247+
return nil, fmt.Errorf("invalid import ID %q, expected format: owner/repository", d.Id())
248+
}
249+
250+
d.SetId(buildTwoPartID(parts[0], parts[1]))
251+
return []*schema.ResourceData{d}, nil
252+
}

0 commit comments

Comments
 (0)