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

valksor / go-mehrhof / 21521768385

30 Jan 2026 03:49PM UTC coverage: 40.116% (+0.003%) from 40.113%
21521768385

push

github

k0d3r1s
Update documentation for new features
Documentation updates across CLI and Web UI:
- README: Feature highlights and updates
- docs/cli: Command documentation for new features
- docs/web-ui: Web UI feature documentation
- docs/configuration: Config guide updates
- docs/quickstart: Getting started updates

CLI documentation:
- auto: Auto mode documentation
- commit: Commit command docs
- serve: Server options and settings
- submit: Submit command enhancements
- simplify: Simplify command updates
- review: Review command docs
- optimize: Optimize command updates

Web UI documentation:
- api: API endpoint documentation
- authentication: Auth and role-based access
- auto: Auto mode in Web UI
- commit: Commit page documentation
- quick-tasks: Quick tasks feature
- settings: Settings page documentation
- standalone-simplify: Standalone simplify page

Other:
- e2e tests: E2E test updates
- wrike provider: Minor cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

28802 of 71796 relevant lines covered (40.12%)

34.03 hits per line

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

56.41
/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/provider"
13
        "github.com/valksor/go-toolkit/cache"
14
        "github.com/valksor/go-toolkit/slug"
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.CapFetchParent:        true,
3✔
70
                        provider.CapFetchPR:            true,
3✔
71
                        provider.CapPRComment:          true,
3✔
72
                        provider.CapFetchPRComments:    true,
3✔
73
                        provider.CapUpdatePRComment:    true,
3✔
74
                        provider.CapCreateDependency:   true,
3✔
75
                        provider.CapFetchDependencies:  true,
3✔
76
                },
3✔
77
        }
3✔
78
}
3✔
79

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

272
        return wu, nil
×
273
}
274

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

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

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

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

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

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

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

338
        return snapshot, nil
×
339
}
340

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

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

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

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

362
// --- Helper functions ---
363

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

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

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

398
        return "issue"
7✔
399
}
400

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

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

419
        return provider.PriorityNormal
9✔
420
}
421

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

428
        return names
11✔
429
}
430

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

441
        return persons
10✔
442
}
443

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

458
        return result
3✔
459
}
460

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

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

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

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

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

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

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

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

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

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

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

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

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