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

boinger / confvis / 22011396192

14 Feb 2026 04:43AM UTC coverage: 83.992% (-0.05%) from 84.037%
22011396192

push

github

boinger
refactor: codebase cleanup — split impl_test.go, modernize idioms, update docs

- Split impl_test.go (5,038 lines) into 7 focused test files by command:
  generate, fetch, aggregate, gauge, check, comment_github, baseline
- Consolidate duplicate intPtrI/intPtrH test helpers (drop intPtrI)
- Replace interface{} with any in httpclient unexported methods
- Add compile-time interface compliance checks (var _ Fetcher = (*Client)(nil))
  for snyk, ghactions, gitleaks, gosec, trufflehog
- Update docs/architecture.md with missing CLI and source entries
- Track future feature ideas; remove completed modularize-gauge plan

2 of 2 new or added lines in 1 file covered. (100.0%)

75 existing lines in 7 files now uncovered.

3956 of 4710 relevant lines covered (83.99%)

10.62 hits per line

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

85.66
/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
        "net/http"
12
        "os"
13
        "strings"
14
        "time"
15

16
        "github.com/boinger/confvis/internal/confidence"
17
        "github.com/boinger/confvis/internal/sources/httpclient"
18
)
19

20
const (
21
        defaultGitHubAPIURL = "https://api.github.com"
22
        defaultTimeout      = 30 * time.Second
23

24
        // HTTP headers.
25
        headerAuthorization    = "Authorization"
26
        headerAccept           = "Accept"
27
        headerContentType      = "Content-Type"
28
        headerGitHubAPIVersion = "X-GitHub-Api-Version"
29

30
        // Header values.
31
        bearerPrefix     = "Bearer "
32
        acceptGitHubJSON = "application/vnd.github+json"
33
        contentTypeJSON  = "application/json"
34
        gitHubAPIVersion = "2022-11-28"
35

36
        // Pagination.
37
        commentsPerPage = 100
38

39
        // Endpoint format strings.
40
        issueCommentsEndpoint = "%s/repos/%s/%s/issues/%d/comments"
41

42
        // Error format strings.
43
        errMarshalingRequest = "marshaling request: %w"
44
        errCreatingRequest   = "creating request: %w"
45
        errMakingRequest     = "making request: %w"
46
        errReadingResponse   = "reading response: %w"
47
        errDecodingResponse  = "decoding response: %w"
48
        errAPIStatus         = "API returned status %d: %s"
49
)
50

51
// Sentinel errors for validation.
52
var (
53
        errOwnerRepoRequired = errors.New("owner and repo are required")
54
        errPRRequired        = errors.New("PR number is required")
55
        errNoPRInEvent       = errors.New("no PR number found in event")
56
)
57

58
// GitHubClient is an HTTP client for the GitHub Checks API.
59
type GitHubClient struct {
60
        baseURL    string
61
        token      string
62
        httpClient *http.Client
63
}
64

65
// GitHubClientConfig holds configuration for creating a GitHub client.
66
type GitHubClientConfig struct {
67
        BaseURL string
68
        Token   string
69
        Timeout time.Duration
70
}
71

72
// NewGitHubClient creates a new GitHub Checks API client.
73
func NewGitHubClient(cfg GitHubClientConfig) *GitHubClient {
3✔
74
        timeout := cfg.Timeout
3✔
75
        if timeout == 0 {
6✔
76
                timeout = defaultTimeout
3✔
77
        }
3✔
78

79
        return &GitHubClient{
3✔
80
                baseURL:    httpclient.NormalizeBaseURL(cfg.BaseURL, defaultGitHubAPIURL),
3✔
81
                token:      cfg.Token,
3✔
82
                httpClient: &http.Client{Timeout: timeout},
3✔
83
        }
3✔
84
}
85

86
// NewGitHubClientWithHTTP creates a new client with a custom HTTP client.
87
// This is primarily intended for testing.
88
func NewGitHubClientWithHTTP(cfg GitHubClientConfig, httpClient *http.Client) *GitHubClient {
20✔
89
        return &GitHubClient{
20✔
90
                baseURL:    httpclient.NormalizeBaseURL(cfg.BaseURL, defaultGitHubAPIURL),
20✔
91
                token:      cfg.Token,
20✔
92
                httpClient: httpClient,
20✔
93
        }
20✔
94
}
20✔
95

