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

valksor / go-mehrhof / 20733530775

06 Jan 2026 12:24AM UTC coverage: 46.273% (-0.5%) from 46.751%
20733530775

push

github

k0d3r1s
Add configuration validation to GitHub provider

Integrate ValidateConfig into GitHub provider's New function for
configuration validation.

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

Co-Authored-By: Z.ai GLM-4.7 <noreply@z.ai>

6 of 8 new or added lines in 1 file covered. (75.0%)

752 existing lines in 19 files now uncovered.

14109 of 30491 relevant lines covered (46.27%)

5.48 hits per line

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

56.01
/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/cache"
13
        "github.com/valksor/go-mehrhof/internal/naming"
14
        "github.com/valksor/go-mehrhof/internal/provider"
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
                },
2✔
70
        }
2✔
71
}
2✔
72

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

84
        // Extract config values
85
        token := cfg.GetString("token")
5✔
86
        owner := cfg.GetString("owner")
5✔
87
        repo := cfg.GetString("repo")
5✔
88
        branchPattern := cfg.GetString("branch_pattern")
5✔
89
        commitPrefix := cfg.GetString("commit_prefix")
5✔
90
        targetBranch := cfg.GetString("target_branch")
5✔
91
        draftPR := cfg.GetBool("draft_pr")
5✔
92

5✔
93
        // Resolve token
5✔
94
        resolvedToken, err := ResolveToken(token)
5✔
95
        if err != nil {
5✔
96
                return nil, err
×
97
        }
×
98

99
        // Set defaults
100
        if branchPattern == "" {
10✔
101
                branchPattern = "issue/{key}-{slug}"
5✔
102
        }
5✔
103
        if commitPrefix == "" {
10✔
104
                commitPrefix = "[#{key}]"
5✔
105
        }
5✔
106

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

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

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

134
        return &Provider{
5✔
135
                client: NewClientWithCache(ctx, resolvedToken, owner, repo, providerCache),
5✔
136
                owner:  owner,
5✔
137
                repo:   repo,
5✔
138
                config: config,
5✔
139
                cache:  providerCache,
5✔
140
        }, nil
5✔
141
}
142

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

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

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

160
        // Otherwise, check if we have owner/repo configured
161
        owner := p.owner
5✔
162
        repo := p.repo
5✔
163

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

168
        return fmt.Sprintf("%s/%s#%d", owner, repo, ref.IssueNumber), nil
2✔
169
}
170

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

178
        // Determine owner/repo
179
        owner := ref.Owner
×
180
        repo := ref.Repo
×
181
        if owner == "" {
×
182
                owner = p.owner
×
183
        }
×
184
        if repo == "" {
×
185
                repo = p.repo
×
186
        }
×
187
        if owner == "" || repo == "" {
×
188
                return nil, ErrRepoNotConfigured
×
189
        }
×
190

191
        // Update client with correct owner/repo
192
        p.client.SetOwnerRepo(owner, repo)
×
193

×
194
        // Fetch issue
×
195
        issue, err := p.client.GetIssue(ctx, ref.IssueNumber)
×
196
        if err != nil {
×
197
                return nil, err
×
198
        }
×
199

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

×
219
                // Naming fields for branch/commit customization
×
220
                ExternalKey: strconv.Itoa(issue.GetNumber()),
×
221
                TaskType:    inferTypeFromLabels(issue.Labels),
×
222
                Slug:        naming.Slugify(issue.GetTitle(), 50),
×
223

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

×
235
        // Fetch comments if available
×
236
        comments, err := p.client.GetIssueComments(ctx, ref.IssueNumber)
×
237
        if err == nil && len(comments) > 0 {
×
238
                wu.Comments = mapComments(comments)
×
239
        }
×
240

241
        // Extract linked issues
242
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
243
        if len(linkedNums) > 0 {
×
244
                wu.Metadata["linked_issues"] = linkedNums
×
245
        }
×
246

247
        // Extract image URLs
248
        imageURLs := ExtractImageURLs(issue.GetBody())
×
249
        if len(imageURLs) > 0 {
×
250
                wu.Attachments = make([]provider.Attachment, len(imageURLs))
×
251
                for i, url := range imageURLs {
×
252
                        wu.Attachments[i] = provider.Attachment{
×
253
                                ID:   fmt.Sprintf("img-%d", i),
×
254
                                Name: fmt.Sprintf("image-%d", i),
×
255
                                URL:  url,
×
256
                        }
×
257
                }
×
258
        }
259

260
        return wu, nil
×
261
}
262

