webctx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +97 -0
  2. package/CONTRIBUTORS.md +126 -0
  3. package/LICENSE +202 -0
  4. package/Makefile +69 -0
  5. package/README.md +121 -0
  6. package/bin/webctx.js +28 -0
  7. package/cmd/webctx/main.go +11 -0
  8. package/docs/porting-status.md +173 -0
  9. package/go.mod +3 -0
  10. package/internal/app/app.go +139 -0
  11. package/internal/app/app_test.go +194 -0
  12. package/internal/app/github.go +2084 -0
  13. package/internal/app/github_acceptance_test.go +166 -0
  14. package/internal/app/github_actions.go +1008 -0
  15. package/internal/app/github_actions_test.go +484 -0
  16. package/internal/app/github_activity_deployments.go +503 -0
  17. package/internal/app/github_activity_deployments_test.go +321 -0
  18. package/internal/app/github_commits.go +1004 -0
  19. package/internal/app/github_commits_test.go +503 -0
  20. package/internal/app/github_discussions_gists.go +1156 -0
  21. package/internal/app/github_discussions_gists_test.go +425 -0
  22. package/internal/app/github_issues.go +1616 -0
  23. package/internal/app/github_issues_test.go +731 -0
  24. package/internal/app/github_packages_projects.go +429 -0
  25. package/internal/app/github_packages_projects_test.go +168 -0
  26. package/internal/app/github_phase9_overview_stress_test.go +178 -0
  27. package/internal/app/github_pull_views.go +1141 -0
  28. package/internal/app/github_pull_views_test.go +603 -0
  29. package/internal/app/github_pulls.go +1459 -0
  30. package/internal/app/github_pulls_test.go +715 -0
  31. package/internal/app/github_refs.go +621 -0
  32. package/internal/app/github_refs_test.go +260 -0
  33. package/internal/app/github_search_profiles.go +505 -0
  34. package/internal/app/github_search_profiles_test.go +352 -0
  35. package/internal/app/github_test.go +888 -0
  36. package/internal/app/scrape.go +264 -0
  37. package/internal/app/tools.go +670 -0
  38. package/internal/buildinfo/buildinfo.go +16 -0
  39. package/package.json +66 -0
  40. package/scripts/postinstall.js +137 -0