96
// CreateCheckOptions configures the check run creation.
97
type CreateCheckOptions struct {
98
        Owner   string // Repository owner
99
        Repo    string // Repository name
100
        SHA     string // Commit SHA
101
        Name    string // Check name
102
        BaseURL string // Optional: Override base URL for compare baseline
103
}
104

105
// CheckRunOutput represents the output section of a check run.
106
type CheckRunOutput struct {
107
        Title   string `json:"title"`
108
        Summary string `json:"summary"`
109
        Text    string `json:"text,omitempty"`
110
}
111

112
// CheckRunRequest is the request body for creating a check run.
113
type CheckRunRequest struct {
114
        Name       string          `json:"name"`
115
        HeadSHA    string          `json:"head_sha"`
116
        Status     string          `json:"status"`
117
        Conclusion string          `json:"conclusion,omitempty"`
118
        Output     *CheckRunOutput `json:"output,omitempty"`
119
}
120

121
// CheckRunResponse is the response from creating a check run.
122
type CheckRunResponse struct {
123
        ID      int64  `json:"id"`
124
        HTMLURL string `json:"html_url"`
125
        Status  string `json:"status"`
126
}
127

128
// CreateCheck creates a check run for the given confidence report.
129
func (c *GitHubClient) CreateCheck(ctx context.Context, report *confidence.Report, opts CreateCheckOptions) (*CheckRunResponse, error) {
9✔
130
        if opts.Owner == "" || opts.Repo == "" {
11✔
131
                return nil, errOwnerRepoRequired
2✔
132
        }
2✔
133
        if opts.SHA == "" {
8✔
134
                return nil, fmt.Errorf("SHA is required")
1✔
135
        }
1✔
136
        if opts.Name == "" {
11✔
137
                opts.Name = "Confidence Score"
5✔
138
        }
5✔
139

140
        // Determine conclusion based on threshold
141
        conclusion := "success"
6✔
142
        if !report.Passed() {
7✔
143
                conclusion = "failure"
1✔
144
        }
1✔
145

146
        // Build output
147
        output := buildCheckOutput(report)
6✔
148

6✔
149
        req := CheckRunRequest{
6✔
150
                Name:       opts.Name,
6✔
151
                HeadSHA:    opts.SHA,
6✔
152
                Status:     "completed",
6✔
153
                Conclusion: conclusion,
6✔
154
                Output:     output,
6✔
155
        }
6✔
156

6✔
157
        endpoint := fmt.Sprintf("%s/repos/%s/%s/check-runs", c.baseURL, opts.Owner, opts.Repo)
6✔
158
        return c.postCheckRun(ctx, endpoint, req)
6✔
159
}
160

161
// doRequest executes an HTTP request with standard GitHub API headers.
162
// It returns the response body bytes on success, or an error if the request
163
// fails or the status code doesn't match expectedStatus.
164
func (c *GitHubClient) doRequest(ctx context.Context, method, endpoint string, body io.Reader, expectedStatus int) ([]byte, error) {
18✔
165
        httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, body)
18✔
166
        if err != nil {
18✔
167
                return nil, fmt.Errorf(errCreatingRequest, err)
×
168
        }
×
169

170
        httpReq.Header.Set(headerAuthorization, bearerPrefix+c.token)
18✔
171
        httpReq.Header.Set(headerAccept, acceptGitHubJSON)
18✔
172
        if body != nil {
26✔
173
                httpReq.Header.Set(headerContentType, contentTypeJSON)
8✔
174
        }
8✔
175
        httpReq.Header.Set(headerGitHubAPIVersion, gitHubAPIVersion)
18✔
176

18✔
177
        resp, err := c.httpClient.Do(httpReq)
18✔
178
        if err != nil {
18✔
179
                return nil, fmt.Errorf(errMakingRequest, err)
×
180
        }
×
181
        defer func() { _ = resp.Body.Close() }()
36✔
182

183
        respBody, err := io.ReadAll(resp.Body)
