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,505 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ var githubSearchTypes = map[string]string{
14
+ "repositories": "repositories",
15
+ "issues": "issues",
16
+ "pullrequests": "issues",
17
+ "code": "code",
18
+ "commits": "commits",
19
+ "users": "users",
20
+ }
21
+
22
+ func isSupportedGitHubSearchURLQuery(query url.Values) bool {
23
+ allowed := map[string]struct{}{"q": {}, "type": {}, "s": {}, "o": {}, "p": {}}
24
+ for key := range query {
25
+ if _, ok := allowed[key]; !ok {
26
+ return false
27
+ }
28
+ }
29
+ if strings.TrimSpace(query.Get("q")) == "" {
30
+ return false
31
+ }
32
+ if _, ok := githubSearchTypes[query.Get("type")]; !ok {
33
+ return false
34
+ }
35
+ if order := query.Get("o"); order != "" && order != "asc" && order != "desc" {
36
+ return false
37
+ }
38
+ if page := query.Get("p"); page != "" {
39
+ n, err := strconv.Atoi(page)
40
+ if err != nil || n <= 0 {
41
+ return false
42
+ }
43
+ }
44
+ return true
45
+ }
46
+
47
+ func isSupportedGitHubProfileURLQuery(query url.Values) bool {
48
+ for key := range query {
49
+ if key != "tab" && key != "page" {
50
+ return false
51
+ }
52
+ }
53
+ if page := query.Get("page"); page != "" {
54
+ n, err := strconv.Atoi(page)
55
+ if err != nil || n <= 0 {
56
+ return false
57
+ }
58
+ }
59
+ if tab := query.Get("tab"); tab != "" {
60
+ switch tab {
61
+ case "repositories", "gists", "stars", "followers", "following", "people":
62
+ default:
63
+ return false
64
+ }
65
+ }
66
+ return true
67
+ }
68
+
69
+ type githubSearchEnvelope struct {
70
+ TotalCount int `json:"total_count"`
71
+ IncompleteResults bool `json:"incomplete_results"`
72
+ Items json.RawMessage `json:"items"`
73
+ }
74
+
75
+ type githubCodeSearchItem struct {
76
+ Name string `json:"name"`
77
+ Path string `json:"path"`
78
+ SHA string `json:"sha"`
79
+ HTMLURL string `json:"html_url"`
80
+ Repository struct {
81
+ FullName string `json:"full_name"`
82
+ } `json:"repository"`
83
+ }
84
+
85
+ type githubCommitSearchItem struct {
86
+ SHA string `json:"sha"`
87
+ HTMLURL string `json:"html_url"`
88
+ Commit struct {
89
+ Message string `json:"message"`
90
+ Author struct {
91
+ Date string `json:"date"`
92
+ } `json:"author"`
93
+ } `json:"commit"`
94
+ Repository struct {
95
+ FullName string `json:"full_name"`
96
+ } `json:"repository"`
97
+ }
98
+
99
+ func readGitHubSearch(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
100
+ if target == nil || target.Kind != GitHubTargetSearch || !isSupportedGitHubSearchURLQuery(target.Query) {
101
+ return "", fmt.Errorf("GitHub Search URL is not supported by the native reader")
102
+ }
103
+ if target.Fragment != "" {
104
+ return "", fmt.Errorf("GitHub Search fragment %q is not a supported selector", target.Fragment)
105
+ }
106
+ uiType := target.Query.Get("type")
107
+ apiType := githubSearchTypes[uiType]
108
+ q := strings.TrimSpace(target.Query.Get("q"))
109
+ if target.Owner != "" && target.Repo != "" {
110
+ q += " repo:" + target.Owner + "/" + target.Repo
111
+ }
112
+ if uiType == "issues" {
113
+ q += " is:issue"
114
+ } else if uiType == "pullrequests" {
115
+ q += " is:pr"
116
+ }
117
+ provider := url.Values{"q": []string{q}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
118
+ if sort := target.Query.Get("s"); sort != "" {
119
+ provider.Set("sort", sort)
120
+ }
121
+ if order := target.Query.Get("o"); order != "" {
122
+ provider.Set("order", order)
123
+ }
124
+ if page := target.Query.Get("p"); page != "" {
125
+ provider.Set("page", page)
126
+ }
127
+ endpoint := "/search/" + apiType + "?" + provider.Encode()
128
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
129
+ if err != nil {
130
+ return "", err
131
+ }
132
+ var envelope githubSearchEnvelope
133
+ if err := json.Unmarshal(resp.Body, &envelope); err != nil {
134
+ return "", fmt.Errorf("decode GitHub Search results: %w", err)
135
+ }
136
+ return renderGitHubSearch(target, uiType, envelope, resp.Links())
137
+ }
138
+
139
+ func renderGitHubSearch(target *GitHubTarget, searchType string, envelope githubSearchEnvelope, links GitHubLinkRelations) (string, error) {
140
+ page := target.Query.Get("p")
141
+ if page == "" {
142
+ page = "1"
143
+ }
144
+ var rawItems []json.RawMessage
145
+ if len(envelope.Items) > 0 {
146
+ if err := json.Unmarshal(envelope.Items, &rawItems); err != nil {
147
+ return "", fmt.Errorf("decode GitHub Search result count: %w", err)
148
+ }
149
+ }
150
+ returned := len(rawItems)
151
+ indexed := returned
152
+ queryPreview, queryTruncated := githubOverviewInlinePreview(target.Query.Get("q"), 300)
153
+ lines := []string{
154
+ "---",
155
+ "view: " + yamlScalar("search:"+searchType),
156
+ "query: " + yamlScalar(queryPreview),
157
+ "page: " + page,
158
+ fmt.Sprintf("total_count: %d", envelope.TotalCount),
159
+ fmt.Sprintf("incomplete_results: %t", envelope.IncompleteResults),
160
+ fmt.Sprintf("returned: %d", returned),
161
+ fmt.Sprintf("indexed: %d", indexed),
162
+ fmt.Sprintf("local_omitted: %d", returned-indexed),
163
+ }
164
+ if queryTruncated {
165
+ lines = append(lines, "query_preview_truncated: true")
166
+ }
167
+ if target.Owner != "" && target.Repo != "" {
168
+ lines = append(lines, "repository_scope: "+yamlScalar(target.Owner+"/"+target.Repo))
169
+ }
170
+ if envelope.TotalCount > 1000 {
171
+ lines = append(lines, "provider_result_ceiling: 1000")
172
+ }
173
+ lines = append(lines, "---", "", "# GitHub search", "")
174
+ if envelope.IncompleteResults {
175
+ lines = append(lines, "> GitHub marked these search results incomplete.", "")
176
+ }
177
+ if envelope.TotalCount > 1000 {
178
+ lines = append(lines, "> GitHub Search exposes at most 1000 results for a query; this bounded page does not imply access beyond that provider ceiling.", "")
179
+ }
180
+ switch searchType {
181
+ case "repositories":
182
+ var items []githubRepository
183
+ if err := json.Unmarshal(envelope.Items, &items); err != nil {
184
+ return "", fmt.Errorf("decode GitHub repository search items: %w", err)
185
+ }
186
+ for _, item := range items[:minInt(indexed, len(items))] {
187
+ href := item.HTMLURL
188
+ if href == "" {
189
+ href = "https://github.com/" + item.FullName
190
+ }
191
+ meta := []string{fmt.Sprintf("%d stars", item.StargazersCount)}
192
+ if item.Language != "" {
193
+ meta = append(meta, item.Language)
194
+ }
195
+ name, nameTruncated := githubOverviewInlinePreview(item.FullName, 120)
196
+ if nameTruncated {
197
+ name += "…"
198
+ }
199
+ line := "- [" + escapeMarkdownLinkText(name) + "](" + href + ") — " + strings.Join(meta, " · ")
200
+ if item.Description != "" {
201
+ description, truncated := githubOverviewInlinePreview(item.Description, 140)
202
+ if truncated {
203
+ description += "…"
204
+ }
205
+ line += " — " + description
206
+ }
207
+ lines = append(lines, line)
208
+ }
209
+ case "issues", "pullrequests":
210
+ var items []githubIssue
211
+ if err := json.Unmarshal(envelope.Items, &items); err != nil {
212
+ return "", fmt.Errorf("decode GitHub Issue search items: %w", err)
213
+ }
214
+ for _, item := range items[:minInt(indexed, len(items))] {
215
+ meta := []string{item.State}
216
+ if item.User.Login != "" {
217
+ meta = append(meta, "@"+item.User.Login)
218
+ }
219
+ title, truncated := githubOverviewInlinePreview(item.Title, 140)
220
+ if truncated {
221
+ title += "…"
222
+ }
223
+ lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", item.Number, escapeMarkdownLinkText(title), item.HTMLURL, strings.Join(meta, " · ")))
224
+ }
225
+ case "code":
226
+ var items []githubCodeSearchItem
227
+ if err := json.Unmarshal(envelope.Items, &items); err != nil {
228
+ return "", fmt.Errorf("decode GitHub code search items: %w", err)
229
+ }
230
+ for _, item := range items[:minInt(indexed, len(items))] {
231
+ path, truncated := githubOverviewInlinePreview(item.Path, 140)
232
+ if truncated {
233
+ path += "…"
234
+ }
235
+ repo, repoTruncated := githubOverviewInlinePreview(item.Repository.FullName, 120)
236
+ if repoTruncated {
237
+ repo += "…"
238
+ }
239
+ lines = append(lines, fmt.Sprintf("- [%s](%s) — %s · `%s`", escapeMarkdownLinkText(path), item.HTMLURL, repo, shortSHA(item.SHA)))
240
+ }
241
+ case "commits":
242
+ var items []githubCommitSearchItem
243
+ if err := json.Unmarshal(envelope.Items, &items); err != nil {
244
+ return "", fmt.Errorf("decode GitHub commit search items: %w", err)
245
+ }
246
+ for _, item := range items[:minInt(indexed, len(items))] {
247
+ message, truncated := githubOverviewInlinePreview(firstLine(item.Commit.Message), 140)
248
+ if truncated {
249
+ message += "…"
250
+ }
251
+ line := fmt.Sprintf("- [`%s`](%s) %s", shortSHA(item.SHA), item.HTMLURL, message)
252
+ if item.Repository.FullName != "" {
253
+ repo, repoTruncated := githubOverviewInlinePreview(item.Repository.FullName, 120)
254
+ if repoTruncated {
255
+ repo += "…"
256
+ }
257
+ line += " — " + repo
258
+ }
259
+ if item.Commit.Author.Date != "" {
260
+ line += " · " + item.Commit.Author.Date
261
+ }
262
+ lines = append(lines, line)
263
+ }
264
+ case "users":
265
+ var items []githubUser
266
+ if err := json.Unmarshal(envelope.Items, &items); err != nil {
267
+ return "", fmt.Errorf("decode GitHub user search items: %w", err)
268
+ }
269
+ for _, item := range items[:minInt(indexed, len(items))] {
270
+ line := renderGitHubUserIdentity(item)
271
+ if item.Type != "" {
272
+ line += " — " + item.Type
273
+ }
274
+ lines = append(lines, line)
275
+ }
276
+ }
277
+ if returned == 0 {
278
+ lines = append(lines, "_No results returned on this page._")
279
+ }
280
+ if nav := renderGitHubSearchNavigation(target, links); len(nav) > 0 {
281
+ lines = append(lines, "", "## Navigation", "")
282
+ lines = append(lines, nav...)
283
+ }
284
+ return strings.TrimSpace(strings.Join(lines, "\n")), nil
285
+ }
286
+
287
+ func renderGitHubSearchNavigation(target *GitHubTarget, links GitHubLinkRelations) []string {
288
+ lines := []string{}
289
+ for _, rel := range []string{"prev", "next"} {
290
+ page, ok := pageFromGitHubLink(links[rel])
291
+ if !ok {
292
+ continue
293
+ }
294
+ parsed, err := url.Parse(target.OriginalURL)
295
+ if err != nil {
296
+ continue
297
+ }
298
+ q := parsed.Query()
299
+ q.Set("p", strconv.Itoa(page))
300
+ parsed.RawQuery = q.Encode()
301
+ label := "Previous"
302
+ if rel == "next" {
303
+ label = "Next"
304
+ }
305
+ lines = append(lines, "- "+label+": "+parsed.String())
306
+ }
307
+ return lines
308
+ }
309
+
310
+ type githubProfile struct {
311
+ Login string `json:"login"`
312
+ Type string `json:"type"`
313
+ Name string `json:"name"`
314
+ Bio string `json:"bio"`
315
+ Blog string `json:"blog"`
316
+ Location string `json:"location"`
317
+ Company string `json:"company"`
318
+ HTMLURL string `json:"html_url"`
319
+ PublicRepos int `json:"public_repos"`
320
+ PublicGists int `json:"public_gists"`
321
+ Followers int `json:"followers"`
322
+ Following int `json:"following"`
323
+ CreatedAt string `json:"created_at"`
324
+ }
325
+
326
+ type githubProfileGist struct {
327
+ ID string `json:"id"`
328
+ HTMLURL string `json:"html_url"`
329
+ Description string `json:"description"`
330
+ Files map[string]struct {
331
+ Filename string `json:"filename"`
332
+ } `json:"files"`
333
+ }
334
+
335
+ func readGitHubProfile(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
336
+ if target == nil || target.Kind != GitHubTargetProfile || target.Owner == "" {
337
+ return "", fmt.Errorf("GitHub profile target is invalid")
338
+ }
339
+ if target.Fragment != "" || !isSupportedGitHubProfileURLQuery(target.Query) {
340
+ return "", fmt.Errorf("GitHub profile query/fragment is not supported")
341
+ }
342
+ resp, err := client.REST(ctx, http.MethodGet, "/users/"+url.PathEscape(target.Owner), "application/vnd.github+json")
343
+ if err != nil {
344
+ return "", err
345
+ }
346
+ var profile githubProfile
347
+ if err := json.Unmarshal(resp.Body, &profile); err != nil {
348
+ return "", fmt.Errorf("decode GitHub profile: %w", err)
349
+ }
350
+ if profile.Login == "" {
351
+ profile.Login = target.Owner
352
+ }
353
+ if strings.EqualFold(profile.Type, "Organization") {
354
+ orgResp, err := client.REST(ctx, http.MethodGet, "/orgs/"+url.PathEscape(target.Owner), "application/vnd.github+json")
355
+ if err != nil {
356
+ return "", err
357
+ }
358
+ var org githubProfile
359
+ if err := json.Unmarshal(orgResp.Body, &org); err != nil {
360
+ return "", fmt.Errorf("decode GitHub Organization profile: %w", err)
361
+ }
362
+ org.Type = "Organization"
363
+ if org.Login == "" {
364
+ org.Login = target.Owner
365
+ }
366
+ profile = org
367
+ }
368
+ tab := target.Query.Get("tab")
369
+ if target.Name == "people" {
370
+ tab = "people"
371
+ }
372
+ if tab == "" {
373
+ return renderGitHubProfile(profile), nil
374
+ }
375
+ return readGitHubProfileTab(ctx, client, target, profile, tab)
376
+ }
377
+
378
+ func renderGitHubProfile(profile githubProfile) string {
379
+ lines := []string{"---", "login: " + yamlScalar(profile.Login), "type: " + yamlScalar(profile.Type)}
380
+ if profile.Name != "" {
381
+ lines = append(lines, "name: "+yamlScalar(profile.Name))
382
+ }
383
+ lines = append(lines, fmt.Sprintf("public_repositories: %d", profile.PublicRepos))
384
+ if !strings.EqualFold(profile.Type, "Organization") {
385
+ lines = append(lines, fmt.Sprintf("public_gists: %d", profile.PublicGists), fmt.Sprintf("followers: %d", profile.Followers), fmt.Sprintf("following: %d", profile.Following))
386
+ }
387
+ if profile.Location != "" {
388
+ lines = append(lines, "location: "+yamlScalar(profile.Location))
389
+ }
390
+ if profile.CreatedAt != "" {
391
+ lines = append(lines, "created: "+yamlScalar(profile.CreatedAt))
392
+ }
393
+ if profile.HTMLURL != "" {
394
+ lines = append(lines, "url: "+yamlScalar(profile.HTMLURL))
395
+ }
396
+ lines = append(lines, "---", "", "# "+profile.Login, "")
397
+ if profile.Bio != "" {
398
+ lines = append(lines, profile.Bio, "")
399
+ }
400
+ base := "https://github.com/" + url.PathEscape(profile.Login)
401
+ lines = append(lines, "## Useful GitHub URLs", "", "- Repositories: "+base+"?tab=repositories")
402
+ if strings.EqualFold(profile.Type, "Organization") {
403
+ lines = append(lines, "- Public members: https://github.com/orgs/"+url.PathEscape(profile.Login)+"/people")
404
+ } else {
405
+ lines = append(lines, "- Gists: "+base+"?tab=gists", "- Stars: "+base+"?tab=stars", "- Followers: "+base+"?tab=followers", "- Following: "+base+"?tab=following")
406
+ }
407
+ return strings.TrimSpace(strings.Join(lines, "\n"))
408
+ }
409
+
410
+ func readGitHubProfileTab(ctx context.Context, client *GitHubClient, target *GitHubTarget, profile githubProfile, tab string) (string, error) {
411
+ page := target.Query.Get("page")
412
+ provider := url.Values{"per_page": []string{strconv.Itoa(githubPageableListSize)}}
413
+ if page != "" {
414
+ provider.Set("page", page)
415
+ }
416
+ endpoint := ""
417
+ kind := ""
418
+ isOrg := strings.EqualFold(profile.Type, "Organization")
419
+ if isOrg {
420
+ switch tab {
421
+ case "repositories":
422
+ endpoint, kind = "/orgs/"+url.PathEscape(profile.Login)+"/repos", "repositories"
423
+ case "people":
424
+ endpoint, kind = "/orgs/"+url.PathEscape(profile.Login)+"/public_members", "people"
425
+ default:
426
+ return "", fmt.Errorf("GitHub Organization profile tab %q is not supported", tab)
427
+ }
428
+ } else {
429
+ switch tab {
430
+ case "repositories":
431
+ endpoint, kind = "/users/"+url.PathEscape(profile.Login)+"/repos", "repositories"
432
+ case "stars":
433
+ endpoint, kind = "/users/"+url.PathEscape(profile.Login)+"/starred", "repositories"
434
+ case "gists":
435
+ endpoint, kind = "/users/"+url.PathEscape(profile.Login)+"/gists", "gists"
436
+ case "followers":
437
+ endpoint, kind = "/users/"+url.PathEscape(profile.Login)+"/followers", "users"
438
+ case "following":
439
+ endpoint, kind = "/users/"+url.PathEscape(profile.Login)+"/following", "users"
440
+ default:
441
+ return "", fmt.Errorf("GitHub User profile tab %q is not supported", tab)
442
+ }
443
+ }
444
+ resp, err := client.REST(ctx, http.MethodGet, endpoint+"?"+provider.Encode(), "application/vnd.github+json")
445
+ if err != nil {
446
+ return "", err
447
+ }
448
+ if page == "" {
449
+ page = "1"
450
+ }
451
+ rows := []string{}
452
+ returned := 0
453
+ indexed := 0
454
+ switch kind {
455
+ case "repositories":
456
+ var items []githubRepository
457
+ if err := json.Unmarshal(resp.Body, &items); err != nil {
458
+ return "", fmt.Errorf("decode GitHub profile repositories: %w", err)
459
+ }
460
+ returned = len(items)
461
+ indexed = returned
462
+ for _, item := range items[:indexed] {
463
+ name, truncated := githubOverviewInlinePreview(item.FullName, 120)
464
+ if truncated {
465
+ name += "…"
466
+ }
467
+ rows = append(rows, fmt.Sprintf("- [%s](%s) — %d stars", escapeMarkdownLinkText(name), item.HTMLURL, item.StargazersCount))
468
+ }
469
+ case "gists":
470
+ var items []githubProfileGist
471
+ if err := json.Unmarshal(resp.Body, &items); err != nil {
472
+ return "", fmt.Errorf("decode GitHub profile Gists: %w", err)
473
+ }
474
+ returned = len(items)
475
+ indexed = returned
476
+ for _, item := range items[:indexed] {
477
+ line := "- [" + item.ID + "](" + item.HTMLURL + ")"
478
+ if item.Description != "" {
479
+ description, truncated := githubOverviewInlinePreview(item.Description, 140)
480
+ if truncated {
481
+ description += "…"
482
+ }
483
+ line += " — " + description
484
+ }
485
+ rows = append(rows, line)
486
+ }
487
+ case "users", "people":
488
+ var items []githubUser
489
+ if err := json.Unmarshal(resp.Body, &items); err != nil {
490
+ return "", fmt.Errorf("decode GitHub profile users: %w", err)
491
+ }
492
+ returned = len(items)
493
+ indexed = returned
494
+ for _, item := range items[:indexed] {
495
+ rows = append(rows, renderGitHubUserIdentity(item))
496
+ }
497
+ }
498
+ lines := []string{"---", "login: " + yamlScalar(profile.Login), "type: " + yamlScalar(profile.Type), "view: " + yamlScalar(tab), "page: " + page, fmt.Sprintf("returned: %d", returned), fmt.Sprintf("indexed: %d", indexed), fmt.Sprintf("local_omitted: %d", returned-indexed), "---", "", "# " + profile.Login + " — " + tab, ""}
499
+ lines = append(lines, rows...)
500
+ if nav := renderGitHubUIPageNavigation(target, resp.Links()); len(nav) > 0 {
501
+ lines = append(lines, "", "## Navigation", "")
502
+ lines = append(lines, nav...)
503
+ }
504
+ return strings.TrimSpace(strings.Join(lines, "\n")), nil
505
+ }