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,503 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "sort"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ type githubRepositoryActivity struct {
16
+ ID int64 `json:"id"`
17
+ NodeID string `json:"node_id"`
18
+ Before string `json:"before"`
19
+ After string `json:"after"`
20
+ Ref string `json:"ref"`
21
+ Timestamp string `json:"timestamp"`
22
+ ActivityType string `json:"activity_type"`
23
+ Actor githubUser `json:"actor"`
24
+ }
25
+
26
+ type githubContributorStats struct {
27
+ Total int `json:"total"`
28
+ Author githubUser `json:"author"`
29
+ Weeks []struct {
30
+ Week int64 `json:"w"`
31
+ Additions int `json:"a"`
32
+ Deletions int `json:"d"`
33
+ Commits int `json:"c"`
34
+ } `json:"weeks"`
35
+ }
36
+
37
+ type githubCommitActivityWeek struct {
38
+ Days []int `json:"days"`
39
+ Total int `json:"total"`
40
+ Week int64 `json:"week"`
41
+ }
42
+
43
+ type githubDeployment struct {
44
+ ID int64 `json:"id"`
45
+ SHA string `json:"sha"`
46
+ Ref string `json:"ref"`
47
+ Task string `json:"task"`
48
+ Environment string `json:"environment"`
49
+ Description *string `json:"description"`
50
+ CreatedAt string `json:"created_at"`
51
+ UpdatedAt string `json:"updated_at"`
52
+ Creator githubUser `json:"creator"`
53
+ TransientEnvironment bool `json:"transient_environment"`
54
+ ProductionEnvironment bool `json:"production_environment"`
55
+ }
56
+
57
+ type githubDeploymentStatus struct {
58
+ ID int64 `json:"id"`
59
+ State string `json:"state"`
60
+ Description string `json:"description"`
61
+ Environment string `json:"environment"`
62
+ LogURL string `json:"log_url"`
63
+ EnvironmentURL string `json:"environment_url"`
64
+ CreatedAt string `json:"created_at"`
65
+ UpdatedAt string `json:"updated_at"`
66
+ Creator githubUser `json:"creator"`
67
+ }
68
+
69
+ type githubEnvironment struct {
70
+ ID int64 `json:"id"`
71
+ Name string `json:"name"`
72
+ HTMLURL string `json:"html_url"`
73
+ CreatedAt string `json:"created_at"`
74
+ UpdatedAt string `json:"updated_at"`
75
+ }
76
+
77
+ type githubDeploymentLatestStatus struct {
78
+ Status *githubDeploymentStatus
79
+ ProviderMore bool
80
+ }
81
+
82
+ func readGitHubActivity(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
83
+ if target.Fragment != "" {
84
+ return "", fmt.Errorf("GitHub activity fragment %q is not a supported native selector", target.Fragment)
85
+ }
86
+ allowed := map[string]struct{}{"ref": {}, "activity_type": {}, "actor": {}, "time_period": {}, "before": {}, "after": {}, "page": {}}
87
+ for key := range target.Query {
88
+ if _, ok := allowed[key]; !ok {
89
+ return "", fmt.Errorf("GitHub activity query parameter %q is not supported by the native reader", key)
90
+ }
91
+ }
92
+ q := copySelectedQuery(target.Query, []string{"ref", "activity_type", "actor", "time_period", "before", "after", "page"})
93
+ q.Set("per_page", strconv.Itoa(githubPageableListSize))
94
+ if page := q.Get("page"); page != "" {
95
+ n, err := strconv.Atoi(page)
96
+ if err != nil || n <= 0 {
97
+ return "", fmt.Errorf("invalid GitHub activity page %q", page)
98
+ }
99
+ }
100
+ endpoint := fmt.Sprintf("/repos/%s/%s/activity?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), q.Encode())
101
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
102
+ if err != nil {
103
+ return "", err
104
+ }
105
+ var items []githubRepositoryActivity
106
+ if err := json.Unmarshal(resp.Body, &items); err != nil {
107
+ return "", fmt.Errorf("decode GitHub repository activity: %w", err)
108
+ }
109
+ limit := len(items)
110
+ lines := listFrontmatter(target, "activity", len(items), limit)
111
+ lines = append(lines, "# Repository activity", "")
112
+ if len(items) == 0 {
113
+ lines = append(lines, "_No activity returned by GitHub on this page._")
114
+ }
115
+ for _, item := range items[:limit] {
116
+ activityType, _ := githubOverviewInlinePreview(item.ActivityType, 60)
117
+ line := "- " + activityType
118
+ if item.Actor.Login != "" {
119
+ actor, truncated := githubOverviewInlinePreview(item.Actor.Login, 80)
120
+ if truncated {
121
+ actor += "…"
122
+ }
123
+ line += " by @" + actor
124
+ }
125
+ if item.Ref != "" {
126
+ ref, truncated := githubOverviewInlinePreview(item.Ref, 120)
127
+ if truncated {
128
+ ref += "…"
129
+ }
130
+ line += " — `" + ref + "`"
131
+ }
132
+ if item.Before != "" || item.After != "" {
133
+ line += " — `" + shortSHA(item.Before) + "` → `" + shortSHA(item.After) + "`"
134
+ }
135
+ if item.Timestamp != "" {
136
+ line += " — " + item.Timestamp
137
+ }
138
+ lines = append(lines, line)
139
+ }
140
+ return appendListNavigation(lines, target, resp.Links()), nil
141
+ }
142
+
143
+ func readGitHubContributorStats(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
144
+ resp, err := readGitHubStatistics(ctx, client, target, "contributors")
145
+ if err != nil || resp.StatusCode == http.StatusAccepted {
146
+ return renderStatisticsPending(target, "contributors", resp), err
147
+ }
148
+ var stats []githubContributorStats
149
+ if err := json.Unmarshal(resp.Body, &stats); err != nil {
150
+ return "", fmt.Errorf("decode GitHub contributor statistics: %w", err)
151
+ }
152
+ sort.SliceStable(stats, func(i, j int) bool {
153
+ if stats[i].Total != stats[j].Total {
154
+ return stats[i].Total > stats[j].Total
155
+ }
156
+ left := strings.ToLower(stats[i].Author.Login)
157
+ right := strings.ToLower(stats[j].Author.Login)
158
+ if left == right {
159
+ return stats[i].Author.Login < stats[j].Author.Login
160
+ }
161
+ return left < right
162
+ })
163
+ return renderGitHubContributorStats(target, stats), nil
164
+ }
165
+
166
+ func renderGitHubContributorStats(target *GitHubTarget, stats []githubContributorStats) string {
167
+ limit := minInt(70, len(stats))
168
+ for {
169
+ out := renderGitHubContributorStatsWithLimit(target, stats, limit)
170
+ if githubOverviewFits(out) || limit <= 1 {
171
+ return out
172
+ }
173
+ limit--
174
+ }
175
+ }
176
+
177
+ func renderGitHubContributorStatsWithLimit(target *GitHubTarget, stats []githubContributorStats, limit int) string {
178
+ limit = minInt(limit, len(stats))
179
+ lines := []string{
180
+ "---",
181
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
182
+ "view: contributors",
183
+ fmt.Sprintf("contributors_returned: %d", len(stats)),
184
+ fmt.Sprintf("contributors_indexed: %d", limit),
185
+ fmt.Sprintf("contributors_local_omitted: %d", len(stats)-limit),
186
+ "---",
187
+ "",
188
+ "# Contributor statistics",
189
+ "",
190
+ statisticsFreshnessNote(),
191
+ "",
192
+ }
193
+ for _, contributor := range stats[:limit] {
194
+ adds, dels := 0, 0
195
+ for _, week := range contributor.Weeks {
196
+ adds += week.Additions
197
+ dels += week.Deletions
198
+ }
199
+ name := contributor.Author.Login
200
+ if name == "" {
201
+ name = "unknown"
202
+ }
203
+ lines = append(lines, fmt.Sprintf("- @%s — %d commits · +%d -%d", name, contributor.Total, adds, dels))
204
+ }
205
+ if note := githubLocalOmissionNote("contributors returned by GitHub", len(stats)-limit); note != "" {
206
+ lines = append(lines, "", note)
207
+ }
208
+ return strings.TrimSpace(strings.Join(lines, "\n"))
209
+ }
210
+
211
+ func readGitHubCommitActivityStats(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
212
+ resp, err := readGitHubStatistics(ctx, client, target, "commit_activity")
213
+ if err != nil || resp.StatusCode == http.StatusAccepted {
214
+ return renderStatisticsPending(target, "commit activity", resp), err
215
+ }
216
+ var weeks []githubCommitActivityWeek
217
+ if err := json.Unmarshal(resp.Body, &weeks); err != nil {
218
+ return "", fmt.Errorf("decode GitHub commit activity statistics: %w", err)
219
+ }
220
+ lines := statisticsFrontmatter(target, "commit_activity", len(weeks))
221
+ lines = append(lines, "# Commit activity", "", statisticsFreshnessNote(), "")
222
+ for _, week := range weeks {
223
+ stamp := time.Unix(week.Week, 0).UTC().Format("2006-01-02")
224
+ lines = append(lines, fmt.Sprintf("- %s — %d commits", stamp, week.Total))
225
+ }
226
+ return strings.TrimSpace(strings.Join(lines, "\n")), nil
227
+ }
228
+
229
+ func readGitHubCodeFrequencyStats(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
230
+ resp, err := readGitHubStatistics(ctx, client, target, "code_frequency")
231
+ if err != nil || resp.StatusCode == http.StatusAccepted {
232
+ return renderStatisticsPending(target, "code frequency", resp), err
233
+ }
234
+ var weeks [][]int64
235
+ if err := json.Unmarshal(resp.Body, &weeks); err != nil {
236
+ return "", fmt.Errorf("decode GitHub code-frequency statistics: %w", err)
237
+ }
238
+ return renderGitHubCodeFrequencyStats(target, weeks), nil
239
+ }
240
+
241
+ func renderGitHubCodeFrequencyStats(target *GitHubTarget, rawWeeks [][]int64) string {
242
+ weeks := make([][]int64, 0, len(rawWeeks))
243
+ var additionsTotal, deletionsTotal int64
244
+ for _, week := range rawWeeks {
245
+ if len(week) < 3 {
246
+ continue
247
+ }
248
+ weeks = append(weeks, week)
249
+ additionsTotal += week[1]
250
+ deletionsTotal += week[2]
251
+ }
252
+ sort.SliceStable(weeks, func(i, j int) bool { return weeks[i][0] < weeks[j][0] })
253
+ limit := minInt(52, len(weeks))
254
+ start := len(weeks) - limit
255
+ lines := []string{
256
+ "---",
257
+ "repository: " + yamlScalar(target.Owner+"/"+target.Repo),
258
+ "view: code_frequency",
259
+ fmt.Sprintf("weeks_returned: %d", len(weeks)),
260
+ fmt.Sprintf("weeks_indexed: %d", limit),
261
+ fmt.Sprintf("weeks_local_omitted: %d", start),
262
+ fmt.Sprintf("additions_total: %d", additionsTotal),
263
+ fmt.Sprintf("deletions_total: %d", deletionsTotal),
264
+ "---",
265
+ "",
266
+ "# Code frequency",
267
+ "",
268
+ statisticsFreshnessNote(),
269
+ "",
270
+ }
271
+ if len(weeks) == 0 {
272
+ lines = append(lines, "_No code-frequency weeks returned by GitHub._")
273
+ }
274
+ if start > 0 {
275
+ lines = append(lines, fmt.Sprintf("> Showing the most recent %d weekly buckets; %d older weekly buckets returned by GitHub are locally omitted from this overview. Aggregate totals above include every returned bucket.", limit, start), "")
276
+ }
277
+ for _, week := range weeks[start:] {
278
+ stamp := time.Unix(week[0], 0).UTC().Format("2006-01-02")
279
+ lines = append(lines, fmt.Sprintf("- %s — +%d %d", stamp, week[1], week[2]))
280
+ }
281
+ return strings.TrimSpace(strings.Join(lines, "\n"))
282
+ }
283
+
284
+ func readGitHubStatistics(ctx context.Context, client *GitHubClient, target *GitHubTarget, kind string) (GitHubResponse, error) {
285
+ if target.Fragment != "" || len(target.Query) > 0 {
286
+ return GitHubResponse{}, fmt.Errorf("GitHub statistics views do not support query/fragment selectors in the native reader")
287
+ }
288
+ endpoint := fmt.Sprintf("/repos/%s/%s/stats/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), kind)
289
+ return client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
290
+ }
291
+
292
+ func renderStatisticsPending(target *GitHubTarget, view string, resp GitHubResponse) string {
293
+ if resp.StatusCode != http.StatusAccepted {
294
+ return ""
295
+ }
296
+ return fmt.Sprintf("---\nrepository: %s\nview: %s\nprovider_status: computing\n---\n\n# %s\n\nGitHub returned HTTP 202 while computing cached repository statistics. Retry the same URL later; webctx has no local cache and cannot make the provider compute them synchronously.", yamlScalar(target.Owner+"/"+target.Repo), yamlScalar(view), strings.Title(view))
297
+ }
298
+
299
+ func statisticsFrontmatter(target *GitHubTarget, view string, returned int) []string {
300
+ return []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: " + yamlScalar(view), fmt.Sprintf("returned: %d", returned), "---", ""}
301
+ }
302
+
303
+ func statisticsFreshnessNote() string {
304
+ return "_GitHub computes/caches repository statistics upstream. This command made a fresh provider request, but the statistics themselves may reflect GitHub's cached computation._"
305
+ }
306
+
307
+ func readGitHubDeployments(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
308
+ if target.Fragment != "" {
309
+ return "", fmt.Errorf("GitHub deployments fragment %q is not a supported native selector", target.Fragment)
310
+ }
311
+ allowed := map[string]struct{}{"sha": {}, "ref": {}, "task": {}, "environment": {}, "page": {}}
312
+ for key := range target.Query {
313
+ if _, ok := allowed[key]; !ok {
314
+ return "", fmt.Errorf("GitHub deployments query parameter %q is not supported by the native reader", key)
315
+ }
316
+ }
317
+ q := copySelectedQuery(target.Query, []string{"sha", "ref", "task", "environment", "page"})
318
+ q.Set("per_page", strconv.Itoa(githubPageableListSize))
319
+ resp, deployments, err := fetchGitHubDeploymentPage(ctx, client, target, q)
320
+ if err != nil {
321
+ return "", err
322
+ }
323
+ limit := len(deployments)
324
+ lines := listFrontmatter(target, "deployments", len(deployments), limit)
325
+ lines = append(lines, "# Deployments", "")
326
+ lines = append(lines, renderDeploymentRows(target, deployments[:limit])...)
327
+ return appendListNavigation(lines, target, resp.Links()), nil
328
+ }
329
+
330
+ func readGitHubDeploymentEnvironment(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
331
+ if target.Fragment != "" {
332
+ return "", fmt.Errorf("GitHub deployment environment fragment %q is not a supported native selector", target.Fragment)
333
+ }
334
+ for key := range target.Query {
335
+ if key != "page" {
336
+ return "", fmt.Errorf("GitHub deployment environment query parameter %q is not supported", key)
337
+ }
338
+ }
339
+ envEndpoint := fmt.Sprintf("/repos/%s/%s/environments/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
340
+ envResp, err := client.REST(ctx, http.MethodGet, envEndpoint, "application/vnd.github+json")
341
+ if err != nil {
342
+ return "", err
343
+ }
344
+ var environment githubEnvironment
345
+ if err := json.Unmarshal(envResp.Body, &environment); err != nil {
346
+ return "", fmt.Errorf("decode GitHub environment: %w", err)
347
+ }
348
+ q := url.Values{"environment": []string{target.Name}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
349
+ if page := target.Query.Get("page"); page != "" {
350
+ n, err := strconv.Atoi(page)
351
+ if err != nil || n <= 0 {
352
+ return "", fmt.Errorf("invalid GitHub deployment environment page %q", page)
353
+ }
354
+ q.Set("page", page)
355
+ }
356
+ depResp, deployments, err := fetchGitHubDeploymentPage(ctx, client, target, q)
357
+ if err != nil {
358
+ return "", err
359
+ }
360
+ latestStatuses := make([]githubDeploymentLatestStatus, 0, len(deployments))
361
+ for _, deployment := range deployments {
362
+ latest, providerMore, err := fetchGitHubLatestDeploymentStatus(ctx, client, target, deployment.ID)
363
+ if err != nil {
364
+ return "", err
365
+ }
366
+ latestStatuses = append(latestStatuses, githubDeploymentLatestStatus{Status: latest, ProviderMore: providerMore})
367
+ }
368
+ return renderGitHubDeploymentEnvironment(target, environment, deployments, latestStatuses, depResp.Links()), nil
369
+ }
370
+
371
+ func renderGitHubDeploymentEnvironment(target *GitHubTarget, environment githubEnvironment, deployments []githubDeployment, latestStatuses []githubDeploymentLatestStatus, links GitHubLinkRelations) string {
372
+ return renderGitHubDeploymentEnvironmentWithLimit(target, environment, deployments, latestStatuses, links, len(deployments))
373
+ }
374
+
375
+ func renderGitHubDeploymentEnvironmentWithLimit(target *GitHubTarget, environment githubEnvironment, deployments []githubDeployment, latestStatuses []githubDeploymentLatestStatus, links GitHubLinkRelations, limit int) string {
376
+ limit = minInt(limit, len(deployments))
377
+ olderCount := 0
378
+ for _, latest := range latestStatuses {
379
+ if latest.ProviderMore {
380
+ olderCount++
381
+ }
382
+ }
383
+ lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "environment: " + yamlScalar(target.Name), fmt.Sprintf("deployments_returned: %d", len(deployments)), fmt.Sprintf("deployments_indexed: %d", limit), fmt.Sprintf("deployments_local_omitted: %d", len(deployments)-limit), fmt.Sprintf("deployments_with_older_statuses: %d", olderCount)}
384
+ if environment.CreatedAt != "" {
385
+ lines = append(lines, "created: "+yamlScalar(environment.CreatedAt))
386
+ }
387
+ if environment.UpdatedAt != "" {
388
+ lines = append(lines, "updated: "+yamlScalar(environment.UpdatedAt))
389
+ }
390
+ lines = append(lines, "---", "", "# Deployment environment: "+target.Name, "", "_GitHub controls deployment-status retention. webctx reads only the latest returned status for each deployment in this bounded overview; older status history may remain available upstream._", "")
391
+ for i, deployment := range deployments[:limit] {
392
+ lines = append(lines, renderDeploymentHeading(deployment), "")
393
+ if i >= len(latestStatuses) || latestStatuses[i].Status == nil {
394
+ lines = append(lines, "_No deployment statuses returned by GitHub._", "")
395
+ continue
396
+ }
397
+ latest := latestStatuses[i]
398
+ status := *latest.Status
399
+ line := fmt.Sprintf("- latest status %d — %s", status.ID, status.State)
400
+ if status.Description != "" {
401
+ preview, truncated := githubOverviewPreview(status.Description, 160)
402
+ line += " — " + preview
403
+ if truncated {
404
+ line += "…"
405
+ }
406
+ }
407
+ if status.Creator.Login != "" {
408
+ line += " — @" + status.Creator.Login
409
+ }
410
+ if status.CreatedAt != "" {
411
+ line += " — " + status.CreatedAt
412
+ }
413
+ if status.LogURL != "" {
414
+ line += " — logs " + status.LogURL
415
+ }
416
+ lines = append(lines, line)
417
+ if status.EnvironmentURL != "" {
418
+ lines = append(lines, " Environment: "+status.EnvironmentURL)
419
+ }
420
+ if latest.ProviderMore {
421
+ lines = append(lines, " Older statuses: available upstream; not expanded in this overview.")
422
+ }
423
+ lines = append(lines, "")
424
+ }
425
+ if note := githubLocalOmissionNote("deployments returned on this environment page", len(deployments)-limit); note != "" {
426
+ lines = append(lines, note, "")
427
+ }
428
+ if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
429
+ lines = append(lines, "## Navigation", "")
430
+ lines = append(lines, nav...)
431
+ }
432
+ return strings.TrimSpace(strings.Join(lines, "\n"))
433
+ }
434
+
435
+ func fetchGitHubDeploymentPage(ctx context.Context, client *GitHubClient, target *GitHubTarget, q url.Values) (GitHubResponse, []githubDeployment, error) {
436
+ endpoint := fmt.Sprintf("/repos/%s/%s/deployments?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), q.Encode())
437
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
438
+ if err != nil {
439
+ return GitHubResponse{}, nil, err
440
+ }
441
+ var deployments []githubDeployment
442
+ if err := json.Unmarshal(resp.Body, &deployments); err != nil {
443
+ return GitHubResponse{}, nil, fmt.Errorf("decode GitHub deployments: %w", err)
444
+ }
445
+ return resp, deployments, nil
446
+ }
447
+
448
+ func fetchGitHubLatestDeploymentStatus(ctx context.Context, client *GitHubClient, target *GitHubTarget, deploymentID int64) (*githubDeploymentStatus, bool, error) {
449
+ endpoint := fmt.Sprintf("/repos/%s/%s/deployments/%d/statuses?per_page=1", url.PathEscape(target.Owner), url.PathEscape(target.Repo), deploymentID)
450
+ resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
451
+ if err != nil {
452
+ return nil, false, err
453
+ }
454
+ var statuses []githubDeploymentStatus
455
+ if err := json.Unmarshal(resp.Body, &statuses); err != nil {
456
+ return nil, false, fmt.Errorf("decode GitHub deployment statuses: %w", err)
457
+ }
458
+ if len(statuses) == 0 {
459
+ return nil, resp.Links()["next"] != "", nil
460
+ }
461
+ return &statuses[0], resp.Links()["next"] != "", nil
462
+ }
463
+
464
+ func renderDeploymentRows(target *GitHubTarget, deployments []githubDeployment) []string {
465
+ if len(deployments) == 0 {
466
+ return []string{"_No deployments returned by GitHub._"}
467
+ }
468
+ lines := []string{}
469
+ for _, deployment := range deployments {
470
+ line := "- " + renderDeploymentHeading(deployment)
471
+ if deployment.Environment != "" {
472
+ href := fmt.Sprintf("https://github.com/%s/%s/deployments/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), escapePathPreservingSlashes(deployment.Environment))
473
+ environment, truncated := githubOverviewInlinePreview(deployment.Environment, 100)
474
+ if truncated {
475
+ environment += "…"
476
+ }
477
+ line += " — [" + escapeMarkdownLinkText(environment) + "](" + href + ")"
478
+ }
479
+ lines = append(lines, line)
480
+ }
481
+ return lines
482
+ }
483
+
484
+ func renderDeploymentHeading(deployment githubDeployment) string {
485
+ line := fmt.Sprintf("Deployment %d", deployment.ID)
486
+ if deployment.Ref != "" {
487
+ ref, truncated := githubOverviewInlinePreview(deployment.Ref, 120)
488
+ if truncated {
489
+ ref += "…"
490
+ }
491
+ line += " — `" + ref + "`"
492
+ }
493
+ if deployment.SHA != "" {
494
+ line += " @ `" + shortSHA(deployment.SHA) + "`"
495
+ }
496
+ if deployment.Creator.Login != "" {
497
+ line += " — @" + deployment.Creator.Login
498
+ }
499
+ if deployment.CreatedAt != "" {
500
+ line += " — " + deployment.CreatedAt
501
+ }
502
+ return line
503
+ }