• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

boinger / confvis / 24609176026

18 Apr 2026 04:00PM UTC coverage: 86.634% (+0.5%) from 86.156%
24609176026

push

github

boinger
feat(history): cap history growth via Prune/PruneRef + --history-max-entries

AppendToFile and AppendToRef had no retention: history grew forever, and
Last(n) had to load every entry into memory before returning the last N.
For long-lived CI dogfood pipelines this is slow over time and wasteful
at read time.

Add history.Prune(path, maxEntries) and history.PruneGitRef(ref,
maxEntries) — symmetric helpers for the two storage backends. File
backend uses write-to-sibling-tempfile + atomic rename so readers never
see a partial file. Ref backend reuses the existing blob
read-modify-write flow. Both are no-ops when the input has <=maxEntries
or when maxEntries <=0.

Expose via a new --history-max-entries flag on `confvis gauge`,
default 5000 (0 or negative disables pruning). CLI wires through
GaugeDeps as HistoryPruner / GitRefPruner so tests can inject mocks.
appendToHistory calls the pruner after a successful append; prune
failures surface as errors, preserving the pre-prune append so the
next run retries.

10 new tests (5 file-backend, 5 git-ref-backend) cover trims-to-last-N,
no-op-under-cap, disabled-on-zero-or-negative, missing-input-no-op,
and order preservation.

50 of 82 new or added lines in 5 files covered. (60.98%)

25 existing lines in 1 file now uncovered.

4453 of 5140 relevant lines covered (86.63%)

15.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.24
/internal/checks/github.go
1
// Package checks provides integrations for creating check runs on CI platforms.
2
package checks
3

4
import (
5
        "bytes"
6
        "context"
7
        "encoding/json"
8
        "errors"
9
        "fmt"
10
        "io"
11
        "math"
12
        "math/rand/v2"
13
        "net/http"
14
        "os"
15
        "strings"
16
        "time"
17

18
        "github.com/boinger/confvis/internal/confidence"
19
        "github.com/boinger/confvis/internal/httputil"
20
        "github.com/boinger/confvis/internal/sources/httpclient"
21
)
22

23
const (
24
        defaultGitHubAPIURL = "https://api.github.com"
25
        defaultTimeout      = 30 * time.Second
26

27
        // HTTP headers.
28
        headerAuthorization    = "Authorization"
29
        headerAccept           = "Accept"
30
        headerContentType      = "Content-Type"
31
        headerGitHubAPIVersion = "X-GitHub-Api-Version"
32

33
        // Header values.
34
        bearerPrefix     = "Bearer "
35
        acceptGitHubJSON = "application/vnd.github+json"
36
        contentTypeJSON  = "application/json"
37
        gitHubAPIVersion = "2022-11-28"
38

39
        // Pagination.
40
        commentsPerPage  = 100
41
        maxCommentPages  = 20
42

43
        // Retry configuration for GET requests.
44
        maxRetries        = 3
45
        initialBackoff    = 1 * time.Second
46
        maxJitterFraction = 0.5
47

48
        // Endpoint format strings.
49
        issueCommentsEndpoint = "%s/repos/%s/%s/issues/%d/comments"
50

51
        // Error format strings.
52
        errMarshalingRequest = "marshaling request: %w"
53
        errCreatingRequest   = "creating request: %w"
54
        errMakingRequest     = "making request: %w"
55
        errReadingResponse   = "reading response: %w"
56
        errDecodingResponse  = "decoding response: %w"
57
        errAPIStatus         = "API returned status %d: %s"
58
)
59

60
// Sentinel errors for validation.
61
var (
62
        errOwnerRepoRequired = errors.New("owner and repo are required")
63
        errPRRequired        = errors.New("PR number is required")
64
        errNoPRInEvent       = errors.New("no PR number found in event")
65
)
66