@@ -0,0 +1,2084 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "path"
14
+ "regexp"
15
+ "sort"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+ "unicode"
20
+ "unicode/utf8"
21
+ )
22
+
23
+ const (
24
+ githubRESTAPIVersion = "2026-03-10"
25
+ githubDefaultAPIBase = "https://api.github.com"
26
+ githubDefaultRawBase = "https://raw.githubusercontent.com"
27
+ githubOverviewRunes = 5000
28
+ // Pageable list readers render every provider row; keep the provider page
29
+ // itself small instead of fetching a larger page and creating an unreachable
30
+ // locally omitted tail before the next GitHub page.
31
+ githubPageableListSize = 8
32
+ githubRootPreviewRunes = githubOverviewRunes
33
+ githubIndexPreviewRunes = 240
34
+ githubBlobMaxBytes = int64(100 * 1024 * 1024)
35
+ )
36
+
37
+ type GitHubTargetKind string
38
+
39
+ const (
40
+ GitHubTargetRepository GitHubTargetKind = "repository"
41
+ GitHubTargetBlob GitHubTargetKind = "blob"
42
+ GitHubTargetTree GitHubTargetKind = "tree"
43
+ GitHubTargetIssue GitHubTargetKind = "issue"
44
+ GitHubTargetIssueList GitHubTargetKind = "issue_list"
45
+ GitHubTargetPullList GitHubTargetKind = "pull_list"
46
+ GitHubTargetLabel GitHubTargetKind = "label"
47
+ GitHubTargetLabelList GitHubTargetKind = "label_list"
48
+ GitHubTargetMilestone GitHubTargetKind = "milestone"
49
+ GitHubTargetMilestones GitHubTargetKind = "milestones"
50
+ GitHubTargetPull GitHubTargetKind = "pull"
51
+ GitHubTargetPullFiles GitHubTargetKind = "pull_files"
52
+ GitHubTargetPullCommits GitHubTargetKind = "pull_commits"
53
+ GitHubTargetPullChecks GitHubTargetKind = "pull_checks"
54
+ GitHubTargetPullDiff GitHubTargetKind = "pull_diff"
55
+ GitHubTargetPullPatch GitHubTargetKind = "pull_patch"
56
+ GitHubTargetCommit GitHubTargetKind = "commit"
57
+ GitHubTargetCommitDiff GitHubTargetKind = "commit_diff"
58
+ GitHubTargetCommitPatch GitHubTargetKind = "commit_patch"
59
+ GitHubTargetCompare GitHubTargetKind = "compare"
60
+ GitHubTargetCompareDiff GitHubTargetKind = "compare_diff"
61
+ GitHubTargetComparePatch GitHubTargetKind = "compare_patch"
62
+ GitHubTargetHistory GitHubTargetKind = "history"
63
+ GitHubTargetBlame GitHubTargetKind = "blame"
64
+ GitHubTargetActions GitHubTargetKind = "actions"
65
+ GitHubTargetWorkflows GitHubTargetKind = "actions_workflows"
66
+ GitHubTargetWorkflow GitHubTargetKind = "actions_workflow"
67
+ GitHubTargetActionsRun GitHubTargetKind = "actions_run"
68
+ GitHubTargetActionsJob GitHubTargetKind = "actions_job"
69
+ GitHubTargetBranches GitHubTargetKind = "branches"
70
+ GitHubTargetTags GitHubTargetKind = "tags"
71
+ GitHubTargetReleases GitHubTargetKind = "releases"
72
+ GitHubTargetRelease GitHubTargetKind = "release"
73
+ GitHubTargetReleaseLatest GitHubTargetKind = "release_latest"
74
+ GitHubTargetForks GitHubTargetKind = "forks"
75
+ GitHubTargetStargazers GitHubTargetKind = "stargazers"
76
+ GitHubTargetWatchers GitHubTargetKind = "watchers"
77
+ GitHubTargetDiscussions GitHubTargetKind = "discussions"
78
+ GitHubTargetDiscussion GitHubTargetKind = "discussion"
79
+ GitHubTargetGist GitHubTargetKind = "gist"
80
+ GitHubTargetSearch GitHubTargetKind = "search"
81
+ GitHubTargetProfile GitHubTargetKind = "profile"
82
+ GitHubTargetActivity GitHubTargetKind = "activity"
83
+ GitHubTargetStatsContributors GitHubTargetKind = "stats_contributors"
84
+ GitHubTargetStatsCommitActivity GitHubTargetKind = "stats_commit_activity"
85
+ GitHubTargetStatsCodeFrequency GitHubTargetKind = "stats_code_frequency"
86
+ GitHubTargetDeployments GitHubTargetKind = "deployments"
87
+ GitHubTargetDeploymentEnvironment GitHubTargetKind = "deployment_environment"
88
+ GitHubTargetPackage GitHubTargetKind = "package"
89
+ GitHubTargetProjectV2 GitHubTargetKind = "project_v2"
90
+ )
91
+
92
+ // GitHubTarget is the semantic identity parsed from a GitHub URL. Blob/tree
93
+ // tails deliberately stay unresolved until a provider-backed reader needs to
94
+ // distinguish the ref from the repository path.
95
+ type GitHubTarget struct {
96
+ Host string
97
+ Owner string
98
+ Repo string
99
+ Kind GitHubTargetKind
100
+ Tail []string
101
+ Number int
102
+ Name string
103
+ RunID int64
104
+ JobID int64
105
+ Fragment string
106
+ Query url.Values
107
+ OriginalURL string
108
+ CanonicalURL string
109
+ }
110
+
111
+ type GitHubNativeOutcome int
112
+
113
+ const (
114
+ GitHubNativeUnsupported GitHubNativeOutcome = iota
115
+ GitHubNativeSuccess
116
+ GitHubNativeFailure
117
+ )
118
+
119
+ type GitHubNativeResult struct {
120
+ Outcome GitHubNativeOutcome
121
+ Markdown string
122
+ Err error
123
+ }
124
+
125
+ type githubHTTPDoer interface {
126
+ Do(*http.Request) (*http.Response, error)
127
+ }
128
+
129
+ // GitHubClient is the single GitHub provider boundary. It preserves status,
130
+ // headers, and response bodies, pins the REST version, owns optional token
131
+ // selection, and exposes Link pagination primitives without persistent cache.
132
+ type GitHubClient struct {
133
+ httpClient githubHTTPDoer
134
+ apiBase string
135
+ rawBase string
136
+ token string
137
+ userAgent string
138
+ }
139
+
140
+ type GitHubResponse struct {
141
+ StatusCode int
142
+ Header http.Header
143
+ Body []byte
144
+ FinalURL string
145
+ TooLarge bool
146
+ }
147
+
148
+ type GitHubErrorKind string
149
+
150
+ const (
151
+ GitHubErrorNotFound GitHubErrorKind = "not_found"
152
+ GitHubErrorAuthentication GitHubErrorKind = "authentication"
153
+ GitHubErrorForbidden GitHubErrorKind = "forbidden"
154
+ GitHubErrorRateLimit GitHubErrorKind = "rate_limit"
155
+ GitHubErrorProviderLimit GitHubErrorKind = "provider_limit"
156
+ GitHubErrorGone GitHubErrorKind = "gone"
157
+ GitHubErrorHTTP GitHubErrorKind = "http"
158
+ )
159
+
160
+ type GitHubError struct {
161
+ Kind GitHubErrorKind
162
+ StatusCode int
163
+ HasToken bool
164
+ RetryAfter string
165
+ RateReset string
166
+ RateResource string
167
+ ProviderLimit string
168
+ }
169
+
170
+ func (e *GitHubError) Error() string {
171
+ if e == nil {
172
+ return "GitHub request failed"
173
+ }
174
+ status := ""
175
+ if e.StatusCode != 0 {
176
+ status = fmt.Sprintf(" (%d)", e.StatusCode)
177
+ }
178
+ switch e.Kind {
179
+ case GitHubErrorNotFound:
180
+ if e.HasToken {
181
+ return "GitHub resource was not found or is inaccessible with the configured token" + status + "."
182
+ }
183
+ return "GitHub resource was not found or may be private" + status + ". Hint: set GH_TOKEN or GITHUB_TOKEN to read resources your account can access."
184
+ case GitHubErrorAuthentication:
185
+ if e.HasToken {
186
+ return "GitHub authentication failed" + status + ". Check GH_TOKEN or GITHUB_TOKEN."
187
+ }
188
+ return "GitHub authentication is required" + status + ". Set GH_TOKEN or GITHUB_TOKEN."
189
+ case GitHubErrorForbidden:
190
+ if e.HasToken {
191
+ return "GitHub denied access to this resource" + status + ". Check the configured token permissions."
192
+ }
193
+ return "GitHub denied access to this resource" + status + ". Hint: set GH_TOKEN or GITHUB_TOKEN if the resource requires authentication."
194
+ case GitHubErrorRateLimit:
195
+ parts := []string{"GitHub rate limit exceeded" + status + "."}
196
+ if e.RateResource != "" {
197
+ parts = append(parts, "Resource: "+e.RateResource+".")
198
+ }
199
+ if e.RetryAfter != "" {
200
+ parts = append(parts, "Retry after: "+e.RetryAfter+".")
201
+ }
202
+ if e.RateReset != "" {
203
+ parts = append(parts, "Reset: "+e.RateReset+".")
204
+ }
205
+ if !e.HasToken {
206
+ parts = append(parts, "Hint: set GH_TOKEN or GITHUB_TOKEN for authenticated GitHub capacity.")
207
+ }
208
+ return strings.Join(parts, " ")
209
+ case GitHubErrorProviderLimit:
210
+ if e.ProviderLimit != "" {
211
+ return "GitHub provider limit: " + e.ProviderLimit + "."
212
+ }
213
+ return "GitHub provider limit prevented a complete read" + status + "."
214
+ case GitHubErrorGone:
215
+ return "GitHub resource is no longer available" + status + "."
216
+ default:
217
+ return "GitHub request failed" + status + "."
218
+ }
219
+ }
220
+
221
+ type GitHubLinkRelations map[string]string
222
+
223
+ func splitGitHubPath(raw string) []string {
224
+ trimmed := strings.Trim(raw, "/")
225
+ if trimmed == "" {
226
+ return nil
227
+ }
228
+ parts := strings.Split(trimmed, "/")
229
+ out := make([]string, 0, len(parts))
230
+ for _, part := range parts {
231
+ if part != "" {
232
+ out = append(out, part)
233
+ }
234
+ }
235
+ return out
236
+ }
237
+
238
+ func cloneURLValues(in url.Values) url.Values {
239
+ out := make(url.Values, len(in))
240
+ for key, values := range in {
241
+ out[key] = append([]string(nil), values...)
242
+ }
243
+ return out
244
+ }
245
+
246
+ func parseGitHubTarget(raw string) *GitHubTarget {
247
+ parsed, err := url.Parse(raw)
248
+ if err != nil {
249
+ return nil
250
+ }
251
+ if strings.EqualFold(parsed.Hostname(), "gist.github.com") {
252
+ parts := splitGitHubPath(parsed.Path)
253
+ if len(parts) == 1 {
254
+ gistID := parts[0]
255
+ if !githubBareGistIDRE.MatchString(gistID) {
256
+ return nil
257
+ }
258
+ return &GitHubTarget{
259
+ Host: "gist.github.com",
260
+ Kind: GitHubTargetGist,
261
+ Name: gistID,
262
+ OriginalURL: raw,
263
+ CanonicalURL: parsed.String(),
264
+ Query: cloneURLValues(parsed.Query()),
265
+ Fragment: parsed.Fragment,
266
+ }
267
+ }
268
+ if len(parts) < 2 || len(parts) > 3 {
269
+ return nil
270
+ }
271
+ gistID := parts[1]
272
+ if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(gistID) == "" || gistID == "raw" {
273
+ return nil
274
+ }
275
+ target := &GitHubTarget{
276
+ Host: "gist.github.com",
277
+ Owner: parts[0],
278
+ Kind: GitHubTargetGist,
279
+ Name: gistID,
280
+ OriginalURL: raw,
281
+ CanonicalURL: parsed.String(),
282
+ Query: cloneURLValues(parsed.Query()),
283
+ Fragment: parsed.Fragment,
284
+ }
285
+ if len(parts) == 3 {
286
+ if parts[2] == "raw" {
287
+ return nil
288
+ }
289
+ target.Tail = []string{parts[2]}
290
+ }
291
+ return target
292
+ }
293
+ if !strings.EqualFold(parsed.Hostname(), "github.com") {
294
+ return nil
295
+ }
296
+ parts := splitGitHubPath(parsed.Path)
297
+ if len(parts) == 0 {
298
+ return nil
299
+ }
300
+
301
+ // Global namespaces are resolved before the generic owner/repository shape
302
+ // so pages such as /settings/profile can never be misclassified as a repo.
303
+ if parts[0] == "search" {
304
+ if len(parts) != 1 || !isSupportedGitHubSearchURLQuery(parsed.Query()) {
305
+ return nil
306
+ }
307
+ return &GitHubTarget{Host: "github.com", Kind: GitHubTargetSearch, Query: cloneURLValues(parsed.Query()), Fragment: parsed.Fragment, OriginalURL: raw, CanonicalURL: parsed.String()}
308
+ }
309
+ if (parts[0] == "orgs" || parts[0] == "users") && len(parts) >= 2 {
310
+ scope := strings.TrimSuffix(parts[0], "s")
311
+ owner := parts[1]
312
+ if len(parts) == 3 && parts[0] == "orgs" && parts[2] == "people" {
313
+ return &GitHubTarget{Host: "github.com", Owner: owner, Kind: GitHubTargetProfile, Name: "people", Query: cloneURLValues(parsed.Query()), Fragment: parsed.Fragment, OriginalURL: raw, CanonicalURL: parsed.String()}
314
+ }
315
+ if len(parts) == 4 && parts[2] == "projects" {
316
+ n, err := strconv.Atoi(parts[3])
317
+ if err == nil && n > 0 {
318
+ return &GitHubTarget{Host: "github.com", Owner: owner, Kind: GitHubTargetProjectV2, Number: n, Tail: []string{scope}, Query: cloneURLValues(parsed.Query()), Fragment: parsed.Fragment, OriginalURL: raw, CanonicalURL: parsed.String()}
319
+ }
320
+ }
321
+ if len(parts) == 6 && parts[2] == "packages" && parts[4] == "package" && isSupportedGitHubPackageType(parts[3]) {
322
+ name, err := url.PathUnescape(parts[5])
323
+ if err == nil && strings.TrimSpace(name) != "" {
324
+ return &GitHubTarget{Host: "github.com", Owner: owner, Kind: GitHubTargetPackage, Name: name, Tail: []string{scope, parts[3]}, Query: cloneURLValues(parsed.Query()), Fragment: parsed.Fragment, OriginalURL: raw, CanonicalURL: parsed.String()}
325
+ }
326
+ }
327
+ return nil
328
+ }
329
+
330
+ reserved := map[string]struct{}{
331
+ "settings": {}, "login": {}, "logout": {}, "join": {}, "marketplace": {}, "notifications": {}, "codespaces": {}, "features": {}, "enterprise": {}, "sponsors": {}, "topics": {}, "trending": {}, "collections": {}, "events": {}, "security": {}, "site": {}, "about": {}, "pricing": {}, "organizations": {},
332
+ }
333
+ if _, blocked := reserved[parts[0]]; blocked {
334
+ return nil
335
+ }
336
+ if len(parts) == 1 {
337
+ if !isSupportedGitHubProfileURLQuery(parsed.Query()) {
338
+ return nil
339
+ }
340
+ return &GitHubTarget{Host: "github.com", Owner: parts[0], Kind: GitHubTargetProfile, Query: cloneURLValues(parsed.Query()), Fragment: parsed.Fragment, OriginalURL: raw, CanonicalURL: parsed.String()}
341
+ }
342
+ if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
343
+ return nil
344
+ }
345
+ target := &GitHubTarget{
346
+ Host: "github.com",
347
+ Owner: parts[0],
348
+ Repo: parts[1],
349
+ Fragment: parsed.Fragment,
350
+ Query: cloneURLValues(parsed.Query()),
351
+ OriginalURL: raw,
352
+ CanonicalURL: parsed.String(),
353
+ }
354
+ if len(parts) == 2 {
355
+ target.Kind = GitHubTargetRepository
356
+ return target
357
+ }
358
+ switch parts[2] {
359
+ case "search":
360
+ if len(parts) == 3 && isSupportedGitHubSearchURLQuery(parsed.Query()) {
361
+ target.Kind = GitHubTargetSearch
362
+ return target
363
+ }
364
+ return nil
365
+ case "blob":
366
+ target.Kind = GitHubTargetBlob
367
+ target.Tail = append([]string(nil), parts[3:]...)
368
+ return target
369
+ case "tree":
370
+ target.Kind = GitHubTargetTree
371
+ target.Tail = append([]string(nil), parts[3:]...)
372
+ return target
373
+ case "issues":
374
+ if len(parts) == 3 {
375
+ target.Kind = GitHubTargetIssueList
376
+ return target
377
+ }
378
+ if len(parts) == 4 {
379
+ number, err := strconv.Atoi(parts[3])
380
+ if err == nil && number > 0 {
381
+ target.Kind = GitHubTargetIssue
382
+ target.Number = number
383
+ return target
384
+ }
385
+ }
386
+ return nil
387
+ case "pulls":
388
+ if len(parts) == 3 {
389
+ target.Kind = GitHubTargetPullList
390
+ return target
391
+ }
392
+ return nil
393
+ case "labels":
394
+ if len(parts) == 3 {
395
+ target.Kind = GitHubTargetLabelList
396
+ return target
397
+ }
398
+ if len(parts) >= 4 {
399
+ name, err := url.PathUnescape(strings.Join(parts[3:], "/"))
400
+ if err == nil && strings.TrimSpace(name) != "" {
401
+ target.Kind = GitHubTargetLabel
402
+ target.Name = name
403
+ return target
404
+ }
405
+ }
406
+ return nil
407
+ case "milestones":
408
+ if len(parts) == 3 {
409
+ target.Kind = GitHubTargetMilestones
410
+ return target
411
+ }
412
+ return nil
413
+ case "milestone":
414
+ if len(parts) == 4 {
415
+ number, err := strconv.Atoi(parts[3])
416
+ if err == nil && number > 0 {
417
+ target.Kind = GitHubTargetMilestone
418
+ target.Number = number
419
+ return target
420
+ }
421
+ }
422
+ return nil
423
+ case "pull":
424
+ if len(parts) == 4 {
425
+ rawNumber := parts[3]
426
+ kind := GitHubTargetPull
427
+ switch {
428
+ case strings.HasSuffix(rawNumber, ".diff"):
429
+ kind = GitHubTargetPullDiff
430
+ rawNumber = strings.TrimSuffix(rawNumber, ".diff")
431
+ case strings.HasSuffix(rawNumber, ".patch"):
432
+ kind = GitHubTargetPullPatch
433
+ rawNumber = strings.TrimSuffix(rawNumber, ".patch")
434
+ }
435
+ number, err := strconv.Atoi(rawNumber)
436
+ if err == nil && number > 0 {
437
+ target.Kind = kind
438
+ target.Number = number
439
+ return target
440
+ }
441
+ }
442
+ if len(parts) == 5 {
443
+ number, err := strconv.Atoi(parts[3])
444
+ if err != nil || number <= 0 {
445
+ return nil
446
+ }
447
+ switch parts[4] {
448
+ case "files":
449
+ target.Kind = GitHubTargetPullFiles
450
+ case "commits":
451
+ target.Kind = GitHubTargetPullCommits
452
+ case "checks":
453
+ target.Kind = GitHubTargetPullChecks
454
+ default:
455
+ return nil
456
+ }
457
+ target.Number = number
458
+ return target
459
+ }
460
+ return nil
461
+ case "commit":
462
+ if len(parts) != 4 {
463
+ return nil
464
+ }
465
+ ref := parts[3]
466
+ kind := GitHubTargetCommit
467
+ switch {
468
+ case strings.HasSuffix(ref, ".diff"):
469
+ kind = GitHubTargetCommitDiff
470
+ ref = strings.TrimSuffix(ref, ".diff")
471
+ case strings.HasSuffix(ref, ".patch"):
472
+ kind = GitHubTargetCommitPatch
473
+ ref = strings.TrimSuffix(ref, ".patch")
474
+ }
475
+ if strings.TrimSpace(ref) == "" {
476
+ return nil
477
+ }
478
+ target.Kind = kind
479
+ target.Name = ref
480
+ return target
481
+ case "compare":
482
+ if len(parts) < 4 {
483
+ return nil
484
+ }
485
+ comparison := strings.Join(parts[3:], "/")
486
+ kind := GitHubTargetCompare
487
+ switch {
488
+ case strings.HasSuffix(comparison, ".diff"):
489
+ kind = GitHubTargetCompareDiff
490
+ comparison = strings.TrimSuffix(comparison, ".diff")
491
+ case strings.HasSuffix(comparison, ".patch"):
492
+ kind = GitHubTargetComparePatch
493
+ comparison = strings.TrimSuffix(comparison, ".patch")
494
+ }
495
+ base, head, ok := strings.Cut(comparison, "...")
496
+ if !ok || strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" {
497
+ return nil
498
+ }
499
+ target.Kind = kind
500
+ target.Tail = []string{base, head}
501
+ return target
502
+ case "commits":
503
+ if len(parts) < 4 {
504
+ return nil
505
+ }
506
+ target.Kind = GitHubTargetHistory
507
+ target.Tail = append([]string(nil), parts[3:]...)
508
+ return target
509
+ case "blame":
510
+ if len(parts) < 5 {
511
+ return nil
512
+ }
513
+ target.Kind = GitHubTargetBlame
514
+ target.Tail = append([]string(nil), parts[3:]...)
515
+ return target
516
+ case "actions":
517
+ if len(parts) == 3 {
518
+ target.Kind = GitHubTargetActions
519
+ return target
520
+ }
521
+ switch parts[3] {
522
+ case "workflows":
523
+ if len(parts) == 4 {
524
+ target.Kind = GitHubTargetWorkflows
525
+ return target
526
+ }
527
+ if len(parts) == 5 {
528
+ workflow, err := url.PathUnescape(parts[4])
529
+ if err == nil && strings.TrimSpace(workflow) != "" {
530
+ target.Kind = GitHubTargetWorkflow
531
+ target.Name = workflow
532
+ return target
533
+ }
534
+ }
535
+ case "runs":
536
+ if len(parts) < 5 {
537
+ return nil
538
+ }
539
+ runID, err := strconv.ParseInt(parts[4], 10, 64)
540
+ if err != nil || runID <= 0 {
541
+ return nil
542
+ }
543
+ if len(parts) == 5 {
544
+ target.Kind = GitHubTargetActionsRun
545
+ target.RunID = runID
546
+ return target
547
+ }
548
+ if len(parts) == 7 && parts[5] == "job" {
549
+ jobID, err := strconv.ParseInt(parts[6], 10, 64)
550
+ if err == nil && jobID > 0 {
551
+ target.Kind = GitHubTargetActionsJob
552
+ target.RunID = runID
553
+ target.JobID = jobID
554
+ return target
555
+ }
556
+ }
557
+ }
558
+ return nil
559
+ case "branches":
560
+ if len(parts) == 3 {
561
+ target.Kind = GitHubTargetBranches
562
+ return target
563
+ }
564
+ return nil
565
+ case "tags":
566
+ if len(parts) == 3 {
567
+ target.Kind = GitHubTargetTags
568
+ return target
569
+ }
570
+ return nil
571
+ case "releases":
572
+ if len(parts) == 3 {
573
+ target.Kind = GitHubTargetReleases
574
+ return target
575
+ }
576
+ if len(parts) == 4 && parts[3] == "latest" {
577
+ target.Kind = GitHubTargetReleaseLatest
578
+ return target
579
+ }
580
+ if len(parts) >= 5 && parts[3] == "tag" {
581
+ tag, err := url.PathUnescape(strings.Join(parts[4:], "/"))
582
+ if err == nil && strings.TrimSpace(tag) != "" {
583
+ target.Kind = GitHubTargetRelease
584
+ target.Name = tag
585
+ return target
586
+ }
587
+ }
588
+ return nil
589
+ case "forks":
590
+ if len(parts) == 3 {
591
+ target.Kind = GitHubTargetForks
592
+ return target
593
+ }
594
+ return nil
595
+ case "stargazers":
596
+ if len(parts) == 3 {
597
+ target.Kind = GitHubTargetStargazers
598
+ return target
599
+ }
600
+ return nil
601
+ case "watchers":
602
+ if len(parts) == 3 {
603
+ target.Kind = GitHubTargetWatchers
604
+ return target
605
+ }
606
+ return nil
607
+ case "discussions":
608
+ if len(parts) == 3 {
609
+ target.Kind = GitHubTargetDiscussions
610
+ return target
611
+ }
612
+ if len(parts) == 4 {
613
+ number, err := strconv.Atoi(parts[3])
614
+ if err == nil && number > 0 {
615
+ target.Kind = GitHubTargetDiscussion
616
+ target.Number = number
617
+ return target
618
+ }
619
+ }
620
+ return nil
621
+ case "activity":
622
+ if len(parts) == 3 {
623
+ target.Kind = GitHubTargetActivity
624
+ return target
625
+ }
626
+ return nil
627
+ case "graphs":
628
+ if len(parts) != 4 {
629
+ return nil
630
+ }
631
+ switch parts[3] {
632
+ case "contributors":
633
+ target.Kind = GitHubTargetStatsContributors
634
+ case "commit-activity":
635
+ target.Kind = GitHubTargetStatsCommitActivity
636
+ case "code-frequency":
637
+ target.Kind = GitHubTargetStatsCodeFrequency
638
+ default:
639
+ return nil
640
+ }
641
+ return target
642
+ case "deployments":
643
+ if len(parts) == 3 {
644
+ target.Kind = GitHubTargetDeployments
645
+ return target
646
+ }
647
+ if len(parts) >= 4 {
648
+ name, err := url.PathUnescape(strings.Join(parts[3:], "/"))
649
+ if err == nil && strings.TrimSpace(name) != "" {
650
+ target.Kind = GitHubTargetDeploymentEnvironment
651
+ target.Name = name
652
+ return target
653
+ }
654
+ }
655
+ return nil
656
+ default:
657
+ // Security pages and every route family not yet implemented remain on
658
+ // the existing generic markdown/Firecrawl fallback path.
659
+ return nil
660
+ }
661
+ }
662
+
663
+ func newGitHubClient() *GitHubClient {
664
+ return &GitHubClient{
665
+ httpClient: http.DefaultClient,
666
+ apiBase: githubDefaultAPIBase,
667
+ rawBase: githubDefaultRawBase,
668
+ token: githubTokenFromEnv(),
669
+ userAgent: "webctx/" + version,
670
+ }
671
+ }
672
+
673
+ func githubTokenFromEnv() string {
674
+ if value := strings.TrimSpace(os.Getenv("GH_TOKEN")); value != "" {
675
+ return value
676
+ }
677
+ return strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
678
+ }
679
+
680
+ func (c *GitHubClient) hasToken() bool {
681
+ return c != nil && strings.TrimSpace(c.token) != ""
682
+ }
683
+
684
+ func (c *GitHubClient) REST(ctx context.Context, method, endpoint, accept string) (GitHubResponse, error) {
685
+ if c == nil {
686
+ return GitHubResponse{}, fmt.Errorf("GitHub client is nil")
687
+ }
688
+ rawURL := endpoint
689
+ if strings.HasPrefix(endpoint, "/") {
690
+ rawURL = strings.TrimRight(c.apiBase, "/") + endpoint
691
+ }
692
+ if !sameOrigin(rawURL, c.apiBase) {
693
+ return GitHubResponse{}, fmt.Errorf("refusing GitHub API request to an untrusted origin")
694
+ }
695
+ if accept == "" {
696
+ accept = "application/vnd.github+json"
697
+ }
698
+ return c.request(ctx, method, rawURL, accept, true, c.hasToken())
699
+ }
700
+
701
+ // RESTPublic is for provider endpoints that GitHub documents as anonymously
702
+ // readable for public resources. A narrow fine-grained token can be rejected
703
+ // by such an endpoint even though the same public URL is readable without a
704
+ // token, so retry token-specific auth/permission/not-found failures once
705
+ // without Authorization. Private/auth-required callers should use REST.
706
+ func (c *GitHubClient) RESTPublic(ctx context.Context, method, endpoint, accept string) (GitHubResponse, error) {
707
+ resp, err := c.REST(ctx, method, endpoint, accept)
708
+ if err == nil || method != http.MethodGet || !c.hasToken() || !githubShouldRetryAnonymous(err) {
709
+ return resp, err
710
+ }
711
+ rawURL := endpoint
712
+ if strings.HasPrefix(endpoint, "/") {
713
+ rawURL = strings.TrimRight(c.apiBase, "/") + endpoint
714
+ }
715
+ anonymous, anonymousErr := c.request(ctx, method, rawURL, accept, true, false)
716
+ if anonymousErr == nil {
717
+ return anonymous, nil
718
+ }
719
+ return resp, err
720
+ }
721
+
722
+ func githubShouldRetryAnonymous(err error) bool {
723
+ ghErr, ok := err.(*GitHubError)
724
+ if !ok {
725
+ return false
726
+ }
727
+ switch ghErr.Kind {
728
+ case GitHubErrorAuthentication, GitHubErrorForbidden, GitHubErrorNotFound:
729
+ return true
730
+ default:
731
+ return false
732
+ }
733
+ }
734
+
735
+ // RESTPages follows GitHub-provided Link rel=next URLs until the selected
736
+ // resource is complete. It deliberately does not synthesize page numbers.
737
+ func (c *GitHubClient) RESTPages(ctx context.Context, endpoint, accept string) ([]GitHubResponse, error) {
738
+ pages := []GitHubResponse{}
739
+ next := endpoint
740
+ seen := map[string]struct{}{}
741
+ for strings.TrimSpace(next) != "" {
742
+ if _, ok := seen[next]; ok {
743
+ return nil, fmt.Errorf("GitHub pagination returned a cycle at %s", next)
744
+ }
745
+ seen[next] = struct{}{}
746
+ resp, err := c.REST(ctx, http.MethodGet, next, accept)
747
+ if err != nil {
748
+ return nil, err
749
+ }
750
+ pages = append(pages, resp)
751
+ next = resp.Links()["next"]
752
+ }
753
+ return pages, nil
754
+ }
755
+
756
+ // GraphQL performs an authenticated GitHub GraphQL query using the same
757
+ // injected HTTP boundary as REST. GraphQL is capability-specific enrichment;
758
+ // callers decide whether a failure is fatal for the selected native resource.
759
+ func (c *GitHubClient) GraphQL(ctx context.Context, query string, variables map[string]any, result any) error {
760
+ if c == nil {
761
+ return fmt.Errorf("GitHub client is nil")
762
+ }
763
+ if !c.hasToken() {
764
+ return &GitHubError{Kind: GitHubErrorAuthentication, StatusCode: http.StatusUnauthorized, HasToken: false}
765
+ }
766
+ endpoint := strings.TrimRight(c.apiBase, "/") + "/graphql"
767
+ if !sameOrigin(endpoint, c.apiBase) {
768
+ return fmt.Errorf("refusing GitHub GraphQL request to an untrusted origin")
769
+ }
770
+ payload, err := json.Marshal(map[string]any{"query": query, "variables": variables})
771
+ if err != nil {
772
+ return err
773
+ }
774
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
775
+ if err != nil {
776
+ return err
777
+ }
778
+ req.Header.Set("Accept", "application/json")
779
+ req.Header.Set("Content-Type", "application/json")
780
+ if strings.TrimSpace(c.userAgent) != "" {
781
+ req.Header.Set("User-Agent", c.userAgent)
782
+ }
783
+ req.Header.Set("Authorization", "Bearer "+c.token)
784
+
785
+ resp, err := c.httpClient.Do(req)
786
+ if err != nil {
787
+ return err
788
+ }
789
+ defer resp.Body.Close()
790
+ body, err := io.ReadAll(io.LimitReader(resp.Body, githubBlobMaxBytes+1))
791
+ if err != nil {
792
+ return err
793
+ }
794
+ providerResp := GitHubResponse{StatusCode: resp.StatusCode, Header: resp.Header.Clone(), Body: body}
795
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
796
+ return classifyGitHubError(providerResp, true)
797
+ }
798
+ var envelope struct {
799
+ Data json.RawMessage `json:"data"`
800
+ Errors []struct {
801
+ Message string `json:"message"`
802
+ } `json:"errors"`
803
+ }
804
+ if err := json.Unmarshal(body, &envelope); err != nil {
805
+ return fmt.Errorf("decode GitHub GraphQL response: %w", err)
806
+ }
807
+ if len(envelope.Errors) > 0 {
808
+ // Do not echo provider error bodies because auth/private responses can
809
+ // contain details that should not become normal CLI output.
810
+ return fmt.Errorf("GitHub GraphQL query failed")
811
+ }
812
+ if result == nil {
813
+ return nil
814
+ }
815
+ if err := json.Unmarshal(envelope.Data, result); err != nil {
816
+ return fmt.Errorf("decode GitHub GraphQL data: %w", err)
817
+ }
818
+ return nil
819
+ }
820
+
821
+ func (c *GitHubClient) Raw(ctx context.Context, rawURL string) (GitHubResponse, error) {
822
+ if c == nil {
823
+ return GitHubResponse{}, fmt.Errorf("GitHub client is nil")
824
+ }
825
+ if !sameOrigin(rawURL, c.rawBase) {
826
+ return GitHubResponse{}, fmt.Errorf("refusing GitHub raw request to an untrusted origin")
827
+ }
828
+ return c.request(ctx, http.MethodGet, rawURL, "", false, false)
829
+ }
830
+
831
+ func (c *GitHubClient) request(ctx context.Context, method, rawURL, accept string, rest bool, sendToken bool) (GitHubResponse, error) {
832
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, nil)
833
+ if err != nil {
834
+ return GitHubResponse{}, err
835
+ }
836
+ if strings.TrimSpace(c.userAgent) != "" {
837
+ req.Header.Set("User-Agent", c.userAgent)
838
+ }
839
+ if rest {
840
+ req.Header.Set("Accept", accept)
841
+ req.Header.Set("X-GitHub-Api-Version", githubRESTAPIVersion)
842
+ if sendToken && c.hasToken() {
843
+ req.Header.Set("Authorization", "Bearer "+c.token)
844
+ }
845
+ }
846
+
847
+ resp, err := c.httpClient.Do(req)
848
+ if err != nil {
849
+ return GitHubResponse{}, err
850
+ }
851
+ defer resp.Body.Close()
852
+
853
+ out := GitHubResponse{StatusCode: resp.StatusCode, Header: resp.Header.Clone()}
854
+ if resp.Request != nil && resp.Request.URL != nil {
855
+ out.FinalURL = resp.Request.URL.String()
856
+ } else {
857
+ out.FinalURL = rawURL
858
+ }
859
+ if resp.ContentLength > githubBlobMaxBytes {
860
+ out.TooLarge = true
861
+ } else {
862
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, githubBlobMaxBytes+1))
863
+ if readErr != nil {
864
+ return out, readErr
865
+ }
866
+ if int64(len(body)) > githubBlobMaxBytes {
867
+ out.TooLarge = true
868
+ } else {
869
+ out.Body = body
870
+ }
871
+ }
872
+
873
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
874
+ return out, classifyGitHubError(out, sendToken && c.hasToken())
875
+ }
876
+ return out, nil
877
+ }
878
+
879
+ func sameOrigin(rawURL, baseURL string) bool {
880
+ u, err := url.Parse(rawURL)
881
+ if err != nil {
882
+ return false
883
+ }
884
+ base, err := url.Parse(baseURL)
885
+ if err != nil {
886
+ return false
887
+ }
888
+ return strings.EqualFold(u.Scheme, base.Scheme) && strings.EqualFold(u.Host, base.Host)
889
+ }
890
+
891
+ func classifyGitHubError(resp GitHubResponse, hasToken bool) error {
892
+ message := strings.ToLower(githubErrorMessage(resp.Body))
893
+ kind := GitHubErrorHTTP
894
+ switch resp.StatusCode {
895
+ case http.StatusUnauthorized:
896
+ kind = GitHubErrorAuthentication
897
+ case http.StatusNotFound:
898
+ kind = GitHubErrorNotFound
899
+ case http.StatusGone:
900
+ kind = GitHubErrorGone
901
+ case http.StatusForbidden:
902
+ if resp.Header.Get("X-RateLimit-Remaining") == "0" || resp.Header.Get("Retry-After") != "" || strings.Contains(message, "rate limit") || strings.Contains(message, "abuse detection") {
903
+ kind = GitHubErrorRateLimit
904
+ } else if githubMessageIsProviderLimit(message) {
905
+ kind = GitHubErrorProviderLimit
906
+ } else {
907
+ kind = GitHubErrorForbidden
908
+ }
909
+ case http.StatusTooManyRequests:
910
+ kind = GitHubErrorRateLimit
911
+ default:
912
+ if githubMessageIsProviderLimit(message) {
913
+ kind = GitHubErrorProviderLimit
914
+ }
915
+ }
916
+
917
+ ghErr := &GitHubError{
918
+ Kind: kind,
919
+ StatusCode: resp.StatusCode,
920
+ HasToken: hasToken,
921
+ RetryAfter: strings.TrimSpace(resp.Header.Get("Retry-After")),
922
+ RateResource: strings.TrimSpace(resp.Header.Get("X-RateLimit-Resource")),
923
+ }
924
+ if reset := strings.TrimSpace(resp.Header.Get("X-RateLimit-Reset")); reset != "" {
925
+ if epoch, err := strconv.ParseInt(reset, 10, 64); err == nil {
926
+ ghErr.RateReset = time.Unix(epoch, 0).UTC().Format(time.RFC3339)
927
+ } else {
928
+ ghErr.RateReset = reset
929
+ }
930
+ }
931
+ if kind == GitHubErrorProviderLimit {
932
+ ghErr.ProviderLimit = "GitHub does not expose this source through the selected API because it exceeds the provider's supported content size"
933
+ }
934
+ return ghErr
935
+ }
936
+
937
+ func githubErrorMessage(body []byte) string {
938
+ var payload struct {
939
+ Message string `json:"message"`
940
+ }
941
+ if json.Unmarshal(body, &payload) == nil {
942
+ return payload.Message
943
+ }
944
+ return ""
945
+ }
946
+
947
+ func githubMessageIsProviderLimit(message string) bool {
948
+ return strings.Contains(message, "too large") || strings.Contains(message, "100 mb") || strings.Contains(message, "100mb") || strings.Contains(message, "blob size")
949
+ }
950
+
951
+ func ParseGitHubLinkHeader(header string) GitHubLinkRelations {
952
+ links := GitHubLinkRelations{}
953
+ for _, part := range strings.Split(header, ",") {
954
+ part = strings.TrimSpace(part)
955
+ if !strings.HasPrefix(part, "<") {
956
+ continue
957
+ }
958
+ end := strings.Index(part, ">")
959
+ if end <= 1 {
960
+ continue
961
+ }
962
+ target := part[1:end]
963
+ params := strings.Split(part[end+1:], ";")
964
+ for _, param := range params {
965
+ param = strings.TrimSpace(param)
966
+ if !strings.HasPrefix(param, "rel=") {
967
+ continue
968
+ }
969
+ rel := strings.Trim(strings.TrimPrefix(param, "rel="), `"`)
970
+ if rel != "" {
971
+ links[rel] = target
972
+ }
973
+ }
974
+ }
975
+ return links
976
+ }
977
+
978
+ func (r GitHubResponse) Links() GitHubLinkRelations {
979
+ return ParseGitHubLinkHeader(r.Header.Get("Link"))
980
+ }
981
+
982
+ func readGitHubNative(rawURL string) GitHubNativeResult {
983
+ target := parseGitHubTarget(rawURL)
984
+ if target == nil {
985
+ return GitHubNativeResult{Outcome: GitHubNativeUnsupported}
986
+ }
987
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
988
+ defer cancel()
989
+ return readGitHubNativeWithClient(ctx, target, newGitHubClient())
990
+ }
991
+
992
+ func readGitHubNativeWithClient(ctx context.Context, target *GitHubTarget, client *GitHubClient) GitHubNativeResult {
993
+ if target == nil {
994
+ return GitHubNativeResult{Outcome: GitHubNativeUnsupported}
995
+ }
996
+ var (
997
+ markdown string
998
+ err error
999
+ )
1000
+ switch target.Kind {
1001
+ case GitHubTargetRepository:
1002
+ markdown, err = readGitHubRepository(ctx, client, target)
1003
+ case GitHubTargetBlob:
1004
+ markdown, err = readGitHubBlob(ctx, client, target)
1005
+ case GitHubTargetTree:
1006
+ markdown, err = readGitHubTree(ctx, client, target)
1007
+ case GitHubTargetIssue:
1008
+ markdown, err = readGitHubIssue(ctx, client, target)
1009
+ case GitHubTargetIssueList:
1010
+ markdown, err = readGitHubIssueList(ctx, client, target)
1011
+ case GitHubTargetPullList:
1012
+ markdown, err = readGitHubPullList(ctx, client, target)
1013
+ case GitHubTargetLabel:
1014
+ markdown, err = readGitHubLabel(ctx, client, target)
1015
+ case GitHubTargetLabelList:
1016
+ markdown, err = readGitHubLabelList(ctx, client, target)
1017
+ case GitHubTargetMilestone:
1018
+ markdown, err = readGitHubMilestone(ctx, client, target)
1019
+ case GitHubTargetMilestones:
1020
+ markdown, err = readGitHubMilestones(ctx, client, target)
1021
+ case GitHubTargetPull:
1022
+ markdown, err = readGitHubPullRequest(ctx, client, target)
1023
+ case GitHubTargetPullFiles:
1024
+ markdown, err = readGitHubPullFiles(ctx, client, target)
1025
+ case GitHubTargetPullCommits:
1026
+ markdown, err = readGitHubPullCommits(ctx, client, target)
1027
+ case GitHubTargetPullChecks:
1028
+ markdown, err = readGitHubPullChecks(ctx, client, target)
1029
+ case GitHubTargetPullDiff:
1030
+ markdown, err = readGitHubPullRawDiff(ctx, client, target, false)
1031
+ case GitHubTargetPullPatch:
1032
+ markdown, err = readGitHubPullRawDiff(ctx, client, target, true)
1033
+ case GitHubTargetCommit:
1034
+ markdown, err = readGitHubCommit(ctx, client, target)
1035
+ case GitHubTargetCommitDiff:
1036
+ markdown, err = readGitHubCommitRawDiff(ctx, client, target, false)
1037
+ case GitHubTargetCommitPatch:
1038
+ markdown, err = readGitHubCommitRawDiff(ctx, client, target, true)
1039
+ case GitHubTargetCompare:
1040
+ markdown, err = readGitHubCompare(ctx, client, target)
1041
+ case GitHubTargetCompareDiff:
1042
+ markdown, err = readGitHubCompareRawDiff(ctx, client, target, false)
1043
+ case GitHubTargetComparePatch:
1044
+ markdown, err = readGitHubCompareRawDiff(ctx, client, target, true)
1045
+ case GitHubTargetHistory:
1046
+ markdown, err = readGitHubHistory(ctx, client, target)
1047
+ case GitHubTargetBlame:
1048
+ markdown, err = readGitHubBlame(ctx, client, target)
1049
+ case GitHubTargetActions:
1050
+ markdown, err = readGitHubActionsOverview(ctx, client, target)
1051
+ case GitHubTargetWorkflows:
1052
+ markdown, err = readGitHubWorkflows(ctx, client, target)
1053
+ case GitHubTargetWorkflow:
1054
+ markdown, err = readGitHubWorkflow(ctx, client, target)
1055
+ case GitHubTargetActionsRun:
1056
+ markdown, err = readGitHubActionsRun(ctx, client, target)
1057
+ case GitHubTargetActionsJob:
1058
+ markdown, err = readGitHubActionsJob(ctx, client, target)
1059
+ case GitHubTargetBranches:
1060
+ markdown, err = readGitHubBranches(ctx, client, target)
1061
+ case GitHubTargetTags:
1062
+ markdown, err = readGitHubTags(ctx, client, target)
1063
+ case GitHubTargetReleases:
1064
+ markdown, err = readGitHubReleases(ctx, client, target)
1065
+ case GitHubTargetRelease:
1066
+ markdown, err = readGitHubRelease(ctx, client, target, false)
1067
+ case GitHubTargetReleaseLatest:
1068
+ markdown, err = readGitHubRelease(ctx, client, target, true)
1069
+ case GitHubTargetForks:
1070
+ markdown, err = readGitHubForks(ctx, client, target)
1071
+ case GitHubTargetStargazers:
1072
+ markdown, err = readGitHubStargazers(ctx, client, target)
1073
+ case GitHubTargetWatchers:
1074
+ markdown, err = readGitHubWatchers(ctx, client, target)
1075
+ case GitHubTargetDiscussions:
1076
+ markdown, err = readGitHubDiscussions(ctx, client, target)
1077
+ case GitHubTargetDiscussion:
1078
+ markdown, err = readGitHubDiscussion(ctx, client, target)
1079
+ case GitHubTargetGist:
1080
+ markdown, err = readGitHubGist(ctx, client, target)
1081
+ case GitHubTargetSearch:
1082
+ markdown, err = readGitHubSearch(ctx, client, target)
1083
+ case GitHubTargetProfile:
1084
+ markdown, err = readGitHubProfile(ctx, client, target)
1085
+ case GitHubTargetActivity:
1086
+ markdown, err = readGitHubActivity(ctx, client, target)
1087
+ case GitHubTargetStatsContributors:
1088
+ markdown, err = readGitHubContributorStats(ctx, client, target)
1089
+ case GitHubTargetStatsCommitActivity:
1090
+ markdown, err = readGitHubCommitActivityStats(ctx, client, target)
1091
+ case GitHubTargetStatsCodeFrequency:
1092
+ markdown, err = readGitHubCodeFrequencyStats(ctx, client, target)
1093
+ case GitHubTargetDeployments:
1094
+ markdown, err = readGitHubDeployments(ctx, client, target)
1095
+ case GitHubTargetDeploymentEnvironment:
1096
+ markdown, err = readGitHubDeploymentEnvironment(ctx, client, target)
1097
+ case GitHubTargetPackage:
1098
+ markdown, err = readGitHubPackage(ctx, client, target)
1099
+ case GitHubTargetProjectV2:
1100
+ markdown, err = readGitHubProjectV2(ctx, client, target)
1101
+ default:
1102
+ return GitHubNativeResult{Outcome: GitHubNativeUnsupported}
1103
+ }
1104
+ if err != nil {
1105
+ return GitHubNativeResult{Outcome: GitHubNativeFailure, Err: err}
1106
+ }
1107
+ return GitHubNativeResult{Outcome: GitHubNativeSuccess, Markdown: markdown}
1108
+ }
1109
+
1110
+ type githubRepository struct {
1111
+ FullName string `json:"full_name"`
1112
+ HTMLURL string `json:"html_url"`
1113
+ Description string `json:"description"`
1114
+ Language string `json:"language"`
1115
+ DefaultBranch string `json:"default_branch"`
1116
+ StargazersCount int `json:"stargazers_count"`
1117
+ Topics []string `json:"topics"`
1118
+ Fork bool `json:"fork"`
1119
+ Archived bool `json:"archived"`
1120
+ Private bool `json:"private"`
1121
+ Visibility string `json:"visibility"`
1122
+ License *struct {
1123
+ SPDXID string `json:"spdx_id"`
1124
+ Name string `json:"name"`
1125
+ } `json:"license"`
1126
+ }
1127
+
1128
+ type githubContent struct {
1129
+ Type string `json:"type"`
1130
+ Name string `json:"name"`
1131
+ Path string `json:"path"`
1132
+ SHA string `json:"sha"`
1133
+ Size int64 `json:"size"`
1134
+ Encoding string `json:"encoding"`
1135
+ Content string `json:"content"`
1136
+ HTMLURL string `json:"html_url"`
1137
+ DownloadURL string `json:"download_url"`
1138
+ }
1139
+
1140
+ func readGitHubRepository(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1141
+ if target.Fragment != "" {
1142
+ return "", fmt.Errorf("GitHub repository-root fragment %q is not a supported native selector", target.Fragment)
1143
+ }
1144
+ baseEndpoint := fmt.Sprintf("/repos/%s/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo))
1145
+ resp, err := client.REST(ctx, http.MethodGet, baseEndpoint, "application/vnd.github+json")
1146
+ if err != nil {
1147
+ return "", err
1148
+ }
1149
+ var repo githubRepository
1150
+ if err := json.Unmarshal(resp.Body, &repo); err != nil {
1151
+ return "", fmt.Errorf("decode GitHub repository metadata: %w", err)
1152
+ }
1153
+ if repo.FullName == "" {
1154
+ repo.FullName = target.Owner + "/" + target.Repo
1155
+ }
1156
+ if repo.HTMLURL == "" {
1157
+ repo.HTMLURL = "https://github.com/" + escapePathPreservingSlashes(repo.FullName)
1158
+ }
1159
+
1160
+ readmeResp, readmeErr := client.REST(ctx, http.MethodGet, baseEndpoint+"/readme", "application/vnd.github+json")
1161
+ var readme *githubContent
1162
+ var readmeText string
1163
+ if readmeErr != nil {
1164
+ var ghErr *GitHubError
1165
+ if !asGitHubError(readmeErr, &ghErr) || ghErr.Kind != GitHubErrorNotFound {
1166
+ return "", readmeErr
1167
+ }
1168
+ } else {
1169
+ var content githubContent
1170
+ if err := json.Unmarshal(readmeResp.Body, &content); err != nil {
1171
+ return "", fmt.Errorf("decode GitHub README metadata: %w", err)
1172
+ }
1173
+ readme = &content
1174
+ readmeText, err = githubContentText(ctx, client, target.Owner, target.Repo, repo.DefaultBranch, &content)
1175
+ if err != nil {
1176
+ return "", err
1177
+ }
1178
+ }
1179
+
1180
+ return renderGitHubRepository(repo, readme, readmeText), nil
1181
+ }
1182
+
1183
+ func renderGitHubRepository(repo githubRepository, readme *githubContent, readmeText string) string {
1184
+ lines := []string{"---", "repository: " + yamlScalar(repo.FullName)}
1185
+ if strings.TrimSpace(repo.Description) != "" {
1186
+ lines = append(lines, "description: "+yamlScalar(repo.Description))
1187
+ }
1188
+ if strings.TrimSpace(repo.Language) != "" {
1189
+ lines = append(lines, "language: "+yamlScalar(repo.Language))
1190
+ }
1191
+ if strings.TrimSpace(repo.DefaultBranch) != "" {
1192
+ lines = append(lines, "default_branch: "+yamlScalar(repo.DefaultBranch))
1193
+ }
1194
+ if repo.License != nil {
1195
+ license := strings.TrimSpace(repo.License.SPDXID)
1196
+ if license == "" || strings.EqualFold(license, "NOASSERTION") {
1197
+ license = strings.TrimSpace(repo.License.Name)
1198
+ }
1199
+ if license != "" {
1200
+ lines = append(lines, "license: "+yamlScalar(license))
1201
+ }
1202
+ }
1203
+ lines = append(lines, fmt.Sprintf("stars: %d", repo.StargazersCount))
1204
+ if len(repo.Topics) > 0 {
1205
+ sortedTopics := append([]string(nil), repo.Topics...)
1206
+ sort.Strings(sortedTopics)
1207
+ encoded, _ := json.Marshal(sortedTopics)
1208
+ lines = append(lines, "topics: "+string(encoded))
1209
+ }
1210
+ if repo.Fork {
1211
+ lines = append(lines, "fork: true")
1212
+ }
1213
+ if repo.Archived {
1214
+ lines = append(lines, "archived: true")
1215
+ }
1216
+ visibility := strings.TrimSpace(repo.Visibility)
1217
+ if visibility == "" && repo.Private {
1218
+ visibility = "private"
1219
+ }
1220
+ if visibility != "" && !strings.EqualFold(visibility, "public") {
1221
+ lines = append(lines, "visibility: "+yamlScalar(visibility))
1222
+ }
1223
+ lines = append(lines, "---", "", "## README preview", "")
1224
+
1225
+ if readme == nil {
1226
+ lines = append(lines, "No repository README was found.")
1227
+ } else {
1228
+ humanReadme := stripInvisibleHTMLComments(readmeText)
1229
+ preview, truncated := truncateMarkdownSafe(humanReadme, githubRootPreviewRunes)
1230
+ if strings.TrimSpace(preview) == "" {
1231
+ lines = append(lines, "The repository README is empty.")
1232
+ } else {
1233
+ lines = append(lines, preview)
1234
+ }
1235
+ if truncated {
1236
+ lines = append(lines, "", "> README preview truncated near 5,000 characters.")
1237
+ if readme.HTMLURL != "" {
1238
+ lines = append(lines, "> Full README: "+readme.HTMLURL)
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ hints := githubRootHints(repo, readme)
1244
+ if len(hints) > 0 {
1245
+ lines = append(lines, "", "## Useful GitHub URLs", "")
1246
+ for _, hint := range hints {
1247
+ lines = append(lines, "- "+hint)
1248
+ }
1249
+ }
1250
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1251
+ }
1252
+
1253
+ func githubRootHints(repo githubRepository, readme *githubContent) []string {
1254
+ base := strings.TrimRight(repo.HTMLURL, "/")
1255
+ if base == "" {
1256
+ base = "https://github.com/" + escapePathPreservingSlashes(repo.FullName)
1257
+ }
1258
+ ref := strings.TrimSpace(repo.DefaultBranch)
1259
+ if ref == "" {
1260
+ ref = "HEAD"
1261
+ }
1262
+ refPath := escapePathPreservingSlashes(ref)
1263
+ hints := []string{}
1264
+ if readme != nil && readme.HTMLURL != "" {
1265
+ hints = append(hints, "Full README source: "+readme.HTMLURL)
1266
+ }
1267
+ hints = append(hints,
1268
+ "Select source lines: "+base+"/blob/"+refPath+"/path/to/file#L20-L40",
1269
+ "List one directory level: "+base+"/tree/"+refPath+"/path/to/directory",
1270
+ "Browse Issues: "+base+"/issues",
1271
+ "Read a Pull Request: "+base+"/pull/123",
1272
+ )
1273
+ return hints
1274
+ }
1275
+
1276
+ func readGitHubBlob(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1277
+ if len(target.Tail) < 2 {
1278
+ return "", fmt.Errorf("GitHub blob URL must include a ref and file path")
1279
+ }
1280
+
1281
+ // Preserve the existing cheapest faithful path for public blobs. The raw
1282
+ // host resolves the complete unresolved tail, including slash refs, without
1283
+ // spending a REST request or requiring gh/authentication.
1284
+ rawURL := githubRawURL(client.rawBase, target.Owner, target.Repo, target.Tail)
1285
+ rawResp, rawErr := client.Raw(ctx, rawURL)
1286
+ if rawErr == nil {
1287
+ if rawResp.TooLarge {
1288
+ return "", githubBlobProviderLimitError()
1289
+ }
1290
+ return renderGitHubBlobSelection(target, rawResp.Body, rawResp.Header.Get("Content-Type"), rawURL)
1291
+ }
1292
+
1293
+ var rawGHError *GitHubError
1294
+ if !asGitHubError(rawErr, &rawGHError) || rawGHError.Kind != GitHubErrorNotFound || !client.hasToken() {
1295
+ return "", rawErr
1296
+ }
1297
+
1298
+ // A raw 404 can be a private source. With an optional token configured,
1299
+ // resolve ref/path through Contents and fetch the authenticated source.
1300
+ resolved, err := resolveGitHubRefPath(ctx, client, target, "file")
1301
+ if err != nil {
1302
+ return "", err
1303
+ }
1304
+ var item githubContent
1305
+ if err := json.Unmarshal(resolved.Response.Body, &item); err != nil {
1306
+ return "", fmt.Errorf("decode GitHub source metadata: %w", err)
1307
+ }
1308
+ content, contentType, err := githubContentBytes(ctx, client, target.Owner, target.Repo, resolved.Ref, &item)
1309
+ if err != nil {
1310
+ return "", err
1311
+ }
1312
+ downloadURL := item.DownloadURL
1313
+ if downloadURL == "" {
1314
+ downloadURL = target.OriginalURL
1315
+ }
1316
+ return renderGitHubBlobSelection(target, content, contentType, downloadURL)
1317
+ }
1318
+
1319
+ func renderGitHubBlobSelection(target *GitHubTarget, content []byte, contentType, downloadURL string) (string, error) {
1320
+ if int64(len(content)) > githubBlobMaxBytes {
1321
+ return "", githubBlobProviderLimitError()
1322
+ }
1323
+ name := path.Base(strings.Join(target.Tail, "/"))
1324
+ if name == "." || name == "/" || name == "" {
1325
+ name = target.Repo
1326
+ }
1327
+ if !isTextContent(content, contentType) {
1328
+ parts := []string{"# " + name, "", "**URL:** " + target.OriginalURL, "", "Binary/non-text source is not emitted as terminal bytes."}
1329
+ if downloadURL != "" {
1330
+ parts = append(parts, "", "Raw/download: "+downloadURL)
1331
+ }
1332
+ return strings.Join(parts, "\n"), nil
1333
+ }
1334
+ text := string(content)
1335
+ if target.Fragment == "" {
1336
+ title := firstHeadingOrFallback(text, name)
1337
+ return formatReadLink(title, target.OriginalURL, text), nil
1338
+ }
1339
+
1340
+ if start, end, ok, err := parseGitHubLineSelector(target.Fragment); ok {
1341
+ if err != nil {
1342
+ return "", err
1343
+ }
1344
+ selected, total, err := selectSourceLines(text, start, end)
1345
+ if err != nil {
1346
+ return "", err
1347
+ }
1348
+ coord := fmt.Sprintf("**Lines:** %d", start)
1349
+ if end != start {
1350
+ coord = fmt.Sprintf("**Lines:** %d-%d", start, end)
1351
+ }
1352
+ coord += fmt.Sprintf(" of %d", total)
1353
+ return strings.Join([]string{"# " + name, "", "**URL:** " + target.OriginalURL, coord, "", selected}, "\n"), nil
1354
+ }
1355
+
1356
+ if !isMarkdownPath(name) {
1357
+ return "", fmt.Errorf("GitHub source fragment %q is not a supported selector for %s", target.Fragment, name)
1358
+ }
1359
+ section, err := selectMarkdownHeadingSection(text, target.Fragment)
1360
+ if err != nil {
1361
+ return "", err
1362
+ }
1363
+ return formatReadLink(firstHeadingOrFallback(section, name), target.OriginalURL, section), nil
1364
+ }
1365
+
1366
+ func githubBlobProviderLimitError() error {
1367
+ return &GitHubError{Kind: GitHubErrorProviderLimit, ProviderLimit: "source exceeds GitHub's 100 MB Contents/Git blob support; use the GitHub blob/raw URL as a download instead"}
1368
+ }
1369
+
1370
+ type resolvedGitHubPath struct {
1371
+ Ref string
1372
+ Path string
1373
+ Endpoint string
1374
+ Response GitHubResponse
1375
+ }
1376
+
1377
+ type githubAmbiguousRefError struct {
1378
+ candidates []resolvedGitHubPath
1379
+ }
1380
+
1381
+ func (e *githubAmbiguousRefError) Error() string {
1382
+ parts := make([]string, 0, len(e.candidates))
1383
+ for _, candidate := range e.candidates {
1384
+ if candidate.Path == "" {
1385
+ parts = append(parts, candidate.Ref+" (repository root)")
1386
+ } else {
1387
+ parts = append(parts, candidate.Ref+" + "+candidate.Path)
1388
+ }
1389
+ }
1390
+ return "GitHub ref/path is ambiguous; multiple provider-valid splits exist: " + strings.Join(parts, ", ")
1391
+ }
1392
+
1393
+ func resolveGitHubRefPath(ctx context.Context, client *GitHubClient, target *GitHubTarget, wantKind string) (resolvedGitHubPath, error) {
1394
+ if wantKind == "history" {
1395
+ return resolveGitHubHistoryRefPath(ctx, client, target)
1396
+ }
1397
+ if target == nil || len(target.Tail) == 0 {
1398
+ return resolvedGitHubPath{}, fmt.Errorf("GitHub source URL is missing a ref")
1399
+ }
1400
+ maxSplit := len(target.Tail)
1401
+ if wantKind == "file" {
1402
+ maxSplit--
1403
+ }
1404
+ if maxSplit < 1 {
1405
+ return resolvedGitHubPath{}, fmt.Errorf("GitHub source URL is missing a repository path")
1406
+ }
1407
+
1408
+ matches := []resolvedGitHubPath{}
1409
+ var lastNotFound error
1410
+ for split := 1; split <= maxSplit; split++ {
1411
+ ref := strings.Join(target.Tail[:split], "/")
1412
+ filePath := strings.Join(target.Tail[split:], "/")
1413
+ endpoint := githubContentsEndpoint(target.Owner, target.Repo, filePath, ref)
1414
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
1415
+ if err != nil {
1416
+ var ghErr *GitHubError
1417
+ if asGitHubError(err, &ghErr) && ghErr.Kind == GitHubErrorNotFound {
1418
+ lastNotFound = err
1419
+ continue
1420
+ }
1421
+ return resolvedGitHubPath{}, err
1422
+ }
1423
+ kind, err := githubContentsKind(resp.Body)
1424
+ if err != nil {
1425
+ return resolvedGitHubPath{}, err
1426
+ }
1427
+ if wantKind != "any" && kind != wantKind {
1428
+ continue
1429
+ }
1430
+ matches = append(matches, resolvedGitHubPath{Ref: ref, Path: filePath, Endpoint: endpoint, Response: resp})
1431
+ }
1432
+
1433
+ if len(matches) == 1 {
1434
+ return matches[0], nil
1435
+ }
1436
+ if len(matches) > 1 {
1437
+ return resolvedGitHubPath{}, &githubAmbiguousRefError{candidates: matches}
1438
+ }
1439
+ if lastNotFound != nil {
1440
+ return resolvedGitHubPath{}, lastNotFound
1441
+ }
1442
+ return resolvedGitHubPath{}, &GitHubError{Kind: GitHubErrorNotFound, StatusCode: http.StatusNotFound, HasToken: client.hasToken()}
1443
+ }
1444
+
1445
+ func githubContentsKind(body []byte) (string, error) {
1446
+ trimmed := bytes.TrimSpace(body)
1447
+ if len(trimmed) == 0 {
1448
+ return "", fmt.Errorf("GitHub Contents API returned an empty response")
1449
+ }
1450
+ if trimmed[0] == '[' {
1451
+ var entries []githubContent
1452
+ if err := json.Unmarshal(trimmed, &entries); err != nil {
1453
+ return "", fmt.Errorf("decode GitHub directory contents: %w", err)
1454
+ }
1455
+ return "dir", nil
1456
+ }
1457
+ var item githubContent
1458
+ if err := json.Unmarshal(trimmed, &item); err != nil {
1459
+ return "", fmt.Errorf("decode GitHub source content: %w", err)
1460
+ }
1461
+ if item.Type == "dir" {
1462
+ return "dir", nil
1463
+ }
1464
+ return "file", nil
1465
+ }
1466
+
1467
+ func githubContentsEndpoint(owner, repo, filePath, ref string) string {
1468
+ endpoint := fmt.Sprintf("/repos/%s/%s/contents", url.PathEscape(owner), url.PathEscape(repo))
1469
+ if strings.TrimSpace(filePath) != "" {
1470
+ endpoint += "/" + escapePathPreservingSlashes(filePath)
1471
+ }
1472
+ if ref != "" {
1473
+ endpoint += "?ref=" + url.QueryEscape(ref)
1474
+ }
1475
+ return endpoint
1476
+ }
1477
+
1478
+ func githubContentBytes(ctx context.Context, client *GitHubClient, owner, repo, ref string, item *githubContent) ([]byte, string, error) {
1479
+ if item == nil {
1480
+ return nil, "", fmt.Errorf("GitHub source metadata is missing")
1481
+ }
1482
+ if item.Size > githubBlobMaxBytes {
1483
+ return nil, "", githubBlobProviderLimitError()
1484
+ }
1485
+ if strings.EqualFold(item.Encoding, "base64") && strings.TrimSpace(item.Content) != "" {
1486
+ compact := strings.Map(func(r rune) rune {
1487
+ if unicode.IsSpace(r) {
1488
+ return -1
1489
+ }
1490
+ return r
1491
+ }, item.Content)
1492
+ decoded, err := base64.StdEncoding.DecodeString(compact)
1493
+ if err != nil {
1494
+ return nil, "", fmt.Errorf("decode GitHub base64 source: %w", err)
1495
+ }
1496
+ return decoded, http.DetectContentType(decoded), nil
1497
+ }
1498
+ endpoint := githubContentsEndpoint(owner, repo, item.Path, ref)
1499
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github.raw+json")
1500
+ if err != nil {
1501
+ return nil, "", err
1502
+ }
1503
+ if resp.TooLarge {
1504
+ return nil, "", githubBlobProviderLimitError()
1505
+ }
1506
+ return resp.Body, resp.Header.Get("Content-Type"), nil
1507
+ }
1508
+
1509
+ func githubContentText(ctx context.Context, client *GitHubClient, owner, repo, ref string, item *githubContent) (string, error) {
1510
+ content, contentType, err := githubContentBytes(ctx, client, owner, repo, ref, item)
1511
+ if err != nil {
1512
+ return "", err
1513
+ }
1514
+ if !isTextContent(content, contentType) {
1515
+ return "", fmt.Errorf("GitHub README is not text content")
1516
+ }
1517
+ return string(content), nil
1518
+ }
1519
+
1520
+ func readGitHubTree(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
1521
+ if len(target.Tail) < 1 {
1522
+ return "", fmt.Errorf("GitHub tree URL must include a ref")
1523
+ }
1524
+ if target.Fragment != "" {
1525
+ return "", fmt.Errorf("GitHub tree fragment %q is not a supported native selector", target.Fragment)
1526
+ }
1527
+ resolved, err := resolveGitHubRefPath(ctx, client, target, "dir")
1528
+ if err != nil {
1529
+ return "", err
1530
+ }
1531
+ var entries []githubContent
1532
+ if err := json.Unmarshal(resolved.Response.Body, &entries); err != nil {
1533
+ return "", fmt.Errorf("decode GitHub directory listing: %w", err)
1534
+ }
1535
+ sort.SliceStable(entries, func(i, j int) bool {
1536
+ left := strings.ToLower(entries[i].Name)
1537
+ right := strings.ToLower(entries[j].Name)
1538
+ if left == right {
1539
+ return entries[i].Name < entries[j].Name
1540
+ }
1541
+ return left < right
1542
+ })
1543
+
1544
+ var readme *githubContent
1545
+ var readmeText string
1546
+ if candidate := chooseDirectoryREADME(entries); candidate != nil {
1547
+ readme = candidate
1548
+ readmeText, err = githubContentText(ctx, client, target.Owner, target.Repo, resolved.Ref, candidate)
1549
+ if err != nil {
1550
+ return "", err
1551
+ }
1552
+ }
1553
+ return renderGitHubTree(target, resolved, entries, readme, readmeText), nil
1554
+ }
1555
+
1556
+ func renderGitHubTree(target *GitHubTarget, resolved resolvedGitHubPath, entries []githubContent, readme *githubContent, readmeText string) string {
1557
+ entryLimit := minInt(40, len(entries))
1558
+ readmeRunes := 2400
1559
+ if readme == nil {
1560
+ readmeRunes = 0
1561
+ }
1562
+ for {
1563
+ out := renderGitHubTreeWithLimits(target, resolved, entries, readme, readmeText, entryLimit, readmeRunes)
1564
+ if githubOverviewFits(out) {
1565
+ return out
1566
+ }
1567
+ switch {
1568
+ case readmeRunes > 800:
1569
+ readmeRunes -= 200
1570
+ case entryLimit > 1:
1571
+ entryLimit--
1572
+ case readmeRunes > 200:
1573
+ readmeRunes -= 200
1574
+ default:
1575
+ return out
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ func renderGitHubTreeWithLimits(target *GitHubTarget, resolved resolvedGitHubPath, entries []githubContent, readme *githubContent, readmeText string, entryLimit, readmeRunes int) string {
1581
+ entryLimit = minInt(entryLimit, len(entries))
1582
+ lines := []string{
1583
+ "---",
1584
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
1585
+ "ref: " + yamlScalar(resolved.Ref),
1586
+ "path: " + yamlScalar(treeDisplayPath(resolved.Path)),
1587
+ fmt.Sprintf("entries: %d", len(entries)),
1588
+ fmt.Sprintf("entries_returned: %d", len(entries)),
1589
+ fmt.Sprintf("entries_indexed: %d", entryLimit),
1590
+ fmt.Sprintf("entries_local_omitted: %d", len(entries)-entryLimit),
1591
+ }
1592
+ providerCeiling := len(entries) >= 1000
1593
+ if providerCeiling {
1594
+ lines = append(lines, "complete: false", "provider_complete: false", "provider_result_ceiling: 1000")
1595
+ } else {
1596
+ lines = append(lines, "provider_complete: true")
1597
+ }
1598
+ lines = append(lines, "---", "", "# "+treeDisplayPath(resolved.Path), "")
1599
+ if providerCeiling {
1600
+ lines = append(lines, "> GitHub Contents API returns at most 1,000 entries for a directory; this one-level listing may be incomplete.", "")
1601
+ }
1602
+ if len(entries) == 0 {
1603
+ lines = append(lines, "_Directory is empty._")
1604
+ }
1605
+ for _, entry := range entries[:entryLimit] {
1606
+ label := entry.Name
1607
+ if entry.Type == "dir" {
1608
+ label += "/"
1609
+ }
1610
+ kind := entry.Type
1611
+ if kind == "" {
1612
+ kind = "file"
1613
+ }
1614
+ if entry.HTMLURL != "" {
1615
+ lines = append(lines, fmt.Sprintf("- %s [%s](%s)", kind, escapeMarkdownLinkText(label), entry.HTMLURL))
1616
+ } else {
1617
+ lines = append(lines, fmt.Sprintf("- %s %s", kind, label))
1618
+ }
1619
+ }
1620
+ if note := githubLocalOmissionNote("directory entries returned by GitHub", len(entries)-entryLimit); note != "" {
1621
+ lines = append(lines, "", note)
1622
+ }
1623
+
1624
+ if readme != nil {
1625
+ preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(readmeText), readmeRunes)
1626
+ lines = append(lines, "", "## Directory README", "", preview)
1627
+ if truncated {
1628
+ lines = append(lines, "", "> Directory README preview truncated locally so the tree overview stays bounded.")
1629
+ if readme.HTMLURL != "" {
1630
+ lines = append(lines, "> Full README: "+readme.HTMLURL)
1631
+ }
1632
+ }
1633
+ }
1634
+ return strings.TrimSpace(strings.Join(lines, "\n"))
1635
+ }
1636
+
1637
+ func chooseDirectoryREADME(entries []githubContent) *githubContent {
1638
+ best := -1
1639
+ bestScore := 100
1640
+ for i := range entries {
1641
+ if entries[i].Type != "file" {
1642
+ continue
1643
+ }
1644
+ name := strings.ToLower(entries[i].Name)
1645
+ score := 100
1646
+ switch name {
1647
+ case "readme.md":
1648
+ score = 0
1649
+ case "readme":
1650
+ score = 1
1651
+ default:
1652
+ if strings.HasPrefix(name, "readme.") {
1653
+ score = 2
1654
+ }
1655
+ }
1656
+ if score < bestScore {
1657
+ best = i
1658
+ bestScore = score
1659
+ }
1660
+ }
1661
+ if best < 0 {
1662
+ return nil
1663
+ }
1664
+ return &entries[best]
1665
+ }
1666
+
1667
+ func treeDisplayPath(filePath string) string {
1668
+ if strings.TrimSpace(filePath) == "" {
1669
+ return "/"
1670
+ }
1671
+ return filePath
1672
+ }
1673
+
1674
+ func parseGitHubLineSelector(fragment string) (start, end int, ok bool, err error) {
1675
+ matches := githubLineSelectorRE.FindStringSubmatch(fragment)
1676
+ if matches == nil {
1677
+ return 0, 0, false, nil
1678
+ }
1679
+ start, _ = strconv.Atoi(matches[1])
1680
+ end = start
1681
+ if matches[2] != "" {
1682
+ end, _ = strconv.Atoi(matches[2])
1683
+ }
1684
+ if start <= 0 || end <= 0 {
1685
+ return 0, 0, true, fmt.Errorf("GitHub line selector must use positive line numbers")
1686
+ }
1687
+ if end < start {
1688
+ return 0, 0, true, fmt.Errorf("GitHub line selector range is reversed: L%d-L%d", start, end)
1689
+ }
1690
+ return start, end, true, nil
1691
+ }
1692
+
1693
+ func selectSourceLines(text string, start, end int) (string, int, error) {
1694
+ lines := strings.Split(text, "\n")
1695
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
1696
+ lines = lines[:len(lines)-1]
1697
+ }
1698
+ total := len(lines)
1699
+ if start > total || end > total {
1700
+ return "", total, fmt.Errorf("GitHub line selector L%d-L%d is outside the file's %d lines", start, end, total)
1701
+ }
1702
+ return strings.Join(lines[start-1:end], "\n"), total, nil
1703
+ }
1704
+
1705
+ type markdownHeading struct {
1706
+ Level int
1707
+ Slug string
1708
+ Line int
1709
+ }
1710
+
1711
+ func selectMarkdownHeadingSection(text, fragment string) (string, error) {
1712
+ headings := markdownHeadings(text)
1713
+ matchIndex := -1
1714
+ for i, heading := range headings {
1715
+ if heading.Slug == fragment {
1716
+ matchIndex = i
1717
+ break
1718
+ }
1719
+ }
1720
+ if matchIndex < 0 {
1721
+ return "", fmt.Errorf("GitHub Markdown heading selector #%s was not found", fragment)
1722
+ }
1723
+ lines := strings.Split(text, "\n")
1724
+ start := headings[matchIndex].Line
1725
+ end := len(lines)
1726
+ for _, heading := range headings[matchIndex+1:] {
1727
+ if heading.Level <= headings[matchIndex].Level {
1728
+ end = heading.Line
1729
+ break
1730
+ }
1731
+ }
1732
+ return strings.TrimRight(strings.Join(lines[start:end], "\n"), "\n"), nil
1733
+ }
1734
+
1735
+ func markdownHeadings(text string) []markdownHeading {
1736
+ lines := strings.Split(text, "\n")
1737
+ counts := map[string]int{}
1738
+ out := []markdownHeading{}
1739
+ inFence := false
1740
+ fenceMarker := ""
1741
+ for lineNo, line := range lines {
1742
+ trimmed := strings.TrimSpace(line)
1743
+ if marker, ok := markdownFenceMarker(trimmed); ok {
1744
+ if !inFence {
1745
+ inFence = true
1746
+ fenceMarker = marker
1747
+ } else if strings.HasPrefix(trimmed, fenceMarker) {
1748
+ inFence = false
1749
+ fenceMarker = ""
1750
+ }
1751
+ continue
1752
+ }
1753
+ if inFence {
1754
+ continue
1755
+ }
1756
+ level, headingText, ok := parseATXHeading(line)
1757
+ if !ok {
1758
+ continue
1759
+ }
1760
+ base := githubHeadingSlug(headingText)
1761
+ if base == "" {
1762
+ continue
1763
+ }
1764
+ count := counts[base]
1765
+ counts[base] = count + 1
1766
+ slug := base
1767
+ if count > 0 {
1768
+ slug = fmt.Sprintf("%s-%d", base, count)
1769
+ }
1770
+ out = append(out, markdownHeading{Level: level, Slug: slug, Line: lineNo})
1771
+ }
1772
+ return out
1773
+ }
1774
+
1775
+ func parseATXHeading(line string) (int, string, bool) {
1776
+ trimmedLeft := strings.TrimLeft(line, " ")
1777
+ if len(line)-len(trimmedLeft) > 3 {
1778
+ return 0, "", false
1779
+ }
1780
+ level := 0
1781
+ for level < len(trimmedLeft) && level < 6 && trimmedLeft[level] == '#' {
1782
+ level++
1783
+ }
1784
+ if level == 0 || level >= len(trimmedLeft) || (trimmedLeft[level] != ' ' && trimmedLeft[level] != '\t') {
1785
+ return 0, "", false
1786
+ }
1787
+ text := strings.TrimSpace(trimmedLeft[level:])
1788
+ text = strings.TrimSpace(strings.TrimRight(text, "#"))
1789
+ if text == "" {
1790
+ return 0, "", false
1791
+ }
1792
+ return level, text, true
1793
+ }
1794
+
1795
+ func githubHeadingSlug(text string) string {
1796
+ text = markdownImageRE.ReplaceAllString(text, "$1")
1797
+ text = markdownLinkRE.ReplaceAllString(text, "$1")
1798
+ text = htmlTagRE.ReplaceAllString(text, "")
1799
+ text = strings.ReplaceAll(text, "`", "")
1800
+ var b strings.Builder
1801
+ for _, r := range strings.ToLower(text) {
1802
+ switch {
1803
+ case unicode.IsLetter(r) || unicode.IsNumber(r) || r == '-' || r == '_':
1804
+ b.WriteRune(r)
1805
+ case unicode.IsSpace(r):
1806
+ b.WriteRune('-')
1807
+ }
1808
+ }
1809
+ return b.String()
1810
+ }
1811
+
1812
+ func stripInvisibleHTMLComments(markdown string) string {
1813
+ lines := strings.SplitAfter(markdown, "\n")
1814
+ var out strings.Builder
1815
+ inFence := false
1816
+ fenceMarker := ""
1817
+ inComment := false
1818
+ for _, line := range lines {
1819
+ trimmed := strings.TrimSpace(line)
1820
+ if marker, ok := markdownFenceMarker(trimmed); ok && !inComment {
1821
+ if !inFence {
1822
+ inFence = true
1823
+ fenceMarker = marker
1824
+ } else if strings.HasPrefix(trimmed, fenceMarker) {
1825
+ inFence = false
1826
+ fenceMarker = ""
1827
+ }
1828
+ out.WriteString(line)
1829
+ continue
1830
+ }
1831
+ if inFence {
1832
+ out.WriteString(line)
1833
+ continue
1834
+ }
1835
+ out.WriteString(stripHTMLCommentsFromLine(line, &inComment))
1836
+ }
1837
+ return strings.TrimSpace(out.String())
1838
+ }
1839
+
1840
+ func stripHTMLCommentsFromLine(line string, inComment *bool) string {
1841
+ var out strings.Builder
1842
+ rest := line
1843
+ for rest != "" {
1844
+ if *inComment {
1845
+ end := strings.Index(rest, "-->")
1846
+ if end < 0 {
1847
+ return out.String()
1848
+ }
1849
+ *inComment = false
1850
+ rest = rest[end+3:]
1851
+ continue
1852
+ }
1853
+ start := strings.Index(rest, "<!--")
1854
+ if start < 0 {
1855
+ out.WriteString(rest)
1856
+ break
1857
+ }
1858
+ out.WriteString(rest[:start])
1859
+ rest = rest[start+4:]
1860
+ *inComment = true
1861
+ }
1862
+ return out.String()
1863
+ }
1864
+
1865
+ func truncateMarkdownSafe(markdown string, maxRunes int) (string, bool) {
1866
+ if maxRunes <= 0 {
1867
+ return "", strings.TrimSpace(markdown) != ""
1868
+ }
1869
+ if utf8.RuneCountInString(markdown) <= maxRunes {
1870
+ return strings.TrimSpace(markdown), false
1871
+ }
1872
+ lines := strings.SplitAfter(markdown, "\n")
1873
+ runeCount := 0
1874
+ byteCount := 0
1875
+ lastSafeLine := 0
1876
+ lastParagraph := 0
1877
+ inFence := false
1878
+ fenceMarker := ""
1879
+ for _, line := range lines {
1880
+ lineRunes := utf8.RuneCountInString(line)
1881
+ if runeCount+lineRunes > maxRunes {
1882
+ break
1883
+ }
1884
+ trimmed := strings.TrimSpace(line)
1885
+ if marker, ok := markdownFenceMarker(trimmed); ok {
1886
+ if !inFence {
1887
+ inFence = true
1888
+ fenceMarker = marker
1889
+ } else if strings.HasPrefix(trimmed, fenceMarker) {
1890
+ inFence = false
1891
+ fenceMarker = ""
1892
+ }
1893
+ }
1894
+ runeCount += lineRunes
1895
+ byteCount += len(line)
1896
+ if !inFence {
1897
+ lastSafeLine = byteCount
1898
+ if trimmed == "" {
1899
+ lastParagraph = byteCount
1900
+ }
1901
+ }
1902
+ }
1903
+ end := lastSafeLine
1904
+ if lastParagraph > 0 && utf8.RuneCountInString(markdown[:lastParagraph]) >= maxRunes*2/3 {
1905
+ end = lastParagraph
1906
+ }
1907
+ if end <= 0 {
1908
+ // No safe line boundary exists before the budget. This only happens for
1909
+ // a single enormous line; a rune boundary is still preferable to an
1910
+ // invalid UTF-8 byte cut.
1911
+ runes := []rune(markdown)
1912
+ if len(runes) > maxRunes {
1913
+ runes = runes[:maxRunes]
1914
+ }
1915
+ return strings.TrimSpace(string(runes)), true
1916
+ }
1917
+ return strings.TrimSpace(markdown[:end]), true
1918
+ }
1919
+
1920
+ // githubOverviewPreview is the shared native-GitHub preview primitive for
1921
+ // overview/container renderers. It intentionally reuses the Markdown-safe,
1922
+ // rune-aware truncation contract instead of introducing token/model coupling.
1923
+ func githubOverviewPreview(markdown string, maxRunes int) (string, bool) {
1924
+ return truncateMarkdownSafe(strings.TrimSpace(markdown), maxRunes)
1925
+ }
1926
+
1927
+ // githubOverviewInlinePreview is for compact overview/index rows whose
1928
+ // provider text can contain arbitrary whitespace or human-authored prose.
1929
+ // Exact selectors keep the original text; only the landing/index copy is
1930
+ // normalized and bounded here.
1931
+ func githubOverviewInlinePreview(text string, maxRunes int) (string, bool) {
1932
+ cleaned := strings.Join(strings.Fields(stripInvisibleHTMLComments(text)), " ")
1933
+ if cleaned == "" {
1934
+ return "", false
1935
+ }
1936
+ preview, truncated := githubOverviewPreview(cleaned, maxRunes)
1937
+ return strings.Join(strings.Fields(preview), " "), truncated
1938
+ }
1939
+
1940
+ func githubOverviewLabelList(values []string, limit int) string {
1941
+ if limit < 0 {
1942
+ limit = 0
1943
+ }
1944
+ shownValues := values[:minInt(limit, len(values))]
1945
+ shownParts := make([]string, 0, len(shownValues))
1946
+ for _, value := range shownValues {
1947
+ preview, truncated := githubOverviewInlinePreview(value, 60)
1948
+ if truncated {
1949
+ preview += "…"
1950
+ }
1951
+ shownParts = append(shownParts, preview)
1952
+ }
1953
+ shown := strings.Join(shownParts, ", ")
1954
+ if len(values) <= limit {
1955
+ return shown
1956
+ }
1957
+ if shown == "" {
1958
+ return fmt.Sprintf("+%d more", len(values)-limit)
1959
+ }
1960
+ return fmt.Sprintf("%s, +%d more", shown, len(values)-limit)
1961
+ }
1962
+
1963
+ func githubBoundedOverviewList(length, initialLimit int, render func(limit int) string) string {
1964
+ limit := minInt(initialLimit, length)
1965
+ for {
1966
+ out := render(limit)
1967
+ if githubOverviewFits(out) || limit <= 1 {
1968
+ return out
1969
+ }
1970
+ limit--
1971
+ }
1972
+ }
1973
+
1974
+ // githubOverviewFits reports whether a candidate overview stays within the
1975
+ // shared soft target. Mandatory metadata/navigation may still make a renderer
1976
+ // modestly exceed the target, but optional child indexes should use this gate.
1977
+ func githubOverviewFits(markdown string) bool {
1978
+ return utf8.RuneCountInString(markdown) <= githubOverviewRunes
1979
+ }
1980
+
1981
+ func githubLocalOmissionNote(kind string, omitted int) string {
1982
+ if omitted <= 0 {
1983
+ return ""
1984
+ }
1985
+ return fmt.Sprintf("> %d %s locally omitted from this overview to keep the read bounded.", omitted, kind)
1986
+ }
1987
+
1988
+ func markdownFenceMarker(trimmed string) (string, bool) {
1989
+ if strings.HasPrefix(trimmed, "```") {
1990
+ return "```", true
1991
+ }
1992
+ if strings.HasPrefix(trimmed, "~~~") {
1993
+ return "~~~", true
1994
+ }
1995
+ return "", false
1996
+ }
1997
+
1998
+ func isMarkdownPath(name string) bool {
1999
+ lower := strings.ToLower(name)
2000
+ return strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") || strings.HasSuffix(lower, ".mdown") || strings.HasSuffix(lower, ".mkdn")
2001
+ }
2002
+
2003
+ func isTextContent(content []byte, contentType string) bool {
2004
+ if bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) {
2005
+ return false
2006
+ }
2007
+ contentType = strings.ToLower(contentType)
2008
+ if isKnownBinaryContentType(contentType) {
2009
+ return false
2010
+ }
2011
+ if strings.HasPrefix(contentType, "text/") || strings.Contains(contentType, "json") || strings.Contains(contentType, "xml") || strings.Contains(contentType, "javascript") || strings.Contains(contentType, "yaml") || strings.Contains(contentType, "toml") {
2012
+ return true
2013
+ }
2014
+ detected := strings.ToLower(http.DetectContentType(content))
2015
+ if isKnownBinaryContentType(detected) {
2016
+ return false
2017
+ }
2018
+ if strings.HasPrefix(detected, "text/") || strings.Contains(detected, "json") || strings.Contains(detected, "xml") {
2019
+ return true
2020
+ }
2021
+ // GitHub may serve extensionless source as application/octet-stream. Valid
2022
+ // UTF-8 without NULs is safe to treat as textual source for terminal output.
2023
+ return true
2024
+ }
2025
+
2026
+ func isKnownBinaryContentType(contentType string) bool {
2027
+ contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
2028
+ if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "audio/") || strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "font/") {
2029
+ return true
2030
+ }
2031
+ switch contentType {
2032
+ case "application/pdf", "application/zip", "application/gzip", "application/x-gzip", "application/x-rar-compressed", "application/vnd.rar", "application/x-7z-compressed", "application/wasm":
2033
+ return true
2034
+ default:
2035
+ return false
2036
+ }
2037
+ }
2038
+
2039
+ func githubRawURL(rawBase, owner, repo string, tail []string) string {
2040
+ parts := []string{url.PathEscape(owner), url.PathEscape(repo)}
2041
+ for _, segment := range tail {
2042
+ parts = append(parts, url.PathEscape(segment))
2043
+ }
2044
+ return strings.TrimRight(rawBase, "/") + "/" + strings.Join(parts, "/")
2045
+ }
2046
+
2047
+ func escapePathPreservingSlashes(value string) string {
2048
+ parts := strings.Split(value, "/")
2049
+ for i := range parts {
2050
+ parts[i] = url.PathEscape(parts[i])
2051
+ }
2052
+ return strings.Join(parts, "/")
2053
+ }
2054
+
2055
+ func yamlScalar(value string) string {
2056
+ return strconv.Quote(value)
2057
+ }
2058
+
2059
+ func escapeMarkdownLinkText(value string) string {
2060
+ value = strings.ReplaceAll(value, "\\", "\\\\")
2061
+ value = strings.ReplaceAll(value, "[", "\\[")
2062
+ value = strings.ReplaceAll(value, "]", "\\]")
2063
+ return value
2064
+ }
2065
+
2066
+ func asGitHubError(err error, target **GitHubError) bool {
2067
+ if err == nil {
2068
+ return false
2069
+ }
2070
+ ghErr, ok := err.(*GitHubError)
2071
+ if !ok {
2072
+ return false
2073
+ }
2074
+ *target = ghErr
2075
+ return true
2076
+ }
2077
+
2078
+ var (
2079
+ githubLineSelectorRE = regexp.MustCompile(`^L([0-9]+)(?:-L([0-9]+))?$`)
2080
+ githubBareGistIDRE = regexp.MustCompile(`^[0-9A-Fa-f]{20,64}$`)
2081
+ htmlTagRE = regexp.MustCompile(`<[^>]+>`)
2082
+ markdownImageRE = regexp.MustCompile(`!\[([^\]]*)\]\([^)]*\)`)
2083
+ markdownLinkRE = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
2084
+ )