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.
- package/AGENTS.md +97 -0
- package/CONTRIBUTORS.md +126 -0
- package/LICENSE +202 -0
- package/Makefile +69 -0
- package/README.md +121 -0
- package/bin/webctx.js +28 -0
- package/cmd/webctx/main.go +11 -0
- package/docs/porting-status.md +173 -0
- package/go.mod +3 -0
- package/internal/app/app.go +139 -0
- package/internal/app/app_test.go +194 -0
- package/internal/app/github.go +2084 -0
- package/internal/app/github_acceptance_test.go +166 -0
- package/internal/app/github_actions.go +1008 -0
- package/internal/app/github_actions_test.go +484 -0
- package/internal/app/github_activity_deployments.go +503 -0
- package/internal/app/github_activity_deployments_test.go +321 -0
- package/internal/app/github_commits.go +1004 -0
- package/internal/app/github_commits_test.go +503 -0
- package/internal/app/github_discussions_gists.go +1156 -0
- package/internal/app/github_discussions_gists_test.go +425 -0
- package/internal/app/github_issues.go +1616 -0
- package/internal/app/github_issues_test.go +731 -0
- package/internal/app/github_packages_projects.go +429 -0
- package/internal/app/github_packages_projects_test.go +168 -0
- package/internal/app/github_phase9_overview_stress_test.go +178 -0
- package/internal/app/github_pull_views.go +1141 -0
- package/internal/app/github_pull_views_test.go +603 -0
- package/internal/app/github_pulls.go +1459 -0
- package/internal/app/github_pulls_test.go +715 -0
- package/internal/app/github_refs.go +621 -0
- package/internal/app/github_refs_test.go +260 -0
- package/internal/app/github_search_profiles.go +505 -0
- package/internal/app/github_search_profiles_test.go +352 -0
- package/internal/app/github_test.go +888 -0
- package/internal/app/scrape.go +264 -0
- package/internal/app/tools.go +670 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/package.json +66 -0
- 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
|
+
)
|