67
// retryableStatusCodes are HTTP status codes that indicate a transient failure.
68
var retryableStatusCodes = map[int]bool{
69
        http.StatusTooManyRequests:    true, // 429
70
        http.StatusBadGateway:         true, // 502
71
        http.StatusServiceUnavailable: true, // 503
72
        http.StatusGatewayTimeout:     true, // 504
73
}
74

75
// GitHubClient is an HTTP client for the GitHub Checks API.
76
type GitHubClient struct {
77
        baseURL    string
78
        token      string
79
        httpClient *http.Client
80
}
81

82
// GitHubClientConfig holds configuration for creating a GitHub client.
83
type GitHubClientConfig struct {
84
        BaseURL string
85
        Token   string
86
        Timeout time.Duration
87
}
88

89
// NewGitHubClient creates a new GitHub Checks API client.
90
func NewGitHubClient(cfg GitHubClientConfig) *GitHubClient {
3✔
91
        timeout := cfg.Timeout
3✔
92
        if timeout == 0 {
6✔
93
                timeout = defaultTimeout
3✔
94
        }
3✔
95

96
        return &GitHubClient{
3✔
97
                baseURL:    httpclient.NormalizeBaseURL(cfg.BaseURL, defaultGitHubAPIURL),
3✔
98
                token:      cfg.Token,
3✔
99
                httpClient: &http.Client{Timeout: timeout},
3✔
100
        }
3✔
101
}
102

103
// NewGitHubClientWithHTTP creates a new client with a custom HTTP client.
104
// This is primarily intended for testing.
105
func NewGitHubClientWithHTTP(cfg GitHubClientConfig, httpClient *http.Client) *GitHubClient {
29✔
106
        return &GitHubClient{
29✔
107
                baseURL:    httpclient.NormalizeBaseURL(cfg.BaseURL, defaultGitHubAPIURL),
29✔
108
                token:      cfg.Token,
29✔
109
                httpClient: httpClient,
29✔
110
        }
29✔
111
}
29✔
112

113
// CreateCheckOptions configures the check run creation.
114
type CreateCheckOptions struct {
115
        Owner   string // Repository owner
116
        Repo    string // Repository name
117
        SHA     string // Commit SHA
118
        Name    string // Check name
119
        BaseURL string // Optional: Override base URL for compare baseline
120
}
121

122
// CheckRunOutput represents the output section of a check run.
123
type CheckRunOutput struct {
124
        Title   string `json:"title"`
125
        Summary string `json:"summary"`
126
        Text    string `json:"text,omitempty"`
127
}
128

129
// CheckRunRequest is the request body for creating a check run.
130
type CheckRunRequest struct {
131
        Name       string          `json:"name"`
132
        HeadSHA    string          `json:"head_sha"`
133
        Status     string          `json:"status"`
134
        Conclusion string          `json:"conclusion,omitempty"`
135
        Output     *CheckRunOutput `json:"output,omitempty"`
136
}
137

138
// CheckRunResponse is the response from creating a check run.
139
type CheckRunResponse struct {
140
        ID      int64  `json:"id"`
141
        HTMLURL string `json:"html_url"`
142
        Status  string `json:"status"`
143
}
144

145
// CreateCheck creates a check run for the given confidence report.
146
func (c *GitHubClient) CreateCheck(ctx context.Context, report *confidence.Report, opts CreateCheckOptions) (*CheckRunResponse, error) {
9✔
147
        if opts.Owner == "" || opts.Repo == "" {
11✔
148
                return nil, errOwnerRepoRequired
2✔
149
        }
2✔
150
        if opts.SHA == "" {
8✔
151
                return nil, fmt.Errorf("SHA is required")
1✔
152
        }
1✔
153
        if opts.Name == "" {
11✔
154
                opts.Name = "Confidence Score"
5✔
155
        }
5✔
156

157
        // Determine conclusion based on threshold
158
        conclusion := "success"
6✔
159
        if !report.Passed() {
7✔
160
                conclusion = "failure"
1✔
161
        }
1✔
162

163
        // Build output
164
        output := buildCheckOutput(report)
6✔
165

6✔
166
        req := CheckRunRequest{
6✔
167
                Name:       opts.Name,
6✔
168
                HeadSHA:    opts.SHA,
6✔
169
                Status:     "completed",
6✔
170
                Conclusion: conclusion,
6✔
171
                Output:     output,
6✔
172
        }
6✔
173

6✔
174
        endpoint := fmt.Sprintf("%s/repos/%s/%s/check-runs", c.baseURL, opts.Owner, opts.Repo)
6✔
175
        return c.postCheckRun(ctx, endpoint, req)
6✔
176
}
177

