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

valksor / go-mehrhof / 21315164809

24 Jan 2026 12:34PM UTC coverage: 39.969% (-0.5%) from 40.506%
21315164809

push

github

k0d3r1s
Fixes JavaScript evaluation in browser context

Improves JavaScript evaluation by using RuntimeEvaluate directly.
This avoids issues with Rod's function call mechanism.

Also, unskips the Eval integration test and configures SessionManager
to launch the browser in headless mode.

7 of 9 new or added lines in 1 file covered. (77.78%)

1449 existing lines in 34 files now uncovered.

20481 of 51242 relevant lines covered (39.97%)

21.48 hits per line

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

56.29
/internal/provider/github/github.go
1
package github
2

3
import (
4
        "context"
5
        "fmt"
6
        "strconv"
7
        "strings"
8
        "time"
9

10
        gh "github.com/google/go-github/v67/github"
11

12
        "github.com/valksor/go-mehrhof/internal/naming"
13
        "github.com/valksor/go-mehrhof/internal/provider"
14
        "github.com/valksor/go-toolkit/cache"
15
)
16

17
// ProviderName is the registered name for this provider.
18
const ProviderName = "github"
19

20
// Provider handles GitHub issue tasks.
21
type Provider struct {
22
        client *Client
23
        owner  string
24
        repo   string
25
        config *Config
26
        cache  *cache.Cache
27
}
28

29
// Config holds GitHub provider configuration.
30
type Config struct {
31
        Token         string
32
        Owner         string
33
        Repo          string
34
        BranchPattern string // Default: "issue/{key}-{slug}"
35
        CommitPrefix  string // Default: "[#{key}]"
36
        TargetBranch  string // Default: detected from repo
37
        DraftPR       bool
38
        Comments      *CommentsConfig
39
}
40

41
// CommentsConfig controls automated commenting.
42
type CommentsConfig struct {
43
        Enabled         bool
44
        OnBranchCreated bool
45
        OnPlanDone      bool
46
        OnImplementDone bool
47
        OnPRCreated     bool
48
}
49

50
// Info returns provider metadata.
51
func Info() provider.ProviderInfo {
3✔
52
        return provider.ProviderInfo{
3✔
53
                Name:        ProviderName,
3✔
54
                Description: "GitHub Issues task source",
3✔
55
                Schemes:     []string{"github", "gh"},
3✔
56
                Priority:    20, // Higher than file/directory
3✔
57
                Capabilities: provider.CapabilitySet{
3✔
58
                        provider.CapRead:               true,
3✔
59
                        provider.CapList:               true,
3✔
60
                        provider.CapFetchComments:      true,
3✔
61
                        provider.CapComment:            true,
3✔
62
                        provider.CapUpdateStatus:       true,
3✔
63
                        provider.CapManageLabels:       true,
3✔
64
                        provider.CapCreateWorkUnit:     true,
3✔
65
                        provider.CapCreatePR:           true,
3✔
66
                        provider.CapDownloadAttachment: true,
3✔
67
                        provider.CapSnapshot:           true,
3✔
68
                        provider.CapFetchSubtasks:      true,
3✔
69
                        provider.CapFetchPR:            true,
3✔
70
                        provider.CapPRComment:          true,
3✔
71
                        provider.CapFetchPRComments:    true,
3✔
72
                        provider.CapUpdatePRComment:    true,
3✔
73
                        provider.CapCreateDependency:   true,
3✔
74
                        provider.CapFetchDependencies:  true,
3✔
75
                },
3✔
76
        }
3✔
77
}
3✔
78