263
// Snapshot captures the issue content for storage.
264
func (p *Provider) Snapshot(ctx context.Context, id string) (*provider.Snapshot, error) {
×
265
        ref, err := ParseReference(id)
×
266
        if err != nil {
×
267
                return nil, err
×
268
        }
×
269

270
        owner := ref.Owner
×
271
        repo := ref.Repo
×
272
        if owner == "" {
×
273
                owner = p.owner
×
274
        }
×
275
        if repo == "" {
×
276
                repo = p.repo
×
277
        }
×
278
        if owner == "" || repo == "" {
×
279
                return nil, ErrRepoNotConfigured
×
280
        }
×
281

282
        p.client.SetOwnerRepo(owner, repo)
×
283

×
284
        // Fetch main issue
×
285
        issue, err := p.client.GetIssue(ctx, ref.IssueNumber)
×
286
        if err != nil {
×
287
                return nil, err
×
288
        }
×
289

290
        snapshot := &provider.Snapshot{
×
291
                Type: ProviderName,
×
292
                Ref:  id,
×
293
                Files: []provider.SnapshotFile{
×
294
                        {
×
295
                                Path:    "issue.md",
×
296
                                Content: formatIssueMarkdown(issue),
×
297
                        },
×
298
                },
×
299
        }
×
300

×
301
        // Fetch and include comments
×
302
        comments, err := p.client.GetIssueComments(ctx, ref.IssueNumber)
×
303
        if err == nil && len(comments) > 0 {
×
304
                snapshot.Files = append(snapshot.Files, provider.SnapshotFile{
×
305
                        Path:    "comments.md",
×
306
                        Content: formatCommentsMarkdown(comments),
×
307
                })
×
308
        }
×
309

310
        // Fetch linked issues
311
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
312
        for _, num := range linkedNums {
×
313
                if num == ref.IssueNumber {
×
314
                        continue // Skip self-reference
×
315
                }
316
                linked, err := p.client.GetIssue(ctx, num)
×
317
                if err != nil {
×
318
                        continue // Skip issues we can't fetch
×
319
                }
320
                snapshot.Files = append(snapshot.Files, provider.SnapshotFile{
×
321
                        Path:    fmt.Sprintf("linked/issue-%d.md", num),
×
322
                        Content: formatIssueMarkdown(linked),
×
323
                })
×
324
        }
325

326
        return snapshot, nil
×
327
}
328

329
// GetConfig returns the provider configuration.
330
func (p *Provider) GetConfig() *Config {
×
331
        return p.config
×
332
}
×
333

334
// GetClient returns the GitHub API client.
335
func (p *Provider) GetClient() *Client {
×
336
        return p.client
×
337
}
×
338

339
// SetCache sets or replaces the cache for this provider and its client.
340
func (p *Provider) SetCache(c *cache.Cache) {
×
341
        p.cache = c
×
342
        p.client.SetCache(c)
×
343
}
×
344

345
// GetCache returns the cache for this provider.
346
func (p *Provider) GetCache() *cache.Cache {
×
347
        return p.cache
×
348
}
×
349

350
// --- Helper functions ---
351

352
func mapGitHubState(state string) provider.Status {
12✔
353
        switch state {
12✔
354
        case "open":
9✔
355
                return provider.StatusOpen
9✔
356
        case "closed":
1✔
357
                return provider.StatusClosed
1✔
358
        default:
2✔
359
                return provider.StatusOpen
2✔
360
        }
361
}
362

363
// labelTypeMap maps GitHub labels to task types.
364
var labelTypeMap = map[string]string{
365
        "bug":           "fix",
366
        "bugfix":        "fix",
367
        "fix":           "fix",
368
        "feature":       "feature",
369
        "enhancement":   "feature",
370
        "docs":          "docs",
371
        "documentation": "docs",
372
        "refactor":      "refactor",
373
        "chore":         "chore",
374
        "test":          "test",
375
        "ci":            "ci",
376
}
377

378
func inferTypeFromLabels(labels []*gh.Label) string {
21✔
379
        for _, label := range labels {
36✔
380
                name := strings.ToLower(label.GetName())
15✔
381
                if t, ok := labelTypeMap[name]; ok {
29✔
382
                        return t
14✔
383
                }
14✔
384
        }
385

386
        return "issue"
7✔
387
}
388

389
// labelPriorityMap maps GitHub labels to priorities.
390
var labelPriorityMap = map[string]provider.Priority{
391
        "critical":      provider.PriorityCritical,
392
        "urgent":        provider.PriorityCritical,
393
        "priority:high": provider.PriorityHigh,
394
        "high-priority": provider.PriorityHigh,
395
        "priority:low":  provider.PriorityLow,
396
        "low-priority":  provider.PriorityLow,
397
}
398