178
// doRequest executes an HTTP request with standard GitHub API headers.
179
// It returns the response body bytes on success, or an error if the request
180
// fails or the status code doesn't match expectedStatus.
181
//
182
// GET requests are automatically retried on transient failures (429, 502, 503,
183
// 504, and network errors) with exponential backoff. Non-GET requests (POST,
184
// PATCH, DELETE) are NOT retried because they are not idempotent — retrying a
185
// POST that the server already processed would create duplicate check runs or
186
// comments.
187
func (c *GitHubClient) doRequest(ctx context.Context, method, endpoint string, body io.Reader, expectedStatus int) ([]byte, error) {
65✔
188
        httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, body)
65✔
189
        if err != nil {
65✔
190
                return nil, fmt.Errorf(errCreatingRequest, err)
×
191
        }
×
192

193
        httpReq.Header.Set(headerAuthorization, bearerPrefix+c.token)
65✔
194
        httpReq.Header.Set(headerAccept, acceptGitHubJSON)
65✔
195
        if body != nil {
73✔
196
                httpReq.Header.Set(headerContentType, contentTypeJSON)
8✔
197
        }
8✔
198
        httpReq.Header.Set(headerGitHubAPIVersion, gitHubAPIVersion)
65✔
199

65✔
200
        // Non-GET requests: single attempt (not idempotent).
65✔
201
        if method != http.MethodGet {
75✔
202
                return c.doHTTP(httpReq, expectedStatus)
10✔
203
        }
10✔
204

205
        // GET requests: retry on transient failures. The request has a nil body,
206
        // so it is safe to reuse across attempts without re-reading.
207
        var lastErr error
55✔
208
        for attempt := range maxRetries + 1 {
117✔
209
                if attempt > 0 {
69✔
210
                        delay := c.retryDelay(attempt, lastErr)
7✔
211
                        if err := httputil.SleepWithContext(ctx, delay); err != nil {
8✔
212
                                return nil, lastErr
1✔
213
                        }
1✔
214
                }
215

216
                respBody, err := c.doHTTP(httpReq, expectedStatus)
61✔
217
                if err == nil {
112✔
218
                        return respBody, nil
51✔
219
                }
51✔
220
                lastErr = err
10✔
221

10✔
222
                if !isRetryableError(err) {
12✔
223
                        return nil, err
2✔
224
                }
2✔
225
        }
226

227
        return nil, lastErr
1✔
228
}
229

230
// doHTTP performs a single HTTP request attempt and returns the response body.
231
func (c *GitHubClient) doHTTP(httpReq *http.Request, expectedStatus int) ([]byte, error) {
71✔
232
        resp, err := c.httpClient.Do(httpReq)
71✔
233
        if err != nil {
72✔
234
                // Network errors (no response) are retryable.
1✔
235
                return nil, &retryableHTTPError{err: fmt.Errorf(errMakingRequest, err)}
1✔
236
        }
1✔
237
        defer func() { _ = resp.Body.Close() }()
140✔
238

239
        respBody, err := io.ReadAll(resp.Body)
70✔
240
        if err != nil {
70✔
241
                return nil, fmt.Errorf(errReadingResponse, err)
×
242
        }
×
243

244
        if resp.StatusCode != expectedStatus {
82✔
245
                apiErr := fmt.Errorf(errAPIStatus, resp.StatusCode, string(respBody))
12✔
246
                if retryableStatusCodes[resp.StatusCode] {
20✔
247
                        return nil, &retryableHTTPError{
8✔
248
                                err:        apiErr,
8✔
249
                                retryAfter: resp.Header.Get("Retry-After"),
8✔
250
                        }
8✔
251
                }
8✔
252
                return nil, apiErr
4✔
253
        }
254

255
        return respBody, nil
58✔
256
}
257