79
// New creates a GitHub provider.
80
func New(ctx context.Context, cfg provider.Config) (any, error) {
5✔
81
        // Validate configuration
5✔
82
        if err := provider.ValidateConfig("github", cfg, func(v *provider.Validator) {
10✔
83
                // Token is required (may be resolved from config or env)
5✔
84
                // Skip validation here as ResolveToken handles it with better error messages
5✔
85
                // Owner and repo are optional - can be specified in reference
5✔
86
        }); err != nil {
5✔
UNCOV
87
                return nil, err
×
UNCOV
88
        }
×
89

90
        // Extract config values
91
        token := cfg.GetString("token")
5✔
92
        owner := cfg.GetString("owner")
5✔
93
        repo := cfg.GetString("repo")
5✔
94
        branchPattern := cfg.GetString("branch_pattern")
5✔
95
        commitPrefix := cfg.GetString("commit_prefix")
5✔
96
        targetBranch := cfg.GetString("target_branch")
5✔
97
        draftPR := cfg.GetBool("draft_pr")
5✔
98

5✔
99
        // Resolve token
5✔
100
        resolvedToken, err := ResolveToken(token)
5✔
101
        if err != nil {
5✔
UNCOV
102
                return nil, err
×
UNCOV
103
        }
×
104

105
        // Set defaults
106
        if branchPattern == "" {
10✔
107
                branchPattern = "issue/{key}-{slug}"
5✔
108
        }
5✔
109
        if commitPrefix == "" {
10✔
110
                commitPrefix = "[#{key}]"
5✔
111
        }
5✔
112

113
        // Parse comments config
114
        commentsEnabled := cfg.GetBool("comments.enabled")
5✔
115
        comments := &CommentsConfig{
5✔
116
                Enabled:         commentsEnabled,
5✔
117
                OnBranchCreated: cfg.GetBool("comments.on_branch_created"),
5✔
118
                OnPlanDone:      cfg.GetBool("comments.on_plan_done"),
5✔
119
                OnImplementDone: cfg.GetBool("comments.on_implement_done"),
5✔
120
                OnPRCreated:     cfg.GetBool("comments.on_pr_created"),
5✔
121
        }
5✔
122

5✔
123
        config := &Config{
5✔
124
                Token:         resolvedToken,
5✔
125
                Owner:         owner,
5✔
126
                Repo:          repo,
5✔
127
                BranchPattern: branchPattern,
5✔
128
                CommitPrefix:  commitPrefix,
5✔
129
                TargetBranch:  targetBranch,
5✔
130
                DraftPR:       draftPR,
5✔
131
                Comments:      comments,
5✔
132
        }
5✔
133

5✔
134
        // Create cache (enabled by default, can be disabled via config or SetCache(nil))
5✔
135
        providerCache := cache.New()
5✔
136
        if cfg.GetBool("cache.disabled") {
5✔
UNCOV
137
                providerCache.Disable()
×
UNCOV
138
        }
×
139

140
        return &Provider{
5✔
141
                client: NewClientWithCache(ctx, resolvedToken, owner, repo, providerCache),
5✔
142
                owner:  owner,
5✔
143
                repo:   repo,
5✔
144
                config: config,
5✔
145
                cache:  providerCache,
5✔
146
        }, nil
5✔
147
}
148

149
// Match checks if input has the github: or gh: scheme prefix.
150
func (p *Provider) Match(input string) bool {
8✔
151
        return strings.HasPrefix(input, "github:") || strings.HasPrefix(input, "gh:")
8✔
152
}
8✔
153

154
// Parse extracts the issue reference from input.
155
func (p *Provider) Parse(input string) (string, error) {
8✔
156
        ref, err := ParseReference(input)
8✔
157
        if err != nil {
9✔
158
                return "", err
1✔
159
        }
1✔
160

161
        // If explicit owner/repo provided, use it
162
        if ref.IsExplicit {
9✔
163
                return fmt.Sprintf("%s/%s#%d", ref.Owner, ref.Repo, ref.IssueNumber), nil
2✔
164
        }
2✔
165

166
        // Otherwise, check if we have owner/repo configured
167
        owner := p.owner
5✔
168
        repo := p.repo
5✔
169

5✔
170
        if owner == "" || repo == "" {
8✔
171
                return "", fmt.Errorf("%w: use github:owner/repo#N format or configure github.owner and github.repo", ErrRepoNotConfigured)
3✔
172
        }
3✔
173

174
        return fmt.Sprintf("%s/%s#%d", owner, repo, ref.IssueNumber), nil
2✔
175
}
176

