Skip to content

Commit 1d24fd3

Browse files
DjinnSGuillaume Leccese
authored andcommitted
feat: commit statuses and merge comment
Adds two related improvements to the pull request lifecycle: - Post a comment on the pull/merge request once Burrito applies the merged changes, reporting success or failure per affected layer (internal/controllers/terraformpullrequest/comment/apply.go). The TerraformPullRequest resource is kept alive after merge (annotated with the merge date instead of being deleted) so its controller can track layer applies and post that comment once they complete. - Report plan and apply status via the GitHub Commit Statuses API and GitLab External Commit Statuses (internal/controllers/terraformpullrequest/status, APIProvider.SetStatus in the github/gitlab/mock providers). The status is set to pending when a phase starts and updated to success/failure based on the actual outcome: - plan: pending when temp layers are created, success/failure once the comment is posted, based on whether every layer produced a plan. - apply: pending while waiting for the merged layers to apply, success/failure once they all have, based on LastApplyDate vs a failed TerraformRun created after the merge. Status updates are best-effort and never fail the reconciliation loop.
1 parent 1378bf1 commit 1d24fd3

26 files changed

Lines changed: 912 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ credentials/
5151

5252
test-repo/
5353
TIltfile
54+
.DS_Store

deploy/charts/burrito/values-dev.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ config:
33
runner:
44
image:
55
repository: burrito
6-
tag: "1783089222"
6+
tag: "1783690876"
77
pullPolicy: Never
88
datastore:
99
storage:
@@ -16,7 +16,7 @@ global:
1616
deployment:
1717
image:
1818
repository: burrito
19-
tag: "1783089222"
19+
tag: "1783690876"
2020
pullPolicy: Never
2121
tenants:
2222
- namespace:

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ require (
8989
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
9090
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
9191
github.com/google/gnostic-models v0.7.0 // indirect
92+
github.com/google/go-github/v66 v66.0.0 // indirect
9293
github.com/google/go-querystring v1.2.0 // indirect
9394
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
9495
github.com/google/s2a-go v0.1.9 // indirect
@@ -121,6 +122,7 @@ require (
121122
github.com/valyala/bytebufferpool v1.0.0 // indirect
122123
github.com/valyala/fasttemplate v1.2.2 // indirect
123124
github.com/x448/float16 v0.8.4 // indirect
125+
github.com/xanzy/go-gitlab v0.115.0 // indirect
124126
github.com/xanzy/ssh-agent v0.3.3 // indirect
125127
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
126128
github.com/zclconf/go-cty v1.18.1 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
221221
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
222222
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
223223
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
224+
github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
225+
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
224226
github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
225227
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
226228
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
@@ -416,6 +418,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
416418
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
417419
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
418420
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
421+
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
422+
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
419423
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
420424
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
421425
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=

internal/annotations/annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const (
1818
LastPlanRun string = "runner.terraform.padok.cloud/plan-run"
1919
Lock string = "runner.terraform.padok.cloud/lock"
2020

21+
MergedAt string = "webhook.terraform.padok.cloud/merged-at"
22+
MergeCommit string = "webhook.terraform.padok.cloud/merge-commit"
2123
LastBranchCommit string = "webhook.terraform.padok.cloud/branch-commit"
2224
LastBranchCommitDate string = "webhook.terraform.padok.cloud/branch-commit-date"
2325
LastRelevantCommit string = "webhook.terraform.padok.cloud/relevant-commit"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package comment
2+
3+
import (
4+
"bytes"
5+
"text/template"
6+
7+
_ "embed"
8+
)
9+
10+
var (
11+
//go:embed templates/apply.md
12+
applyTemplateRaw string
13+
applyTemplate = template.Must(template.New("apply-report").Parse(applyTemplateRaw))
14+
)
15+
16+
type ApplyReportedLayer struct {
17+
Path string
18+
Succeeded bool
19+
}
20+
21+
type ApplyComment struct {
22+
layers []ApplyReportedLayer
23+
}
24+
25+
func NewApplyComment(layers []ApplyReportedLayer) *ApplyComment {
26+
return &ApplyComment{layers: layers}
27+
}
28+
29+
func (c *ApplyComment) Generate(commit string) (string, error) {
30+
data := struct {
31+
Commit string
32+
Layers []ApplyReportedLayer
33+
}{
34+
Commit: commit,
35+
Layers: c.layers,
36+
}
37+
buf := bytes.NewBufferString("")
38+
err := applyTemplate.Execute(buf, data)
39+
if err != nil {
40+
return "", err
41+
}
42+
return buf.String(), nil
43+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## :burrito: Burrito Apply Report
2+
3+
{{ len .Layers }} layer(s) affected by this pull request have been applied on commit `{{ .Commit }}`.
4+
5+
{{ range .Layers }}
6+
### Layer {{ .Path }}
7+
8+
{{ if .Succeeded }}:white_check_mark: Apply succeeded.{{ else }}:x: Apply failed. Check the Burrito UI or runner logs for details.{{ end }}
9+
10+
{{ end }}

internal/controllers/terraformpullrequest/conditions.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package terraformpullrequest
22

33
import (
4+
"context"
45
"time"
56

67
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
@@ -107,6 +108,68 @@ func (r *Reconciler) AreLayersStillPlanning(pr *configv1alpha1.TerraformPullRequ
107108
return condition, false
108109
}
109110

111+
func (r *Reconciler) IsMerged(pr *configv1alpha1.TerraformPullRequest) (metav1.Condition, bool) {
112+
condition := metav1.Condition{
113+
Type: "IsMerged",
114+
ObservedGeneration: pr.GetObjectMeta().GetGeneration(),
115+
Status: metav1.ConditionUnknown,
116+
LastTransitionTime: metav1.NewTime(time.Now()),
117+
}
118+
_, ok := pr.Annotations[annotations.MergedAt]
119+
if ok {
120+
condition.Reason = "PullRequestMerged"
121+
condition.Message = "Pull request has been merged."
122+
condition.Status = metav1.ConditionTrue
123+
return condition, true
124+
}
125+
condition.Reason = "PullRequestNotMerged"
126+
condition.Message = "Pull request has not been merged yet."
127+
condition.Status = metav1.ConditionFalse
128+
return condition, false
129+
}
130+
131+
func (r *Reconciler) AreLayersApplied(ctx context.Context, pr *configv1alpha1.TerraformPullRequest, mainLayers []configv1alpha1.TerraformLayer) (metav1.Condition, bool, []LayerApplyResult) {
132+
condition := metav1.Condition{
133+
Type: "AreLayersApplied",
134+
ObservedGeneration: pr.GetObjectMeta().GetGeneration(),
135+
Status: metav1.ConditionUnknown,
136+
LastTransitionTime: metav1.NewTime(time.Now()),
137+
}
138+
139+
mergedAtStr := pr.Annotations[annotations.MergedAt]
140+
mergedAt, err := time.Parse(time.RFC3339, mergedAtStr)
141+
if err != nil {
142+
condition.Reason = "InvalidMergedAt"
143+
condition.Message = "Could not parse merged-at annotation, this is likely a bug."
144+
condition.Status = metav1.ConditionFalse
145+
return condition, false, nil
146+
}
147+
148+
if len(mainLayers) == 0 {
149+
condition.Reason = "NoAffectedLayers"
150+
condition.Message = "No layers are affected by this pull request."
151+
condition.Status = metav1.ConditionTrue
152+
return condition, true, nil
153+
}
154+
155+
results := make([]LayerApplyResult, 0, len(mainLayers))
156+
for _, layer := range mainLayers {
157+
res := r.getLayerApplyResult(ctx, layer, mergedAt)
158+
results = append(results, res)
159+
if !res.Applied {
160+
condition.Reason = "LayerNotApplied"
161+
condition.Message = "At least one layer has not applied since merge."
162+
condition.Status = metav1.ConditionFalse
163+
return condition, false, results
164+
}
165+
}
166+
167+
condition.Reason = "AllLayersApplied"
168+
condition.Message = "All affected layers have applied since merge."
169+
condition.Status = metav1.ConditionTrue
170+
return condition, true, results
171+
}
172+
110173
func (r *Reconciler) IsCommentUpToDate(pr *configv1alpha1.TerraformPullRequest) (metav1.Condition, bool) {
111174
condition := metav1.Condition{
112175
Type: "IsCommentUpToDate",

internal/controllers/terraformpullrequest/layer.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/hex"
77
"fmt"
88
"slices"
9+
"time"
910

1011
corev1 "k8s.io/api/core/v1"
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -166,7 +167,6 @@ func (r *Reconciler) deleteTempLayersByLabel(ctx context.Context, pr *configv1al
166167
ctx, &configv1alpha1.TerraformLayer{}, client.InNamespace(pr.Namespace), client.MatchingLabels{managedByLabel: labelValue},
167168
)
168169
}
169-
170170
func tempLayerGenerateName(layerName string, pr *configv1alpha1.TerraformPullRequest) string {
171171
hash := shortHash(fmt.Sprintf("%s/%s/%s", pr.Namespace, pr.Name, pr.Spec.ID))
172172
prefix := fmt.Sprintf("%s-pr-%s-", layerName, hash)
@@ -186,3 +186,84 @@ func shortHash(value string) string {
186186
sum := sha256.Sum256([]byte(value))
187187
return hex.EncodeToString(sum[:])[:16]
188188
}
189+
190+
// getMainBranchLayers returns the main-branch layers that correspond to the
191+
// temp layers linked to this PR (same path + repository, on the base branch).
192+
func (r *Reconciler) getMainBranchLayers(ctx context.Context, pr *configv1alpha1.TerraformPullRequest) ([]configv1alpha1.TerraformLayer, error) {
193+
tempLayers, err := GetLinkedLayers(r.Client, pr)
194+
if err != nil {
195+
return nil, err
196+
}
197+
198+
allLayers := &configv1alpha1.TerraformLayerList{}
199+
if err := r.Client.List(ctx, allLayers); err != nil {
200+
return nil, err
201+
}
202+
203+
result := []configv1alpha1.TerraformLayer{}
204+
for _, layer := range allLayers.Items {
205+
if layer.Spec.Branch != pr.Spec.Base {
206+
continue
207+
}
208+
if layer.Spec.Repository.Name != pr.Spec.Repository.Name ||
209+
layer.Spec.Repository.Namespace != pr.Spec.Repository.Namespace {
210+
continue
211+
}
212+
for _, temp := range tempLayers {
213+
if layer.Spec.Path == temp.Spec.Path {
214+
result = append(result, layer)
215+
break
216+
}
217+
}
218+
}
219+
return result, nil
220+
}
221+
222+
type LayerApplyResult struct {
223+
Layer configv1alpha1.TerraformLayer
224+
Applied bool
225+
Succeeded bool
226+
}
227+
228+
// getLayerApplyResult determines whether a layer has applied after mergedAt.
229+
// It checks:
230+
// - success: LastApplyDate annotation is set and after mergedAt
231+
// - failure: a TerraformRun with action=apply exists, was created after mergedAt,
232+
// and is in Failed state
233+
func (r *Reconciler) getLayerApplyResult(ctx context.Context, layer configv1alpha1.TerraformLayer, mergedAt time.Time) LayerApplyResult {
234+
result := LayerApplyResult{Layer: layer}
235+
236+
// Check success via annotation (only updated when apply succeeds)
237+
lastApplyDateStr := layer.Annotations[annotations.LastApplyDate]
238+
if lastApplyDateStr != "" {
239+
lastApplyDate, err := time.Parse(time.UnixDate, lastApplyDateStr)
240+
if err == nil && lastApplyDate.After(mergedAt) {
241+
result.Applied = true
242+
result.Succeeded = true
243+
return result
244+
}
245+
}
246+
247+
// Check failure by looking for a Failed TerraformRun with action=apply after mergedAt
248+
runs := &configv1alpha1.TerraformRunList{}
249+
requirement, err := labels.NewRequirement(managedByLabel, selection.Equals, []string{layer.Name})
250+
if err != nil {
251+
return result
252+
}
253+
selector := labels.NewSelector().Add(*requirement)
254+
if err := r.Client.List(ctx, runs, client.MatchingLabelsSelector{Selector: selector}, client.InNamespace(layer.Namespace)); err != nil {
255+
return result
256+
}
257+
for _, run := range runs.Items {
258+
if run.Spec.Action != "apply" {
259+
continue
260+
}
261+
if run.CreationTimestamp.After(mergedAt) && run.Status.State == "Failed" {
262+
result.Applied = true
263+
result.Succeeded = false
264+
return result
265+
}
266+
}
267+
268+
return result
269+
}

0 commit comments

Comments
 (0)