258
// retryableHTTPError wraps an error to indicate it can be retried.
259
type retryableHTTPError struct {
260
        err        error
261
        retryAfter string // Retry-After header value, if present
262
}
263

264
func (e *retryableHTTPError) Error() string { return e.err.Error() }
×
265
func (e *retryableHTTPError) Unwrap() error { return e.err }
×
266

267
// isRetryableError reports whether an error should trigger a retry.
268
func isRetryableError(err error) bool {
10✔
269
        var re *retryableHTTPError
10✔
270
        return errors.As(err, &re)
10✔
271
}
10✔
272

273
// retryDelay calculates the delay before a retry attempt. If the previous
274
// error carried a Retry-After value, that takes precedence over exponential
275
// backoff.
276
func (c *GitHubClient) retryDelay(attempt int, lastErr error) time.Duration {
7✔
277
        var re *retryableHTTPError
7✔
278
        if errors.As(lastErr, &re) && re.retryAfter != "" {
8✔
279
                if d := httputil.ParseRetryAfter(re.retryAfter); d > 0 {
2✔
280
                        return d
1✔
281
                }
1✔
282
        }
283

284
        // Exponential backoff with jitter.
285
        backoff := float64(initialBackoff) * math.Pow(2, float64(attempt-1))
6✔
286
        jitter := backoff * maxJitterFraction * rand.Float64() //#nosec G404 -- jitter for retry backoff, not security-sensitive
6✔
287
        return time.Duration(backoff + jitter)
6✔
288
}
289

290
// marshalAndDo marshals a request body and sends it via doRequest.
291
func (c *GitHubClient) marshalAndDo(ctx context.Context, method, endpoint string, reqBody any, expectedStatus int) ([]byte, error) {
8✔
292
        jsonBody, err := json.Marshal(reqBody)
8✔
293
        if err != nil {
8✔
294
                return nil, fmt.Errorf(errMarshalingRequest, err)
×
295
        }
×
296
        return c.doRequest(ctx, method, endpoint, bytes.NewReader(jsonBody), expectedStatus)
8✔
297
}
298

299
func (c *GitHubClient) postCheckRun(ctx context.Context, endpoint string, req CheckRunRequest) (*CheckRunResponse, error) {
6✔
300
        respBody, err := c.marshalAndDo(ctx, http.MethodPost, endpoint, req, http.StatusCreated)
6✔
301
        if err != nil {
8✔
302
                return nil, err
2✔
303
        }
2✔
304

305
        var result CheckRunResponse
4✔
306
        if err := json.Unmarshal(respBody, &result); err != nil {
4✔
307
                return nil, fmt.Errorf(errDecodingResponse, err)
×
308
        }
×
309

310
        return &result, nil
4✔
311
}
312

313
// escapeMDCell escapes markdown metacharacters that would break a table
314
// cell or the check title: pipes split cells, newlines split rows,
315
// backticks open unintended code spans.
316
func escapeMDCell(s string) string {
32✔
317
        s = strings.ReplaceAll(s, "|", `\|`)
32✔
318
        s = strings.ReplaceAll(s, "\n", " ")
32✔
319
        s = strings.ReplaceAll(s, "\r", " ")
32✔
320
        s = strings.ReplaceAll(s, "`", "\\`")
32✔
321
        return s
32✔
322
}
32✔
323