177
// Fetch reads a GitHub issue and creates a WorkUnit.
178
func (p *Provider) Fetch(ctx context.Context, id string) (*provider.WorkUnit, error) {
×
179
        ref, err := ParseReference(id)
×
180
        if err != nil {
×
UNCOV
181
                return nil, err
×
UNCOV
182
        }
×
183

184
        // Determine owner/repo
185
        owner := ref.Owner
×
186
        repo := ref.Repo
×
187
        if owner == "" {
×
188
                owner = p.owner
×
189
        }
×
190
        if repo == "" {
×
191
                repo = p.repo
×
192
        }
×
193
        if owner == "" || repo == "" {
×
UNCOV
194
                return nil, ErrRepoNotConfigured
×
UNCOV
195
        }
×
196

197
        // Update client with correct owner/repo
198
        p.client.SetOwnerRepo(owner, repo)
×
199

×
200
        // Fetch issue
×
201
        issue, err := p.client.GetIssue(ctx, ref.IssueNumber)
×
202
        if err != nil {
×
UNCOV
203
                return nil, err
×
UNCOV
204
        }
×
205

206
        // Map to WorkUnit
207
        wu := &provider.WorkUnit{
×
208
                ID:          strconv.Itoa(issue.GetNumber()),
×
209
                ExternalID:  fmt.Sprintf("%s/%s#%d", owner, repo, issue.GetNumber()),
×
210
                Provider:    ProviderName,
×
211
                Title:       issue.GetTitle(),
×
212
                Description: issue.GetBody(),
×
213
                Status:      mapGitHubState(issue.GetState()),
×
214
                Priority:    inferPriorityFromLabels(issue.Labels),
×
215
                Labels:      extractLabelNames(issue.Labels),
×
216
                Assignees:   mapAssignees(issue.Assignees),
×
217
                CreatedAt:   issue.GetCreatedAt().Time,
×
218
                UpdatedAt:   issue.GetUpdatedAt().Time,
×
219
                Source: provider.SourceInfo{
×
220
                        Type:      ProviderName,
×
221
                        Reference: id,
×
222
                        SyncedAt:  time.Now(),
×
223
                },
×
224

×
225
                // Naming fields for branch/commit customization
×
226
                ExternalKey: strconv.Itoa(issue.GetNumber()),
×
227
                TaskType:    inferTypeFromLabels(issue.Labels),
×
228
                Slug:        naming.Slugify(issue.GetTitle(), 50),
×
229

×
230
                Metadata: map[string]any{
×
231
                        "html_url":       issue.GetHTMLURL(),
×
232
                        "owner":          owner,
×
233
                        "repo":           repo,
×
234
                        "issue_number":   issue.GetNumber(),
×
235
                        "labels_raw":     issue.Labels,
×
236
                        "branch_pattern": p.config.BranchPattern,
×
237
                        "commit_prefix":  p.config.CommitPrefix,
×
238
                },
×
239
        }
×
240

×
241
        // Add milestone if present
×
242
        if issue.Milestone != nil && issue.Milestone.GetTitle() != "" {
×
UNCOV
243
                wu.Metadata["milestone"] = issue.Milestone.GetTitle()
×
UNCOV
244
        }
×
245

246
        // Fetch comments if available
247
        comments, err := p.client.GetIssueComments(ctx, ref.IssueNumber)
×
248
        if err == nil && len(comments) > 0 {
×
UNCOV
249
                wu.Comments = mapComments(comments)
×
UNCOV
250
        }
×
251

252
        // Extract linked issues
253
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
254
        if len(linkedNums) > 0 {
×
UNCOV
255
                wu.Metadata["linked_issues"] = linkedNums
×
UNCOV
256
        }
×
257

258
        // Extract image URLs
259
        imageURLs := ExtractImageURLs(issue.GetBody())
×
260
        if len(imageURLs) > 0 {
×
261
                wu.Attachments = make([]provider.Attachment, len(imageURLs))
×
262
                for i, url := range imageURLs {
×
263
                        wu.Attachments[i] = provider.Attachment{
×
264
                                ID:   AttachmentIDFromURL(url), // Stable ID based on URL hash
×
265
                                Name: fmt.Sprintf("image-%d", i+1),
×
266
                                URL:  url,
×
UNCOV
267
                        }
×
UNCOV
268
                }
×
269
        }
270

UNCOV
271
        return wu, nil
×
272
}
273