18✔
184
        if err != nil {
18✔
185
                return nil, fmt.Errorf(errReadingResponse, err)
×
186
        }
×
187

188
        if resp.StatusCode != expectedStatus {
21✔
189
                return nil, fmt.Errorf(errAPIStatus, resp.StatusCode, string(respBody))
3✔
190
        }
3✔
191

192
        return respBody, nil
15✔
193
}
194

195
// marshalAndDo marshals a request body and sends it via doRequest.
196
func (c *GitHubClient) marshalAndDo(ctx context.Context, method, endpoint string, reqBody any, expectedStatus int) ([]byte, error) {
8✔
197
        jsonBody, err := json.Marshal(reqBody)
8✔
198
        if err != nil {
8✔
199
                return nil, fmt.Errorf(errMarshalingRequest, err)
×
200
        }
×
201
        return c.doRequest(ctx, method, endpoint, bytes.NewReader(jsonBody), expectedStatus)
8✔
202
}
203

204
func (c *GitHubClient) postCheckRun(ctx context.Context, endpoint string, req CheckRunRequest) (*CheckRunResponse, error) {
6✔
205
        respBody, err := c.marshalAndDo(ctx, http.MethodPost, endpoint, req, http.StatusCreated)
6✔
206
        if err != nil {
8✔
207
                return nil, err
2✔
208
        }
2✔
209

210
        var result CheckRunResponse
4✔
211
        if err := json.Unmarshal(respBody, &result); err != nil {
4✔
212
                return nil, fmt.Errorf(errDecodingResponse, err)
×
213
        }
×
214

215
        return &result, nil
4✔
216
}
217

218
func buildCheckOutput(report *confidence.Report) *CheckRunOutput {
9✔
219
        status := "Passed"
9✔
220
        if !report.Passed() {
11✔
221
                status = "Failed"
2✔
222
        }
2✔
223

224
        title := fmt.Sprintf("%s: %d%% (%s)", report.Title, report.ScoreValue(), status)
9✔
225

9✔
226
        // Build summary with score and threshold
9✔
227
        var summary strings.Builder
9✔
228
        summary.WriteString(fmt.Sprintf("**Score:** %d%% | **Threshold:** %d%%\n\n", report.ScoreValue(), report.Threshold))
9✔
229

9✔
230
        if report.Description != "" {
10✔
231
                summary.WriteString(report.Description)
1✔
232
                summary.WriteString("\n\n")
1✔
233
        }
1✔
234

235
        // Add factors table if present
236
        if len(report.Factors) > 0 {
15✔
237
                summary.WriteString("### Factor Breakdown\n\n")
6✔
238
                summary.WriteString("| Factor | Score | Weight |\n")
6✔
239
                summary.WriteString("|--------|------:|-------:|\n")
6✔
240
                for _, f := range report.Factors {
18✔
241
                        summary.WriteString(fmt.Sprintf("| %s | %d%% | %d%% |\n", f.Name, f.Score, f.Weight))
12✔
242
                }
12✔
243
        }
244

245
        return &CheckRunOutput{
9✔
246
                Title:   title,
9✔
247
                Summary: summary.String(),
9✔
248
        }
9✔
249
}
250

251
// GitHubEnv holds environment variables from GitHub Actions.
252
type GitHubEnv struct {
253
        Token      string
254
        Repository string // "owner/repo" format
255
        SHA        string
256
        APIURL     string
257
}
258

259
// LoadGitHubEnv loads GitHub Actions environment variables.
260
// Returns an error only if GITHUB_TOKEN is missing.
261
func LoadGitHubEnv() (*GitHubEnv, error) {
9✔
262
        token := os.Getenv("GITHUB_TOKEN")
9✔
263
        if token == "" {
10✔
264
                return nil, fmt.Errorf("GITHUB_TOKEN environment variable is required")
1✔
265
        }
1✔
266

267
        return &GitHubEnv{
8✔
268
                Token:      token,
8✔
269
                Repository: os.Getenv("GITHUB_REPOSITORY"),
8✔
270
                SHA:        os.Getenv("GITHUB_SHA"),
8✔
271
                APIURL:     os.Getenv("GITHUB_API_URL"),
8✔
272
        }, nil
8✔
273
}
274

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

