Skip to content

Commit 1045063

Browse files
mattmoorimjasonh
authored andcommitted
Add a changemanager package (chainguard-dev#1189)
This is intended to help manage the creation of pull requests as a product of a reconciler. --------- Signed-off-by: Matt Moore <[email protected]>
1 parent bc5a83c commit 1045063

File tree

6 files changed

+654
-0
lines changed

6 files changed

+654
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package changemanager
7+
8+
import (
9+
"bytes"
10+
"encoding/json"
11+
"errors"
12+
"fmt"
13+
"regexp"
14+
"text/template"
15+
)
16+
17+
// executeTemplate executes the given template with the provided data.
18+
func (cm *CM[T]) executeTemplate(tmpl *template.Template, data *T) (string, error) {
19+
var buf bytes.Buffer
20+
if err := tmpl.Execute(&buf, data); err != nil {
21+
return "", fmt.Errorf("executing template: %w", err)
22+
}
23+
return buf.String(), nil
24+
}
25+
26+
// embedData embeds the given data as JSON in HTML comments within the body.
27+
// The embedded data is placed at the end of the body using markers based on the identity.
28+
func (cm *CM[T]) embedData(body string, data *T) (string, error) {
29+
jsonData, err := json.MarshalIndent(data, "", " ")
30+
if err != nil {
31+
return "", fmt.Errorf("marshaling data: %w", err)
32+
}
33+
34+
marker := cm.identity + "-pr-data"
35+
embedded := fmt.Sprintf("\n\n<!--%s-->\n<!--\n%s\n-->\n<!--/%s-->", marker, string(jsonData), marker)
36+
37+
return body + embedded, nil
38+
}
39+
40+
// extractData extracts embedded data from the PR body.
41+
// Returns an error if the data cannot be found or parsed.
42+
func (cm *CM[T]) extractData(body string) (*T, error) {
43+
marker := cm.identity + "-pr-data"
44+
pattern := fmt.Sprintf(`(?s)<!--%s-->\s*<!--\s*(.+?)\s*-->\s*<!--/%s-->`, regexp.QuoteMeta(marker), regexp.QuoteMeta(marker))
45+
46+
re, err := regexp.Compile(pattern)
47+
if err != nil {
48+
return nil, fmt.Errorf("compiling regex: %w", err)
49+
}
50+
51+
matches := re.FindStringSubmatch(body)
52+
if len(matches) < 2 {
53+
return nil, errors.New("embedded data not found in PR body")
54+
}
55+
56+
var data T
57+
if err := json.Unmarshal([]byte(matches[1]), &data); err != nil {
58+
return nil, fmt.Errorf("unmarshaling data: %w", err)
59+
}
60+
61+
return &data, nil
62+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package changemanager
7+
8+
import (
9+
"strings"
10+
"testing"
11+
"text/template"
12+
)
13+
14+
type testData struct {
15+
PackageName string
16+
Version string
17+
Commit string
18+
}
19+
20+
func Test_embedData(t *testing.T) {
21+
titleTmpl := template.Must(template.New("title").Parse("{{.PackageName}}"))
22+
bodyTmpl := template.Must(template.New("body").Parse("{{.Version}}"))
23+
cm, err := New[testData]("test-bot", titleTmpl, bodyTmpl)
24+
if err != nil {
25+
t.Fatalf("New() failed: %v", err)
26+
}
27+
28+
data := &testData{
29+
PackageName: "foo",
30+
Version: "1.2.3",
31+
Commit: "abc123",
32+
}
33+
34+
body := "This is the PR body"
35+
embedded, err := cm.embedData(body, data)
36+
if err != nil {
37+
t.Fatalf("embedData failed: %v", err)
38+
}
39+
40+
// Verify the original body is present
41+
if !strings.Contains(embedded, body) {
42+
t.Errorf("embedded body missing original content")
43+
}
44+
45+
// Verify the markers are present
46+
if !strings.Contains(embedded, "<!--test-bot-pr-data-->") {
47+
t.Errorf("embedded body missing start marker")
48+
}
49+
if !strings.Contains(embedded, "<!--/test-bot-pr-data-->") {
50+
t.Errorf("embedded body missing end marker")
51+
}
52+
53+
// Verify we can extract the data back
54+
extracted, err := cm.extractData(embedded)
55+
if err != nil {
56+
t.Fatalf("extractData failed: %v", err)
57+
}
58+
59+
if extracted.PackageName != data.PackageName {
60+
t.Errorf("PackageName: got = %q, wanted = %q", extracted.PackageName, data.PackageName)
61+
}
62+
if extracted.Version != data.Version {
63+
t.Errorf("Version: got = %q, wanted = %q", extracted.Version, data.Version)
64+
}
65+
if extracted.Commit != data.Commit {
66+
t.Errorf("Commit: got = %q, wanted = %q", extracted.Commit, data.Commit)
67+
}
68+
}
69+
70+
func Test_extractData_notFound(t *testing.T) {
71+
titleTmpl := template.Must(template.New("title").Parse("{{.PackageName}}"))
72+
bodyTmpl := template.Must(template.New("body").Parse("{{.Version}}"))
73+
cm, err := New[testData]("test-bot", titleTmpl, bodyTmpl)
74+
if err != nil {
75+
t.Fatalf("New() failed: %v", err)
76+
}
77+
78+
body := "This is a PR body without embedded data"
79+
_, err = cm.extractData(body)
80+
if err == nil {
81+
t.Error("extractData should have failed for body without embedded data")
82+
}
83+
}
84+
85+
func Test_executeTemplate(t *testing.T) {
86+
titleTmpl := template.Must(template.New("title").Parse("{{.PackageName}}/{{.Version}}"))
87+
bodyTmpl := template.Must(template.New("body").Parse("Update"))
88+
cm, err := New[testData]("test-bot", titleTmpl, bodyTmpl)
89+
if err != nil {
90+
t.Fatalf("New() failed: %v", err)
91+
}
92+
93+
data := &testData{
94+
PackageName: "foo",
95+
Version: "1.2.3",
96+
}
97+
98+
result, err := cm.executeTemplate(titleTmpl, data)
99+
if err != nil {
100+
t.Fatalf("executeTemplate failed: %v", err)
101+
}
102+
103+
expected := "foo/1.2.3"
104+
if result != expected {
105+
t.Errorf("template result: got = %q, wanted = %q", result, expected)
106+
}
107+
}
108+
109+
func TestNew(t *testing.T) {
110+
titleTmpl := template.Must(template.New("title").Parse("{{.PackageName}}/{{.Version}}"))
111+
bodyTmpl := template.Must(template.New("body").Parse("Update {{.PackageName}} to {{.Version}}"))
112+
113+
tests := []struct {
114+
name string
115+
identity string
116+
titleTemplate *template.Template
117+
bodyTemplate *template.Template
118+
wantErr bool
119+
errContains string
120+
}{{
121+
name: "valid templates",
122+
identity: "test-bot",
123+
titleTemplate: titleTmpl,
124+
bodyTemplate: bodyTmpl,
125+
wantErr: false,
126+
}, {
127+
name: "nil title template",
128+
identity: "test-bot",
129+
titleTemplate: nil,
130+
bodyTemplate: bodyTmpl,
131+
wantErr: true,
132+
errContains: "titleTemplate cannot be nil",
133+
}, {
134+
name: "nil body template",
135+
identity: "test-bot",
136+
titleTemplate: titleTmpl,
137+
bodyTemplate: nil,
138+
wantErr: true,
139+
errContains: "bodyTemplate cannot be nil",
140+
}, {
141+
name: "both templates nil",
142+
identity: "test-bot",
143+
titleTemplate: nil,
144+
bodyTemplate: nil,
145+
wantErr: true,
146+
errContains: "titleTemplate cannot be nil",
147+
}}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
cm, err := New[testData](tt.identity, tt.titleTemplate, tt.bodyTemplate)
152+
if (err != nil) != tt.wantErr {
153+
t.Errorf("New() error: got = %v, wantErr = %v", err, tt.wantErr)
154+
return
155+
}
156+
157+
if tt.wantErr {
158+
if err == nil {
159+
t.Error("New() should have returned an error")
160+
} else if !strings.Contains(err.Error(), tt.errContains) {
161+
t.Errorf("New() error message: got = %q, wanted to contain %q", err.Error(), tt.errContains)
162+
}
163+
} else {
164+
if cm == nil {
165+
t.Fatal("New() returned nil CM when error is nil")
166+
}
167+
if cm.identity != tt.identity {
168+
t.Errorf("New() identity: got = %q, wanted = %q", cm.identity, tt.identity)
169+
}
170+
}
171+
})
172+
}
173+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
// Package changemanager provides an abstraction for managing GitHub Pull Request
7+
// lifecycle operations, similar to how the statusmanager handles GitHub Check Runs.
8+
//
9+
// The ChangeManager encapsulates common PR patterns including:
10+
// - Finding existing PRs by head branch
11+
// - Embedding/extracting structured data in PR bodies
12+
// - Creating or updating PRs (upsert pattern)
13+
// - Checking for skip labels
14+
// - Closing any outstanding PRs
15+
//
16+
// # Generic Type Parameter
17+
//
18+
// Like the Status Manager, ChangeManager is generic over a type T that represents
19+
// structured data to be embedded in PR bodies. This data is used to:
20+
// - Execute Go templates for PR titles and bodies
21+
// - Determine if a PR needs to be refreshed
22+
// - Store metadata about the PR's purpose
23+
//
24+
// # Usage
25+
//
26+
// Create a ChangeManager once per identity with parsed templates:
27+
//
28+
// titleTmpl, _ := template.New("title").Parse(`{{.PackageName}}/{{.Version}} update`)
29+
// bodyTmpl, _ := template.New("body").Parse(`Update {{.PackageName}} to {{.Version}}`)
30+
//
31+
// cm := changemanager.New[MyData]("update-bot", titleTmpl, bodyTmpl)
32+
//
33+
// Create a session per reconciliation:
34+
//
35+
// session, err := cm.NewSession(ctx, ghClient, resource)
36+
// if err != nil {
37+
// return err
38+
// }
39+
//
40+
// Check for skip labels:
41+
//
42+
// if session.HasSkipLabel() {
43+
// return nil
44+
// }
45+
//
46+
// Upsert a PR with data:
47+
//
48+
// prURL, err := session.Upsert(ctx, data, draft, labels, func(ctx context.Context, branchName string) error {
49+
// // Make code changes on the branch
50+
// return makeChanges(ctx, branchName)
51+
// })
52+
//
53+
// Close any outstanding PRs with a message:
54+
//
55+
// if err := session.CloseAnyOutstanding(ctx, "Closing due to version downgrade"); err != nil {
56+
// return err
57+
// }
58+
package changemanager
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
Copyright 2025 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package changemanager_test
7+
8+
import (
9+
"context"
10+
"text/template"
11+
12+
"github.com/chainguard-dev/terraform-infra-common/pkg/githubreconciler"
13+
"github.com/chainguard-dev/terraform-infra-common/pkg/githubreconciler/changemanager"
14+
"github.com/google/go-github/v75/github"
15+
)
16+
17+
type UpdateData struct {
18+
PackageName string
19+
Version string
20+
Commit string
21+
}
22+
23+
func Example() {
24+
// Parse templates once at initialization
25+
titleTmpl := template.Must(template.New("title").Parse(`{{.PackageName}}/{{.Version}} package update`))
26+
bodyTmpl := template.Must(template.New("body").Parse(`Update {{.PackageName}} to {{.Version}}
27+
28+
{{if .Commit}}**Commit**: {{.Commit}}{{end}}`))
29+
30+
// Create manager once per identity
31+
cm, err := changemanager.New[UpdateData]("update-bot", titleTmpl, bodyTmpl)
32+
if err != nil {
33+
// handle error
34+
return
35+
}
36+
37+
// In your reconciler, create a session per resource
38+
ctx := context.Background()
39+
var ghClient *github.Client // your GitHub client
40+
var res *githubreconciler.Resource
41+
42+
session, err := cm.NewSession(ctx, ghClient, res)
43+
if err != nil {
44+
// handle error
45+
return
46+
}
47+
48+
// Check for skip label
49+
if session.HasSkipLabel() {
50+
// skip this resource
51+
return
52+
}
53+
54+
// Upsert a PR with data
55+
data := &UpdateData{
56+
PackageName: "foo",
57+
Version: "1.2.3",
58+
Commit: "abc123",
59+
}
60+
61+
_, err = session.Upsert(ctx, data, false, []string{"automated pr"}, func(_ context.Context, _ string) error {
62+
// Make code changes on the branch
63+
// e.g., update package YAML, commit changes, push to remote
64+
return nil
65+
})
66+
if err != nil {
67+
// handle error
68+
return
69+
}
70+
}

0 commit comments

Comments
 (0)