399
func inferPriorityFromLabels(labels []*gh.Label) provider.Priority {
16✔
400
        for _, label := range labels {
26✔
401
                name := strings.ToLower(label.GetName())
10✔
402
                if p, ok := labelPriorityMap[name]; ok {
17✔
403
                        return p
7✔
404
                }
7✔
405
        }
406

407
        return provider.PriorityNormal
9✔
408
}
409

410
func extractLabelNames(labels []*gh.Label) []string {
11✔
411
        names := make([]string, len(labels))
11✔
412
        for i, label := range labels {
18✔
413
                names[i] = label.GetName()
7✔
414
        }
7✔
415

416
        return names
11✔
417
}
418

419
func mapAssignees(assignees []*gh.User) []provider.Person {
10✔
420
        persons := make([]provider.Person, len(assignees))
10✔
421
        for i, u := range assignees {
13✔
422
                persons[i] = provider.Person{
3✔
423
                        ID:    strconv.FormatInt(u.GetID(), 10),
3✔
424
                        Name:  u.GetLogin(),
3✔
425
                        Email: u.GetEmail(),
3✔
426
                }
3✔
427
        }
3✔
428

429
        return persons
10✔
430
}
431

432
func mapComments(comments []*gh.IssueComment) []provider.Comment {
3✔
433
        result := make([]provider.Comment, len(comments))
3✔
434
        for i, c := range comments {
5✔
435
                result[i] = provider.Comment{
2✔
436
                        ID:        strconv.FormatInt(c.GetID(), 10),
2✔
437
                        Body:      c.GetBody(),
2✔
438
                        CreatedAt: c.GetCreatedAt().Time,
2✔
439
                        Author: provider.Person{
2✔
440
                                ID:   strconv.FormatInt(c.GetUser().GetID(), 10),
2✔
441
                                Name: c.GetUser().GetLogin(),
2✔
442
                        },
2✔
443
                }
2✔
444
        }
2✔
445

446
        return result
3✔
447
}
448

449
func formatIssueMarkdown(issue *gh.Issue) string {
3✔
450
        var sb strings.Builder
3✔
451

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

3✔
454
        // Metadata
3✔
455
        sb.WriteString("## Metadata\n\n")
3✔
456
        sb.WriteString(fmt.Sprintf("- **State:** %s\n", issue.GetState()))
3✔
457
        sb.WriteString(fmt.Sprintf("- **Created:** %s\n", issue.GetCreatedAt().Format(time.RFC3339)))
3✔
458
        sb.WriteString(fmt.Sprintf("- **Updated:** %s\n", issue.GetUpdatedAt().Format(time.RFC3339)))
3✔
459

3✔
460
        if issue.GetUser() != nil {
4✔
461
                sb.WriteString(fmt.Sprintf("- **Author:** @%s\n", issue.GetUser().GetLogin()))
1✔
462
        }
1✔
463

464
        if len(issue.Labels) > 0 {
4✔
465
                labels := make([]string, len(issue.Labels))
1✔
466
                for i, l := range issue.Labels {
3✔
467
                        labels[i] = l.GetName()
2✔
468
                }
2✔
469
                sb.WriteString(fmt.Sprintf("- **Labels:** %s\n", strings.Join(labels, ", ")))
1✔
470
        }
471

472
        if len(issue.Assignees) > 0 {
4✔
473
                assignees := make([]string, len(issue.Assignees))
1✔
474
                for i, a := range issue.Assignees {
2✔
475
                        assignees[i] = "@" + a.GetLogin()
1✔
476
                }
1✔
477
                sb.WriteString(fmt.Sprintf("- **Assignees:** %s\n", strings.Join(assignees, ", ")))
1✔
478
        }
479

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

3✔
482
        // Body
3✔
483
        sb.WriteString("\n## Description\n\n")
3✔
484
        sb.WriteString(issue.GetBody())
3✔
485
        sb.WriteString("\n")
3✔
486

3✔
487
        return sb.String()
3✔
488
}
489

490
func formatCommentsMarkdown(comments []*gh.IssueComment) string {
2✔
491
        var sb strings.Builder
2✔
492

2✔
493
        sb.WriteString("# Comments\n\n")
2✔
494

2✔
495
        for _, c := range comments {
4✔
496
                sb.WriteString(fmt.Sprintf("## Comment by @%s\n\n", c.GetUser().GetLogin()))
2✔
497
                sb.WriteString(fmt.Sprintf("*%s*\n\n", c.GetCreatedAt().Format(time.RFC3339)))
2✔
498
                sb.WriteString(c.GetBody())
2✔
499
                sb.WriteString("\n\n---\n\n")
2✔
500
        }
2✔
501

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