@@ -24,9 +24,15 @@ type GithubPullRequest struct {
2424}
2525
2626const (
27+ HTTPResponseOK = 200
2728 HTTPResponseCreated = 201
2829)
2930
31+ // commentMarker is an HTML comment embedded at the top of the report. It renders
32+ // invisibly on GitHub but lets a later run find the comment it posted earlier so
33+ // it can update that one in place instead of posting a new comment every push.
34+ const commentMarker = "<!-- pull-request-code-coverage:patch-coverage -->"
35+
3036func NewGithubPullRequest (apiKey string , apiBaseURL string , pr string , owner string , repo string , httpClient pluginhttp.Client , jsonClient pluginjson.Client ) * GithubPullRequest {
3137 return & GithubPullRequest {
3238 apiKey : apiKey ,
@@ -51,12 +57,32 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
5157 return errors .Wrap (bodyErr , "Failed creating payload for github" )
5258 }
5359
54- url := fmt .Sprintf ("%v/repos/%v/%v/issues/%v/comments" , strings .TrimRight (s .apiBaseURL , "/" ), s .owner , s .repo , s .pr )
60+ existingID , findErr := s .findExistingCommentID ()
61+ if findErr != nil {
62+ return findErr
63+ }
64+
65+ // Update the comment from a previous run when we find one; otherwise post a
66+ // fresh comment. This keeps a single, always-current coverage comment on the
67+ // PR instead of a new one per push.
68+ if existingID != 0 {
69+ url := fmt .Sprintf ("%v/repos/%v/%v/issues/comments/%v" , s .baseURL (), s .owner , s .repo , existingID )
70+ return s .send ("PATCH" , url , body , HTTPResponseOK )
71+ }
72+
73+ url := fmt .Sprintf ("%v/repos/%v/%v/issues/%v/comments" , s .baseURL (), s .owner , s .repo , s .pr )
74+ return s .send ("POST" , url , body , HTTPResponseCreated )
75+ }
76+
77+ // baseURL returns the configured GitHub API root without a trailing slash.
78+ func (s * GithubPullRequest ) baseURL () string {
79+ return strings .TrimRight (s .apiBaseURL , "/" )
80+ }
5581
56- req , newErr := s . httpClient . NewRequest (
57- "POST" ,
58- url ,
59- body )
82+ // send issues a write request (POST/PATCH) carrying the comment payload and
83+ // verifies the response status.
84+ func ( s * GithubPullRequest ) send ( method string , url string , body io. Reader , wantStatus int ) error {
85+ req , newErr := s . httpClient . NewRequest ( method , url , body )
6086
6187 if newErr != nil {
6288 return errors .Wrap (newErr , "Failed creating request to github" )
@@ -75,19 +101,89 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
75101 _ = resp .Body .Close ()
76102 }()
77103
78- if resp .StatusCode != HTTPResponseCreated {
104+ if resp .StatusCode != wantStatus {
79105 return errors .Errorf ("Failed calling github: bad status code: %v" , resp .StatusCode )
80106 }
81107
82108 return nil
83109}
84110
111+ // findExistingCommentID looks for a coverage comment this plugin posted on an
112+ // earlier run, identified by the hidden commentMarker. It returns 0 when none is
113+ // found. Only the first page of comments is checked (per_page=100), which covers
114+ // any realistic PR. The GET only needs read access, so it also works on fork PRs
115+ // even though the follow-up write may not.
116+ func (s * GithubPullRequest ) findExistingCommentID () (int64 , error ) {
117+ url := fmt .Sprintf ("%v/repos/%v/%v/issues/%v/comments?per_page=100" , s .baseURL (), s .owner , s .repo , s .pr )
118+
119+ req , newErr := s .httpClient .NewRequest ("GET" , url , nil )
120+ if newErr != nil {
121+ return 0 , errors .Wrap (newErr , "Failed creating request to github" )
122+ }
123+
124+ req .Header .Add ("Authorization" , "token " + s .apiKey )
125+
126+ resp , doErr := s .httpClient .Do (req )
127+ if doErr != nil {
128+ return 0 , errors .Wrap (doErr , "Failed calling github" )
129+ }
130+
131+ defer func () {
132+ _ = resp .Body .Close ()
133+ }()
134+
135+ if resp .StatusCode != HTTPResponseOK {
136+ return 0 , errors .Errorf ("Failed listing github comments: bad status code: %v" , resp .StatusCode )
137+ }
138+
139+ respBody , readErr := io .ReadAll (resp .Body )
140+ if readErr != nil {
141+ return 0 , errors .Wrap (readErr , "Failed reading github comments response" )
142+ }
143+
144+ var comments []struct {
145+ ID int64 `json:"id"`
146+ Body string `json:"body"`
147+ }
148+
149+ if unmarshalErr := s .jsonClient .Unmarshal (respBody , & comments ); unmarshalErr != nil {
150+ return 0 , errors .Wrap (unmarshalErr , "Failed parsing github comments response" )
151+ }
152+
153+ for _ , c := range comments {
154+ if strings .Contains (c .Body , commentMarker ) {
155+ return c .ID , nil
156+ }
157+ }
158+
159+ return 0 , nil
160+ }
161+
85162func (s * GithubPullRequest ) GetName () string {
86163 return "github pull request reporter"
87164}
88165
89166func (s * GithubPullRequest ) createCommentBody (changedLinesWithCoverage domain.SourceLineCoverageReport ) (io.Reader , error ) {
90167
168+ data := map [string ]string {
169+ "body" : buildMarkdownReport (changedLinesWithCoverage ),
170+ }
171+
172+ dataBytes , marshalErr := s .jsonClient .Marshal (data )
173+
174+ if marshalErr != nil {
175+ return nil , errors .Wrap (marshalErr , "Failed marshalling payload to json" )
176+ }
177+
178+ return bytes .NewBuffer (dataBytes ), nil
179+ }
180+
181+ // buildMarkdownReport renders the changed-line coverage report as GitHub-flavored
182+ // Markdown. It is shared by the PR-comment reporter and the job-summary reporter
183+ // so both show identical output. The leading commentMarker is invisible when
184+ // rendered and lets the PR reporter find and update its own comment.
185+ func buildMarkdownReport (changedLinesWithCoverage domain.SourceLineCoverageReport ) string {
186+
91187 modules := collectModules (changedLinesWithCoverage )
92188
93189 covered := changedLinesWithCoverage .TotalCoveredInstructions ()
@@ -105,6 +201,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So
105201
106202 var b strings.Builder
107203
204+ b .WriteString (commentMarker + "\n " )
108205 b .WriteString ("## 🛡️ Patch Coverage Report\n \n " )
109206 b .WriteString ("> Scope: **changed lines only** — the code this PR adds or edits, not whole files or the repo. " )
110207 b .WriteString ("It answers one thing — *did your tests run the code you just touched?*\n \n " )
@@ -129,17 +226,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So
129226 b .WriteString (missedInstructionsSection (changedLinesWithCoverage ))
130227 b .WriteString ("\n <sub>🤖 Generated by <a href=\" https://github.com/target/pull-request-code-coverage\" >pull-request-code-coverage</a> — coverage for changed lines only.</sub>\n " )
131228
132- data := map [string ]string {
133- "body" : b .String (),
134- }
135-
136- dataBytes , marshalErr := s .jsonClient .Marshal (data )
137-
138- if marshalErr != nil {
139- return nil , errors .Wrap (marshalErr , "Failed marshalling payload to json" )
140- }
141-
142- return bytes .NewBuffer (dataBytes ), nil
229+ return b .String ()
143230}
144231
145232// fileCoverage holds the aggregated changed-line coverage for a single file.
0 commit comments