274
// Snapshot captures the issue content for storage.
275
func (p *Provider) Snapshot(ctx context.Context, id string) (*provider.Snapshot, error) {
×
276
        ref, err := ParseReference(id)
×
277
        if err != nil {
×
UNCOV
278
                return nil, err
×
279
        }
×
280

281
        owner := ref.Owner
×
282
        repo := ref.Repo
×
283
        if owner == "" {
×
284
                owner = p.owner
×
285
        }
×
286
        if repo == "" {
×
287
                repo = p.repo
×
288
        }
×
289
        if owner == "" || repo == "" {
×
UNCOV
290
                return nil, ErrRepoNotConfigured
×
291
        }
×
292

293
        p.client.SetOwnerRepo(owner, repo)
×
294

×
295
        // Fetch main issue
×
296
        issue, err := p.client.GetIssue(ctx, ref.IssueNumber)
×
297
        if err != nil {
×
UNCOV
298
                return nil, err
×
299
        }
×
300

301
        snapshot := &provider.Snapshot{
×
302
                Type: ProviderName,
×
303
                Ref:  id,
×
304
                Files: []provider.SnapshotFile{
×
305
                        {
×
306
                                Path:    "issue.md",
×
307
                                Content: formatIssueMarkdown(issue),
×
308
                        },
×
309
                },
×
310
        }
×
311

×
312
        // Fetch and include comments
×
313
        comments, err := p.client.GetIssueComments(ctx, ref.IssueNumber)
×
314
        if err == nil && len(comments) > 0 {
×
315
                snapshot.Files = append(snapshot.Files, provider.SnapshotFile{
×
316
                        Path:    "comments.md",
×
317
                        Content: formatCommentsMarkdown(comments),
×
UNCOV
318
                })
×
UNCOV
319
        }
×
320

321
        // Fetch linked issues
322
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
323
        for _, num := range linkedNums {
×
UNCOV
324
                if num == ref.IssueNumber {
×
325
                        continue // Skip self-reference
×
326
                }
327
                linked, err := p.client.GetIssue(ctx, num)
×
UNCOV
328
                if err != nil {
×
329
                        continue // Skip issues we can't fetch
×
330
                }
331
                snapshot.Files = append(snapshot.Files, provider.SnapshotFile{
×
332
                        Path:    fmt.Sprintf("linked/issue-%d.md", num),
×
UNCOV
333
                        Content: formatIssueMarkdown(linked),
×
UNCOV
334
                })
×
335
        }
336

UNCOV
337
        return snapshot, nil
×
338
}
339

340
// GetConfig returns the provider configuration.
341
func (p *Provider) GetConfig() *Config {
×
UNCOV
342
        return p.config
×
UNCOV
343
}
×
344

345
// GetClient returns the GitHub API client.
346
func (p *Provider) GetClient() *Client {
×
UNCOV
347
        return p.client
×
UNCOV
348
}
×
349

350
// SetCache sets or replaces the cache for this provider and its client.
351
func (p *Provider) SetCache(c *cache.Cache) {
×
352
        p.cache = c
×
UNCOV
353
        p.client.SetCache(c)
×
UNCOV
354
}
×
355

356
// GetCache returns the cache for this provider.
357
func (p *Provider) GetCache() *cache.Cache {
×
UNCOV
358
        return p.cache
×
UNCOV
359
}
×
360

361
// --- Helper functions ---
362

363
func mapGitHubState(state string) provider.Status {
12✔
364
        switch state {
12✔
365
        case "open":
9✔
366
                return provider.StatusOpen
9✔
367
        case "closed":
1✔
368
                return provider.StatusClosed
1✔
369
        default:
2✔
370
                return provider.StatusOpen
2✔
371
        }
372
}
373

374
// labelTypeMap maps GitHub labels to task types.
375
var labelTypeMap = map[string]string{
376
        "bug":           "fix",
377
        "bugfix":        "fix",
378
        "fix":           "fix",
379
        "feature":       "feature",
380
        "enhancement":   "feature",
381
        "docs":          "docs",
382
        "documentation": "docs",
383
        "refactor":      "refactor",
384
        "chore":         "chore",
385
        "test":          "test",
386
        "ci":            "ci",
387
}
388

389
func inferTypeFromLabels(labels []*gh.Label) string {
21✔
390
        for _, label := range labels {
36✔
391
                name := strings.ToLower(label.GetName())
15✔
392
                if t, ok := labelTypeMap[name]; ok {
29✔
393
                        return t
14✔
394
                }
14✔
395
        }
396

397
        return "issue"
7✔
398
}
399

400
// labelPriorityMap maps GitHub labels to priorities.
401
var labelPriorityMap = map[string]provider.Priority{
402
        "critical":      provider.PriorityCritical,
403
        "urgent":        provider.PriorityCritical,
404
        "priority:high": provider.PriorityHigh,
405
        "high-priority": provider.PriorityHigh,
406
        "priority:low":  provider.PriorityLow,
407
        "low-priority":  provider.PriorityLow,
408
}
409

410
func inferPriorityFromLabels(labels []*gh.Label) provider.Priority {
16✔
411
        for _, label := range labels {
26✔
412
                name := strings.ToLower(label.GetName())
10✔
413
                if p, ok := labelPriorityMap[name]; ok {
17✔
414
                        return p
7✔
415
                }
7✔
416
        }
417

418
        return provider.PriorityNormal
9✔
419
}
420