282
// CommentOptions configures comment posting.
283
type CommentOptions struct {
284
        Owner string // Repository owner
285
        Repo  string // Repository name
286
        PR    int    // Pull request number
287
}
288

289
// CommentResponse is the response from creating/updating a comment.
290
type CommentResponse struct {
291
        ID      int64  `json:"id"`
292
        HTMLURL string `json:"html_url"`
293
        Body    string `json:"body"`
294
}
295

296
// issueCommentsResponse is used to parse the list comments API response.
297
type issueCommentsResponse []struct {
298
        ID   int64  `json:"id"`
299
        Body string `json:"body"`
300
}
301

302
// FindComment finds an existing confvis comment on a PR.
303
// Returns nil if no confvis comment is found.
304
func (c *GitHubClient) FindComment(ctx context.Context, opts CommentOptions) (*CommentResponse, error) {
5✔
305
        comments, err := c.FindAllConfvisComments(ctx, opts)
5✔
306
        if err != nil {
6✔
307
                return nil, err
1✔
308
        }
1✔
309
        if len(comments) == 0 {
6✔
310
                return nil, nil //nolint:nilnil // nil response with no error means "not found"
2✔
311
        }
2✔
312
        return &comments[0], nil
2✔
313
}
314

315
// PostComment creates a new comment on a PR.
316
func (c *GitHubClient) PostComment(ctx context.Context, opts CommentOptions, body string) (*CommentResponse, error) {
1✔
317
        if opts.Owner == "" || opts.Repo == "" {
1✔
318
                return nil, errOwnerRepoRequired
×
UNCOV
319
        }
×
320
        if opts.PR <= 0 {
1✔
UNCOV
321
                return nil, errPRRequired
×
UNCOV
322
        }
×
323

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

1✔
326
        respBody, err := c.marshalAndDo(ctx, http.MethodPost, endpoint, struct {
1✔
327
                Body string `json:"body"`
1✔
328
        }{Body: body}, http.StatusCreated)
1✔
329
        if err != nil {
1✔
UNCOV
330
                return nil, err
×
331
        }
×
332

333
        var result CommentResponse
1✔
334
        if err := json.Unmarshal(respBody, &result); err != nil {
1✔
UNCOV
335
                return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
336
        }
×
337

338
        return &result, nil
1✔
339
}
340

341
// UpdateComment updates an existing comment.
342
func (c *GitHubClient) UpdateComment(ctx context.Context, opts CommentOptions, commentID int64, body string) (*CommentResponse, error) {
1✔
343
        if opts.Owner == "" || opts.Repo == "" {
1✔
UNCOV
344
                return nil, errOwnerRepoRequired
×
UNCOV
345
        }
×
346

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

1✔
349
        respBody, err := c.marshalAndDo(ctx, http.MethodPatch, endpoint, struct {
1✔
350
                Body string `json:"body"`
1✔
351
        }{Body: body}, http.StatusOK)
1✔
352
        if err != nil {
1✔
UNCOV
353
                return nil, err
×
354
        }
×
355

356
        var result CommentResponse
1✔
357
        if err := json.Unmarshal(respBody, &result); err != nil {
1✔
UNCOV
358
                return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
359
        }
×
360

361
        return &result, nil
1✔
362
}
363

364
// DeleteComment deletes a comment.
365
func (c *GitHubClient) DeleteComment(ctx context.Context, opts CommentOptions, commentID int64) error {
1✔
366
        if opts.Owner == "" || opts.Repo == "" {
1✔
UNCOV
367
                return errOwnerRepoRequired
×
UNCOV
368
        }
×
369

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

1✔
372
        _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, http.StatusNoContent)
1✔
373
        return err
1✔
374
}
375

