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

valksor / go-mehrhof / 20794239058

07 Jan 2026 07:39PM UTC coverage: 45.236% (+0.1%) from 45.098%
20794239058

push

github

k0d3r1s
Updates sync command documentation

Updates sync command documentation to reflect new change detection capabilities including labels and assignees. Reorganizes provider list into Local Providers (file, directory, empty) and External Providers (github, wrike, gitlab, jira, linear) categories for better clarity. Adds detailed descriptions of what each provider detects during sync operations.

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

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

15744 of 34804 relevant lines covered (45.24%)

5.59 hits per line

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

55.52
/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✔
81
                return nil, err
×
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
        // Add milestone if present
×
236
        if issue.Milestone != nil && issue.Milestone.GetTitle() != "" {
×
237
                wu.Metadata["milestone"] = issue.Milestone.GetTitle()
×
238
        }
×
239

240
        // Fetch comments if available
241
        comments, err := p.client.GetIssueComments(ctx, ref.IssueNumber)
×
242
        if err == nil && len(comments) > 0 {
×
243
                wu.Comments = mapComments(comments)
×
244
        }
×
245

246
        // Extract linked issues
247
        linkedNums := ExtractLinkedIssues(issue.GetBody())
×
248
        if len(linkedNums) > 0 {
×
249
                wu.Metadata["linked_issues"] = linkedNums
×
250
        }
×
251

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

265
        return wu, nil
×
266
}
267

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

275
        owner := ref.Owner
×
276
        repo := ref.Repo
×
277
        if owner == "" {
×
278
                owner = p.owner
×
279
        }
×
280
        if repo == "" {
×
281
                repo = p.repo
×
282
        }
×
283
        if owner == "" || repo == "" {
×
284
                return nil, ErrRepoNotConfigured
×
285
        }
×
286

287
        p.client.SetOwnerRepo(owner, repo)
×
288

×
289
        // Fetch main issue
×
290
        issue, err := p.client.GetIssue(ctx, ref.IssueNumber)
×
291
        if err != nil {
×
292
                return nil, err
×
293
        }
×
294

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

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

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

331
        return snapshot, nil
×
332
}
333

334
// GetConfig returns the provider configuration.
335
func (p *Provider) GetConfig() *Config {
×
336
        return p.config
×
337
}
×
338

339
// GetClient returns the GitHub API client.
340
func (p *Provider) GetClient() *Client {
×
341
        return p.client
×
342
}
×
343

344
// SetCache sets or replaces the cache for this provider and its client.
345
func (p *Provider) SetCache(c *cache.Cache) {
×
346
        p.cache = c
×
347
        p.client.SetCache(c)
×
348
}
×
349

350
// GetCache returns the cache for this provider.
351
func (p *Provider) GetCache() *cache.Cache {
×
352
        return p.cache
×
353
}
×
354

355
// --- Helper functions ---
356

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

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

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

391
        return "issue"
7✔
392
}
393

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

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

412
        return provider.PriorityNormal
9✔
413
}
414

415
func extractLabelNames(labels []*gh.Label) []string {
11✔
416
        names := make([]string, len(labels))
11✔
417
        for i, label := range labels {
18✔
418
                names[i] = label.GetName()
7✔
419
        }
7✔
420

421
        return names
11✔
422
}
423

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

434
        return persons
10✔
435
}
436

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

451
        return result
3✔
452
}
453

454
func formatIssueMarkdown(issue *gh.Issue) string {
3✔
455
        var sb strings.Builder
3✔
456

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

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

3✔
465
        if issue.GetUser() != nil {
4✔
466
                sb.WriteString(fmt.Sprintf("- **Author:** @%s\n", issue.GetUser().GetLogin()))
1✔
467
        }
1✔
468

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

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

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

3✔
487
        // Body
3✔
488
        sb.WriteString("\n## Description\n\n")
3✔
489
        sb.WriteString(issue.GetBody())
3✔
490
        sb.WriteString("\n")
3✔
491

3✔
492
        return sb.String()
3✔
493
}
494

495
func formatCommentsMarkdown(comments []*gh.IssueComment) string {
2✔
496
        var sb strings.Builder
2✔
497

2✔
498
        sb.WriteString("# Comments\n\n")
2✔
499

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

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