421
func extractLabelNames(labels []*gh.Label) []string {
11✔
422
        names := make([]string, len(labels))
11✔
423
        for i, label := range labels {
18✔
424
                names[i] = label.GetName()
7✔
425
        }
7✔
426

427
        return names
11✔
428
}
429

430
func mapAssignees(assignees []*gh.User) []provider.Person {
10✔
431
        persons := make([]provider.Person, len(assignees))
10✔
432
        for i, u := range assignees {
13✔
433
                persons[i] = provider.Person{
3✔
434
                        ID:    strconv.FormatInt(u.GetID(), 10),
3✔
435
                        Name:  u.GetLogin(),
3✔
436
                        Email: u.GetEmail(),
3✔
437
                }
3✔
438
        }
3✔
439

440
        return persons
10✔
441
}
442

443
func mapComments(comments []*gh.IssueComment) []provider.Comment {
3✔
444
        result := make([]provider.Comment, len(comments))
3✔
445
        for i, c := range comments {
5✔
446
                result[i] = provider.Comment{
2✔
447
                        ID:        strconv.FormatInt(c.GetID(), 10),
2✔
448
                        Body:      c.GetBody(),
2✔
449
                        CreatedAt: c.GetCreatedAt().Time,
2✔
450
                        Author: provider.Person{
2✔
451
                                ID:   strconv.FormatInt(c.GetUser().GetID(), 10),
2✔
452
                                Name: c.GetUser().GetLogin(),
2✔
453
                        },
2✔
454
                }
2✔
455
        }
2✔
456

457
        return result
3✔
458
}
459

460
func formatIssueMarkdown(issue *gh.Issue) string {
3✔
461
        var sb strings.Builder
3✔
462

3✔
463
        sb.WriteString(fmt.Sprintf("# #%d: %s\n\n", issue.GetNumber(), issue.GetTitle()))
3✔
464

3✔
465
        // Metadata
3✔
466
        sb.WriteString("## Metadata\n\n")
3✔
467
        sb.WriteString(fmt.Sprintf("- **State:** %s\n", issue.GetState()))
3✔
468
        sb.WriteString(fmt.Sprintf("- **Created:** %s\n", issue.GetCreatedAt().Format(time.RFC3339)))
3✔
469
        sb.WriteString(fmt.Sprintf("- **Updated:** %s\n", issue.GetUpdatedAt().Format(time.RFC3339)))
3✔
470

3✔
471
        if issue.GetUser() != nil {
4✔
472
                sb.WriteString(fmt.Sprintf("- **Author:** @%s\n", issue.GetUser().GetLogin()))
1✔
473
        }
1✔
474

475
        if len(issue.Labels) > 0 {
4✔
476
                labels := make([]string, len(issue.Labels))
1✔
477
                for i, l := range issue.Labels {
3✔
478
                        labels[i] = l.GetName()
2✔
479
                }
2✔
480
                sb.WriteString(fmt.Sprintf("- **Labels:** %s\n", strings.Join(labels, ", ")))
1✔
481
        }
482

483
        if len(issue.Assignees) > 0 {
4✔
484
                assignees := make([]string, len(issue.Assignees))
1✔
485
                for i, a := range issue.Assignees {
2✔
486
                        assignees[i] = "@" + a.GetLogin()
1✔
487
                }
1✔
488
                sb.WriteString(fmt.Sprintf("- **Assignees:** %s\n", strings.Join(assignees, ", ")))
1✔
489
        }
490

491
        sb.WriteString(fmt.Sprintf("- **URL:** %s\n", issue.GetHTMLURL()))
3✔
492

3✔
493
        // Body
3✔
494
        sb.WriteString("\n## Description\n\n")
3✔
495
        sb.WriteString(issue.GetBody())
3✔
496
        sb.WriteString("\n")
3✔
497

3✔
498
        return sb.String()
3✔
499
}
500

501
func formatCommentsMarkdown(comments []*gh.IssueComment) string {
2✔
502
        var sb strings.Builder
2✔
503

2✔
504
        sb.WriteString("# Comments\n\n")
2✔
505

2✔
506
        for _, c := range comments {
4✔
507
                sb.WriteString(fmt.Sprintf("## Comment by @%s\n\n", c.GetUser().GetLogin()))
2✔
508
                sb.WriteString(fmt.Sprintf("*%s*\n\n", c.GetCreatedAt().Format(time.RFC3339)))
2✔
509
                sb.WriteString(c.GetBody())
2✔
510
                sb.WriteString("\n\n---\n\n")
2✔
511
        }
2✔
512

513
        return sb.String()
2✔
514
}
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