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,429 @@
|
|
|
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 githubPackageTypes = map[string]struct{}{
|
|
14
|
+
"container": {}, "docker": {}, "maven": {}, "npm": {}, "nuget": {}, "rubygems": {},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type githubPackage struct {
|
|
18
|
+
ID int64 `json:"id"`
|
|
19
|
+
Name string `json:"name"`
|
|
20
|
+
PackageType string `json:"package_type"`
|
|
21
|
+
HTMLURL string `json:"html_url"`
|
|
22
|
+
Visibility string `json:"visibility"`
|
|
23
|
+
Description *string `json:"description"`
|
|
24
|
+
VersionCount int `json:"version_count"`
|
|
25
|
+
CreatedAt string `json:"created_at"`
|
|
26
|
+
UpdatedAt string `json:"updated_at"`
|
|
27
|
+
Owner githubUser `json:"owner"`
|
|
28
|
+
Repository *struct {
|
|
29
|
+
FullName string `json:"full_name"`
|
|
30
|
+
HTMLURL string `json:"html_url"`
|
|
31
|
+
} `json:"repository"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type githubPackageVersion struct {
|
|
35
|
+
ID int64 `json:"id"`
|
|
36
|
+
Name string `json:"name"`
|
|
37
|
+
URL string `json:"url"`
|
|
38
|
+
PackageHTMLURL string `json:"package_html_url"`
|
|
39
|
+
CreatedAt string `json:"created_at"`
|
|
40
|
+
UpdatedAt string `json:"updated_at"`
|
|
41
|
+
Metadata struct {
|
|
42
|
+
PackageType string `json:"package_type"`
|
|
43
|
+
Container struct {
|
|
44
|
+
Tags []string `json:"tags"`
|
|
45
|
+
} `json:"container"`
|
|
46
|
+
} `json:"metadata"`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type githubProjectV2 struct {
|
|
50
|
+
ID string
|
|
51
|
+
Number int
|
|
52
|
+
Title string
|
|
53
|
+
ShortDescription string
|
|
54
|
+
URL string
|
|
55
|
+
Public bool
|
|
56
|
+
Closed bool
|
|
57
|
+
CreatedAt string
|
|
58
|
+
UpdatedAt string
|
|
59
|
+
Items []githubProjectV2Item
|
|
60
|
+
MoreItems bool
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type githubProjectV2Item struct {
|
|
64
|
+
ID string
|
|
65
|
+
Type string
|
|
66
|
+
Title string
|
|
67
|
+
URL string
|
|
68
|
+
State string
|
|
69
|
+
Repository string
|
|
70
|
+
Number int
|
|
71
|
+
Body string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func isSupportedGitHubPackageType(packageType string) bool {
|
|
75
|
+
_, ok := githubPackageTypes[packageType]
|
|
76
|
+
return ok
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func readGitHubPackage(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
80
|
+
if target.Fragment != "" {
|
|
81
|
+
return "", fmt.Errorf("GitHub Package fragment %q is not a supported native selector", target.Fragment)
|
|
82
|
+
}
|
|
83
|
+
for key := range target.Query {
|
|
84
|
+
if key != "page" {
|
|
85
|
+
return "", fmt.Errorf("GitHub Package query parameter %q is not supported by the native reader", key)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
page := target.Query.Get("page")
|
|
89
|
+
if page != "" {
|
|
90
|
+
n, err := strconv.Atoi(page)
|
|
91
|
+
if err != nil || n <= 0 {
|
|
92
|
+
return "", fmt.Errorf("invalid GitHub Package page %q", page)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if len(target.Tail) != 2 {
|
|
96
|
+
return "", fmt.Errorf("GitHub Package target is missing scope/type identity")
|
|
97
|
+
}
|
|
98
|
+
scope, packageType := target.Tail[0], target.Tail[1]
|
|
99
|
+
base, err := githubPackageAPIBase(scope, target.Owner, packageType, target.Name)
|
|
100
|
+
if err != nil {
|
|
101
|
+
return "", err
|
|
102
|
+
}
|
|
103
|
+
resp, err := client.REST(ctx, http.MethodGet, base, "application/vnd.github+json")
|
|
104
|
+
if err != nil {
|
|
105
|
+
return "", err
|
|
106
|
+
}
|
|
107
|
+
var pkg githubPackage
|
|
108
|
+
if err := json.Unmarshal(resp.Body, &pkg); err != nil {
|
|
109
|
+
return "", fmt.Errorf("decode GitHub Package: %w", err)
|
|
110
|
+
}
|
|
111
|
+
q := url.Values{"per_page": []string{strconv.Itoa(githubPageableListSize)}}
|
|
112
|
+
if page != "" {
|
|
113
|
+
q.Set("page", page)
|
|
114
|
+
}
|
|
115
|
+
versionsResp, err := client.REST(ctx, http.MethodGet, base+"/versions?"+q.Encode(), "application/vnd.github+json")
|
|
116
|
+
if err != nil {
|
|
117
|
+
return "", err
|
|
118
|
+
}
|
|
119
|
+
var versions []githubPackageVersion
|
|
120
|
+
if err := json.Unmarshal(versionsResp.Body, &versions); err != nil {
|
|
121
|
+
return "", fmt.Errorf("decode GitHub Package versions: %w", err)
|
|
122
|
+
}
|
|
123
|
+
return renderGitHubPackage(target, pkg, versions, versionsResp.Links()), nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func githubPackageAPIBase(scope, owner, packageType, name string) (string, error) {
|
|
127
|
+
prefix := ""
|
|
128
|
+
switch scope {
|
|
129
|
+
case "org":
|
|
130
|
+
prefix = "/orgs/" + url.PathEscape(owner)
|
|
131
|
+
case "user":
|
|
132
|
+
prefix = "/users/" + url.PathEscape(owner)
|
|
133
|
+
default:
|
|
134
|
+
return "", fmt.Errorf("unsupported GitHub Package scope %q", scope)
|
|
135
|
+
}
|
|
136
|
+
return fmt.Sprintf("%s/packages/%s/%s", prefix, url.PathEscape(packageType), url.PathEscape(name)), nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func renderGitHubPackage(target *GitHubTarget, pkg githubPackage, versions []githubPackageVersion, links GitHubLinkRelations) string {
|
|
140
|
+
return renderGitHubPackageWithLimit(target, pkg, versions, links, len(versions))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func renderGitHubPackageWithLimit(target *GitHubTarget, pkg githubPackage, versions []githubPackageVersion, links GitHubLinkRelations, limit int) string {
|
|
144
|
+
limit = minInt(limit, len(versions))
|
|
145
|
+
page := target.Query.Get("page")
|
|
146
|
+
if page == "" {
|
|
147
|
+
page = "1"
|
|
148
|
+
}
|
|
149
|
+
lines := []string{
|
|
150
|
+
"---",
|
|
151
|
+
"owner: " + yamlScalar(target.Owner),
|
|
152
|
+
"package: " + yamlScalar(pkg.Name),
|
|
153
|
+
"package_type: " + yamlScalar(pkg.PackageType),
|
|
154
|
+
"visibility: " + yamlScalar(pkg.Visibility),
|
|
155
|
+
fmt.Sprintf("version_count: %d", pkg.VersionCount),
|
|
156
|
+
"versions_page: " + page,
|
|
157
|
+
fmt.Sprintf("versions_returned: %d", len(versions)),
|
|
158
|
+
fmt.Sprintf("versions_indexed: %d", limit),
|
|
159
|
+
fmt.Sprintf("versions_local_omitted: %d", len(versions)-limit),
|
|
160
|
+
}
|
|
161
|
+
if pkg.Repository != nil && pkg.Repository.FullName != "" {
|
|
162
|
+
lines = append(lines, "repository: "+yamlScalar(pkg.Repository.FullName))
|
|
163
|
+
}
|
|
164
|
+
if pkg.CreatedAt != "" {
|
|
165
|
+
lines = append(lines, "created: "+yamlScalar(pkg.CreatedAt))
|
|
166
|
+
}
|
|
167
|
+
if pkg.UpdatedAt != "" {
|
|
168
|
+
lines = append(lines, "updated: "+yamlScalar(pkg.UpdatedAt))
|
|
169
|
+
}
|
|
170
|
+
if pkg.HTMLURL != "" {
|
|
171
|
+
lines = append(lines, "url: "+yamlScalar(pkg.HTMLURL))
|
|
172
|
+
}
|
|
173
|
+
lines = append(lines, "---", "", "# Package: "+pkg.Name, "")
|
|
174
|
+
if pkg.Description != nil && strings.TrimSpace(*pkg.Description) != "" {
|
|
175
|
+
preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(*pkg.Description), 1200)
|
|
176
|
+
lines = append(lines, preview, "")
|
|
177
|
+
if truncated && pkg.HTMLURL != "" {
|
|
178
|
+
lines = append(lines, "> Package description preview locally truncated. Full package: "+pkg.HTMLURL, "")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
lines = append(lines, "## Versions", "")
|
|
182
|
+
if len(versions) == 0 {
|
|
183
|
+
lines = append(lines, "_No package versions returned on this page._")
|
|
184
|
+
}
|
|
185
|
+
for _, version := range versions[:limit] {
|
|
186
|
+
name := version.Name
|
|
187
|
+
if name == "" {
|
|
188
|
+
name = fmt.Sprintf("version %d", version.ID)
|
|
189
|
+
}
|
|
190
|
+
namePreview, truncated := githubOverviewInlinePreview(name, 120)
|
|
191
|
+
if truncated {
|
|
192
|
+
namePreview += "…"
|
|
193
|
+
}
|
|
194
|
+
line := "- " + namePreview
|
|
195
|
+
if len(version.Metadata.Container.Tags) > 0 {
|
|
196
|
+
tags := make([]string, 0, minInt(5, len(version.Metadata.Container.Tags)))
|
|
197
|
+
for _, tag := range version.Metadata.Container.Tags[:minInt(5, len(version.Metadata.Container.Tags))] {
|
|
198
|
+
tagPreview, tagTruncated := githubOverviewInlinePreview(tag, 80)
|
|
199
|
+
if tagTruncated {
|
|
200
|
+
tagPreview += "…"
|
|
201
|
+
}
|
|
202
|
+
tags = append(tags, tagPreview)
|
|
203
|
+
}
|
|
204
|
+
line += " — tags: " + githubOverviewLabelList(tags, 5)
|
|
205
|
+
if len(version.Metadata.Container.Tags) > 5 {
|
|
206
|
+
line += fmt.Sprintf(" (+%d more)", len(version.Metadata.Container.Tags)-5)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if version.UpdatedAt != "" {
|
|
210
|
+
line += " — updated " + version.UpdatedAt
|
|
211
|
+
}
|
|
212
|
+
if version.PackageHTMLURL != "" {
|
|
213
|
+
line += " — " + version.PackageHTMLURL
|
|
214
|
+
}
|
|
215
|
+
lines = append(lines, line)
|
|
216
|
+
}
|
|
217
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
218
|
+
lines = append(lines, "", "## Version navigation", "")
|
|
219
|
+
lines = append(lines, nav...)
|
|
220
|
+
}
|
|
221
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func readGitHubProjectV2(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
225
|
+
if target.Fragment != "" || len(target.Query) > 0 {
|
|
226
|
+
return "", fmt.Errorf("GitHub Projects v2 query/fragment selectors are not part of the native project contract")
|
|
227
|
+
}
|
|
228
|
+
if len(target.Tail) != 1 || (target.Tail[0] != "org" && target.Tail[0] != "user") {
|
|
229
|
+
return "", fmt.Errorf("GitHub Projects v2 target is missing owner scope")
|
|
230
|
+
}
|
|
231
|
+
project, err := fetchGitHubProjectV2(ctx, client, target)
|
|
232
|
+
if err != nil {
|
|
233
|
+
return "", err
|
|
234
|
+
}
|
|
235
|
+
return renderGitHubProjectV2(target, project), nil
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func fetchGitHubProjectV2(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubProjectV2, error) {
|
|
239
|
+
prefix := "/orgs/" + url.PathEscape(target.Owner)
|
|
240
|
+
if target.Tail[0] == "user" {
|
|
241
|
+
prefix = "/users/" + url.PathEscape(target.Owner)
|
|
242
|
+
}
|
|
243
|
+
base := fmt.Sprintf("%s/projectsV2/%d", prefix, target.Number)
|
|
244
|
+
projectResp, err := client.RESTPublic(ctx, http.MethodGet, base, "application/vnd.github+json")
|
|
245
|
+
if err != nil {
|
|
246
|
+
return githubProjectV2{}, err
|
|
247
|
+
}
|
|
248
|
+
var raw struct {
|
|
249
|
+
ID int64 `json:"id"`
|
|
250
|
+
Number int `json:"number"`
|
|
251
|
+
Title string `json:"title"`
|
|
252
|
+
Description *string `json:"description"`
|
|
253
|
+
ShortDescription *string `json:"short_description"`
|
|
254
|
+
Public bool `json:"public"`
|
|
255
|
+
State string `json:"state"`
|
|
256
|
+
CreatedAt string `json:"created_at"`
|
|
257
|
+
UpdatedAt string `json:"updated_at"`
|
|
258
|
+
}
|
|
259
|
+
if err := json.Unmarshal(projectResp.Body, &raw); err != nil {
|
|
260
|
+
return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2: %w", err)
|
|
261
|
+
}
|
|
262
|
+
description := ""
|
|
263
|
+
if raw.ShortDescription != nil {
|
|
264
|
+
description = strings.TrimSpace(*raw.ShortDescription)
|
|
265
|
+
}
|
|
266
|
+
if description == "" && raw.Description != nil {
|
|
267
|
+
description = strings.TrimSpace(*raw.Description)
|
|
268
|
+
}
|
|
269
|
+
project := githubProjectV2{
|
|
270
|
+
ID: strconv.FormatInt(raw.ID, 10),
|
|
271
|
+
Number: raw.Number,
|
|
272
|
+
Title: raw.Title,
|
|
273
|
+
ShortDescription: description,
|
|
274
|
+
URL: target.OriginalURL,
|
|
275
|
+
Public: raw.Public,
|
|
276
|
+
Closed: strings.EqualFold(raw.State, "closed"),
|
|
277
|
+
CreatedAt: raw.CreatedAt,
|
|
278
|
+
UpdatedAt: raw.UpdatedAt,
|
|
279
|
+
}
|
|
280
|
+
itemsResp, err := client.RESTPublic(ctx, http.MethodGet, base+"/items?per_page=50", "application/vnd.github+json")
|
|
281
|
+
if err != nil {
|
|
282
|
+
return githubProjectV2{}, err
|
|
283
|
+
}
|
|
284
|
+
var rawItems []struct {
|
|
285
|
+
ID int64 `json:"id"`
|
|
286
|
+
ContentType string `json:"content_type"`
|
|
287
|
+
Content json.RawMessage `json:"content"`
|
|
288
|
+
}
|
|
289
|
+
if err := json.Unmarshal(itemsResp.Body, &rawItems); err != nil {
|
|
290
|
+
return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2 items: %w", err)
|
|
291
|
+
}
|
|
292
|
+
project.MoreItems = strings.TrimSpace(itemsResp.Links()["next"]) != ""
|
|
293
|
+
for _, rawItem := range rawItems {
|
|
294
|
+
item := githubProjectV2Item{ID: strconv.FormatInt(rawItem.ID, 10), Type: rawItem.ContentType}
|
|
295
|
+
if len(rawItem.Content) > 0 && string(rawItem.Content) != "null" {
|
|
296
|
+
var content struct {
|
|
297
|
+
Number int `json:"number"`
|
|
298
|
+
Title string `json:"title"`
|
|
299
|
+
Body string `json:"body"`
|
|
300
|
+
State string `json:"state"`
|
|
301
|
+
HTMLURL string `json:"html_url"`
|
|
302
|
+
RepositoryURL string `json:"repository_url"`
|
|
303
|
+
Repository *struct {
|
|
304
|
+
FullName string `json:"full_name"`
|
|
305
|
+
NameWithOwner string `json:"nameWithOwner"`
|
|
306
|
+
} `json:"repository"`
|
|
307
|
+
}
|
|
308
|
+
if err := json.Unmarshal(rawItem.Content, &content); err != nil {
|
|
309
|
+
return githubProjectV2{}, fmt.Errorf("decode GitHub Project v2 item content: %w", err)
|
|
310
|
+
}
|
|
311
|
+
item.Title, item.URL, item.State, item.Number, item.Body = content.Title, content.HTMLURL, content.State, content.Number, content.Body
|
|
312
|
+
if content.Repository != nil {
|
|
313
|
+
item.Repository = content.Repository.FullName
|
|
314
|
+
if item.Repository == "" {
|
|
315
|
+
item.Repository = content.Repository.NameWithOwner
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if item.Repository == "" && content.RepositoryURL != "" {
|
|
319
|
+
if parsed, parseErr := url.Parse(content.RepositoryURL); parseErr == nil {
|
|
320
|
+
parts := splitGitHubPath(parsed.Path)
|
|
321
|
+
if len(parts) >= 3 && parts[0] == "repos" {
|
|
322
|
+
item.Repository = parts[1] + "/" + parts[2]
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
project.Items = append(project.Items, item)
|
|
328
|
+
}
|
|
329
|
+
return project, nil
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func renderGitHubProjectV2(target *GitHubTarget, project githubProjectV2) string {
|
|
333
|
+
return githubBoundedOverviewList(len(project.Items), 20, func(limit int) string {
|
|
334
|
+
return renderGitHubProjectV2WithLimit(target, project, limit)
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
func renderGitHubProjectV2WithLimit(target *GitHubTarget, project githubProjectV2, limit int) string {
|
|
339
|
+
limit = minInt(limit, len(project.Items))
|
|
340
|
+
titlePreview, titleTruncated := githubOverviewInlinePreview(project.Title, 180)
|
|
341
|
+
if titleTruncated {
|
|
342
|
+
titlePreview += "…"
|
|
343
|
+
}
|
|
344
|
+
lines := []string{
|
|
345
|
+
"---",
|
|
346
|
+
"owner: " + yamlScalar(target.Owner),
|
|
347
|
+
fmt.Sprintf("project: %d", project.Number),
|
|
348
|
+
"title: " + yamlScalar(titlePreview),
|
|
349
|
+
fmt.Sprintf("public: %t", project.Public),
|
|
350
|
+
fmt.Sprintf("closed: %t", project.Closed),
|
|
351
|
+
fmt.Sprintf("items_returned: %d", len(project.Items)),
|
|
352
|
+
fmt.Sprintf("items_indexed: %d", limit),
|
|
353
|
+
fmt.Sprintf("items_local_omitted: %d", len(project.Items)-limit),
|
|
354
|
+
fmt.Sprintf("more_items_available: %t", project.MoreItems),
|
|
355
|
+
}
|
|
356
|
+
if project.CreatedAt != "" {
|
|
357
|
+
lines = append(lines, "created: "+yamlScalar(project.CreatedAt))
|
|
358
|
+
}
|
|
359
|
+
if project.UpdatedAt != "" {
|
|
360
|
+
lines = append(lines, "updated: "+yamlScalar(project.UpdatedAt))
|
|
361
|
+
}
|
|
362
|
+
if project.URL != "" {
|
|
363
|
+
lines = append(lines, "url: "+yamlScalar(project.URL))
|
|
364
|
+
}
|
|
365
|
+
lines = append(lines, "---", "", "# "+titlePreview, "")
|
|
366
|
+
if strings.TrimSpace(project.ShortDescription) != "" {
|
|
367
|
+
preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(project.ShortDescription), 1000)
|
|
368
|
+
lines = append(lines, preview, "")
|
|
369
|
+
if truncated && project.URL != "" {
|
|
370
|
+
lines = append(lines, "> Project description preview locally truncated. Full project: "+project.URL, "")
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
lines = append(lines, "## Items", "")
|
|
374
|
+
if len(project.Items) == 0 {
|
|
375
|
+
lines = append(lines, "_No Project items returned._")
|
|
376
|
+
}
|
|
377
|
+
for _, item := range project.Items[:limit] {
|
|
378
|
+
label := item.Title
|
|
379
|
+
if label == "" {
|
|
380
|
+
label = item.Type
|
|
381
|
+
}
|
|
382
|
+
line := "- "
|
|
383
|
+
labelPreview, truncated := githubOverviewInlinePreview(label, 140)
|
|
384
|
+
if truncated {
|
|
385
|
+
labelPreview += "…"
|
|
386
|
+
}
|
|
387
|
+
if item.URL != "" {
|
|
388
|
+
line += "[" + escapeMarkdownLinkText(labelPreview) + "](" + item.URL + ")"
|
|
389
|
+
} else {
|
|
390
|
+
line += labelPreview
|
|
391
|
+
}
|
|
392
|
+
meta := []string{}
|
|
393
|
+
if item.Repository != "" {
|
|
394
|
+
repository, truncated := githubOverviewInlinePreview(item.Repository, 120)
|
|
395
|
+
if truncated {
|
|
396
|
+
repository += "…"
|
|
397
|
+
}
|
|
398
|
+
meta = append(meta, repository)
|
|
399
|
+
}
|
|
400
|
+
if item.Number > 0 {
|
|
401
|
+
meta = append(meta, fmt.Sprintf("#%d", item.Number))
|
|
402
|
+
}
|
|
403
|
+
if item.State != "" {
|
|
404
|
+
state, truncated := githubOverviewInlinePreview(item.State, 40)
|
|
405
|
+
if truncated {
|
|
406
|
+
state += "…"
|
|
407
|
+
}
|
|
408
|
+
meta = append(meta, state)
|
|
409
|
+
}
|
|
410
|
+
if item.Type != "" {
|
|
411
|
+
itemType, truncated := githubOverviewInlinePreview(item.Type, 40)
|
|
412
|
+
if truncated {
|
|
413
|
+
itemType += "…"
|
|
414
|
+
}
|
|
415
|
+
meta = append(meta, itemType)
|
|
416
|
+
}
|
|
417
|
+
if len(meta) > 0 {
|
|
418
|
+
line += " — " + strings.Join(meta, " · ")
|
|
419
|
+
}
|
|
420
|
+
lines = append(lines, line)
|
|
421
|
+
}
|
|
422
|
+
if note := githubLocalOmissionNote("Project items returned by GitHub", len(project.Items)-limit); note != "" {
|
|
423
|
+
lines = append(lines, "", note)
|
|
424
|
+
}
|
|
425
|
+
if project.MoreItems {
|
|
426
|
+
lines = append(lines, "", "> More Project items exist upstream. This native long-tail Project view intentionally returns the first 50 items rather than expanding an unbounded project into context.")
|
|
427
|
+
}
|
|
428
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
429
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestParsePackageProjectTargetsAndLongTailFallback(t *testing.T) {
|
|
15
|
+
for _, tt := range []struct {
|
|
16
|
+
raw string
|
|
17
|
+
kind GitHubTargetKind
|
|
18
|
+
owner string
|
|
19
|
+
name string
|
|
20
|
+
number int
|
|
21
|
+
tail []string
|
|
22
|
+
}{
|
|
23
|
+
{raw: "https://github.com/orgs/acme/packages/container/package/widget", kind: GitHubTargetPackage, owner: "acme", name: "widget", tail: []string{"org", "container"}},
|
|
24
|
+
{raw: "https://github.com/users/alice/packages/npm/package/pkg", kind: GitHubTargetPackage, owner: "alice", name: "pkg", tail: []string{"user", "npm"}},
|
|
25
|
+
{raw: "https://github.com/orgs/acme/projects/7", kind: GitHubTargetProjectV2, owner: "acme", number: 7, tail: []string{"org"}},
|
|
26
|
+
{raw: "https://github.com/users/alice/projects/3", kind: GitHubTargetProjectV2, owner: "alice", number: 3, tail: []string{"user"}},
|
|
27
|
+
} {
|
|
28
|
+
target := parseGitHubTarget(tt.raw)
|
|
29
|
+
if target == nil || target.Kind != tt.kind || target.Owner != tt.owner || target.Name != tt.name || target.Number != tt.number || strings.Join(target.Tail, ",") != strings.Join(tt.tail, ",") {
|
|
30
|
+
t.Fatalf("target %s => %#v", tt.raw, target)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for _, raw := range []string{
|
|
34
|
+
"https://github.com/orgs/acme/packages",
|
|
35
|
+
"https://github.com/users/alice/packages/container",
|
|
36
|
+
"https://github.com/orgs/acme/packages/unknown/package/widget",
|
|
37
|
+
"https://github.com/o/r/wiki",
|
|
38
|
+
"https://github.com/o/r/settings",
|
|
39
|
+
"https://github.com/o/r/security",
|
|
40
|
+
"https://github.com/o/r/archive/refs/heads/main.zip",
|
|
41
|
+
} {
|
|
42
|
+
if target := parseGitHubTarget(raw); target != nil {
|
|
43
|
+
t.Fatalf("excluded/unproven long-tail route was claimed: %s => %#v", raw, target)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestPackageDetailAndBoundedVersions(t *testing.T) {
|
|
49
|
+
var server *httptest.Server
|
|
50
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
51
|
+
w.Header().Set("Content-Type", "application/json")
|
|
52
|
+
switch r.URL.Path {
|
|
53
|
+
case "/orgs/acme/packages/container/widget":
|
|
54
|
+
_, _ = io.WriteString(w, `{"id":1,"name":"widget","package_type":"container","html_url":"https://github.com/orgs/acme/packages/container/package/widget","visibility":"public","description":"demo package","version_count":50,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z","repository":{"full_name":"acme/widget","html_url":"https://github.com/acme/widget"}}`)
|
|
55
|
+
case "/orgs/acme/packages/container/widget/versions":
|
|
56
|
+
if r.URL.Query().Get("per_page") != "8" || r.URL.Query().Get("page") != "2" {
|
|
57
|
+
t.Fatalf("version pagination lost: %s", r.URL.RawQuery)
|
|
58
|
+
}
|
|
59
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/orgs/acme/packages/container/widget/versions?per_page=8&page=1>; rel="prev", <%s/orgs/acme/packages/container/widget/versions?per_page=8&page=3>; rel="next"`, server.URL, server.URL))
|
|
60
|
+
_, _ = io.WriteString(w, `[{"id":11,"name":"sha256:abc","package_html_url":"https://github.com/orgs/acme/packages/container/widget/11","updated_at":"2026-08-01T00:00:00Z","metadata":{"package_type":"container","container":{"tags":["latest","v1"]}}}]`)
|
|
61
|
+
default:
|
|
62
|
+
t.Fatalf("unexpected package request %s", r.URL.Path)
|
|
63
|
+
}
|
|
64
|
+
}))
|
|
65
|
+
defer server.Close()
|
|
66
|
+
out, err := readGitHubPackage(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/orgs/acme/packages/container/package/widget?page=2"))
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
for _, want := range []string{`package: "widget"`, `package_type: "container"`, "version_count: 50", "versions_page: 2", "demo package", "sha256:abc", "tags: latest, v1", "Previous", "page=1", "Next", "page=3"} {
|
|
71
|
+
if !strings.Contains(out, want) {
|
|
72
|
+
t.Fatalf("package output missing %q:\n%s", want, out)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestPublicOrganizationProjectV2UsesRESTWithoutTokenAndBoundsItems(t *testing.T) {
|
|
78
|
+
var server *httptest.Server
|
|
79
|
+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
80
|
+
if r.Header.Get("Authorization") != "" {
|
|
81
|
+
t.Fatalf("anonymous public Project unexpectedly sent auth")
|
|
82
|
+
}
|
|
83
|
+
w.Header().Set("Content-Type", "application/json")
|
|
84
|
+
switch r.URL.Path {
|
|
85
|
+
case "/orgs/acme/projectsV2/7":
|
|
86
|
+
_, _ = io.WriteString(w, `{"id":71,"number":7,"title":"Roadmap","short_description":"Ship it","public":true,"state":"open","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z"}`)
|
|
87
|
+
case "/orgs/acme/projectsV2/7/items":
|
|
88
|
+
if r.URL.Query().Get("per_page") != "50" {
|
|
89
|
+
t.Fatalf("Project items are not bounded: %s", r.URL.RawQuery)
|
|
90
|
+
}
|
|
91
|
+
w.Header().Set("Link", fmt.Sprintf(`<%s/orgs/acme/projectsV2/7/items?per_page=50&after=cursor>; rel="next"`, server.URL))
|
|
92
|
+
_, _ = io.WriteString(w, `[{"id":101,"content_type":"Issue","content":{"number":12,"title":"Feature","html_url":"https://github.com/acme/r/issues/12","state":"open","repository_url":"https://api.github.com/repos/acme/r"}},{"id":102,"content_type":"DraftIssue","content":{"title":"Draft task","body":"details"}}]`)
|
|
93
|
+
default:
|
|
94
|
+
t.Fatalf("unexpected Project REST path %s", r.URL.Path)
|
|
95
|
+
}
|
|
96
|
+
}))
|
|
97
|
+
defer server.Close()
|
|
98
|
+
out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/orgs/acme/projects/7"))
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
for _, want := range []string{`project: 7`, `title: "Roadmap"`, "items_returned: 2", "more_items_available: true", "Ship it", "[Feature](https://github.com/acme/r/issues/12)", "acme/r", "#12", "Draft task", "first 50 items"} {
|
|
103
|
+
if !strings.Contains(out, want) {
|
|
104
|
+
t.Fatalf("Project output missing %q:\n%s", want, out)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestPublicProjectV2RetriesWithoutNarrowFineGrainedToken(t *testing.T) {
|
|
110
|
+
var authCalls, anonymousCalls int32
|
|
111
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
112
|
+
w.Header().Set("Content-Type", "application/json")
|
|
113
|
+
if r.Header.Get("Authorization") != "" {
|
|
114
|
+
atomic.AddInt32(&authCalls, 1)
|
|
115
|
+
w.WriteHeader(http.StatusForbidden)
|
|
116
|
+
_, _ = io.WriteString(w, `{"message":"Resource not accessible by personal access token"}`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
atomic.AddInt32(&anonymousCalls, 1)
|
|
120
|
+
switch r.URL.Path {
|
|
121
|
+
case "/orgs/acme/projectsV2/7":
|
|
122
|
+
_, _ = io.WriteString(w, `{"id":71,"number":7,"title":"Public roadmap","public":true,"state":"open"}`)
|
|
123
|
+
case "/orgs/acme/projectsV2/7/items":
|
|
124
|
+
_, _ = io.WriteString(w, `[]`)
|
|
125
|
+
default:
|
|
126
|
+
t.Fatalf("unexpected Project REST path %s", r.URL.Path)
|
|
127
|
+
}
|
|
128
|
+
}))
|
|
129
|
+
defer server.Close()
|
|
130
|
+
out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, "fake-token"), parseGitHubTarget("https://github.com/orgs/acme/projects/7"))
|
|
131
|
+
if err != nil || !strings.Contains(out, "# Public roadmap") {
|
|
132
|
+
t.Fatalf("fine-grained public Project fallback failed err=%v:\n%s", err, out)
|
|
133
|
+
}
|
|
134
|
+
if atomic.LoadInt32(&authCalls) != 2 || atomic.LoadInt32(&anonymousCalls) != 2 {
|
|
135
|
+
t.Fatalf("Project auth/anonymous retry counts auth=%d anon=%d", authCalls, anonymousCalls)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestUserProjectV2UsesUserRESTPath(t *testing.T) {
|
|
140
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
141
|
+
w.Header().Set("Content-Type", "application/json")
|
|
142
|
+
switch r.URL.Path {
|
|
143
|
+
case "/users/alice/projectsV2/3":
|
|
144
|
+
_, _ = io.WriteString(w, `{"id":31,"number":3,"title":"Personal","public":true,"state":"open"}`)
|
|
145
|
+
case "/users/alice/projectsV2/3/items":
|
|
146
|
+
_, _ = io.WriteString(w, `[]`)
|
|
147
|
+
default:
|
|
148
|
+
t.Fatalf("unexpected User Project path %s", r.URL.Path)
|
|
149
|
+
}
|
|
150
|
+
}))
|
|
151
|
+
defer server.Close()
|
|
152
|
+
out, err := readGitHubProjectV2(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/users/alice/projects/3"))
|
|
153
|
+
if err != nil || !strings.Contains(out, "# Personal") {
|
|
154
|
+
t.Fatalf("User Project failed err=%v:\n%s", err, out)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func TestPackageProjectProviderFailuresStayNative(t *testing.T) {
|
|
159
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
160
|
+
w.WriteHeader(http.StatusNotFound)
|
|
161
|
+
_, _ = io.WriteString(w, `{"message":"private body"}`)
|
|
162
|
+
}))
|
|
163
|
+
defer server.Close()
|
|
164
|
+
result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/orgs/acme/packages/container/package/widget"), testGitHubClient(server.URL, server.URL, ""))
|
|
165
|
+
if result.Outcome != GitHubNativeFailure || result.Err == nil || strings.Contains(result.Err.Error(), "private body") {
|
|
166
|
+
t.Fatalf("Package native failure boundary wrong: %#v", result)
|
|
167
|
+
}
|
|
168
|
+
}
|