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

valksor / go-mehrhof / 21189813300

20 Jan 2026 10:35PM UTC coverage: 39.789% (-0.6%) from 40.418%
21189813300

push

github

k0d3r1s
Update documentation for PR review features

- Add comprehensive PR review state management guide
- Document new 'mehr review pr' and 'generate-secret' commands
- Update README with PR review capabilities section
- Update sidebar with new documentation links
- Update dependencies for new crypto/time imports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

17474 of 43917 relevant lines covered (39.79%)

5.12 hits per line

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

56.03
/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 {
2✔
52
        return provider.ProviderInfo{
2✔
53
                Name:        ProviderName,
2✔
54
                Description: "GitHub Issues task source",
2✔
55
                Schemes:     []string{"github", "gh"},
2✔
56
                Priority:    20, // Higher than file/directory
2✔
57
                Capabilities: provider.CapabilitySet{
2✔
58
                        provider.CapRead:               true,
2✔
59
                        provider.CapList:               true,
2✔
60
                        provider.CapFetchComments:      true,
2✔
61
                        provider.CapComment:            true,
2✔
62
                        provider.CapUpdateStatus:       true,
2✔
63
                        provider.CapManageLabels:       true,
2✔
64
                        provider.CapCreateWorkUnit:     true,
2✔
65
                        provider.CapCreatePR:           true,
2✔
66
                        provider.CapDownloadAttachment: true,
2✔
67
                        provider.CapSnapshot:           true,
2✔
68
                        provider.CapFetchSubtasks:      true,
2✔
69
                        provider.CapFetchPR:            true,
2✔
70
                        provider.CapPRComment:          true,
2✔
71
                        provider.CapFetchPRComments:    true,
2✔
72
                        provider.CapUpdatePRComment:    true,
2✔
73
                },
2✔
74
        }
2✔
75
}
2✔
76

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

195
        // Update client with correct owner/repo
196
        p.client.SetOwnerRepo(owner, repo)
×
197

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

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

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

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

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

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

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

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

269
        return wu, nil
×
270
}
271

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

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

291
        p.client.SetOwnerRepo(owner, repo)
×
292

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

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

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

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

335
        return snapshot, nil
×
336
}
337

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

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

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

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

359
// --- Helper functions ---
360

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

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

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

395
        return "issue"
7✔
396
}
397

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

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

416
        return provider.PriorityNormal
9✔
417
}
418

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

425
        return names
11✔
426
}
427

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

438
        return persons
10✔
439
}
440

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

455
        return result
3✔
456
}
457

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

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

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

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

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

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

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

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

3✔
496
        return sb.String()
3✔
497
}
498

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

2✔
502
        sb.WriteString("# Comments\n\n")
2✔
503

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

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