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,1459 @@
|
|
|
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
|
+
)
|
|
13
|
+
|
|
14
|
+
type githubPullRef struct {
|
|
15
|
+
Label string `json:"label"`
|
|
16
|
+
Ref string `json:"ref"`
|
|
17
|
+
SHA string `json:"sha"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type githubPullRequest struct {
|
|
21
|
+
ID int64 `json:"id"`
|
|
22
|
+
Number int `json:"number"`
|
|
23
|
+
State string `json:"state"`
|
|
24
|
+
Title string `json:"title"`
|
|
25
|
+
Body *string `json:"body"`
|
|
26
|
+
HTMLURL string `json:"html_url"`
|
|
27
|
+
User githubUser `json:"user"`
|
|
28
|
+
AuthorAssociation string `json:"author_association"`
|
|
29
|
+
Draft bool `json:"draft"`
|
|
30
|
+
Merged bool `json:"merged"`
|
|
31
|
+
MergeableState string `json:"mergeable_state"`
|
|
32
|
+
Locked bool `json:"locked"`
|
|
33
|
+
CreatedAt string `json:"created_at"`
|
|
34
|
+
UpdatedAt string `json:"updated_at"`
|
|
35
|
+
ClosedAt string `json:"closed_at"`
|
|
36
|
+
MergedAt string `json:"merged_at"`
|
|
37
|
+
MergeCommitSHA string `json:"merge_commit_sha"`
|
|
38
|
+
Comments int `json:"comments"`
|
|
39
|
+
ReviewComments int `json:"review_comments"`
|
|
40
|
+
Commits int `json:"commits"`
|
|
41
|
+
Additions int `json:"additions"`
|
|
42
|
+
Deletions int `json:"deletions"`
|
|
43
|
+
ChangedFiles int `json:"changed_files"`
|
|
44
|
+
Head githubPullRef `json:"head"`
|
|
45
|
+
Base githubPullRef `json:"base"`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type githubPullReview struct {
|
|
49
|
+
ID int64 `json:"id"`
|
|
50
|
+
User githubUser `json:"user"`
|
|
51
|
+
Body *string `json:"body"`
|
|
52
|
+
State string `json:"state"`
|
|
53
|
+
HTMLURL string `json:"html_url"`
|
|
54
|
+
CommitID string `json:"commit_id"`
|
|
55
|
+
SubmittedAt string `json:"submitted_at"`
|
|
56
|
+
AuthorAssociation string `json:"author_association"`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type githubPullReviewComment struct {
|
|
60
|
+
ID int64 `json:"id"`
|
|
61
|
+
PullRequestReviewID int64 `json:"pull_request_review_id"`
|
|
62
|
+
InReplyToID *int64 `json:"in_reply_to_id"`
|
|
63
|
+
User githubUser `json:"user"`
|
|
64
|
+
Body *string `json:"body"`
|
|
65
|
+
HTMLURL string `json:"html_url"`
|
|
66
|
+
PullRequestURL string `json:"pull_request_url"`
|
|
67
|
+
Path string `json:"path"`
|
|
68
|
+
DiffHunk string `json:"diff_hunk"`
|
|
69
|
+
CommitID string `json:"commit_id"`
|
|
70
|
+
OriginalCommitID string `json:"original_commit_id"`
|
|
71
|
+
Line *int `json:"line"`
|
|
72
|
+
OriginalLine *int `json:"original_line"`
|
|
73
|
+
StartLine *int `json:"start_line"`
|
|
74
|
+
OriginalStartLine *int `json:"original_start_line"`
|
|
75
|
+
Side string `json:"side"`
|
|
76
|
+
OriginalSide string `json:"original_side"`
|
|
77
|
+
StartSide string `json:"start_side"`
|
|
78
|
+
CreatedAt string `json:"created_at"`
|
|
79
|
+
UpdatedAt string `json:"updated_at"`
|
|
80
|
+
AuthorAssociation string `json:"author_association"`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type githubPullThreadState struct {
|
|
84
|
+
Resolved bool
|
|
85
|
+
Outdated bool
|
|
86
|
+
ResolvedBy string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type githubPullThread struct {
|
|
90
|
+
Root githubPullReviewComment
|
|
91
|
+
Replies []githubPullReviewComment
|
|
92
|
+
State *githubPullThreadState
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type githubPullOverviewAvailability struct {
|
|
96
|
+
TimelineProviderMore bool
|
|
97
|
+
ReviewsProviderMore bool
|
|
98
|
+
ReviewCommentsProviderMore bool
|
|
99
|
+
ReviewCommentsReturned int
|
|
100
|
+
ThreadStatesProviderMore bool
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type githubPullListItem struct {
|
|
104
|
+
Number int
|
|
105
|
+
State string
|
|
106
|
+
Title string
|
|
107
|
+
HTMLURL string
|
|
108
|
+
User githubUser
|
|
109
|
+
UpdatedAt string
|
|
110
|
+
CreatedAt string
|
|
111
|
+
Labels []githubIssueLabel
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func readGitHubPullList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
115
|
+
if target.Fragment != "" {
|
|
116
|
+
return "", fmt.Errorf("GitHub Pull Request-list fragment %q is not a supported native selector", target.Fragment)
|
|
117
|
+
}
|
|
118
|
+
if strings.TrimSpace(target.Query.Get("q")) != "" {
|
|
119
|
+
return readGitHubPullSearch(ctx, client, target)
|
|
120
|
+
}
|
|
121
|
+
query, err := githubPullListProviderQuery(target.Query)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return "", err
|
|
124
|
+
}
|
|
125
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
|
|
126
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
127
|
+
if err != nil {
|
|
128
|
+
return "", err
|
|
129
|
+
}
|
|
130
|
+
var pulls []githubPullRequest
|
|
131
|
+
if err := json.Unmarshal(resp.Body, &pulls); err != nil {
|
|
132
|
+
return "", fmt.Errorf("decode GitHub Pull Request list: %w", err)
|
|
133
|
+
}
|
|
134
|
+
items := make([]githubPullListItem, 0, len(pulls))
|
|
135
|
+
for _, pr := range pulls {
|
|
136
|
+
items = append(items, githubPullListItem{
|
|
137
|
+
Number: pr.Number,
|
|
138
|
+
State: pullDisplayState(pr),
|
|
139
|
+
Title: pr.Title,
|
|
140
|
+
HTMLURL: pr.HTMLURL,
|
|
141
|
+
User: pr.User,
|
|
142
|
+
UpdatedAt: pr.UpdatedAt,
|
|
143
|
+
CreatedAt: pr.CreatedAt,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return renderGitHubPullList(target, items, resp.Links(), -1, false), nil
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func readGitHubPullSearch(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
150
|
+
provider, err := githubPullSearchProviderQuery(target.Query, target.Owner, target.Repo)
|
|
151
|
+
if err != nil {
|
|
152
|
+
return "", err
|
|
153
|
+
}
|
|
154
|
+
resp, err := client.REST(ctx, http.MethodGet, "/search/issues?"+provider.Encode(), "application/vnd.github+json")
|
|
155
|
+
if err != nil {
|
|
156
|
+
return "", err
|
|
157
|
+
}
|
|
158
|
+
var result githubIssueSearchResponse
|
|
159
|
+
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
|
160
|
+
return "", fmt.Errorf("decode GitHub Pull Request search: %w", err)
|
|
161
|
+
}
|
|
162
|
+
items := make([]githubPullListItem, 0, len(result.Items))
|
|
163
|
+
for _, item := range result.Items {
|
|
164
|
+
state := item.State
|
|
165
|
+
if item.Draft && item.State == "open" {
|
|
166
|
+
state = "draft"
|
|
167
|
+
}
|
|
168
|
+
href := item.HTMLURL
|
|
169
|
+
if item.PullRequest != nil && item.PullRequest.HTMLURL != "" {
|
|
170
|
+
href = item.PullRequest.HTMLURL
|
|
171
|
+
}
|
|
172
|
+
items = append(items, githubPullListItem{
|
|
173
|
+
Number: item.Number,
|
|
174
|
+
State: state,
|
|
175
|
+
Title: item.Title,
|
|
176
|
+
HTMLURL: href,
|
|
177
|
+
User: item.User,
|
|
178
|
+
UpdatedAt: item.UpdatedAt,
|
|
179
|
+
CreatedAt: item.CreatedAt,
|
|
180
|
+
Labels: item.Labels,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return renderGitHubPullList(target, items, resp.Links(), result.TotalCount, result.IncompleteResults), nil
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func githubPullListProviderQuery(input url.Values) (url.Values, error) {
|
|
187
|
+
allowed := map[string]struct{}{
|
|
188
|
+
"state": {}, "head": {}, "base": {}, "sort": {}, "direction": {}, "page": {},
|
|
189
|
+
}
|
|
190
|
+
for key := range input {
|
|
191
|
+
if _, ok := allowed[key]; !ok {
|
|
192
|
+
return nil, fmt.Errorf("GitHub Pull Request list query parameter %q is not supported by this native view", key)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
query := copySelectedQuery(input, []string{"state", "head", "base", "sort", "direction", "page"})
|
|
196
|
+
if state := query.Get("state"); state != "" && state != "open" && state != "closed" && state != "all" {
|
|
197
|
+
return nil, fmt.Errorf("invalid GitHub Pull Request list state %q", state)
|
|
198
|
+
}
|
|
199
|
+
if sortBy := query.Get("sort"); sortBy != "" && sortBy != "created" && sortBy != "updated" && sortBy != "popularity" && sortBy != "long-running" {
|
|
200
|
+
return nil, fmt.Errorf("invalid GitHub Pull Request list sort %q", sortBy)
|
|
201
|
+
}
|
|
202
|
+
if direction := query.Get("direction"); direction != "" && direction != "asc" && direction != "desc" {
|
|
203
|
+
return nil, fmt.Errorf("invalid GitHub Pull Request list direction %q", direction)
|
|
204
|
+
}
|
|
205
|
+
if err := validateGitHubPageSize(query); err != nil {
|
|
206
|
+
return nil, err
|
|
207
|
+
}
|
|
208
|
+
query.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
209
|
+
return query, nil
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func githubPullSearchProviderQuery(input url.Values, owner, repo string) (url.Values, error) {
|
|
213
|
+
allowed := map[string]struct{}{"q": {}, "sort": {}, "order": {}, "page": {}}
|
|
214
|
+
for key := range input {
|
|
215
|
+
if _, ok := allowed[key]; !ok {
|
|
216
|
+
return nil, fmt.Errorf("GitHub Pull Request search query parameter %q is not supported by this native view", key)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
q := strings.TrimSpace(input.Get("q"))
|
|
220
|
+
if q == "" {
|
|
221
|
+
return nil, fmt.Errorf("GitHub Pull Request search requires a non-empty q parameter")
|
|
222
|
+
}
|
|
223
|
+
resource, err := githubSearchResourceQualifier(q)
|
|
224
|
+
if err != nil {
|
|
225
|
+
return nil, err
|
|
226
|
+
}
|
|
227
|
+
if resource == "issue" {
|
|
228
|
+
return nil, fmt.Errorf("GitHub Pull Request search query cannot explicitly select is:issue")
|
|
229
|
+
}
|
|
230
|
+
if resource == "" {
|
|
231
|
+
q += " is:pr"
|
|
232
|
+
}
|
|
233
|
+
if !hasGitHubSearchQualifierPrefix(q, "repo:") {
|
|
234
|
+
q = "repo:" + owner + "/" + repo + " " + q
|
|
235
|
+
}
|
|
236
|
+
provider := url.Values{"q": []string{strings.TrimSpace(q)}}
|
|
237
|
+
for _, key := range []string{"sort", "order", "page"} {
|
|
238
|
+
if value := input.Get(key); value != "" {
|
|
239
|
+
provider.Set(key, value)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if order := provider.Get("order"); order != "" && order != "asc" && order != "desc" {
|
|
243
|
+
return nil, fmt.Errorf("invalid GitHub Pull Request search order %q", order)
|
|
244
|
+
}
|
|
245
|
+
if err := validateGitHubPageSize(provider); err != nil {
|
|
246
|
+
return nil, err
|
|
247
|
+
}
|
|
248
|
+
provider.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
249
|
+
return provider, nil
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func validateGitHubPageSize(query url.Values) error {
|
|
253
|
+
if rawPage := query.Get("page"); rawPage != "" {
|
|
254
|
+
page, err := strconv.Atoi(rawPage)
|
|
255
|
+
if err != nil || page <= 0 {
|
|
256
|
+
return fmt.Errorf("invalid GitHub list page %q", rawPage)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if rawSize := query.Get("per_page"); rawSize != "" {
|
|
260
|
+
size, err := strconv.Atoi(rawSize)
|
|
261
|
+
if err != nil || size <= 0 || size > 100 {
|
|
262
|
+
return fmt.Errorf("invalid GitHub list per_page %q", rawSize)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return nil
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func renderGitHubPullList(target *GitHubTarget, pulls []githubPullListItem, links GitHubLinkRelations, total int, incomplete bool) string {
|
|
269
|
+
return renderGitHubPullListWithLimit(target, pulls, links, total, incomplete, len(pulls))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func renderGitHubPullListWithLimit(target *GitHubTarget, pulls []githubPullListItem, links GitHubLinkRelations, total int, incomplete bool, limit int) string {
|
|
273
|
+
limit = minInt(limit, len(pulls))
|
|
274
|
+
page := target.Query.Get("page")
|
|
275
|
+
if page == "" {
|
|
276
|
+
page = "1"
|
|
277
|
+
}
|
|
278
|
+
lines := []string{
|
|
279
|
+
"---",
|
|
280
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
281
|
+
"view: pull_requests",
|
|
282
|
+
"page: " + yamlScalar(page),
|
|
283
|
+
fmt.Sprintf("results: %d", len(pulls)),
|
|
284
|
+
fmt.Sprintf("results_indexed: %d", limit),
|
|
285
|
+
fmt.Sprintf("results_local_omitted: %d", len(pulls)-limit),
|
|
286
|
+
}
|
|
287
|
+
if q := target.Query.Get("q"); q != "" {
|
|
288
|
+
preview, truncated := githubOverviewInlinePreview(q, 300)
|
|
289
|
+
lines = append(lines, "query: "+yamlScalar(preview))
|
|
290
|
+
if truncated {
|
|
291
|
+
lines = append(lines, "query_preview_truncated: true")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if total >= 0 {
|
|
295
|
+
lines = append(lines, fmt.Sprintf("total_matches: %d", total))
|
|
296
|
+
if total > 1000 {
|
|
297
|
+
lines = append(lines, "provider_result_ceiling: 1000")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if incomplete {
|
|
301
|
+
lines = append(lines, "complete: false")
|
|
302
|
+
}
|
|
303
|
+
lines = append(lines, "---", "", "# Pull Requests", "")
|
|
304
|
+
if incomplete {
|
|
305
|
+
lines = append(lines, "> GitHub marked this search result set as incomplete.", "")
|
|
306
|
+
}
|
|
307
|
+
if total > 1000 {
|
|
308
|
+
lines = append(lines, "> GitHub Search exposes at most 1,000 results for a query; this page does not imply access beyond that provider ceiling.", "")
|
|
309
|
+
}
|
|
310
|
+
if len(pulls) == 0 {
|
|
311
|
+
lines = append(lines, "_No Pull Requests on this page._")
|
|
312
|
+
}
|
|
313
|
+
for _, pr := range pulls[:limit] {
|
|
314
|
+
href := pr.HTMLURL
|
|
315
|
+
if href == "" {
|
|
316
|
+
href = fmt.Sprintf("https://github.com/%s/%s/pull/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), pr.Number)
|
|
317
|
+
}
|
|
318
|
+
state := strings.TrimSpace(pr.State)
|
|
319
|
+
if state == "" {
|
|
320
|
+
state = "unknown"
|
|
321
|
+
}
|
|
322
|
+
meta := []string{state}
|
|
323
|
+
if pr.User.Login != "" {
|
|
324
|
+
meta = append(meta, "@"+pr.User.Login)
|
|
325
|
+
}
|
|
326
|
+
if pr.UpdatedAt != "" {
|
|
327
|
+
meta = append(meta, "updated "+pr.UpdatedAt)
|
|
328
|
+
} else if pr.CreatedAt != "" {
|
|
329
|
+
meta = append(meta, "created "+pr.CreatedAt)
|
|
330
|
+
}
|
|
331
|
+
if labels := issueLabelNames(pr.Labels); len(labels) > 0 {
|
|
332
|
+
meta = append(meta, "labels: "+githubOverviewLabelList(labels, 3))
|
|
333
|
+
}
|
|
334
|
+
title, truncated := githubOverviewInlinePreview(pr.Title, 140)
|
|
335
|
+
if truncated {
|
|
336
|
+
title += "…"
|
|
337
|
+
}
|
|
338
|
+
lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", pr.Number, escapeMarkdownLinkText(title), href, strings.Join(meta, " · ")))
|
|
339
|
+
}
|
|
340
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
341
|
+
lines = append(lines, "", "## Navigation", "")
|
|
342
|
+
lines = append(lines, nav...)
|
|
343
|
+
}
|
|
344
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func readGitHubPullRequest(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
348
|
+
if target == nil || target.Number <= 0 {
|
|
349
|
+
return "", fmt.Errorf("GitHub Pull Request URL is missing a number")
|
|
350
|
+
}
|
|
351
|
+
if issueID, ok, err := parseIssueBodySelector(target.Fragment); ok {
|
|
352
|
+
if err != nil {
|
|
353
|
+
return "", err
|
|
354
|
+
}
|
|
355
|
+
return readGitHubPullBody(ctx, client, target, issueID)
|
|
356
|
+
}
|
|
357
|
+
if commentID, ok, err := parseIssueCommentSelector(target.Fragment); ok {
|
|
358
|
+
if err != nil {
|
|
359
|
+
return "", err
|
|
360
|
+
}
|
|
361
|
+
return readGitHubIssueComment(ctx, client, target, commentID)
|
|
362
|
+
}
|
|
363
|
+
if commentID, ok, err := parsePullDiscussionSelector(target.Fragment); ok {
|
|
364
|
+
if err != nil {
|
|
365
|
+
return "", err
|
|
366
|
+
}
|
|
367
|
+
return readGitHubPullThreadSelector(ctx, client, target, commentID)
|
|
368
|
+
}
|
|
369
|
+
if reviewID, ok, err := parsePullReviewSelector(target.Fragment); ok {
|
|
370
|
+
if err != nil {
|
|
371
|
+
return "", err
|
|
372
|
+
}
|
|
373
|
+
return readGitHubPullReviewSelector(ctx, client, target, reviewID)
|
|
374
|
+
}
|
|
375
|
+
if target.Fragment != "" {
|
|
376
|
+
return "", fmt.Errorf("GitHub Pull Request fragment %q is not a supported native selector", target.Fragment)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
base := fmt.Sprintf("/repos/%s/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo))
|
|
380
|
+
prResp, err := client.REST(ctx, http.MethodGet, fmt.Sprintf("%s/pulls/%d", base, target.Number), "application/vnd.github+json")
|
|
381
|
+
if err != nil {
|
|
382
|
+
return "", err
|
|
383
|
+
}
|
|
384
|
+
var pr githubPullRequest
|
|
385
|
+
if err := json.Unmarshal(prResp.Body, &pr); err != nil {
|
|
386
|
+
return "", fmt.Errorf("decode GitHub Pull Request: %w", err)
|
|
387
|
+
}
|
|
388
|
+
issue, err := fetchGitHubIssue(ctx, client, target)
|
|
389
|
+
if err != nil {
|
|
390
|
+
return "", err
|
|
391
|
+
}
|
|
392
|
+
if issue.PullRequest == nil || issue.ID <= 0 {
|
|
393
|
+
return "", fmt.Errorf("GitHub Issue-side identity for Pull Request %s/%s#%d was unavailable", target.Owner, target.Repo, target.Number)
|
|
394
|
+
}
|
|
395
|
+
timeline, timelineMore, err := fetchGitHubPullTimelinePage(ctx, client, target)
|
|
396
|
+
if err != nil {
|
|
397
|
+
return "", err
|
|
398
|
+
}
|
|
399
|
+
reviews, reviewsMore, err := fetchGitHubPullReviewsPage(ctx, client, target)
|
|
400
|
+
if err != nil {
|
|
401
|
+
return "", err
|
|
402
|
+
}
|
|
403
|
+
comments, commentsMore, err := fetchGitHubPullReviewCommentsPage(ctx, client, target)
|
|
404
|
+
if err != nil {
|
|
405
|
+
return "", err
|
|
406
|
+
}
|
|
407
|
+
threads := groupGitHubPullThreads(comments)
|
|
408
|
+
enrichmentNote := ""
|
|
409
|
+
threadStatesMore := false
|
|
410
|
+
if client.hasToken() && len(threads) > 0 {
|
|
411
|
+
states, statesMore, enrichErr := fetchGitHubPullThreadStatesOverview(ctx, client, target)
|
|
412
|
+
if enrichErr != nil {
|
|
413
|
+
enrichmentNote = "Review-thread resolved/outdated enrichment was unavailable from GitHub GraphQL; REST thread content is still complete."
|
|
414
|
+
} else {
|
|
415
|
+
for i := range threads {
|
|
416
|
+
if state, ok := states[threads[i].Root.ID]; ok {
|
|
417
|
+
copyState := state
|
|
418
|
+
threads[i].State = ©State
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if statesMore {
|
|
422
|
+
enrichmentNote = "Review-thread state enrichment is limited to GitHub's first GraphQL page for this overview; exact thread reads may resolve deeper state without expanding the PR root."
|
|
423
|
+
threadStatesMore = true
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
availability := githubPullOverviewAvailability{
|
|
428
|
+
TimelineProviderMore: timelineMore,
|
|
429
|
+
ReviewsProviderMore: reviewsMore,
|
|
430
|
+
ReviewCommentsProviderMore: commentsMore,
|
|
431
|
+
ReviewCommentsReturned: len(comments),
|
|
432
|
+
ThreadStatesProviderMore: threadStatesMore,
|
|
433
|
+
}
|
|
434
|
+
return renderGitHubPullRequest(target, pr, issue.ID, timeline, reviews, threads, availability, enrichmentNote, client.hasToken()), nil
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
func readGitHubPullBody(ctx context.Context, client *GitHubClient, target *GitHubTarget, issueID int64) (string, error) {
|
|
438
|
+
issue, err := fetchGitHubIssue(ctx, client, target)
|
|
439
|
+
if err != nil {
|
|
440
|
+
return "", err
|
|
441
|
+
}
|
|
442
|
+
if issue.PullRequest == nil {
|
|
443
|
+
return "", fmt.Errorf("GitHub #%d is an Issue, not a Pull Request", target.Number)
|
|
444
|
+
}
|
|
445
|
+
if issue.ID != issueID {
|
|
446
|
+
return "", fmt.Errorf("GitHub Pull Request body selector issue-%d does not belong to %s/%s#%d (Issue-side id %d)", issueID, target.Owner, target.Repo, target.Number, issue.ID)
|
|
447
|
+
}
|
|
448
|
+
return renderGitHubPullBody(target, issue), nil
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func renderGitHubPullBody(target *GitHubTarget, issue githubIssue) string {
|
|
452
|
+
selector := pullBaseURL(target) + "#issue-" + strconv.FormatInt(issue.ID, 10)
|
|
453
|
+
lines := []string{
|
|
454
|
+
"---",
|
|
455
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
456
|
+
fmt.Sprintf("pull_request: %d", target.Number),
|
|
457
|
+
fmt.Sprintf("issue_id: %d", issue.ID),
|
|
458
|
+
"title: " + yamlScalar(issue.Title),
|
|
459
|
+
}
|
|
460
|
+
if issue.User.Login != "" {
|
|
461
|
+
lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
|
|
462
|
+
}
|
|
463
|
+
lines = append(lines, "url: "+yamlScalar(selector), "---", "", fmt.Sprintf("# Description of %s/%s#%d", target.Owner, target.Repo, target.Number), "")
|
|
464
|
+
if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
|
|
465
|
+
lines = append(lines, "_No description provided._")
|
|
466
|
+
} else {
|
|
467
|
+
lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
|
|
468
|
+
}
|
|
469
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func fetchGitHubPullTimelinePage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubTimelineEvent, bool, error) {
|
|
473
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/timeline?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
474
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
475
|
+
if err != nil {
|
|
476
|
+
return nil, false, err
|
|
477
|
+
}
|
|
478
|
+
var events []githubTimelineEvent
|
|
479
|
+
if err := json.Unmarshal(resp.Body, &events); err != nil {
|
|
480
|
+
return nil, false, fmt.Errorf("decode GitHub Pull Request timeline: %w", err)
|
|
481
|
+
}
|
|
482
|
+
return events, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
func fetchGitHubPullReviewsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReview, bool, error) {
|
|
486
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
487
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
488
|
+
if err != nil {
|
|
489
|
+
return nil, false, err
|
|
490
|
+
}
|
|
491
|
+
var reviews []githubPullReview
|
|
492
|
+
if err := json.Unmarshal(resp.Body, &reviews); err != nil {
|
|
493
|
+
return nil, false, fmt.Errorf("decode GitHub Pull Request reviews: %w", err)
|
|
494
|
+
}
|
|
495
|
+
sort.SliceStable(reviews, func(i, j int) bool {
|
|
496
|
+
if reviews[i].SubmittedAt == reviews[j].SubmittedAt {
|
|
497
|
+
return reviews[i].ID < reviews[j].ID
|
|
498
|
+
}
|
|
499
|
+
return reviews[i].SubmittedAt < reviews[j].SubmittedAt
|
|
500
|
+
})
|
|
501
|
+
return reviews, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func fetchGitHubPullReviewCommentsPage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReviewComment, bool, error) {
|
|
505
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
506
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
507
|
+
if err != nil {
|
|
508
|
+
return nil, false, err
|
|
509
|
+
}
|
|
510
|
+
var comments []githubPullReviewComment
|
|
511
|
+
if err := json.Unmarshal(resp.Body, &comments); err != nil {
|
|
512
|
+
return nil, false, fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
|
|
513
|
+
}
|
|
514
|
+
return comments, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
func parsePullDiscussionSelector(fragment string) (int64, bool, error) {
|
|
518
|
+
if !strings.HasPrefix(fragment, "discussion_r") {
|
|
519
|
+
return 0, false, nil
|
|
520
|
+
}
|
|
521
|
+
raw := strings.TrimPrefix(fragment, "discussion_r")
|
|
522
|
+
id, err := strconv.ParseInt(raw, 10, 64)
|
|
523
|
+
if err != nil || id <= 0 {
|
|
524
|
+
return 0, true, fmt.Errorf("invalid GitHub review-comment selector %q", fragment)
|
|
525
|
+
}
|
|
526
|
+
return id, true, nil
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
func parsePullReviewSelector(fragment string) (int64, bool, error) {
|
|
530
|
+
if !strings.HasPrefix(fragment, "pullrequestreview-") {
|
|
531
|
+
return 0, false, nil
|
|
532
|
+
}
|
|
533
|
+
raw := strings.TrimPrefix(fragment, "pullrequestreview-")
|
|
534
|
+
id, err := strconv.ParseInt(raw, 10, 64)
|
|
535
|
+
if err != nil || id <= 0 {
|
|
536
|
+
return 0, true, fmt.Errorf("invalid GitHub Pull Request review selector %q", fragment)
|
|
537
|
+
}
|
|
538
|
+
return id, true, nil
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
func fetchGitHubPullReviews(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReview, error) {
|
|
542
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
543
|
+
pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
|
|
544
|
+
if err != nil {
|
|
545
|
+
return nil, err
|
|
546
|
+
}
|
|
547
|
+
out := []githubPullReview{}
|
|
548
|
+
for _, page := range pages {
|
|
549
|
+
var batch []githubPullReview
|
|
550
|
+
if err := json.Unmarshal(page.Body, &batch); err != nil {
|
|
551
|
+
return nil, fmt.Errorf("decode GitHub Pull Request reviews: %w", err)
|
|
552
|
+
}
|
|
553
|
+
out = append(out, batch...)
|
|
554
|
+
}
|
|
555
|
+
sort.SliceStable(out, func(i, j int) bool {
|
|
556
|
+
if out[i].SubmittedAt == out[j].SubmittedAt {
|
|
557
|
+
return out[i].ID < out[j].ID
|
|
558
|
+
}
|
|
559
|
+
return out[i].SubmittedAt < out[j].SubmittedAt
|
|
560
|
+
})
|
|
561
|
+
return out, nil
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
func fetchGitHubPullReviewComments(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubPullReviewComment, error) {
|
|
565
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
566
|
+
pages, err := client.RESTPages(ctx, endpoint, "application/vnd.github+json")
|
|
567
|
+
if err != nil {
|
|
568
|
+
return nil, err
|
|
569
|
+
}
|
|
570
|
+
out := []githubPullReviewComment{}
|
|
571
|
+
for _, page := range pages {
|
|
572
|
+
var batch []githubPullReviewComment
|
|
573
|
+
if err := json.Unmarshal(page.Body, &batch); err != nil {
|
|
574
|
+
return nil, fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
|
|
575
|
+
}
|
|
576
|
+
out = append(out, batch...)
|
|
577
|
+
}
|
|
578
|
+
return out, nil
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
func groupGitHubPullThreads(comments []githubPullReviewComment) []githubPullThread {
|
|
582
|
+
byID := make(map[int64]githubPullReviewComment, len(comments))
|
|
583
|
+
for _, comment := range comments {
|
|
584
|
+
byID[comment.ID] = comment
|
|
585
|
+
}
|
|
586
|
+
rootID := func(comment githubPullReviewComment) int64 {
|
|
587
|
+
seen := map[int64]struct{}{comment.ID: {}}
|
|
588
|
+
current := comment
|
|
589
|
+
for current.InReplyToID != nil && *current.InReplyToID > 0 {
|
|
590
|
+
parentID := *current.InReplyToID
|
|
591
|
+
if _, cycle := seen[parentID]; cycle {
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
seen[parentID] = struct{}{}
|
|
595
|
+
parent, ok := byID[parentID]
|
|
596
|
+
if !ok {
|
|
597
|
+
return parentID
|
|
598
|
+
}
|
|
599
|
+
current = parent
|
|
600
|
+
}
|
|
601
|
+
return current.ID
|
|
602
|
+
}
|
|
603
|
+
groups := map[int64][]githubPullReviewComment{}
|
|
604
|
+
for _, comment := range comments {
|
|
605
|
+
id := rootID(comment)
|
|
606
|
+
groups[id] = append(groups[id], comment)
|
|
607
|
+
}
|
|
608
|
+
threads := make([]githubPullThread, 0, len(groups))
|
|
609
|
+
for id, group := range groups {
|
|
610
|
+
root, ok := byID[id]
|
|
611
|
+
if !ok {
|
|
612
|
+
// A missing root should not discard substantive replies. Promote the
|
|
613
|
+
// earliest visible reply and retain the rest in deterministic order.
|
|
614
|
+
sortPullComments(group)
|
|
615
|
+
root = group[0]
|
|
616
|
+
}
|
|
617
|
+
replies := make([]githubPullReviewComment, 0, len(group)-1)
|
|
618
|
+
for _, comment := range group {
|
|
619
|
+
if comment.ID != root.ID {
|
|
620
|
+
replies = append(replies, comment)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
sortPullComments(replies)
|
|
624
|
+
threads = append(threads, githubPullThread{Root: root, Replies: replies})
|
|
625
|
+
}
|
|
626
|
+
sort.SliceStable(threads, func(i, j int) bool {
|
|
627
|
+
if threads[i].Root.CreatedAt == threads[j].Root.CreatedAt {
|
|
628
|
+
return threads[i].Root.ID < threads[j].Root.ID
|
|
629
|
+
}
|
|
630
|
+
return threads[i].Root.CreatedAt < threads[j].Root.CreatedAt
|
|
631
|
+
})
|
|
632
|
+
return threads
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
func sortPullComments(comments []githubPullReviewComment) {
|
|
636
|
+
sort.SliceStable(comments, func(i, j int) bool {
|
|
637
|
+
if comments[i].CreatedAt == comments[j].CreatedAt {
|
|
638
|
+
return comments[i].ID < comments[j].ID
|
|
639
|
+
}
|
|
640
|
+
return comments[i].CreatedAt < comments[j].CreatedAt
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
func fetchGitHubPullThreadStatesOverview(ctx context.Context, client *GitHubClient, target *GitHubTarget) (map[int64]githubPullThreadState, bool, error) {
|
|
645
|
+
const query = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved isOutdated resolvedBy{login} comments(first:1){nodes{fullDatabaseId}}}pageInfo{hasNextPage}}}}}`
|
|
646
|
+
var data struct {
|
|
647
|
+
Repository struct {
|
|
648
|
+
PullRequest *struct {
|
|
649
|
+
ReviewThreads struct {
|
|
650
|
+
Nodes []struct {
|
|
651
|
+
IsResolved bool `json:"isResolved"`
|
|
652
|
+
IsOutdated bool `json:"isOutdated"`
|
|
653
|
+
ResolvedBy *struct {
|
|
654
|
+
Login string `json:"login"`
|
|
655
|
+
} `json:"resolvedBy"`
|
|
656
|
+
Comments struct {
|
|
657
|
+
Nodes []struct {
|
|
658
|
+
FullDatabaseID string `json:"fullDatabaseId"`
|
|
659
|
+
} `json:"nodes"`
|
|
660
|
+
} `json:"comments"`
|
|
661
|
+
} `json:"nodes"`
|
|
662
|
+
PageInfo struct {
|
|
663
|
+
HasNextPage bool `json:"hasNextPage"`
|
|
664
|
+
} `json:"pageInfo"`
|
|
665
|
+
} `json:"reviewThreads"`
|
|
666
|
+
} `json:"pullRequest"`
|
|
667
|
+
} `json:"repository"`
|
|
668
|
+
}
|
|
669
|
+
if err := client.GraphQL(ctx, query, map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number}, &data); err != nil {
|
|
670
|
+
return nil, false, err
|
|
671
|
+
}
|
|
672
|
+
if data.Repository.PullRequest == nil {
|
|
673
|
+
return nil, false, fmt.Errorf("GitHub GraphQL Pull Request was unavailable")
|
|
674
|
+
}
|
|
675
|
+
states := map[int64]githubPullThreadState{}
|
|
676
|
+
connection := data.Repository.PullRequest.ReviewThreads
|
|
677
|
+
for _, node := range connection.Nodes {
|
|
678
|
+
if len(node.Comments.Nodes) == 0 {
|
|
679
|
+
continue
|
|
680
|
+
}
|
|
681
|
+
id, err := strconv.ParseInt(node.Comments.Nodes[0].FullDatabaseID, 10, 64)
|
|
682
|
+
if err != nil || id <= 0 {
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
state := githubPullThreadState{Resolved: node.IsResolved, Outdated: node.IsOutdated}
|
|
686
|
+
if node.ResolvedBy != nil {
|
|
687
|
+
state.ResolvedBy = node.ResolvedBy.Login
|
|
688
|
+
}
|
|
689
|
+
states[id] = state
|
|
690
|
+
}
|
|
691
|
+
return states, connection.PageInfo.HasNextPage, nil
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
func fetchGitHubPullThreadStates(ctx context.Context, client *GitHubClient, target *GitHubTarget) (map[int64]githubPullThreadState, error) {
|
|
695
|
+
const query = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{isResolved isOutdated resolvedBy{login} comments(first:1){nodes{fullDatabaseId}}}pageInfo{hasNextPage endCursor}}}}}`
|
|
696
|
+
states := map[int64]githubPullThreadState{}
|
|
697
|
+
var after any
|
|
698
|
+
for {
|
|
699
|
+
variables := map[string]any{"owner": target.Owner, "repo": target.Repo, "number": target.Number, "after": after}
|
|
700
|
+
var data struct {
|
|
701
|
+
Repository struct {
|
|
702
|
+
PullRequest *struct {
|
|
703
|
+
ReviewThreads struct {
|
|
704
|
+
Nodes []struct {
|
|
705
|
+
IsResolved bool `json:"isResolved"`
|
|
706
|
+
IsOutdated bool `json:"isOutdated"`
|
|
707
|
+
ResolvedBy *struct {
|
|
708
|
+
Login string `json:"login"`
|
|
709
|
+
} `json:"resolvedBy"`
|
|
710
|
+
Comments struct {
|
|
711
|
+
Nodes []struct {
|
|
712
|
+
FullDatabaseID string `json:"fullDatabaseId"`
|
|
713
|
+
} `json:"nodes"`
|
|
714
|
+
} `json:"comments"`
|
|
715
|
+
} `json:"nodes"`
|
|
716
|
+
PageInfo struct {
|
|
717
|
+
HasNextPage bool `json:"hasNextPage"`
|
|
718
|
+
EndCursor string `json:"endCursor"`
|
|
719
|
+
} `json:"pageInfo"`
|
|
720
|
+
} `json:"reviewThreads"`
|
|
721
|
+
} `json:"pullRequest"`
|
|
722
|
+
} `json:"repository"`
|
|
723
|
+
}
|
|
724
|
+
if err := client.GraphQL(ctx, query, variables, &data); err != nil {
|
|
725
|
+
return nil, err
|
|
726
|
+
}
|
|
727
|
+
if data.Repository.PullRequest == nil {
|
|
728
|
+
return nil, fmt.Errorf("GitHub GraphQL Pull Request was unavailable")
|
|
729
|
+
}
|
|
730
|
+
connection := data.Repository.PullRequest.ReviewThreads
|
|
731
|
+
for _, node := range connection.Nodes {
|
|
732
|
+
if len(node.Comments.Nodes) == 0 {
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
735
|
+
id, err := strconv.ParseInt(node.Comments.Nodes[0].FullDatabaseID, 10, 64)
|
|
736
|
+
if err != nil || id <= 0 {
|
|
737
|
+
continue
|
|
738
|
+
}
|
|
739
|
+
state := githubPullThreadState{Resolved: node.IsResolved, Outdated: node.IsOutdated}
|
|
740
|
+
if node.ResolvedBy != nil {
|
|
741
|
+
state.ResolvedBy = node.ResolvedBy.Login
|
|
742
|
+
}
|
|
743
|
+
states[id] = state
|
|
744
|
+
}
|
|
745
|
+
if !connection.PageInfo.HasNextPage {
|
|
746
|
+
break
|
|
747
|
+
}
|
|
748
|
+
if connection.PageInfo.EndCursor == "" {
|
|
749
|
+
return nil, fmt.Errorf("GitHub GraphQL review-thread pagination omitted an end cursor")
|
|
750
|
+
}
|
|
751
|
+
after = connection.PageInfo.EndCursor
|
|
752
|
+
}
|
|
753
|
+
return states, nil
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
func readGitHubPullThreadSelector(ctx context.Context, client *GitHubClient, target *GitHubTarget, commentID int64) (string, error) {
|
|
757
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/comments/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), commentID)
|
|
758
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
759
|
+
if err != nil {
|
|
760
|
+
return "", err
|
|
761
|
+
}
|
|
762
|
+
var selected githubPullReviewComment
|
|
763
|
+
if err := json.Unmarshal(resp.Body, &selected); err != nil {
|
|
764
|
+
return "", fmt.Errorf("decode GitHub review comment: %w", err)
|
|
765
|
+
}
|
|
766
|
+
if selected.PullRequestURL != "" && !strings.HasSuffix(strings.TrimRight(selected.PullRequestURL, "/"), fmt.Sprintf("/repos/%s/%s/pulls/%d", target.Owner, target.Repo, target.Number)) {
|
|
767
|
+
return "", fmt.Errorf("GitHub review comment %d does not belong to %s/%s#%d", commentID, target.Owner, target.Repo, target.Number)
|
|
768
|
+
}
|
|
769
|
+
comments, err := fetchGitHubPullReviewComments(ctx, client, target)
|
|
770
|
+
if err != nil {
|
|
771
|
+
return "", err
|
|
772
|
+
}
|
|
773
|
+
threads := groupGitHubPullThreads(comments)
|
|
774
|
+
for _, thread := range threads {
|
|
775
|
+
if thread.Root.ID == selected.ID || pullThreadContains(thread, selected.ID) {
|
|
776
|
+
if client.hasToken() {
|
|
777
|
+
if states, enrichErr := fetchGitHubPullThreadStates(ctx, client, target); enrichErr == nil {
|
|
778
|
+
if state, ok := states[thread.Root.ID]; ok {
|
|
779
|
+
thread.State = &state
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return renderGitHubPullThreadSelector(target, thread, selected.ID), nil
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// If list pagination changed between reads, preserve the selected comment
|
|
787
|
+
// rather than pretending it did not exist.
|
|
788
|
+
return renderGitHubPullThreadSelector(target, githubPullThread{Root: selected}, selected.ID), nil
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
func pullThreadContains(thread githubPullThread, id int64) bool {
|
|
792
|
+
for _, reply := range thread.Replies {
|
|
793
|
+
if reply.ID == id {
|
|
794
|
+
return true
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return false
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
func readGitHubPullReviewSelector(ctx context.Context, client *GitHubClient, target *GitHubTarget, reviewID int64) (string, error) {
|
|
801
|
+
base := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number, reviewID)
|
|
802
|
+
resp, err := client.REST(ctx, http.MethodGet, base, "application/vnd.github+json")
|
|
803
|
+
if err != nil {
|
|
804
|
+
return "", err
|
|
805
|
+
}
|
|
806
|
+
var review githubPullReview
|
|
807
|
+
if err := json.Unmarshal(resp.Body, &review); err != nil {
|
|
808
|
+
return "", fmt.Errorf("decode GitHub Pull Request review: %w", err)
|
|
809
|
+
}
|
|
810
|
+
pages, err := client.RESTPages(ctx, base+"/comments?per_page=100", "application/vnd.github+json")
|
|
811
|
+
if err != nil {
|
|
812
|
+
return "", err
|
|
813
|
+
}
|
|
814
|
+
comments := []githubPullReviewComment{}
|
|
815
|
+
for _, page := range pages {
|
|
816
|
+
var batch []githubPullReviewComment
|
|
817
|
+
if err := json.Unmarshal(page.Body, &batch); err != nil {
|
|
818
|
+
return "", fmt.Errorf("decode GitHub Pull Request review comments: %w", err)
|
|
819
|
+
}
|
|
820
|
+
comments = append(comments, batch...)
|
|
821
|
+
}
|
|
822
|
+
return renderGitHubPullReviewSelector(target, review, groupGitHubPullThreads(comments)), nil
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
func renderGitHubPullRequest(target *GitHubTarget, pr githubPullRequest, issueID int64, timeline []githubTimelineEvent, reviews []githubPullReview, threads []githubPullThread, availability githubPullOverviewAvailability, enrichmentNote string, hasToken bool) string {
|
|
826
|
+
comments, events := splitPullTimelineForOverview(timeline)
|
|
827
|
+
commentLimit := minInt(3, len(comments))
|
|
828
|
+
eventLimit := minInt(6, len(events))
|
|
829
|
+
reviewLimit := minInt(4, len(reviews))
|
|
830
|
+
threadLimit := minInt(4, len(threads))
|
|
831
|
+
bodyLimit := 900
|
|
832
|
+
for {
|
|
833
|
+
out := renderGitHubPullOverviewWithLimits(target, pr, issueID, comments, events, reviews, threads, availability, enrichmentNote, hasToken, commentLimit, eventLimit, reviewLimit, threadLimit, bodyLimit)
|
|
834
|
+
if githubOverviewFits(out) {
|
|
835
|
+
return out
|
|
836
|
+
}
|
|
837
|
+
switch {
|
|
838
|
+
case eventLimit > 0:
|
|
839
|
+
eventLimit--
|
|
840
|
+
case threadLimit > 1:
|
|
841
|
+
threadLimit--
|
|
842
|
+
case reviewLimit > 1:
|
|
843
|
+
reviewLimit--
|
|
844
|
+
case bodyLimit > 400:
|
|
845
|
+
bodyLimit = 400
|
|
846
|
+
case bodyLimit > 200:
|
|
847
|
+
bodyLimit = 200
|
|
848
|
+
case commentLimit > 1:
|
|
849
|
+
commentLimit--
|
|
850
|
+
default:
|
|
851
|
+
// Mandatory metadata, one representative human comment per available
|
|
852
|
+
// conversation, and exact navigation win over the shared soft target.
|
|
853
|
+
return out
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
func renderGitHubPullOverviewWithLimits(target *GitHubTarget, pr githubPullRequest, issueID int64, comments, events []githubTimelineEvent, reviews []githubPullReview, threads []githubPullThread, availability githubPullOverviewAvailability, enrichmentNote string, hasToken bool, commentLimit, eventLimit, reviewLimit, threadLimit, bodyLimit int) string {
|
|
859
|
+
commentLimit = minInt(commentLimit, len(comments))
|
|
860
|
+
eventLimit = minInt(eventLimit, len(events))
|
|
861
|
+
reviewLimit = minInt(reviewLimit, len(reviews))
|
|
862
|
+
threadLimit = minInt(threadLimit, len(threads))
|
|
863
|
+
titlePreview, titleTruncated := githubOverviewInlinePreview(pr.Title, 180)
|
|
864
|
+
if titleTruncated {
|
|
865
|
+
titlePreview += "…"
|
|
866
|
+
}
|
|
867
|
+
lines := []string{
|
|
868
|
+
"---",
|
|
869
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
870
|
+
fmt.Sprintf("number: %d", pr.Number),
|
|
871
|
+
fmt.Sprintf("issue_id: %d", issueID),
|
|
872
|
+
"state: " + yamlScalar(pullDisplayState(pr)),
|
|
873
|
+
"title: " + yamlScalar(titlePreview),
|
|
874
|
+
"overview: true",
|
|
875
|
+
}
|
|
876
|
+
if titleTruncated {
|
|
877
|
+
lines = append(lines, "title_preview_truncated: true")
|
|
878
|
+
}
|
|
879
|
+
if pr.ID > 0 {
|
|
880
|
+
lines = append(lines, fmt.Sprintf("pull_request_id: %d", pr.ID))
|
|
881
|
+
}
|
|
882
|
+
if pr.Draft {
|
|
883
|
+
lines = append(lines, "draft: true")
|
|
884
|
+
}
|
|
885
|
+
if pr.User.Login != "" {
|
|
886
|
+
lines = append(lines, "author: "+yamlScalar("@"+pr.User.Login))
|
|
887
|
+
}
|
|
888
|
+
if pr.Base.Label != "" {
|
|
889
|
+
base, truncated := githubOverviewInlinePreview(pr.Base.Label, 140)
|
|
890
|
+
if truncated {
|
|
891
|
+
base += "…"
|
|
892
|
+
}
|
|
893
|
+
lines = append(lines, "base: "+yamlScalar(base))
|
|
894
|
+
}
|
|
895
|
+
if pr.Head.Label != "" {
|
|
896
|
+
head, truncated := githubOverviewInlinePreview(pr.Head.Label, 140)
|
|
897
|
+
if truncated {
|
|
898
|
+
head += "…"
|
|
899
|
+
}
|
|
900
|
+
lines = append(lines, "head: "+yamlScalar(head))
|
|
901
|
+
}
|
|
902
|
+
for _, value := range []struct{ key, value string }{{"created", pr.CreatedAt}, {"updated", pr.UpdatedAt}, {"closed", pr.ClosedAt}, {"merged", pr.MergedAt}} {
|
|
903
|
+
if value.value != "" {
|
|
904
|
+
lines = append(lines, value.key+": "+yamlScalar(value.value))
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
lines = append(lines,
|
|
908
|
+
fmt.Sprintf("commits: %d", pr.Commits),
|
|
909
|
+
fmt.Sprintf("changed_files: %d", pr.ChangedFiles),
|
|
910
|
+
fmt.Sprintf("additions: %d", pr.Additions),
|
|
911
|
+
fmt.Sprintf("deletions: %d", pr.Deletions),
|
|
912
|
+
fmt.Sprintf("conversation_comments_reported: %d", pr.Comments),
|
|
913
|
+
fmt.Sprintf("conversation_comments_returned: %d", len(comments)),
|
|
914
|
+
fmt.Sprintf("conversation_comments_indexed: %d", commentLimit),
|
|
915
|
+
fmt.Sprintf("conversation_comments_omitted: %d", len(comments)-commentLimit),
|
|
916
|
+
fmt.Sprintf("conversation_events_returned: %d", len(events)),
|
|
917
|
+
fmt.Sprintf("conversation_events_indexed: %d", eventLimit),
|
|
918
|
+
fmt.Sprintf("conversation_events_omitted: %d", len(events)-eventLimit),
|
|
919
|
+
fmt.Sprintf("conversation_items_returned: %d", len(comments)+len(events)),
|
|
920
|
+
fmt.Sprintf("conversation_items_indexed: %d", commentLimit+eventLimit),
|
|
921
|
+
fmt.Sprintf("conversation_items_omitted: %d", len(comments)+len(events)-commentLimit-eventLimit),
|
|
922
|
+
fmt.Sprintf("reviews_returned: %d", len(reviews)),
|
|
923
|
+
fmt.Sprintf("reviews_indexed: %d", reviewLimit),
|
|
924
|
+
fmt.Sprintf("reviews_omitted: %d", len(reviews)-reviewLimit),
|
|
925
|
+
fmt.Sprintf("review_comments_reported: %d", pr.ReviewComments),
|
|
926
|
+
fmt.Sprintf("review_comments_returned: %d", availability.ReviewCommentsReturned),
|
|
927
|
+
fmt.Sprintf("threads_returned: %d", len(threads)),
|
|
928
|
+
fmt.Sprintf("threads_indexed: %d", threadLimit),
|
|
929
|
+
fmt.Sprintf("threads_omitted: %d", len(threads)-threadLimit),
|
|
930
|
+
)
|
|
931
|
+
if availability.TimelineProviderMore {
|
|
932
|
+
lines = append(lines, "conversation_provider_more_available: true")
|
|
933
|
+
} else if pr.Comments > len(comments) {
|
|
934
|
+
lines = append(lines, "conversation_provider_complete: false")
|
|
935
|
+
}
|
|
936
|
+
if availability.ReviewsProviderMore {
|
|
937
|
+
lines = append(lines, "reviews_provider_more_available: true")
|
|
938
|
+
}
|
|
939
|
+
if availability.ReviewCommentsProviderMore {
|
|
940
|
+
lines = append(lines, "review_comments_provider_more_available: true")
|
|
941
|
+
} else if pr.ReviewComments > availability.ReviewCommentsReturned {
|
|
942
|
+
lines = append(lines, "review_comments_provider_complete: false")
|
|
943
|
+
}
|
|
944
|
+
if len(threads) > 0 && !hasToken {
|
|
945
|
+
lines = append(lines, "thread_state_enrichment: unavailable_without_auth")
|
|
946
|
+
} else if availability.ThreadStatesProviderMore {
|
|
947
|
+
lines = append(lines, "thread_state_enrichment: partial_provider_page")
|
|
948
|
+
} else if enrichmentNote != "" {
|
|
949
|
+
lines = append(lines, "thread_state_enrichment: unavailable")
|
|
950
|
+
}
|
|
951
|
+
if pr.HTMLURL != "" {
|
|
952
|
+
lines = append(lines, "url: "+yamlScalar(pr.HTMLURL))
|
|
953
|
+
}
|
|
954
|
+
lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", pr.Number, titlePreview), "", "> Pull Request overview: description and child conversations are previewed/indexed; use the exact URLs below for full scoped content.")
|
|
955
|
+
|
|
956
|
+
bodySelector := pullBaseURL(target) + "#issue-" + strconv.FormatInt(issueID, 10)
|
|
957
|
+
lines = append(lines, "", "## Description preview", "")
|
|
958
|
+
if pr.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*pr.Body)) == "" {
|
|
959
|
+
lines = append(lines, "_No description provided._")
|
|
960
|
+
} else {
|
|
961
|
+
preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(*pr.Body), bodyLimit)
|
|
962
|
+
lines = append(lines, preview)
|
|
963
|
+
if truncated {
|
|
964
|
+
lines = append(lines, "", "> Description preview locally truncated for this overview.")
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
lines = append(lines, "> Full description: "+bodySelector)
|
|
968
|
+
|
|
969
|
+
lines = append(lines, "", "## Conversation comment index", "")
|
|
970
|
+
if len(comments) == 0 {
|
|
971
|
+
lines = append(lines, "_No ordinary conversation comments returned on this provider page._")
|
|
972
|
+
}
|
|
973
|
+
for _, event := range comments[:commentLimit] {
|
|
974
|
+
comment := githubIssueComment{ID: event.ID, Body: event.Body, HTMLURL: event.HTMLURL, User: event.User, AuthorAssociation: event.AuthorAssociation, CreatedAt: event.CreatedAt, UpdatedAt: event.UpdatedAt, IsPinned: event.IsPinned, Minimized: event.Minimized, MinimizedReason: event.MinimizedReason}
|
|
975
|
+
lines = append(lines, renderPullConversationCommentIndex(target, comment)...)
|
|
976
|
+
}
|
|
977
|
+
if note := githubLocalOmissionNote("conversation comments", len(comments)-commentLimit); note != "" {
|
|
978
|
+
lines = append(lines, "", note)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
lines = append(lines, "", "## Timeline event index", "")
|
|
982
|
+
if len(events) == 0 {
|
|
983
|
+
lines = append(lines, "_No substantive non-comment timeline events returned on this provider page._")
|
|
984
|
+
}
|
|
985
|
+
for _, event := range events[:eventLimit] {
|
|
986
|
+
if rendered, ok := renderPullTimelineState(event); ok {
|
|
987
|
+
lines = append(lines, rendered)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if note := githubLocalOmissionNote("timeline events", len(events)-eventLimit); note != "" {
|
|
991
|
+
lines = append(lines, "", note)
|
|
992
|
+
}
|
|
993
|
+
if availability.TimelineProviderMore {
|
|
994
|
+
lines = append(lines, "", "> GitHub has more conversation timeline items beyond the provider page fetched for this overview.")
|
|
995
|
+
} else if pr.Comments > len(comments) {
|
|
996
|
+
lines = append(lines, "", fmt.Sprintf("> GitHub reports %d conversation comments, while the fetched timeline page returned %d comment events; this provider-incomplete state is separate from local overview omission.", pr.Comments, len(comments)))
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
lines = append(lines, "", "## Review index", "")
|
|
1000
|
+
if len(reviews) == 0 {
|
|
1001
|
+
lines = append(lines, "_No submitted reviews returned on this provider page._")
|
|
1002
|
+
}
|
|
1003
|
+
for _, review := range reviews[:reviewLimit] {
|
|
1004
|
+
lines = append(lines, renderPullReviewIndex(target, review)...)
|
|
1005
|
+
}
|
|
1006
|
+
if note := githubLocalOmissionNote("reviews", len(reviews)-reviewLimit); note != "" {
|
|
1007
|
+
lines = append(lines, "", note)
|
|
1008
|
+
}
|
|
1009
|
+
if availability.ReviewsProviderMore {
|
|
1010
|
+
lines = append(lines, "", "> GitHub has more submitted reviews beyond the provider page fetched for this overview.")
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
lines = append(lines, "", "## Inline thread index", "")
|
|
1014
|
+
if len(threads) == 0 {
|
|
1015
|
+
lines = append(lines, "_No inline review threads returned on this provider page._")
|
|
1016
|
+
}
|
|
1017
|
+
for _, thread := range threads[:threadLimit] {
|
|
1018
|
+
lines = append(lines, renderPullThreadIndex(target, thread)...)
|
|
1019
|
+
}
|
|
1020
|
+
if note := githubLocalOmissionNote("inline review threads", len(threads)-threadLimit); note != "" {
|
|
1021
|
+
lines = append(lines, "", note)
|
|
1022
|
+
}
|
|
1023
|
+
if availability.ReviewCommentsProviderMore {
|
|
1024
|
+
lines = append(lines, "", "> GitHub has more inline review comments beyond the provider page fetched for this overview; additional threads may therefore exist upstream.")
|
|
1025
|
+
} else if pr.ReviewComments > availability.ReviewCommentsReturned {
|
|
1026
|
+
lines = append(lines, "", "> GitHub reports more inline review comments than were returned by the fetched provider page; this provider-incomplete state is separate from local overview omission.")
|
|
1027
|
+
}
|
|
1028
|
+
if enrichmentNote != "" {
|
|
1029
|
+
lines = append(lines, "", "> "+enrichmentNote)
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
base := pullBaseURL(target)
|
|
1033
|
+
lines = append(lines, "", "## Useful GitHub URLs", "", "- Full description: "+bodySelector, "- Files changed: "+base+"/files", "- Commits: "+base+"/commits", "- Checks: "+base+"/checks")
|
|
1034
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
func splitPullTimelineForOverview(timeline []githubTimelineEvent) ([]githubTimelineEvent, []githubTimelineEvent) {
|
|
1038
|
+
comments := make([]githubTimelineEvent, 0, len(timeline))
|
|
1039
|
+
events := make([]githubTimelineEvent, 0, len(timeline))
|
|
1040
|
+
for _, event := range timeline {
|
|
1041
|
+
if event.Event == "commented" {
|
|
1042
|
+
comments = append(comments, event)
|
|
1043
|
+
continue
|
|
1044
|
+
}
|
|
1045
|
+
if _, ok := renderPullTimelineState(event); ok {
|
|
1046
|
+
events = append(events, event)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return comments, events
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
func renderPullConversationCommentIndex(target *GitHubTarget, comment githubIssueComment) []string {
|
|
1053
|
+
heading := fmt.Sprintf("### Comment `%d`", comment.ID)
|
|
1054
|
+
if comment.User.Login != "" {
|
|
1055
|
+
heading += " by @" + comment.User.Login
|
|
1056
|
+
}
|
|
1057
|
+
if comment.CreatedAt != "" {
|
|
1058
|
+
heading += " — " + comment.CreatedAt
|
|
1059
|
+
}
|
|
1060
|
+
body := strings.Join(renderIssueCommentBody(comment, comment.IsPinned), "\n")
|
|
1061
|
+
preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
|
|
1062
|
+
lines := []string{heading, ""}
|
|
1063
|
+
for _, line := range strings.Split(preview, "\n") {
|
|
1064
|
+
lines = append(lines, "> "+line)
|
|
1065
|
+
}
|
|
1066
|
+
if truncated {
|
|
1067
|
+
lines = append(lines, "> _Preview locally truncated._")
|
|
1068
|
+
}
|
|
1069
|
+
lines = append(lines, "", "Selector: "+pullIssueCommentSelectorURL(target, comment), "")
|
|
1070
|
+
return lines
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
func renderPullReviewIndex(target *GitHubTarget, review githubPullReview) []string {
|
|
1074
|
+
heading := fmt.Sprintf("### Review `%d`", review.ID)
|
|
1075
|
+
if review.State != "" {
|
|
1076
|
+
heading += " — " + strings.ToUpper(review.State)
|
|
1077
|
+
}
|
|
1078
|
+
if review.User.Login != "" {
|
|
1079
|
+
heading += " by @" + review.User.Login
|
|
1080
|
+
}
|
|
1081
|
+
if review.SubmittedAt != "" {
|
|
1082
|
+
heading += " — " + review.SubmittedAt
|
|
1083
|
+
}
|
|
1084
|
+
lines := []string{heading, ""}
|
|
1085
|
+
if review.Body != nil {
|
|
1086
|
+
body := strings.TrimSpace(stripInvisibleHTMLComments(*review.Body))
|
|
1087
|
+
if body != "" {
|
|
1088
|
+
preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
|
|
1089
|
+
for _, line := range strings.Split(preview, "\n") {
|
|
1090
|
+
lines = append(lines, "> "+line)
|
|
1091
|
+
}
|
|
1092
|
+
if truncated {
|
|
1093
|
+
lines = append(lines, "> _Preview locally truncated._")
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
lines = append(lines, "", "Selector: "+pullReviewSelectorURL(target, review), "")
|
|
1098
|
+
return lines
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
func renderPullThreadIndex(target *GitHubTarget, thread githubPullThread) []string {
|
|
1102
|
+
root := thread.Root
|
|
1103
|
+
heading := fmt.Sprintf("### Thread `%d`", root.ID)
|
|
1104
|
+
if root.User.Login != "" {
|
|
1105
|
+
heading += " by @" + root.User.Login
|
|
1106
|
+
}
|
|
1107
|
+
if root.CreatedAt != "" {
|
|
1108
|
+
heading += " — " + root.CreatedAt
|
|
1109
|
+
}
|
|
1110
|
+
lines := []string{heading}
|
|
1111
|
+
if coord := pullCommentCoordinate(root); coord != "" {
|
|
1112
|
+
lines = append(lines, "Coordinate: "+coord)
|
|
1113
|
+
}
|
|
1114
|
+
if thread.State != nil {
|
|
1115
|
+
state := "unresolved"
|
|
1116
|
+
if thread.State.Resolved {
|
|
1117
|
+
state = "resolved"
|
|
1118
|
+
}
|
|
1119
|
+
if thread.State.Outdated {
|
|
1120
|
+
state += " · outdated"
|
|
1121
|
+
}
|
|
1122
|
+
if thread.State.ResolvedBy != "" {
|
|
1123
|
+
state += " · by @" + thread.State.ResolvedBy
|
|
1124
|
+
}
|
|
1125
|
+
lines = append(lines, "State: "+state)
|
|
1126
|
+
}
|
|
1127
|
+
if len(thread.Replies) > 0 {
|
|
1128
|
+
lines = append(lines, fmt.Sprintf("Replies: %d", len(thread.Replies)))
|
|
1129
|
+
}
|
|
1130
|
+
lines = append(lines, "")
|
|
1131
|
+
body := "_Review comment body is unavailable or deleted._"
|
|
1132
|
+
if root.Body != nil {
|
|
1133
|
+
body = strings.TrimSpace(stripInvisibleHTMLComments(*root.Body))
|
|
1134
|
+
if body == "" {
|
|
1135
|
+
body = "_Review comment is empty after removing invisible GitHub markup._"
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
|
|
1139
|
+
for _, line := range strings.Split(preview, "\n") {
|
|
1140
|
+
lines = append(lines, "> "+line)
|
|
1141
|
+
}
|
|
1142
|
+
if truncated {
|
|
1143
|
+
lines = append(lines, "> _Preview locally truncated._")
|
|
1144
|
+
}
|
|
1145
|
+
lines = append(lines, "", "Selector: "+pullThreadSelectorURL(target, root), "")
|
|
1146
|
+
return lines
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
func pullBaseURL(target *GitHubTarget) string {
|
|
1150
|
+
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
func pullIssueCommentSelectorURL(target *GitHubTarget, comment githubIssueComment) string {
|
|
1154
|
+
if strings.TrimSpace(comment.HTMLURL) != "" {
|
|
1155
|
+
return comment.HTMLURL
|
|
1156
|
+
}
|
|
1157
|
+
return pullBaseURL(target) + "#issuecomment-" + strconv.FormatInt(comment.ID, 10)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
func pullReviewSelectorURL(target *GitHubTarget, review githubPullReview) string {
|
|
1161
|
+
if strings.TrimSpace(review.HTMLURL) != "" {
|
|
1162
|
+
return review.HTMLURL
|
|
1163
|
+
}
|
|
1164
|
+
return pullBaseURL(target) + "#pullrequestreview-" + strconv.FormatInt(review.ID, 10)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
func pullThreadSelectorURL(target *GitHubTarget, comment githubPullReviewComment) string {
|
|
1168
|
+
if strings.TrimSpace(comment.HTMLURL) != "" {
|
|
1169
|
+
return comment.HTMLURL
|
|
1170
|
+
}
|
|
1171
|
+
return pullBaseURL(target) + "#discussion_r" + strconv.FormatInt(comment.ID, 10)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
func minInt(a, b int) int {
|
|
1175
|
+
if a < b {
|
|
1176
|
+
return a
|
|
1177
|
+
}
|
|
1178
|
+
return b
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
func pullDisplayState(pr githubPullRequest) string {
|
|
1182
|
+
if pr.Merged || pr.MergedAt != "" {
|
|
1183
|
+
return "merged"
|
|
1184
|
+
}
|
|
1185
|
+
if pr.Draft && pr.State == "open" {
|
|
1186
|
+
return "draft"
|
|
1187
|
+
}
|
|
1188
|
+
if pr.State != "" {
|
|
1189
|
+
return pr.State
|
|
1190
|
+
}
|
|
1191
|
+
return "unknown"
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
func renderPullReview(review githubPullReview) []string {
|
|
1195
|
+
heading := "### " + strings.ToUpper(review.State)
|
|
1196
|
+
if review.User.Login != "" {
|
|
1197
|
+
heading += " by @" + review.User.Login
|
|
1198
|
+
}
|
|
1199
|
+
if review.SubmittedAt != "" {
|
|
1200
|
+
heading += " — " + review.SubmittedAt
|
|
1201
|
+
}
|
|
1202
|
+
lines := []string{heading}
|
|
1203
|
+
meta := []string{}
|
|
1204
|
+
if review.ID != 0 {
|
|
1205
|
+
meta = append(meta, fmt.Sprintf("review %d", review.ID))
|
|
1206
|
+
}
|
|
1207
|
+
if review.CommitID != "" {
|
|
1208
|
+
meta = append(meta, "commit `"+shortSHA(review.CommitID)+"`")
|
|
1209
|
+
}
|
|
1210
|
+
if review.AuthorAssociation != "" {
|
|
1211
|
+
meta = append(meta, "association "+review.AuthorAssociation)
|
|
1212
|
+
}
|
|
1213
|
+
if review.HTMLURL != "" {
|
|
1214
|
+
meta = append(meta, review.HTMLURL)
|
|
1215
|
+
}
|
|
1216
|
+
if len(meta) > 0 {
|
|
1217
|
+
lines = append(lines, "", "_"+strings.Join(meta, " · ")+"_")
|
|
1218
|
+
}
|
|
1219
|
+
if review.Body != nil {
|
|
1220
|
+
body := strings.TrimSpace(stripInvisibleHTMLComments(*review.Body))
|
|
1221
|
+
if body != "" {
|
|
1222
|
+
lines = append(lines, "", body)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
lines = append(lines, "")
|
|
1226
|
+
return lines
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
func renderPullThread(thread githubPullThread, selectedID int64) []string {
|
|
1230
|
+
root := thread.Root
|
|
1231
|
+
coord := pullCommentCoordinate(root)
|
|
1232
|
+
heading := fmt.Sprintf("### Thread %d", root.ID)
|
|
1233
|
+
if coord != "" {
|
|
1234
|
+
heading += " — " + coord
|
|
1235
|
+
}
|
|
1236
|
+
if thread.State != nil {
|
|
1237
|
+
states := []string{}
|
|
1238
|
+
if thread.State.Resolved {
|
|
1239
|
+
states = append(states, "resolved")
|
|
1240
|
+
} else {
|
|
1241
|
+
states = append(states, "unresolved")
|
|
1242
|
+
}
|
|
1243
|
+
if thread.State.Outdated {
|
|
1244
|
+
states = append(states, "outdated")
|
|
1245
|
+
}
|
|
1246
|
+
if thread.State.ResolvedBy != "" {
|
|
1247
|
+
states = append(states, "by @"+thread.State.ResolvedBy)
|
|
1248
|
+
}
|
|
1249
|
+
heading += " · " + strings.Join(states, " · ")
|
|
1250
|
+
}
|
|
1251
|
+
lines := []string{heading, ""}
|
|
1252
|
+
lines = append(lines, renderPullReviewComment(root, selectedID == root.ID)...)
|
|
1253
|
+
for _, reply := range thread.Replies {
|
|
1254
|
+
lines = append(lines, "", "#### Reply", "")
|
|
1255
|
+
lines = append(lines, renderPullReviewComment(reply, selectedID == reply.ID)...)
|
|
1256
|
+
}
|
|
1257
|
+
lines = append(lines, "")
|
|
1258
|
+
return lines
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
func renderPullReviewComment(comment githubPullReviewComment, selected bool) []string {
|
|
1262
|
+
meta := []string{}
|
|
1263
|
+
if comment.User.Login != "" {
|
|
1264
|
+
meta = append(meta, "@"+comment.User.Login)
|
|
1265
|
+
}
|
|
1266
|
+
if comment.CreatedAt != "" {
|
|
1267
|
+
meta = append(meta, comment.CreatedAt)
|
|
1268
|
+
}
|
|
1269
|
+
if comment.AuthorAssociation != "" {
|
|
1270
|
+
meta = append(meta, comment.AuthorAssociation)
|
|
1271
|
+
}
|
|
1272
|
+
if selected {
|
|
1273
|
+
meta = append(meta, "selected")
|
|
1274
|
+
}
|
|
1275
|
+
if comment.HTMLURL != "" {
|
|
1276
|
+
meta = append(meta, comment.HTMLURL)
|
|
1277
|
+
}
|
|
1278
|
+
lines := []string{}
|
|
1279
|
+
if len(meta) > 0 {
|
|
1280
|
+
lines = append(lines, "_"+strings.Join(meta, " · ")+"_", "")
|
|
1281
|
+
}
|
|
1282
|
+
if comment.Body == nil {
|
|
1283
|
+
lines = append(lines, "_Review comment body is unavailable or deleted._")
|
|
1284
|
+
} else {
|
|
1285
|
+
body := strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
|
|
1286
|
+
if body == "" {
|
|
1287
|
+
body = "_Review comment is empty after removing invisible GitHub markup._"
|
|
1288
|
+
}
|
|
1289
|
+
lines = append(lines, body)
|
|
1290
|
+
}
|
|
1291
|
+
return lines
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
func pullCommentCoordinate(comment githubPullReviewComment) string {
|
|
1295
|
+
if comment.Path == "" {
|
|
1296
|
+
return ""
|
|
1297
|
+
}
|
|
1298
|
+
line := comment.Line
|
|
1299
|
+
if line == nil {
|
|
1300
|
+
line = comment.OriginalLine
|
|
1301
|
+
}
|
|
1302
|
+
start := comment.StartLine
|
|
1303
|
+
if start == nil {
|
|
1304
|
+
start = comment.OriginalStartLine
|
|
1305
|
+
}
|
|
1306
|
+
coord := "`" + comment.Path + "`"
|
|
1307
|
+
if line != nil {
|
|
1308
|
+
if start != nil && *start != *line {
|
|
1309
|
+
coord += fmt.Sprintf(" lines %d-%d", *start, *line)
|
|
1310
|
+
} else {
|
|
1311
|
+
coord += fmt.Sprintf(" line %d", *line)
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
side := comment.Side
|
|
1315
|
+
if side == "" {
|
|
1316
|
+
side = comment.OriginalSide
|
|
1317
|
+
}
|
|
1318
|
+
if side != "" {
|
|
1319
|
+
coord += " " + strings.ToLower(side)
|
|
1320
|
+
}
|
|
1321
|
+
return coord
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
func renderPullTimelineState(event githubTimelineEvent) (string, bool) {
|
|
1325
|
+
switch event.Event {
|
|
1326
|
+
case "commented":
|
|
1327
|
+
return "", false
|
|
1328
|
+
case "reviewed":
|
|
1329
|
+
// Reviews are rendered from the complete /pulls/{n}/reviews endpoint.
|
|
1330
|
+
return "", false
|
|
1331
|
+
case "mentioned", "subscribed", "unsubscribed", "copilot_work_started":
|
|
1332
|
+
return "", false
|
|
1333
|
+
}
|
|
1334
|
+
actor := event.Actor.Login
|
|
1335
|
+
if actor == "" {
|
|
1336
|
+
actor = event.User.Login
|
|
1337
|
+
}
|
|
1338
|
+
prefix := "- "
|
|
1339
|
+
if event.CreatedAt != "" {
|
|
1340
|
+
prefix += event.CreatedAt + " — "
|
|
1341
|
+
}
|
|
1342
|
+
if actor != "" {
|
|
1343
|
+
prefix += "@" + actor + " "
|
|
1344
|
+
}
|
|
1345
|
+
switch event.Event {
|
|
1346
|
+
case "committed":
|
|
1347
|
+
sha := event.SHA
|
|
1348
|
+
if sha == "" {
|
|
1349
|
+
sha = event.CommitID
|
|
1350
|
+
}
|
|
1351
|
+
detail := "added commit"
|
|
1352
|
+
if sha != "" {
|
|
1353
|
+
detail += " `" + shortSHA(sha) + "`"
|
|
1354
|
+
}
|
|
1355
|
+
if event.Message != "" {
|
|
1356
|
+
detail += " — " + firstLine(event.Message)
|
|
1357
|
+
}
|
|
1358
|
+
return prefix + detail + ".", true
|
|
1359
|
+
case "head_ref_force_pushed":
|
|
1360
|
+
detail := "force-pushed the head ref"
|
|
1361
|
+
if event.CommitID != "" {
|
|
1362
|
+
detail += " to `" + shortSHA(event.CommitID) + "`"
|
|
1363
|
+
}
|
|
1364
|
+
return prefix + detail + ".", true
|
|
1365
|
+
case "base_ref_changed":
|
|
1366
|
+
return prefix + "changed the base ref.", true
|
|
1367
|
+
case "ready_for_review":
|
|
1368
|
+
return prefix + "marked the Pull Request ready for review.", true
|
|
1369
|
+
case "review_requested", "review_request_removed":
|
|
1370
|
+
login := ""
|
|
1371
|
+
if event.RequestedReviewer != nil {
|
|
1372
|
+
login = event.RequestedReviewer.Login
|
|
1373
|
+
}
|
|
1374
|
+
team := ""
|
|
1375
|
+
if event.RequestedTeam != nil {
|
|
1376
|
+
team = event.RequestedTeam.Name
|
|
1377
|
+
if team == "" {
|
|
1378
|
+
team = event.RequestedTeam.Slug
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
verb := "requested review"
|
|
1382
|
+
if event.Event == "review_request_removed" {
|
|
1383
|
+
verb = "removed review request"
|
|
1384
|
+
}
|
|
1385
|
+
if login != "" {
|
|
1386
|
+
return prefix + verb + " from @" + login + ".", true
|
|
1387
|
+
}
|
|
1388
|
+
if team != "" {
|
|
1389
|
+
return prefix + verb + " from team `" + team + "`.", true
|
|
1390
|
+
}
|
|
1391
|
+
return prefix + verb + ".", true
|
|
1392
|
+
case "renamed":
|
|
1393
|
+
if event.Rename != nil {
|
|
1394
|
+
return prefix + "renamed the Pull Request from `" + event.Rename.From + "` to `" + event.Rename.To + "`.", true
|
|
1395
|
+
}
|
|
1396
|
+
return prefix + "renamed the Pull Request.", true
|
|
1397
|
+
case "merged":
|
|
1398
|
+
return prefix + "merged the Pull Request.", true
|
|
1399
|
+
case "closed":
|
|
1400
|
+
return prefix + "closed the Pull Request.", true
|
|
1401
|
+
case "reopened":
|
|
1402
|
+
return prefix + "reopened the Pull Request.", true
|
|
1403
|
+
default:
|
|
1404
|
+
return renderIssueTimelineState(event)
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
func shortSHA(sha string) string {
|
|
1409
|
+
sha = strings.TrimSpace(sha)
|
|
1410
|
+
if len(sha) > 12 {
|
|
1411
|
+
return sha[:12]
|
|
1412
|
+
}
|
|
1413
|
+
return sha
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
func firstLine(text string) string {
|
|
1417
|
+
line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
|
|
1418
|
+
return line
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
func renderGitHubPullThreadSelector(target *GitHubTarget, thread githubPullThread, selectedID int64) string {
|
|
1422
|
+
lines := []string{
|
|
1423
|
+
"---",
|
|
1424
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
1425
|
+
fmt.Sprintf("pull_request: %d", target.Number),
|
|
1426
|
+
fmt.Sprintf("selected_review_comment: %d", selectedID),
|
|
1427
|
+
"---",
|
|
1428
|
+
"",
|
|
1429
|
+
fmt.Sprintf("# Review thread on %s/%s#%d", target.Owner, target.Repo, target.Number),
|
|
1430
|
+
"",
|
|
1431
|
+
}
|
|
1432
|
+
lines = append(lines, renderPullThread(thread, selectedID)...)
|
|
1433
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
func renderGitHubPullReviewSelector(target *GitHubTarget, review githubPullReview, threads []githubPullThread) string {
|
|
1437
|
+
lines := []string{
|
|
1438
|
+
"---",
|
|
1439
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
1440
|
+
fmt.Sprintf("pull_request: %d", target.Number),
|
|
1441
|
+
fmt.Sprintf("review_id: %d", review.ID),
|
|
1442
|
+
"state: " + yamlScalar(review.State),
|
|
1443
|
+
}
|
|
1444
|
+
if review.User.Login != "" {
|
|
1445
|
+
lines = append(lines, "reviewer: "+yamlScalar("@"+review.User.Login))
|
|
1446
|
+
}
|
|
1447
|
+
if review.SubmittedAt != "" {
|
|
1448
|
+
lines = append(lines, "submitted: "+yamlScalar(review.SubmittedAt))
|
|
1449
|
+
}
|
|
1450
|
+
lines = append(lines, "---", "", fmt.Sprintf("# Review on %s/%s#%d", target.Owner, target.Repo, target.Number), "")
|
|
1451
|
+
lines = append(lines, renderPullReview(review)...)
|
|
1452
|
+
if len(threads) > 0 {
|
|
1453
|
+
lines = append(lines, "## Review comments", "")
|
|
1454
|
+
for _, thread := range threads {
|
|
1455
|
+
lines = append(lines, renderPullThread(thread, 0)...)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1459
|
+
}
|