324
func buildCheckOutput(report *confidence.Report) *CheckRunOutput {
10✔
325
        status := "Passed"
10✔
326
        if !report.Passed() {
12✔
327
                status = "Failed"
2✔
328
        }
2✔
329

330
        title := fmt.Sprintf("%s: %d%% (%s)", escapeMDCell(report.Title), report.ScoreValue(), status)
10✔
331

10✔
332
        // Build summary with score and threshold
10✔
333
        var summary strings.Builder
10✔
334
        fmt.Fprintf(&summary, "**Score:** %d%% | **Threshold:** %d%%\n\n", report.ScoreValue(), report.Threshold)
10✔
335

10✔
336
        if report.Description != "" {
11✔
337
                summary.WriteString(report.Description)
1✔
338
                summary.WriteString("\n\n")
1✔
339
        }
1✔
340

341
        // Add factors table if present
342
        if len(report.Factors) > 0 {
17✔
343
                summary.WriteString("### Factor Breakdown\n\n")
7✔
344
                summary.WriteString("| Factor | Score | Weight |\n")
7✔
345
                summary.WriteString("|--------|------:|-------:|\n")
7✔
346
                for _, f := range report.Factors {
22✔
347
                        fmt.Fprintf(&summary, "| %s | %d%% | %d%% |\n", escapeMDCell(f.Name), f.Score, f.Weight)
15✔
348
                }
15✔
349
        }
350

351
        return &CheckRunOutput{
10✔
352
                Title:   title,
10✔
353
                Summary: summary.String(),
10✔
354
        }
10✔
355
}
356

357
// GitHubEnv holds environment variables from GitHub Actions.
358
type GitHubEnv struct {
359
        Token      string
360
        Repository string // "owner/repo" format
361
        SHA        string
362
        APIURL     string
363
}
364

365
// LoadGitHubEnv loads GitHub Actions environment variables.
366
// Returns an error only if GITHUB_TOKEN is missing.
367
func LoadGitHubEnv() (*GitHubEnv, error) {
9✔
368
        token := os.Getenv("GITHUB_TOKEN")
9✔
369
        if token == "" {
10✔
370
                return nil, fmt.Errorf("GITHUB_TOKEN environment variable is required")
1✔
371
        }
1✔
372

373
        return &GitHubEnv{
8✔
374
                Token:      token,
8✔
375
                Repository: os.Getenv("GITHUB_REPOSITORY"),
8✔
376
                SHA:        os.Getenv("GITHUB_SHA"),
8✔
377
                APIURL:     os.Getenv("GITHUB_API_URL"),
8✔
378
        }, nil
8✔
379
}
380

381
// CommentMarker is the hidden HTML comment used to identify confvis comments.
382
// Known limitation: if a user edits a confvis PR comment and removes this
383
// marker, confvis will create a duplicate comment on the next run rather than
384
// updating the existing one. This is by design — reliable identification
385
// without external state requires the marker to be present in the comment body.
386
const CommentMarker = "<!-- confvis-comment -->"
387

388
// CommentOptions configures comment posting.
389
type CommentOptions struct {
390
        Owner string // Repository owner
391
        Repo  string // Repository name
392
        PR    int    // Pull request number
393
}
394

395
// CommentResponse is the response from creating/updating a comment.
396
type CommentResponse struct {
397
        ID      int64  `json:"id"`
398
        HTMLURL string `json:"html_url"`
399
        Body    string `json:"body"`
400
}
401

402
// issueCommentsResponse is used to parse the list comments API response.
403
type issueCommentsResponse []struct {
404
        ID   int64  `json:"id"`
405
        Body string `json:"body"`
406
}
407

408
// FindComment finds an existing confvis comment on a PR.
409
// Returns nil if no confvis comment is found.
410
func (c *GitHubClient) FindComment(ctx context.Context, opts CommentOptions) (*CommentResponse, error) {
5✔
411
        comments, err := c.FindAllConfvisComments(ctx, opts)
5✔
412
        if err != nil {
6✔
413
                return nil, err
1✔
414
        }
1✔
415
        if len(comments) == 0 {
6✔
416
                return nil, nil //nolint:nilnil // nil response with no error means "not found"
2✔
417
        }
2✔
418
        return &comments[0], nil
2✔
419
}
420

