11package prplanner
22
33import (
4+ "encoding/json"
45 "fmt"
6+ "log/slog"
57 "regexp"
68 "strings"
79 "time"
@@ -11,14 +13,21 @@ import (
1113 "k8s.io/apimachinery/pkg/types"
1214)
1315
16+ const (
17+ // Metadata Prefix to identify our comments
18+ metaStart = "<!-- terraform-applier-pr-planner-metadata:"
19+ metaEnd = " -->"
20+ )
21+
1422var (
1523 planReqMsgRegex = regexp .MustCompile ("^`?@terraform-applier plan `?([\\ w-.\\ /]+)`?$" )
1624
25+ // find our hidden JSON block
26+ metadataRegex = regexp .MustCompile (fmt .Sprintf (`(?s)%s(.*?)%s` , regexp .QuoteMeta (metaStart ), regexp .QuoteMeta (metaEnd )))
27+
1728 autoPlanDisabledTml = "Auto plan is disabled for this PR.\n " +
1829 "Please post `@terraform-applier plan <module_name>` as comment if you want to request terraform plan for a particular module."
1930
20- autoPlanDisabledRegex = regexp .MustCompile ("Auto plan is disabled for this PR" )
21-
2231 requestAcknowledgedMsgTml = "Received terraform plan request\n " +
2332 "```\n " +
2433 "Cluster: %s\n " +
3039 "Do not edit this comment. This message will be updated once the plan run is completed.\n " +
3140 "To manually trigger plan again please post `@terraform-applier plan %s` as comment."
3241
33- requestAcknowledgedMsgRegex = regexp .MustCompile (`Received terraform plan request\n\x60{3}\nCluster: (.+)\nModule: (.+)\nPath: (.+)\nCommit ID: (.+)\nRequested At: (.+)` )
34-
3542 runOutputMsgTml = "Terraform run output for\n " +
3643 "```\n " +
3744 "Cluster: %s\n " +
@@ -42,10 +49,57 @@ var (
4249 "<details><summary><b>%s Run Status: %s, Run Summary: %s</b></summary>" +
4350 "\n \n ```terraform\n %s\n ```\n </details>\n " +
4451 "\n > To manually trigger plan again please post `@terraform-applier plan %s` as comment."
52+ )
53+
54+ type MsgType string
4555
46- runOutputMsgRegex = regexp .MustCompile (`Terraform (?:plan|run) output for\n\x60{3}\nCluster: (.+)\nModule: (.+)\nPath: (.+)\nCommit ID: (.+)\n` )
56+ const (
57+ MsgTypePlanRequest MsgType = "PlanRequest"
58+ MsgTypeRunOutput MsgType = "RunOutput"
59+ MsgTypeAutoPlanDisabled MsgType = "AutoPlanDisabled"
4760)
4861
62+ // CommentMetadata is the hidden JSON structure
63+ type CommentMetadata struct {
64+ Type MsgType `json:"type"`
65+ Cluster string `json:"cluster,omitempty"`
66+ Module string `json:"module,omitempty"` // Stores "Namespace/Name"
67+ Path string `json:"path,omitempty"`
68+ CommitID string `json:"commit_id,omitempty"`
69+ ReqAt string `json:"req_at,omitempty"` // RFC3339 String
70+ }
71+
72+ // embedMetadata serializes the struct into a hidden HTML comment
73+ func embedMetadata (meta CommentMetadata ) string {
74+ b , err := json .Marshal (meta )
75+ if err != nil {
76+ panic (fmt .Sprintf ("unable to marshal pr comment metadata: %v" , meta ))
77+ }
78+ return fmt .Sprintf ("\n \n %s %s %s" , metaStart , string (b ), metaEnd )
79+ }
80+
81+ // extractMetadata parses the hidden JSON from a comment body
82+ func extractMetadata (commentBody string ) * CommentMetadata {
83+ matches := metadataRegex .FindStringSubmatch (commentBody )
84+ if len (matches ) < 2 {
85+ return nil
86+ }
87+
88+ rawJson := matches [1 ]
89+
90+ // GitHub markdown/browsers often convert spaces to Non-Breaking Spaces (\u00A0)
91+ // or inject odd formatting.
92+ rawJson = strings .ReplaceAll (rawJson , "\u00A0 " , "" )
93+ rawJson = strings .TrimSpace (rawJson )
94+
95+ var meta CommentMetadata
96+ if err := json .Unmarshal ([]byte (rawJson ), & meta ); err != nil {
97+ slog .Error ("unable to parse PR comment metadata json" , "logger" , "pr-planner" , "err" , err )
98+ return nil
99+ }
100+ return & meta
101+ }
102+
49103func parsePlanReqMsg (commentBody string ) string {
50104 matches := planReqMsgRegex .FindStringSubmatch (commentBody )
51105
@@ -57,29 +111,39 @@ func parsePlanReqMsg(commentBody string) string {
57111}
58112
59113func requestAcknowledgedMsg (cluster , module , path , commitID string , reqAt * metav1.Time ) string {
60- return fmt .Sprintf (requestAcknowledgedMsgTml , cluster , module , path , commitID , reqAt .Format (time .RFC3339 ), path )
114+ display := fmt .Sprintf (requestAcknowledgedMsgTml , cluster , module , path , commitID , reqAt .Format (time .RFC3339 ), path )
115+
116+ meta := CommentMetadata {
117+ Type : MsgTypePlanRequest ,
118+ Cluster : cluster ,
119+ Module : module ,
120+ Path : path ,
121+ CommitID : commitID ,
122+ ReqAt : reqAt .Format (time .RFC3339 ),
123+ }
124+
125+ return display + embedMetadata (meta )
61126}
62127
63128func parseRequestAcknowledgedMsg (commentBody string ) (cluster string , module types.NamespacedName , path string , commID string , ReqAt * time.Time ) {
64- matches := requestAcknowledgedMsgRegex . FindStringSubmatch (commentBody )
65- if len ( matches ) == 6 {
66- t , err := time . Parse ( time . RFC3339 , matches [ 5 ])
67- if err == nil {
68- return matches [ 1 ], parseNamespaceName ( matches [ 2 ]), matches [ 3 ], matches [ 4 ], & t
69- }
70- return matches [ 1 ], parseNamespaceName ( matches [ 2 ]), matches [ 3 ], matches [ 4 ], nil
129+ meta := extractMetadata (commentBody )
130+ if meta == nil || meta . Type != MsgTypePlanRequest {
131+ return
132+ }
133+
134+ if t , err := time . Parse ( time . RFC3339 , meta . ReqAt ); err == nil {
135+ ReqAt = & t
71136 }
72137
73- return
138+ return meta . Cluster , parseNamespaceName ( meta . Module ), meta . Path , meta . CommitID , ReqAt
74139}
75140
76141func parseRunOutputMsg (comment string ) (cluster string , module types.NamespacedName , path string , commit string ) {
77- matches := runOutputMsgRegex . FindStringSubmatch (comment )
78- if len ( matches ) == 5 {
79- return matches [ 1 ], parseNamespaceName ( matches [ 2 ]), matches [ 3 ], matches [ 4 ]
142+ meta := extractMetadata (comment )
143+ if meta == nil || meta . Type != MsgTypeRunOutput {
144+ return
80145 }
81-
82- return
146+ return meta .Cluster , parseNamespaceName (meta .Module ), meta .Path , meta .CommitID
83147}
84148
85149func runOutputMsg (cluster , module , path string , run * v1beta1.Run ) string {
@@ -100,10 +164,20 @@ func runOutputMsg(cluster, module, path string, run *v1beta1.Run) string {
100164
101165 if len (runes ) > characterLimit {
102166 runOutput = "Plan output has reached the max character limit of " + fmt .Sprintf ("%d" , characterLimit ) + " characters. " +
103- "The output is truncated from the top.\n " + string (runes [characterLimit :])
167+ "The output is truncated from the top.\n " + string (runes [(len (runes )- characterLimit ):])
168+ }
169+
170+ display := fmt .Sprintf (runOutputMsgTml , cluster , module , path , run .CommitHash , statusSymbol , run .Status , run .Summary , runOutput , path )
171+
172+ meta := CommentMetadata {
173+ Type : MsgTypeRunOutput ,
174+ Cluster : cluster ,
175+ Module : module ,
176+ Path : path ,
177+ CommitID : run .CommitHash ,
104178 }
105179
106- return fmt . Sprintf ( runOutputMsgTml , cluster , module , path , run . CommitHash , statusSymbol , run . Status , run . Summary , runOutput , path )
180+ return display + embedMetadata ( meta )
107181}
108182
109183func parseNamespaceName (str string ) types.NamespacedName {
@@ -122,7 +196,8 @@ func parseNamespaceName(str string) types.NamespacedName {
122196
123197func isAutoPlanDisabledCommentPosted (prComments []prComment ) bool {
124198 for _ , comment := range prComments {
125- if autoPlanDisabledRegex .MatchString (comment .Body ) {
199+ meta := extractMetadata (comment .Body )
200+ if meta != nil && meta .Type == MsgTypeAutoPlanDisabled {
126201 return true
127202 }
128203 }
@@ -131,7 +206,5 @@ func isAutoPlanDisabledCommentPosted(prComments []prComment) bool {
131206
132207// IsSelfComment will return true if comments matches TF applier comment templates
133208func IsSelfComment (comment string ) bool {
134- return runOutputMsgRegex .MatchString (comment ) ||
135- requestAcknowledgedMsgRegex .MatchString (comment ) ||
136- autoPlanDisabledRegex .MatchString (comment )
209+ return strings .Contains (comment , metaStart )
137210}
0 commit comments