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

valksor / go-mehrhof / 21342980857

26 Jan 2026 01:09AM UTC coverage: 40.055% (-0.5%) from 40.54%
21342980857

push

github

k0d3r1s
Update provider packages to use go-toolkit slug directly

Replaces internal/naming.Slugify wrapper with go-toolkit/slug.Slugify
across all provider packages. Updates mock event bus to use eventbus.

Changes:
- Update provider imports: remove internal/naming, add github.com/valksor/go-toolkit/slug
- Replace naming.Slugify() calls with slug.Slugify()
- Update mock_eventbus.go to use go-toolkit/eventbus types
- Rename local 'slug' variables to avoid shadowing package name

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

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

7 of 61 new or added lines in 31 files covered. (11.48%)

402 existing lines in 15 files now uncovered.

21307 of 53194 relevant lines covered (40.06%)

21.46 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/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.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✔
87
                return nil, err
×
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✔
102
                return nil, err
×
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✔
137
                providerCache.Disable()
×
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 {
×
181
                return nil, err
×
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 == "" {
×
194
                return nil, ErrRepoNotConfigured
×
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 {
×
203
                return nil, err
×
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),
×
NEW
228
                Slug:        slug.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() != "" {
×
243
                wu.Metadata["milestone"] = issue.Milestone.GetTitle()
×
244
        }
×
245

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

252
        // Extract linked issues
253
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
254
        if len(linkedNums) > 0 {
×
255
                wu.Metadata["linked_issues"] = linkedNums
×
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,
×
267
                        }
×
268
                }
×
269
        }
270

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 {
×
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 == "" {
×
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 {
×
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),
×
318
                })
×
319
        }
×
320

321
        // Fetch linked issues
322
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
323
        for _, num := range linkedNums {
×
324
                if num == ref.IssueNumber {
×
325
                        continue // Skip self-reference
×
326
                }
327
                linked, err := p.client.GetIssue(ctx, num)
×
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),
×
333
                        Content: formatIssueMarkdown(linked),
×
334
                })
×
335
        }
336

337
        return snapshot, nil
×
338
}
339

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

345
// GetClient returns the GitHub API client.
346
func (p *Provider) GetClient() *Client {
×
347
        return p.client
×
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
×
353
        p.client.SetCache(c)
×
354
}
×
355

356
// GetCache returns the cache for this provider.
357
func (p *Provider) GetCache() *cache.Cache {
×
358
        return p.cache
×
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