421
// PostComment creates a new comment on a PR.
422
func (c *GitHubClient) PostComment(ctx context.Context, opts CommentOptions, body string) (*CommentResponse, error) {
1✔
423
        if opts.Owner == "" || opts.Repo == "" {
1✔
UNCOV
424
                return nil, errOwnerRepoRequired
×
425
        }
×
426
        if opts.PR <= 0 {
1✔
UNCOV
427
                return nil, errPRRequired
×
UNCOV
428
        }
×
429

430
        endpoint := fmt.Sprintf(issueCommentsEndpoint, c.baseURL, opts.Owner, opts.Repo, opts.PR)
1✔
431

1✔
432
        respBody, err := c.marshalAndDo(ctx, http.MethodPost, endpoint, struct {
1✔
433
                Body string `json:"body"`
1✔
434
        }{Body: body}, http.StatusCreated)
1✔
435
        if err != nil {
1✔
UNCOV
436
                return nil, err
×
UNCOV
437
        }
×
438

439
        var result CommentResponse
1✔
440
        if err := json.Unmarshal(respBody, &result); err != nil {
1✔
UNCOV
441
                return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
442
        }
×
443

444
        return &result, nil
1✔
445
}
446

447
// UpdateComment updates an existing comment.
448
func (c *GitHubClient) UpdateComment(ctx context.Context, opts CommentOptions, commentID int64, body string) (*CommentResponse, error) {
1✔
449
        if opts.Owner == "" || opts.Repo == "" {
1✔
UNCOV
450
                return nil, errOwnerRepoRequired
×
UNCOV
451
        }
×
452

453
        endpoint := fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", c.baseURL, opts.Owner, opts.Repo, commentID)
1✔
454

1✔
455
        respBody, err := c.marshalAndDo(ctx, http.MethodPatch, endpoint, struct {
1✔
456
                Body string `json:"body"`
1✔
457
        }{Body: body}, http.StatusOK)
1✔
458
        if err != nil {
1✔
UNCOV
459
                return nil, err
×
UNCOV
460
        }
×
461

462
        var result CommentResponse
1✔
463
        if err := json.Unmarshal(respBody, &result); err != nil {
1✔
UNCOV
464
                return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
465
        }
×
466

467
        return &result, nil
1✔
468
}
469

470
// DeleteComment deletes a comment.
471
func (c *GitHubClient) DeleteComment(ctx context.Context, opts CommentOptions, commentID int64) error {
1✔
472
        if opts.Owner == "" || opts.Repo == "" {
1✔
UNCOV
473
                return errOwnerRepoRequired
×
UNCOV
474
        }
×
475

476
        endpoint := fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", c.baseURL, opts.Owner, opts.Repo, commentID)
1✔
477

1✔
478
        _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, http.StatusNoContent)
1✔
479
        return err
1✔
480
}
481