376
// FindAllConfvisComments finds all confvis comments on a PR.
377
// Returns empty slice if no confvis comments are found.
378
func (c *GitHubClient) FindAllConfvisComments(ctx context.Context, opts CommentOptions) ([]CommentResponse, error) {
7✔
379
        if opts.Owner == "" || opts.Repo == "" {
7✔
380
                return nil, errOwnerRepoRequired
×
UNCOV
381
        }
×
382
        if opts.PR <= 0 {
7✔
UNCOV
383
                return nil, errPRRequired
×
UNCOV
384
        }
×
385

386
        baseEndpoint := fmt.Sprintf(issueCommentsEndpoint, c.baseURL, opts.Owner, opts.Repo, opts.PR)
7✔
387

7✔
388
        var result []CommentResponse
7✔
389
        for page := 1; ; page++ {
16✔
390
                endpoint := fmt.Sprintf("%s?per_page=%d&page=%d", baseEndpoint, commentsPerPage, page)
9✔
391

9✔
392
                respBody, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, http.StatusOK)
9✔
393
                if err != nil {
10✔
394
                        return nil, err
1✔
395
                }
1✔
396

397
                var comments issueCommentsResponse
8✔
398
                if err := json.Unmarshal(respBody, &comments); err != nil {
8✔
UNCOV
399
                        return nil, fmt.Errorf(errDecodingResponse, err)
×
UNCOV
400
                }
×
401

402
                for _, comment := range comments {
215✔
403
                        if strings.Contains(comment.Body, CommentMarker) {
213✔
404
                                result = append(result, CommentResponse{
6✔
405
                                        ID:   comment.ID,
6✔
406
                                        Body: comment.Body,
6✔
407
                                })
6✔
408
                        }
6✔
409
                }
410

411
                if len(comments) < commentsPerPage {
14✔
412
                        break
6✔
413
                }
414
        }
415

416
        return result, nil
6✔
417
}
418

419
// LoadGitHubEnvWithPR loads GitHub Actions environment variables including PR number.
420
// Returns nil for env if not in a GitHub Actions environment.
421
func LoadGitHubEnvWithPR() (*GitHubEnv, int, error) {
6✔
422
        env, err := LoadGitHubEnv()
6✔
423
        if err != nil {
6✔
UNCOV
424
                return nil, 0, err
×
UNCOV
425
        }
×
426

427
        // Try to get PR number from GITHUB_EVENT_PATH
428
        eventPath := os.Getenv("GITHUB_EVENT_PATH")
6✔
429
        if eventPath == "" {
7✔
430
                return env, 0, nil
1✔
431
        }
1✔
432

433
        prNumber, parseErr := parsePRNumberFromEvent(eventPath)
5✔
434
        if parseErr != nil {
8✔
435
                if errors.Is(parseErr, errNoPRInEvent) {
4✔
436
                        return env, 0, nil
1✔
437
                }
1✔
438
                _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to parse PR from event file %s: %v\n", eventPath, parseErr)
2✔
439
                return env, 0, nil
2✔
440
        }
441

442
        return env, prNumber, nil
2✔
443
}
444

445
// parsePRNumberFromEvent extracts the PR number from the GitHub event JSON file.
446
func parsePRNumberFromEvent(eventPath string) (int, error) {
5✔
447
        data, err := os.ReadFile(eventPath) //#nosec G304 -- path from GITHUB_EVENT_PATH env var set by GitHub Actions
5✔
448
        if err != nil {
6✔
449
                return 0, err
1✔
450
        }
1✔
451

452
        var event struct {
4✔
453
                PullRequest *struct {
4✔
454
                        Number int `json:"number"`
4✔
455
                } `json:"pull_request"`
4✔
456
                Issue *struct {
4✔
457
                        Number int `json:"number"`
4✔
458
                } `json:"issue"`
4✔
459
                Number int `json:"number"` // For issue_comment events
4✔
460
        }
4✔
461

4✔
462
        if err := json.Unmarshal(data, &event); err != nil {
5✔
463
                return 0, err
1✔
464
        }
1✔
465

466
        if event.PullRequest != nil && event.PullRequest.Number > 0 {
4✔
467
                return event.PullRequest.Number, nil
1✔
468
        }
1✔
469
        if event.Issue != nil && event.Issue.Number > 0 {
3✔
470
                return event.Issue.Number, nil
1✔
471
        }
1✔
472
        if event.Number > 0 {
1✔
UNCOV
473
                return event.Number, nil
×
UNCOV
474
        }
×
475

476
        return 0, errNoPRInEvent
1✔
477
}
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