482
// FindAllConfvisComments finds all confvis comments on a PR.
483
// Returns empty slice if no confvis comments are found.
484
func (c *GitHubClient) FindAllConfvisComments(ctx context.Context, opts CommentOptions) ([]CommentResponse, error) {
9✔
485
        if opts.Owner == "" || opts.Repo == "" {
9✔
UNCOV
486
                return nil, errOwnerRepoRequired
×
UNCOV
487
        }
×
488
        if opts.PR <= 0 {
9✔
UNCOV
489
                return nil, errPRRequired
×
UNCOV
490
        }
×
491

492
        baseEndpoint := fmt.Sprintf(issueCommentsEndpoint, c.baseURL, opts.Owner, opts.Repo, opts.PR)
9✔
493

9✔
494
        var result []CommentResponse
9✔
495
        for page := 1; page <= maxCommentPages; page++ {
58✔
496
                endpoint := fmt.Sprintf("%s?per_page=%d&page=%d", baseEndpoint, commentsPerPage, page)
49✔
497

49✔
498
                respBody, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, http.StatusOK)
49✔
499
                if err != nil {
50✔
500
                        return nil, err
1✔
501
                }
1✔
502

503
                var comments issueCommentsResponse
48✔
504
                if err := json.Unmarshal(respBody, &comments); err != nil {
48✔
UNCOV
505
                        return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
506
                }
×
507

508
                for _, comment := range comments {
4,255✔
509
                        if strings.Contains(comment.Body, CommentMarker) {
4,215✔
510
                                result = append(result, CommentResponse{
8✔
511
                                        ID:   comment.ID,
8✔
512
                                        Body: comment.Body,
8✔
513
                                })
8✔
514
                        }
8✔
515
                }
516

517
                if len(comments) < commentsPerPage {
54✔
518
                        break
6✔
519
                }
520

521
                if page == maxCommentPages {
44✔
522
                        _, _ = fmt.Fprintf(os.Stderr, "Warning: comment pagination capped at %d pages (%d comments); results may be incomplete\n",
2✔
523
                                maxCommentPages, maxCommentPages*commentsPerPage)
2✔
524
                }
2✔
525
        }
526

527
        return result, nil
8✔
528
}
529

530
// LoadGitHubEnvWithPR loads GitHub Actions environment variables including PR number.
531
// Returns nil for env if not in a GitHub Actions environment.
532
func LoadGitHubEnvWithPR() (*GitHubEnv, int, error) {
6✔
533
        env, err := LoadGitHubEnv()
6✔
534
        if err != nil {
6✔
UNCOV
535
                return nil, 0, err
×
UNCOV
536
        }
×
537

538
        // Try to get PR number from GITHUB_EVENT_PATH
539
        eventPath := os.Getenv("GITHUB_EVENT_PATH")
6✔
540
        if eventPath == "" {
7✔
541
                return env, 0, nil
1✔
542
        }
1✔
543

544
        prNumber, parseErr := parsePRNumberFromEvent(eventPath)
5✔
545
        if parseErr != nil {
8✔
546
                if errors.Is(parseErr, errNoPRInEvent) {
4✔
547
                        return env, 0, nil
1✔
548
                }
1✔
549
                _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to parse PR from event file %s: %v\n", eventPath, parseErr)
2✔
550
                return env, 0, nil
2✔
551
        }
552

553
        return env, prNumber, nil
2✔
554
}
555

556
// parsePRNumberFromEvent extracts the PR number from the GitHub event JSON file.
557
func parsePRNumberFromEvent(eventPath string) (int, error) {
5✔
558
        data, err := os.ReadFile(eventPath) //#nosec G304 -- path from GITHUB_EVENT_PATH env var set by GitHub Actions
5✔
559
        if err != nil {
6✔
560
                return 0, err
1✔
561
        }
1✔
562

563
        var event struct {
4✔
564
                PullRequest *struct {
4✔
565
                        Number int `json:"number"`
4✔
566
                } `json:"pull_request"`
4✔
567
                Issue *struct {
4✔
568
                        Number int `json:"number"`
4✔
569
                } `json:"issue"`
4✔
570
                Number int `json:"number"` // For issue_comment events
4✔
571
        }
4✔
572

4✔
573
        if err := json.Unmarshal(data, &event); err != nil {
5✔
574
                return 0, err
1✔
575
        }
1✔
576

577
        if event.PullRequest != nil && event.PullRequest.Number > 0 {
4✔
578
                return event.PullRequest.Number, nil
1✔
579
        }
1✔
580
        if event.Issue != nil && event.Issue.Number > 0 {
3✔
581
                return event.Issue.Number, nil
1✔
582
        }
1✔
583
        if event.Number > 0 {
1✔
UNCOV
584
                return event.Number, nil
×
UNCOV
585
        }
×
586

587
        return 0, errNoPRInEvent
1✔
588
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc