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,1616 @@
|
|
|
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 githubUser struct {
|
|
16
|
+
Login string `json:"login"`
|
|
17
|
+
HTMLURL string `json:"html_url"`
|
|
18
|
+
Type string `json:"type"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type githubIssueLabel struct {
|
|
22
|
+
Name string `json:"name"`
|
|
23
|
+
Color string `json:"color"`
|
|
24
|
+
Description string `json:"description"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type githubIssueMilestone struct {
|
|
28
|
+
Number int `json:"number"`
|
|
29
|
+
State string `json:"state"`
|
|
30
|
+
Title string `json:"title"`
|
|
31
|
+
Description string `json:"description"`
|
|
32
|
+
HTMLURL string `json:"html_url"`
|
|
33
|
+
Creator githubUser `json:"creator"`
|
|
34
|
+
OpenIssues int `json:"open_issues"`
|
|
35
|
+
ClosedIssues int `json:"closed_issues"`
|
|
36
|
+
CreatedAt string `json:"created_at"`
|
|
37
|
+
UpdatedAt string `json:"updated_at"`
|
|
38
|
+
ClosedAt string `json:"closed_at"`
|
|
39
|
+
DueOn string `json:"due_on"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type githubIssueType struct {
|
|
43
|
+
Name string `json:"name"`
|
|
44
|
+
Description string `json:"description"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type githubPullMarker struct {
|
|
48
|
+
HTMLURL string `json:"html_url"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type githubIssue struct {
|
|
52
|
+
ID int64 `json:"id"`
|
|
53
|
+
Number int `json:"number"`
|
|
54
|
+
State string `json:"state"`
|
|
55
|
+
StateReason string `json:"state_reason"`
|
|
56
|
+
Title string `json:"title"`
|
|
57
|
+
Body *string `json:"body"`
|
|
58
|
+
HTMLURL string `json:"html_url"`
|
|
59
|
+
User githubUser `json:"user"`
|
|
60
|
+
AuthorAssociation string `json:"author_association"`
|
|
61
|
+
Labels []githubIssueLabel `json:"labels"`
|
|
62
|
+
Assignees []githubUser `json:"assignees"`
|
|
63
|
+
Milestone *githubIssueMilestone `json:"milestone"`
|
|
64
|
+
Draft bool `json:"draft"`
|
|
65
|
+
Locked bool `json:"locked"`
|
|
66
|
+
ActiveLockReason string `json:"active_lock_reason"`
|
|
67
|
+
Comments int `json:"comments"`
|
|
68
|
+
CreatedAt string `json:"created_at"`
|
|
69
|
+
UpdatedAt string `json:"updated_at"`
|
|
70
|
+
ClosedAt string `json:"closed_at"`
|
|
71
|
+
PullRequest *githubPullMarker `json:"pull_request"`
|
|
72
|
+
PinnedComment json.RawMessage `json:"pinned_comment"`
|
|
73
|
+
Type json.RawMessage `json:"type"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type githubIssueComment struct {
|
|
77
|
+
ID int64 `json:"id"`
|
|
78
|
+
Body *string `json:"body"`
|
|
79
|
+
HTMLURL string `json:"html_url"`
|
|
80
|
+
IssueURL string `json:"issue_url"`
|
|
81
|
+
User githubUser `json:"user"`
|
|
82
|
+
AuthorAssociation string `json:"author_association"`
|
|
83
|
+
CreatedAt string `json:"created_at"`
|
|
84
|
+
UpdatedAt string `json:"updated_at"`
|
|
85
|
+
IsPinned bool `json:"is_pinned"`
|
|
86
|
+
Minimized bool `json:"minimized"`
|
|
87
|
+
MinimizedReason string `json:"minimized_reason"`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (comment *githubIssueComment) UnmarshalJSON(data []byte) error {
|
|
91
|
+
type alias githubIssueComment
|
|
92
|
+
aux := struct {
|
|
93
|
+
Minimized json.RawMessage `json:"minimized"`
|
|
94
|
+
MinimizedReason json.RawMessage `json:"minimized_reason"`
|
|
95
|
+
*alias
|
|
96
|
+
}{alias: (*alias)(comment)}
|
|
97
|
+
if err := json.Unmarshal(data, &aux); err != nil {
|
|
98
|
+
return err
|
|
99
|
+
}
|
|
100
|
+
comment.Minimized, comment.MinimizedReason = decodeGitHubMinimized(aux.Minimized, aux.MinimizedReason)
|
|
101
|
+
return nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type githubIssueFieldValue struct {
|
|
105
|
+
IssueFieldName string `json:"issue_field_name"`
|
|
106
|
+
DataType string `json:"data_type"`
|
|
107
|
+
Value any `json:"value"`
|
|
108
|
+
SingleSelectOption *struct {
|
|
109
|
+
Name string `json:"name"`
|
|
110
|
+
} `json:"single_select_option"`
|
|
111
|
+
MultiSelectOptions []struct {
|
|
112
|
+
Name string `json:"name"`
|
|
113
|
+
} `json:"multi_select_options"`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type githubTimelineEvent struct {
|
|
117
|
+
ID int64 `json:"id"`
|
|
118
|
+
Event string `json:"event"`
|
|
119
|
+
Actor githubUser `json:"actor"`
|
|
120
|
+
User githubUser `json:"user"`
|
|
121
|
+
Body *string `json:"body"`
|
|
122
|
+
HTMLURL string `json:"html_url"`
|
|
123
|
+
CreatedAt string `json:"created_at"`
|
|
124
|
+
UpdatedAt string `json:"updated_at"`
|
|
125
|
+
AuthorAssociation string `json:"author_association"`
|
|
126
|
+
Label *githubIssueLabel `json:"label"`
|
|
127
|
+
Milestone *githubIssueMilestone `json:"milestone"`
|
|
128
|
+
Assignee *githubUser `json:"assignee"`
|
|
129
|
+
CommitID string `json:"commit_id"`
|
|
130
|
+
CommitURL string `json:"commit_url"`
|
|
131
|
+
SHA string `json:"sha"`
|
|
132
|
+
Message string `json:"message"`
|
|
133
|
+
State string `json:"state"`
|
|
134
|
+
SubmittedAt string `json:"submitted_at"`
|
|
135
|
+
RequestedReviewer *githubUser `json:"requested_reviewer"`
|
|
136
|
+
ReviewRequester *githubUser `json:"review_requester"`
|
|
137
|
+
RequestedTeam *struct {
|
|
138
|
+
Name string `json:"name"`
|
|
139
|
+
Slug string `json:"slug"`
|
|
140
|
+
} `json:"requested_team"`
|
|
141
|
+
LockReason string `json:"lock_reason"`
|
|
142
|
+
Rename *struct {
|
|
143
|
+
From string `json:"from"`
|
|
144
|
+
To string `json:"to"`
|
|
145
|
+
} `json:"rename"`
|
|
146
|
+
Source *struct {
|
|
147
|
+
Issue *githubIssue `json:"issue"`
|
|
148
|
+
} `json:"source"`
|
|
149
|
+
IsPinned bool `json:"is_pinned"`
|
|
150
|
+
Minimized bool `json:"minimized"`
|
|
151
|
+
MinimizedReason string `json:"minimized_reason"`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func (event *githubTimelineEvent) UnmarshalJSON(data []byte) error {
|
|
155
|
+
type alias githubTimelineEvent
|
|
156
|
+
aux := struct {
|
|
157
|
+
Minimized json.RawMessage `json:"minimized"`
|
|
158
|
+
MinimizedReason json.RawMessage `json:"minimized_reason"`
|
|
159
|
+
*alias
|
|
160
|
+
}{alias: (*alias)(event)}
|
|
161
|
+
if err := json.Unmarshal(data, &aux); err != nil {
|
|
162
|
+
return err
|
|
163
|
+
}
|
|
164
|
+
event.Minimized, event.MinimizedReason = decodeGitHubMinimized(aux.Minimized, aux.MinimizedReason)
|
|
165
|
+
return nil
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func decodeGitHubMinimized(raw, reasonRaw json.RawMessage) (bool, string) {
|
|
169
|
+
minimized := false
|
|
170
|
+
reason := ""
|
|
171
|
+
trimmed := strings.TrimSpace(string(raw))
|
|
172
|
+
switch {
|
|
173
|
+
case trimmed == "", trimmed == "null":
|
|
174
|
+
case trimmed == "true":
|
|
175
|
+
minimized = true
|
|
176
|
+
case trimmed == "false":
|
|
177
|
+
default:
|
|
178
|
+
var object struct {
|
|
179
|
+
Reason json.RawMessage `json:"reason"`
|
|
180
|
+
}
|
|
181
|
+
if json.Unmarshal(raw, &object) == nil && len(object.Reason) > 0 {
|
|
182
|
+
minimized = true
|
|
183
|
+
_ = json.Unmarshal(object.Reason, &reason)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if strings.TrimSpace(reason) == "" {
|
|
187
|
+
_ = json.Unmarshal(reasonRaw, &reason)
|
|
188
|
+
}
|
|
189
|
+
return minimized, strings.TrimSpace(reason)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type githubIssueRelationships struct {
|
|
193
|
+
Parent *githubIssue
|
|
194
|
+
SubIssues []githubIssue
|
|
195
|
+
BlockedBy []githubIssue
|
|
196
|
+
Blocking []githubIssue
|
|
197
|
+
Fields []githubIssueFieldValue
|
|
198
|
+
SubIssuesProviderMore bool
|
|
199
|
+
BlockedByProviderMore bool
|
|
200
|
+
BlockingProviderMore bool
|
|
201
|
+
FieldsProviderMore bool
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type githubIssueAvailability struct {
|
|
205
|
+
TimelineProviderMore bool
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func (r githubIssueRelationships) providerMore() bool {
|
|
209
|
+
return r.SubIssuesProviderMore || r.BlockedByProviderMore || r.BlockingProviderMore || r.FieldsProviderMore
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type githubIssueSearchResponse struct {
|
|
213
|
+
TotalCount int `json:"total_count"`
|
|
214
|
+
IncompleteResults bool `json:"incomplete_results"`
|
|
215
|
+
Items []githubIssue `json:"items"`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func readGitHubIssue(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
219
|
+
if issueID, ok, err := parseIssueBodySelector(target.Fragment); ok {
|
|
220
|
+
if err != nil {
|
|
221
|
+
return "", err
|
|
222
|
+
}
|
|
223
|
+
return readGitHubIssueBody(ctx, client, target, issueID)
|
|
224
|
+
}
|
|
225
|
+
if commentID, ok, err := parseIssueCommentSelector(target.Fragment); ok {
|
|
226
|
+
if err != nil {
|
|
227
|
+
return "", err
|
|
228
|
+
}
|
|
229
|
+
return readGitHubIssueComment(ctx, client, target, commentID)
|
|
230
|
+
}
|
|
231
|
+
if target.Fragment != "" {
|
|
232
|
+
return "", fmt.Errorf("GitHub Issue fragment %q is not a supported native selector", target.Fragment)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
issue, err := fetchGitHubIssue(ctx, client, target)
|
|
236
|
+
if err != nil {
|
|
237
|
+
return "", err
|
|
238
|
+
}
|
|
239
|
+
if issue.PullRequest != nil {
|
|
240
|
+
canonical := issue.PullRequest.HTMLURL
|
|
241
|
+
if canonical == "" {
|
|
242
|
+
canonical = fmt.Sprintf("https://github.com/%s/%s/pull/%d", target.Owner, target.Repo, target.Number)
|
|
243
|
+
}
|
|
244
|
+
return "", fmt.Errorf("GitHub #%d is a pull request, not an Issue. Canonical URL: %s", target.Number, canonical)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
timeline, timelineMore, err := fetchGitHubIssueTimelinePage(ctx, client, target)
|
|
248
|
+
if err != nil {
|
|
249
|
+
return "", err
|
|
250
|
+
}
|
|
251
|
+
relationships, err := fetchGitHubIssueRelationships(ctx, client, target)
|
|
252
|
+
if err != nil {
|
|
253
|
+
return "", err
|
|
254
|
+
}
|
|
255
|
+
return renderGitHubIssue(target, issue, timeline, relationships, githubIssueAvailability{TimelineProviderMore: timelineMore}), nil
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func fetchGitHubIssue(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubIssue, error) {
|
|
259
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
260
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
261
|
+
if err != nil {
|
|
262
|
+
return githubIssue{}, err
|
|
263
|
+
}
|
|
264
|
+
var issue githubIssue
|
|
265
|
+
if err := json.Unmarshal(resp.Body, &issue); err != nil {
|
|
266
|
+
return githubIssue{}, fmt.Errorf("decode GitHub Issue: %w", err)
|
|
267
|
+
}
|
|
268
|
+
return issue, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func parseIssueBodySelector(fragment string) (int64, bool, error) {
|
|
272
|
+
if !strings.HasPrefix(fragment, "issue-") || strings.HasPrefix(fragment, "issuecomment-") {
|
|
273
|
+
return 0, false, nil
|
|
274
|
+
}
|
|
275
|
+
raw := strings.TrimPrefix(fragment, "issue-")
|
|
276
|
+
id, err := strconv.ParseInt(raw, 10, 64)
|
|
277
|
+
if err != nil || id <= 0 {
|
|
278
|
+
return 0, true, fmt.Errorf("invalid GitHub Issue-body selector %q", fragment)
|
|
279
|
+
}
|
|
280
|
+
return id, true, nil
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func readGitHubIssueBody(ctx context.Context, client *GitHubClient, target *GitHubTarget, issueID int64) (string, error) {
|
|
284
|
+
issue, err := fetchGitHubIssue(ctx, client, target)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return "", err
|
|
287
|
+
}
|
|
288
|
+
if issue.PullRequest != nil {
|
|
289
|
+
canonical := issue.PullRequest.HTMLURL
|
|
290
|
+
if canonical == "" {
|
|
291
|
+
canonical = fmt.Sprintf("https://github.com/%s/%s/pull/%d", target.Owner, target.Repo, target.Number)
|
|
292
|
+
}
|
|
293
|
+
return "", fmt.Errorf("GitHub #%d is a pull request, not an Issue. Canonical URL: %s", target.Number, canonical)
|
|
294
|
+
}
|
|
295
|
+
if issue.ID != issueID {
|
|
296
|
+
return "", fmt.Errorf("GitHub Issue body selector issue-%d does not belong to %s/%s#%d (Issue id %d)", issueID, target.Owner, target.Repo, target.Number, issue.ID)
|
|
297
|
+
}
|
|
298
|
+
return renderGitHubIssueBody(target, issue), nil
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func renderGitHubIssueBody(target *GitHubTarget, issue githubIssue) string {
|
|
302
|
+
lines := []string{
|
|
303
|
+
"---",
|
|
304
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
305
|
+
fmt.Sprintf("issue: %d", issue.Number),
|
|
306
|
+
fmt.Sprintf("issue_id: %d", issue.ID),
|
|
307
|
+
"title: " + yamlScalar(issue.Title),
|
|
308
|
+
}
|
|
309
|
+
if issue.User.Login != "" {
|
|
310
|
+
lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
|
|
311
|
+
}
|
|
312
|
+
if issue.HTMLURL != "" {
|
|
313
|
+
lines = append(lines, "url: "+yamlScalar(issue.HTMLURL+"#issue-"+strconv.FormatInt(issue.ID, 10)))
|
|
314
|
+
}
|
|
315
|
+
lines = append(lines, "---", "", fmt.Sprintf("# Description of %s/%s#%d", target.Owner, target.Repo, issue.Number), "")
|
|
316
|
+
if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
|
|
317
|
+
lines = append(lines, "_No description provided._")
|
|
318
|
+
} else {
|
|
319
|
+
lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
|
|
320
|
+
}
|
|
321
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func parseIssueCommentSelector(fragment string) (int64, bool, error) {
|
|
325
|
+
if !strings.HasPrefix(fragment, "issuecomment-") {
|
|
326
|
+
return 0, false, nil
|
|
327
|
+
}
|
|
328
|
+
raw := strings.TrimPrefix(fragment, "issuecomment-")
|
|
329
|
+
id, err := strconv.ParseInt(raw, 10, 64)
|
|
330
|
+
if err != nil || id <= 0 {
|
|
331
|
+
return 0, true, fmt.Errorf("invalid GitHub issue-comment selector %q", fragment)
|
|
332
|
+
}
|
|
333
|
+
return id, true, nil
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func readGitHubIssueComment(ctx context.Context, client *GitHubClient, target *GitHubTarget, commentID int64) (string, error) {
|
|
337
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/issues/comments/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), commentID)
|
|
338
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
339
|
+
if err != nil {
|
|
340
|
+
return "", err
|
|
341
|
+
}
|
|
342
|
+
var comment githubIssueComment
|
|
343
|
+
if err := json.Unmarshal(resp.Body, &comment); err != nil {
|
|
344
|
+
return "", fmt.Errorf("decode GitHub Issue comment: %w", err)
|
|
345
|
+
}
|
|
346
|
+
wantSuffix := fmt.Sprintf("/repos/%s/%s/issues/%d", target.Owner, target.Repo, target.Number)
|
|
347
|
+
if comment.IssueURL != "" && !strings.HasSuffix(strings.TrimRight(comment.IssueURL, "/"), wantSuffix) {
|
|
348
|
+
return "", fmt.Errorf("GitHub comment %d does not belong to %s/%s#%d", commentID, target.Owner, target.Repo, target.Number)
|
|
349
|
+
}
|
|
350
|
+
return renderGitHubIssueComment(target, comment), nil
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func fetchGitHubIssueTimelinePage(ctx context.Context, client *GitHubClient, target *GitHubTarget) ([]githubTimelineEvent, bool, error) {
|
|
354
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/timeline?per_page=100", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
355
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
356
|
+
if err != nil {
|
|
357
|
+
return nil, false, err
|
|
358
|
+
}
|
|
359
|
+
var events []githubTimelineEvent
|
|
360
|
+
if err := json.Unmarshal(resp.Body, &events); err != nil {
|
|
361
|
+
return nil, false, fmt.Errorf("decode GitHub Issue timeline: %w", err)
|
|
362
|
+
}
|
|
363
|
+
return events, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func fetchGitHubIssueRelationships(ctx context.Context, client *GitHubClient, target *GitHubTarget) (githubIssueRelationships, error) {
|
|
367
|
+
base := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
368
|
+
out := githubIssueRelationships{}
|
|
369
|
+
|
|
370
|
+
parentResp, err := client.REST(ctx, http.MethodGet, base+"/parent", "application/vnd.github+json")
|
|
371
|
+
if err != nil {
|
|
372
|
+
if !isOptionalGitHubRelationshipMiss(err) {
|
|
373
|
+
return out, err
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
var parent githubIssue
|
|
377
|
+
if err := json.Unmarshal(parentResp.Body, &parent); err != nil {
|
|
378
|
+
return out, fmt.Errorf("decode GitHub parent Issue: %w", err)
|
|
379
|
+
}
|
|
380
|
+
out.Parent = &parent
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var errFetch error
|
|
384
|
+
out.SubIssues, out.SubIssuesProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/sub_issues?per_page=100")
|
|
385
|
+
if errFetch != nil {
|
|
386
|
+
return out, errFetch
|
|
387
|
+
}
|
|
388
|
+
out.BlockedBy, out.BlockedByProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/dependencies/blocked_by?per_page=100")
|
|
389
|
+
if errFetch != nil {
|
|
390
|
+
return out, errFetch
|
|
391
|
+
}
|
|
392
|
+
out.Blocking, out.BlockingProviderMore, errFetch = fetchOptionalIssuePage(ctx, client, base+"/dependencies/blocking?per_page=100")
|
|
393
|
+
if errFetch != nil {
|
|
394
|
+
return out, errFetch
|
|
395
|
+
}
|
|
396
|
+
out.Fields, out.FieldsProviderMore, errFetch = fetchOptionalIssueFieldPage(ctx, client, base+"/issue-field-values?per_page=100")
|
|
397
|
+
if errFetch != nil {
|
|
398
|
+
return out, errFetch
|
|
399
|
+
}
|
|
400
|
+
return out, nil
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
func isOptionalGitHubRelationshipMiss(err error) bool {
|
|
404
|
+
var ghErr *GitHubError
|
|
405
|
+
if !asGitHubError(err, &ghErr) {
|
|
406
|
+
return false
|
|
407
|
+
}
|
|
408
|
+
return ghErr.Kind == GitHubErrorNotFound || ghErr.Kind == GitHubErrorGone
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func fetchOptionalIssuePage(ctx context.Context, client *GitHubClient, endpoint string) ([]githubIssue, bool, error) {
|
|
412
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
413
|
+
if err != nil {
|
|
414
|
+
if isOptionalGitHubRelationshipMiss(err) {
|
|
415
|
+
return nil, false, nil
|
|
416
|
+
}
|
|
417
|
+
return nil, false, err
|
|
418
|
+
}
|
|
419
|
+
var out []githubIssue
|
|
420
|
+
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
|
421
|
+
return nil, false, fmt.Errorf("decode GitHub Issue relationship: %w", err)
|
|
422
|
+
}
|
|
423
|
+
return out, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func fetchOptionalIssueFieldPage(ctx context.Context, client *GitHubClient, endpoint string) ([]githubIssueFieldValue, bool, error) {
|
|
427
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
428
|
+
if err != nil {
|
|
429
|
+
if isOptionalGitHubRelationshipMiss(err) {
|
|
430
|
+
return nil, false, nil
|
|
431
|
+
}
|
|
432
|
+
return nil, false, err
|
|
433
|
+
}
|
|
434
|
+
var out []githubIssueFieldValue
|
|
435
|
+
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
|
436
|
+
return nil, false, fmt.Errorf("decode GitHub Issue field values: %w", err)
|
|
437
|
+
}
|
|
438
|
+
return out, strings.TrimSpace(resp.Links()["next"]) != "", nil
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func renderGitHubIssue(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability) string {
|
|
442
|
+
complete := renderGitHubIssueComplete(target, issue, timeline, rel)
|
|
443
|
+
if !availability.TimelineProviderMore && !rel.providerMore() && githubOverviewFits(complete) {
|
|
444
|
+
return complete
|
|
445
|
+
}
|
|
446
|
+
return renderGitHubIssueOverview(target, issue, timeline, rel, availability)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func renderGitHubIssueComplete(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships) string {
|
|
450
|
+
lines := []string{
|
|
451
|
+
"---",
|
|
452
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
453
|
+
fmt.Sprintf("number: %d", issue.Number),
|
|
454
|
+
"state: " + yamlScalar(issue.State),
|
|
455
|
+
"title: " + yamlScalar(issue.Title),
|
|
456
|
+
}
|
|
457
|
+
if issue.StateReason != "" {
|
|
458
|
+
lines = append(lines, "state_reason: "+yamlScalar(issue.StateReason))
|
|
459
|
+
}
|
|
460
|
+
if issue.User.Login != "" {
|
|
461
|
+
lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
|
|
462
|
+
}
|
|
463
|
+
if issue.CreatedAt != "" {
|
|
464
|
+
lines = append(lines, "created: "+yamlScalar(issue.CreatedAt))
|
|
465
|
+
}
|
|
466
|
+
if issue.UpdatedAt != "" {
|
|
467
|
+
lines = append(lines, "updated: "+yamlScalar(issue.UpdatedAt))
|
|
468
|
+
}
|
|
469
|
+
if issue.ClosedAt != "" {
|
|
470
|
+
lines = append(lines, "closed: "+yamlScalar(issue.ClosedAt))
|
|
471
|
+
}
|
|
472
|
+
if issue.Locked {
|
|
473
|
+
lines = append(lines, "locked: true")
|
|
474
|
+
if issue.ActiveLockReason != "" {
|
|
475
|
+
lines = append(lines, "lock_reason: "+yamlScalar(issue.ActiveLockReason))
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
|
|
479
|
+
encoded, _ := json.Marshal(labels)
|
|
480
|
+
lines = append(lines, "labels: "+string(encoded))
|
|
481
|
+
}
|
|
482
|
+
if assignees := githubUserLogins(issue.Assignees); len(assignees) > 0 {
|
|
483
|
+
encoded, _ := json.Marshal(assignees)
|
|
484
|
+
lines = append(lines, "assignees: "+string(encoded))
|
|
485
|
+
}
|
|
486
|
+
if issue.Milestone != nil && issue.Milestone.Title != "" {
|
|
487
|
+
lines = append(lines, "milestone: "+yamlScalar(issue.Milestone.Title))
|
|
488
|
+
}
|
|
489
|
+
if issueType := githubIssueTypeName(issue.Type); issueType != "" {
|
|
490
|
+
lines = append(lines, "type: "+yamlScalar(issueType))
|
|
491
|
+
}
|
|
492
|
+
lines = append(lines, fmt.Sprintf("comments: %d", issue.Comments))
|
|
493
|
+
if issue.HTMLURL != "" {
|
|
494
|
+
lines = append(lines, "url: "+yamlScalar(issue.HTMLURL))
|
|
495
|
+
}
|
|
496
|
+
lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", issue.Number, issue.Title), "", "## Body", "")
|
|
497
|
+
if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
|
|
498
|
+
lines = append(lines, "_No description provided._")
|
|
499
|
+
} else {
|
|
500
|
+
lines = append(lines, stripInvisibleHTMLComments(*issue.Body))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
relLines := renderIssueRelationships(rel)
|
|
504
|
+
if len(relLines) > 0 {
|
|
505
|
+
lines = append(lines, "", "## Relationships", "")
|
|
506
|
+
lines = append(lines, relLines...)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
pinned := decodePinnedIssueComment(issue.PinnedComment)
|
|
510
|
+
pinnedRenderedInTimeline := false
|
|
511
|
+
if pinned != nil {
|
|
512
|
+
for _, event := range timeline {
|
|
513
|
+
if event.Event == "commented" && event.ID == pinned.ID {
|
|
514
|
+
pinnedRenderedInTimeline = true
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if pinned != nil && !pinnedRenderedInTimeline {
|
|
520
|
+
lines = append(lines, "", "## Pinned comment", "")
|
|
521
|
+
lines = append(lines, renderIssueCommentBody(*pinned, true)...)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
lines = append(lines, "", "## Timeline", "")
|
|
525
|
+
visible := 0
|
|
526
|
+
for _, event := range timeline {
|
|
527
|
+
if event.Event == "commented" {
|
|
528
|
+
comment := githubIssueComment{
|
|
529
|
+
ID: event.ID,
|
|
530
|
+
Body: event.Body,
|
|
531
|
+
HTMLURL: event.HTMLURL,
|
|
532
|
+
User: event.User,
|
|
533
|
+
AuthorAssociation: event.AuthorAssociation,
|
|
534
|
+
CreatedAt: event.CreatedAt,
|
|
535
|
+
UpdatedAt: event.UpdatedAt,
|
|
536
|
+
IsPinned: event.IsPinned || (pinned != nil && pinned.ID == event.ID),
|
|
537
|
+
Minimized: event.Minimized,
|
|
538
|
+
MinimizedReason: event.MinimizedReason,
|
|
539
|
+
}
|
|
540
|
+
lines = append(lines, renderTimelineComment(comment)...)
|
|
541
|
+
visible++
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
if rendered, ok := renderIssueTimelineState(event); ok {
|
|
545
|
+
lines = append(lines, rendered)
|
|
546
|
+
visible++
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if visible == 0 {
|
|
550
|
+
lines = append(lines, "_No substantive timeline activity._")
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
base := fmt.Sprintf("https://github.com/%s/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo))
|
|
554
|
+
lines = append(lines, "", "## Useful GitHub URLs", "", "- Issue list: "+base+"/issues")
|
|
555
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
func renderGitHubIssueOverview(target *GitHubTarget, issue githubIssue, timeline []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability) string {
|
|
559
|
+
visible := substantiveIssueTimeline(timeline)
|
|
560
|
+
maxIndexed := len(visible)
|
|
561
|
+
if maxIndexed > 10 {
|
|
562
|
+
maxIndexed = 10
|
|
563
|
+
}
|
|
564
|
+
for indexed := maxIndexed; indexed >= 0; indexed-- {
|
|
565
|
+
out := renderGitHubIssueOverviewWithLimit(target, issue, timeline, visible, rel, availability, indexed)
|
|
566
|
+
if githubOverviewFits(out) {
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Mandatory metadata/navigation win over the soft target if an extreme
|
|
571
|
+
// provider title/relationship value alone consumes the whole budget.
|
|
572
|
+
return renderGitHubIssueOverviewWithLimit(target, issue, timeline, visible, rel, availability, 0)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
func renderGitHubIssueOverviewWithLimit(target *GitHubTarget, issue githubIssue, timeline, visible []githubTimelineEvent, rel githubIssueRelationships, availability githubIssueAvailability, indexedLimit int) string {
|
|
576
|
+
if indexedLimit < 0 {
|
|
577
|
+
indexedLimit = 0
|
|
578
|
+
}
|
|
579
|
+
if indexedLimit > len(visible) {
|
|
580
|
+
indexedLimit = len(visible)
|
|
581
|
+
}
|
|
582
|
+
commentReturned := 0
|
|
583
|
+
for _, event := range timeline {
|
|
584
|
+
if event.Event == "commented" {
|
|
585
|
+
commentReturned++
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
commentIndexed := 0
|
|
589
|
+
for _, event := range visible[:indexedLimit] {
|
|
590
|
+
if event.Event == "commented" {
|
|
591
|
+
commentIndexed++
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
lines := []string{
|
|
596
|
+
"---",
|
|
597
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
598
|
+
fmt.Sprintf("number: %d", issue.Number),
|
|
599
|
+
}
|
|
600
|
+
if issue.ID > 0 {
|
|
601
|
+
lines = append(lines, fmt.Sprintf("issue_id: %d", issue.ID))
|
|
602
|
+
}
|
|
603
|
+
titlePreview, titleTruncated := githubOverviewInlinePreview(issue.Title, 180)
|
|
604
|
+
if titleTruncated {
|
|
605
|
+
titlePreview += "…"
|
|
606
|
+
}
|
|
607
|
+
lines = append(lines,
|
|
608
|
+
"state: "+yamlScalar(issue.State),
|
|
609
|
+
"title: "+yamlScalar(titlePreview),
|
|
610
|
+
"overview: true",
|
|
611
|
+
)
|
|
612
|
+
if titleTruncated {
|
|
613
|
+
lines = append(lines, "title_preview_truncated: true")
|
|
614
|
+
}
|
|
615
|
+
if issue.StateReason != "" {
|
|
616
|
+
lines = append(lines, "state_reason: "+yamlScalar(issue.StateReason))
|
|
617
|
+
}
|
|
618
|
+
if issue.User.Login != "" {
|
|
619
|
+
lines = append(lines, "author: "+yamlScalar("@"+issue.User.Login))
|
|
620
|
+
}
|
|
621
|
+
if issue.CreatedAt != "" {
|
|
622
|
+
lines = append(lines, "created: "+yamlScalar(issue.CreatedAt))
|
|
623
|
+
}
|
|
624
|
+
if issue.UpdatedAt != "" {
|
|
625
|
+
lines = append(lines, "updated: "+yamlScalar(issue.UpdatedAt))
|
|
626
|
+
}
|
|
627
|
+
if issue.ClosedAt != "" {
|
|
628
|
+
lines = append(lines, "closed: "+yamlScalar(issue.ClosedAt))
|
|
629
|
+
}
|
|
630
|
+
if issue.Locked {
|
|
631
|
+
lines = append(lines, "locked: true")
|
|
632
|
+
if issue.ActiveLockReason != "" {
|
|
633
|
+
lines = append(lines, "lock_reason: "+yamlScalar(issue.ActiveLockReason))
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
|
|
637
|
+
shown := labels[:minInt(5, len(labels))]
|
|
638
|
+
encoded, _ := json.Marshal(shown)
|
|
639
|
+
lines = append(lines, "labels: "+string(encoded))
|
|
640
|
+
if len(labels) > len(shown) {
|
|
641
|
+
lines = append(lines, fmt.Sprintf("labels_local_omitted: %d", len(labels)-len(shown)))
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if assignees := githubUserLogins(issue.Assignees); len(assignees) > 0 {
|
|
645
|
+
shown := assignees[:minInt(5, len(assignees))]
|
|
646
|
+
encoded, _ := json.Marshal(shown)
|
|
647
|
+
lines = append(lines, "assignees: "+string(encoded))
|
|
648
|
+
if len(assignees) > len(shown) {
|
|
649
|
+
lines = append(lines, fmt.Sprintf("assignees_local_omitted: %d", len(assignees)-len(shown)))
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if issue.Milestone != nil && issue.Milestone.Title != "" {
|
|
653
|
+
lines = append(lines, "milestone: "+yamlScalar(issue.Milestone.Title))
|
|
654
|
+
}
|
|
655
|
+
if issueType := githubIssueTypeName(issue.Type); issueType != "" {
|
|
656
|
+
lines = append(lines, "type: "+yamlScalar(issueType))
|
|
657
|
+
}
|
|
658
|
+
lines = append(lines,
|
|
659
|
+
fmt.Sprintf("comments_reported: %d", issue.Comments),
|
|
660
|
+
fmt.Sprintf("comments_returned: %d", commentReturned),
|
|
661
|
+
fmt.Sprintf("comments_indexed: %d", commentIndexed),
|
|
662
|
+
fmt.Sprintf("timeline_items_returned: %d", len(visible)),
|
|
663
|
+
fmt.Sprintf("timeline_items_indexed: %d", indexedLimit),
|
|
664
|
+
fmt.Sprintf("timeline_items_omitted: %d", len(visible)-indexedLimit),
|
|
665
|
+
)
|
|
666
|
+
if availability.TimelineProviderMore || issue.Comments > commentReturned {
|
|
667
|
+
lines = append(lines, "comments_provider_complete: false")
|
|
668
|
+
}
|
|
669
|
+
if availability.TimelineProviderMore {
|
|
670
|
+
lines = append(lines, "timeline_provider_more_available: true")
|
|
671
|
+
}
|
|
672
|
+
if rel.providerMore() {
|
|
673
|
+
lines = append(lines, "relationships_provider_more_available: true")
|
|
674
|
+
}
|
|
675
|
+
if issue.HTMLURL != "" {
|
|
676
|
+
lines = append(lines, "url: "+yamlScalar(issue.HTMLURL))
|
|
677
|
+
}
|
|
678
|
+
lines = append(lines, "---", "", fmt.Sprintf("# #%d %s", issue.Number, titlePreview), "", "> Large Issue overview: subordinate conversation text is previewed/indexed so the default read stays near 5,000 characters.")
|
|
679
|
+
|
|
680
|
+
bodySelector := issueBodySelectorURL(target, issue)
|
|
681
|
+
lines = append(lines, "", "## Body preview", "")
|
|
682
|
+
if issue.Body == nil || strings.TrimSpace(stripInvisibleHTMLComments(*issue.Body)) == "" {
|
|
683
|
+
lines = append(lines, "_No description provided._")
|
|
684
|
+
} else {
|
|
685
|
+
body := stripInvisibleHTMLComments(*issue.Body)
|
|
686
|
+
preview, truncated := githubOverviewPreview(body, 1200)
|
|
687
|
+
lines = append(lines, preview)
|
|
688
|
+
if truncated {
|
|
689
|
+
lines = append(lines, "", "> Description preview locally truncated for this overview.")
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if bodySelector != "" {
|
|
693
|
+
lines = append(lines, "> Full description: "+bodySelector)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
relLines, relOmitted := renderIssueRelationshipsOverview(rel)
|
|
697
|
+
if len(relLines) > 0 {
|
|
698
|
+
lines = append(lines, "", "## Relationships", "")
|
|
699
|
+
lines = append(lines, relLines...)
|
|
700
|
+
if note := githubLocalOmissionNote("relationship entries", relOmitted); note != "" {
|
|
701
|
+
lines = append(lines, "", note)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
pinned := decodePinnedIssueComment(issue.PinnedComment)
|
|
706
|
+
pinnedInTimeline := false
|
|
707
|
+
if pinned != nil {
|
|
708
|
+
for _, event := range timeline {
|
|
709
|
+
if event.Event == "commented" && event.ID == pinned.ID {
|
|
710
|
+
pinnedInTimeline = true
|
|
711
|
+
break
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if pinned != nil && !pinnedInTimeline {
|
|
716
|
+
lines = append(lines, "", "## Pinned comment", "")
|
|
717
|
+
lines = append(lines, renderIssueCommentIndex(target, *pinned)...)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
lines = append(lines, "", "## Timeline index", "")
|
|
721
|
+
if len(visible) == 0 {
|
|
722
|
+
lines = append(lines, "_No substantive timeline activity._")
|
|
723
|
+
}
|
|
724
|
+
for _, event := range visible[:indexedLimit] {
|
|
725
|
+
if event.Event == "commented" {
|
|
726
|
+
comment := githubIssueComment{
|
|
727
|
+
ID: event.ID,
|
|
728
|
+
Body: event.Body,
|
|
729
|
+
HTMLURL: event.HTMLURL,
|
|
730
|
+
User: event.User,
|
|
731
|
+
AuthorAssociation: event.AuthorAssociation,
|
|
732
|
+
CreatedAt: event.CreatedAt,
|
|
733
|
+
UpdatedAt: event.UpdatedAt,
|
|
734
|
+
IsPinned: event.IsPinned || (pinned != nil && pinned.ID == event.ID),
|
|
735
|
+
Minimized: event.Minimized,
|
|
736
|
+
MinimizedReason: event.MinimizedReason,
|
|
737
|
+
}
|
|
738
|
+
lines = append(lines, renderIssueCommentIndex(target, comment)...)
|
|
739
|
+
continue
|
|
740
|
+
}
|
|
741
|
+
if rendered, ok := renderIssueTimelineState(event); ok {
|
|
742
|
+
lines = append(lines, rendered)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if note := githubLocalOmissionNote("substantive timeline items", len(visible)-indexedLimit); note != "" {
|
|
746
|
+
lines = append(lines, "", note)
|
|
747
|
+
}
|
|
748
|
+
if availability.TimelineProviderMore || issue.Comments > commentReturned {
|
|
749
|
+
lines = append(lines, "", fmt.Sprintf("> Provider-incomplete comment data: GitHub reports %d comments, while the bounded timeline page returned %d comment events. More timeline pages may exist upstream; this is separate from local overview omission.", issue.Comments, commentReturned))
|
|
750
|
+
}
|
|
751
|
+
if rel.providerMore() {
|
|
752
|
+
lines = append(lines, "", "> More Issue relationship entries exist upstream beyond the first provider pages fetched for this overview; local relationship omission is reported separately.")
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
base := fmt.Sprintf("https://github.com/%s/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo))
|
|
756
|
+
lines = append(lines, "", "## Useful GitHub URLs", "")
|
|
757
|
+
if bodySelector != "" {
|
|
758
|
+
lines = append(lines, "- Full Issue description: "+bodySelector)
|
|
759
|
+
}
|
|
760
|
+
lines = append(lines, "- Issue page: "+issuePageURL(target, issue), "- Issue list: "+base+"/issues")
|
|
761
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
func substantiveIssueTimeline(timeline []githubTimelineEvent) []githubTimelineEvent {
|
|
765
|
+
visible := make([]githubTimelineEvent, 0, len(timeline))
|
|
766
|
+
for _, event := range timeline {
|
|
767
|
+
if event.Event == "commented" {
|
|
768
|
+
visible = append(visible, event)
|
|
769
|
+
continue
|
|
770
|
+
}
|
|
771
|
+
if _, ok := renderIssueTimelineState(event); ok {
|
|
772
|
+
visible = append(visible, event)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return visible
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
func renderIssueRelationshipsOverview(rel githubIssueRelationships) ([]string, int) {
|
|
779
|
+
lines := []string{}
|
|
780
|
+
omitted := 0
|
|
781
|
+
if rel.Parent != nil {
|
|
782
|
+
lines = append(lines, "- Parent: "+issueRelationshipLink(*rel.Parent))
|
|
783
|
+
}
|
|
784
|
+
appendIssues := func(label string, issues []githubIssue) {
|
|
785
|
+
limit := len(issues)
|
|
786
|
+
if limit > 3 {
|
|
787
|
+
limit = 3
|
|
788
|
+
}
|
|
789
|
+
for _, issue := range issues[:limit] {
|
|
790
|
+
lines = append(lines, "- "+label+": "+issueRelationshipLink(issue))
|
|
791
|
+
}
|
|
792
|
+
omitted += len(issues) - limit
|
|
793
|
+
}
|
|
794
|
+
appendIssues("Sub-issue", rel.SubIssues)
|
|
795
|
+
appendIssues("Blocked by", rel.BlockedBy)
|
|
796
|
+
appendIssues("Blocking", rel.Blocking)
|
|
797
|
+
fieldLimit := len(rel.Fields)
|
|
798
|
+
if fieldLimit > 4 {
|
|
799
|
+
fieldLimit = 4
|
|
800
|
+
}
|
|
801
|
+
for _, field := range rel.Fields[:fieldLimit] {
|
|
802
|
+
if strings.TrimSpace(field.IssueFieldName) == "" {
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
805
|
+
fieldName, fieldNameTruncated := githubOverviewInlinePreview(field.IssueFieldName, 80)
|
|
806
|
+
if fieldNameTruncated {
|
|
807
|
+
fieldName += "…"
|
|
808
|
+
}
|
|
809
|
+
fieldValue, fieldValueTruncated := githubOverviewInlinePreview(renderIssueFieldValue(field), 140)
|
|
810
|
+
if fieldValueTruncated {
|
|
811
|
+
fieldValue += "…"
|
|
812
|
+
}
|
|
813
|
+
lines = append(lines, "- "+fieldName+": "+fieldValue)
|
|
814
|
+
}
|
|
815
|
+
omitted += len(rel.Fields) - fieldLimit
|
|
816
|
+
return lines, omitted
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
func renderIssueCommentIndex(target *GitHubTarget, comment githubIssueComment) []string {
|
|
820
|
+
heading := fmt.Sprintf("### Comment `%d`", comment.ID)
|
|
821
|
+
if comment.User.Login != "" {
|
|
822
|
+
heading += " by @" + comment.User.Login
|
|
823
|
+
}
|
|
824
|
+
if comment.CreatedAt != "" {
|
|
825
|
+
heading += " — " + comment.CreatedAt
|
|
826
|
+
}
|
|
827
|
+
if comment.IsPinned {
|
|
828
|
+
heading += " · pinned"
|
|
829
|
+
}
|
|
830
|
+
body := strings.Join(renderIssueCommentBody(comment, comment.IsPinned), "\n")
|
|
831
|
+
preview, truncated := githubOverviewPreview(body, githubIndexPreviewRunes)
|
|
832
|
+
lines := []string{heading, ""}
|
|
833
|
+
for _, line := range strings.Split(preview, "\n") {
|
|
834
|
+
lines = append(lines, "> "+line)
|
|
835
|
+
}
|
|
836
|
+
if truncated {
|
|
837
|
+
lines = append(lines, "> _Preview locally truncated._")
|
|
838
|
+
}
|
|
839
|
+
lines = append(lines, "", "Selector: "+issueCommentSelectorURL(target, comment), "")
|
|
840
|
+
return lines
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
func issuePageURL(target *GitHubTarget, issue githubIssue) string {
|
|
844
|
+
if strings.TrimSpace(issue.HTMLURL) != "" {
|
|
845
|
+
return strings.TrimRight(issue.HTMLURL, "/")
|
|
846
|
+
}
|
|
847
|
+
return fmt.Sprintf("https://github.com/%s/%s/issues/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), target.Number)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
func issueBodySelectorURL(target *GitHubTarget, issue githubIssue) string {
|
|
851
|
+
if issue.ID <= 0 {
|
|
852
|
+
return ""
|
|
853
|
+
}
|
|
854
|
+
return issuePageURL(target, issue) + "#issue-" + strconv.FormatInt(issue.ID, 10)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
func issueCommentSelectorURL(target *GitHubTarget, comment githubIssueComment) string {
|
|
858
|
+
if strings.TrimSpace(comment.HTMLURL) != "" {
|
|
859
|
+
return comment.HTMLURL
|
|
860
|
+
}
|
|
861
|
+
return issuePageURL(target, githubIssue{Number: target.Number}) + "#issuecomment-" + strconv.FormatInt(comment.ID, 10)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
func renderGitHubIssueComment(target *GitHubTarget, comment githubIssueComment) string {
|
|
865
|
+
identityKey := "issue"
|
|
866
|
+
headingNoun := "Comment on"
|
|
867
|
+
if target.Kind == GitHubTargetPull {
|
|
868
|
+
identityKey = "pull_request"
|
|
869
|
+
headingNoun = "Comment on Pull Request"
|
|
870
|
+
}
|
|
871
|
+
lines := []string{
|
|
872
|
+
"---",
|
|
873
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
874
|
+
fmt.Sprintf("%s: %d", identityKey, target.Number),
|
|
875
|
+
fmt.Sprintf("comment_id: %d", comment.ID),
|
|
876
|
+
}
|
|
877
|
+
if comment.User.Login != "" {
|
|
878
|
+
lines = append(lines, "author: "+yamlScalar("@"+comment.User.Login))
|
|
879
|
+
}
|
|
880
|
+
if comment.AuthorAssociation != "" {
|
|
881
|
+
lines = append(lines, "association: "+yamlScalar(comment.AuthorAssociation))
|
|
882
|
+
}
|
|
883
|
+
if comment.CreatedAt != "" {
|
|
884
|
+
lines = append(lines, "created: "+yamlScalar(comment.CreatedAt))
|
|
885
|
+
}
|
|
886
|
+
if comment.UpdatedAt != "" && comment.UpdatedAt != comment.CreatedAt {
|
|
887
|
+
lines = append(lines, "updated: "+yamlScalar(comment.UpdatedAt))
|
|
888
|
+
}
|
|
889
|
+
if comment.IsPinned {
|
|
890
|
+
lines = append(lines, "pinned: true")
|
|
891
|
+
}
|
|
892
|
+
if comment.HTMLURL != "" {
|
|
893
|
+
lines = append(lines, "url: "+yamlScalar(comment.HTMLURL))
|
|
894
|
+
}
|
|
895
|
+
lines = append(lines, "---", "", fmt.Sprintf("# %s %s/%s#%d", headingNoun, target.Owner, target.Repo, target.Number), "")
|
|
896
|
+
lines = append(lines, renderIssueCommentBody(comment, false)...)
|
|
897
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
func renderTimelineComment(comment githubIssueComment) []string {
|
|
901
|
+
heading := "### Comment"
|
|
902
|
+
if comment.User.Login != "" {
|
|
903
|
+
heading += " by @" + comment.User.Login
|
|
904
|
+
}
|
|
905
|
+
if comment.CreatedAt != "" {
|
|
906
|
+
heading += " — " + comment.CreatedAt
|
|
907
|
+
}
|
|
908
|
+
if comment.IsPinned {
|
|
909
|
+
heading += " · pinned"
|
|
910
|
+
}
|
|
911
|
+
lines := []string{heading, ""}
|
|
912
|
+
lines = append(lines, renderIssueCommentBody(comment, comment.IsPinned)...)
|
|
913
|
+
lines = append(lines, "")
|
|
914
|
+
return lines
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
func renderIssueCommentBody(comment githubIssueComment, pinned bool) []string {
|
|
918
|
+
if comment.Minimized {
|
|
919
|
+
reason := strings.TrimSpace(comment.MinimizedReason)
|
|
920
|
+
if reason == "" {
|
|
921
|
+
reason = "reason not provided"
|
|
922
|
+
}
|
|
923
|
+
return []string{"_Comment minimized by GitHub (" + reason + ")._"}
|
|
924
|
+
}
|
|
925
|
+
if comment.Body == nil {
|
|
926
|
+
return []string{"_Comment body is unavailable or deleted._"}
|
|
927
|
+
}
|
|
928
|
+
body := strings.TrimSpace(stripInvisibleHTMLComments(*comment.Body))
|
|
929
|
+
if body == "" {
|
|
930
|
+
body = "_Comment is empty after removing invisible GitHub markup._"
|
|
931
|
+
}
|
|
932
|
+
return []string{body}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
func renderIssueTimelineState(event githubTimelineEvent) (string, bool) {
|
|
936
|
+
name := strings.TrimSpace(event.Event)
|
|
937
|
+
if name == "" || name == "commented" {
|
|
938
|
+
return "", false
|
|
939
|
+
}
|
|
940
|
+
switch name {
|
|
941
|
+
case "mentioned", "subscribed", "unsubscribed", "head_ref_deleted", "head_ref_restored":
|
|
942
|
+
return "", false
|
|
943
|
+
}
|
|
944
|
+
actor := event.Actor.Login
|
|
945
|
+
if actor == "" {
|
|
946
|
+
actor = event.User.Login
|
|
947
|
+
}
|
|
948
|
+
prefix := "- "
|
|
949
|
+
if event.CreatedAt != "" {
|
|
950
|
+
prefix += event.CreatedAt + " — "
|
|
951
|
+
}
|
|
952
|
+
if actor != "" {
|
|
953
|
+
prefix += "@" + actor + " "
|
|
954
|
+
}
|
|
955
|
+
switch name {
|
|
956
|
+
case "closed":
|
|
957
|
+
return prefix + "closed the Issue.", true
|
|
958
|
+
case "reopened":
|
|
959
|
+
return prefix + "reopened the Issue.", true
|
|
960
|
+
case "labeled", "unlabeled":
|
|
961
|
+
label := ""
|
|
962
|
+
if event.Label != nil {
|
|
963
|
+
label = event.Label.Name
|
|
964
|
+
}
|
|
965
|
+
verb := "added"
|
|
966
|
+
if name == "unlabeled" {
|
|
967
|
+
verb = "removed"
|
|
968
|
+
}
|
|
969
|
+
return prefix + verb + " label `" + label + "`.", true
|
|
970
|
+
case "milestoned", "demilestoned":
|
|
971
|
+
title := ""
|
|
972
|
+
if event.Milestone != nil {
|
|
973
|
+
title = event.Milestone.Title
|
|
974
|
+
}
|
|
975
|
+
verb := "set milestone"
|
|
976
|
+
if name == "demilestoned" {
|
|
977
|
+
verb = "removed milestone"
|
|
978
|
+
}
|
|
979
|
+
return prefix + verb + " `" + title + "`.", true
|
|
980
|
+
case "assigned", "unassigned":
|
|
981
|
+
login := ""
|
|
982
|
+
if event.Assignee != nil {
|
|
983
|
+
login = event.Assignee.Login
|
|
984
|
+
}
|
|
985
|
+
verb := "assigned"
|
|
986
|
+
if name == "unassigned" {
|
|
987
|
+
verb = "unassigned"
|
|
988
|
+
}
|
|
989
|
+
if login != "" {
|
|
990
|
+
return prefix + verb + " @" + login + ".", true
|
|
991
|
+
}
|
|
992
|
+
return prefix + verb + " the Issue.", true
|
|
993
|
+
case "locked", "unlocked":
|
|
994
|
+
detail := name + " the conversation"
|
|
995
|
+
if name == "locked" && event.LockReason != "" {
|
|
996
|
+
detail += " (" + event.LockReason + ")"
|
|
997
|
+
}
|
|
998
|
+
return prefix + detail + ".", true
|
|
999
|
+
case "renamed":
|
|
1000
|
+
if event.Rename != nil {
|
|
1001
|
+
return prefix + "renamed the Issue from `" + event.Rename.From + "` to `" + event.Rename.To + "`.", true
|
|
1002
|
+
}
|
|
1003
|
+
return prefix + "renamed the Issue.", true
|
|
1004
|
+
case "cross-referenced":
|
|
1005
|
+
if event.Source != nil && event.Source.Issue != nil {
|
|
1006
|
+
source := event.Source.Issue
|
|
1007
|
+
label := fmt.Sprintf("#%d %s", source.Number, source.Title)
|
|
1008
|
+
if source.HTMLURL != "" {
|
|
1009
|
+
return prefix + "cross-referenced [" + escapeMarkdownLinkText(label) + "](" + source.HTMLURL + ").", true
|
|
1010
|
+
}
|
|
1011
|
+
return prefix + "cross-referenced " + label + ".", true
|
|
1012
|
+
}
|
|
1013
|
+
return prefix + "added a cross-reference.", true
|
|
1014
|
+
case "pinned":
|
|
1015
|
+
return prefix + "pinned a comment.", true
|
|
1016
|
+
case "unpinned":
|
|
1017
|
+
return prefix + "unpinned a comment.", true
|
|
1018
|
+
default:
|
|
1019
|
+
return prefix + strings.ReplaceAll(name, "_", " ") + ".", true
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
func renderIssueRelationships(rel githubIssueRelationships) []string {
|
|
1024
|
+
lines := []string{}
|
|
1025
|
+
if rel.Parent != nil {
|
|
1026
|
+
lines = append(lines, "- Parent: "+issueRelationshipLink(*rel.Parent))
|
|
1027
|
+
}
|
|
1028
|
+
if len(rel.SubIssues) > 0 {
|
|
1029
|
+
lines = append(lines, "- Sub-issues: "+joinIssueRelationshipLinks(rel.SubIssues))
|
|
1030
|
+
}
|
|
1031
|
+
if len(rel.BlockedBy) > 0 {
|
|
1032
|
+
lines = append(lines, "- Blocked by: "+joinIssueRelationshipLinks(rel.BlockedBy))
|
|
1033
|
+
}
|
|
1034
|
+
if len(rel.Blocking) > 0 {
|
|
1035
|
+
lines = append(lines, "- Blocking: "+joinIssueRelationshipLinks(rel.Blocking))
|
|
1036
|
+
}
|
|
1037
|
+
for _, field := range rel.Fields {
|
|
1038
|
+
if strings.TrimSpace(field.IssueFieldName) == "" {
|
|
1039
|
+
continue
|
|
1040
|
+
}
|
|
1041
|
+
lines = append(lines, "- "+field.IssueFieldName+": "+renderIssueFieldValue(field))
|
|
1042
|
+
}
|
|
1043
|
+
return lines
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
func issueRelationshipLink(issue githubIssue) string {
|
|
1047
|
+
title, truncated := githubOverviewInlinePreview(issue.Title, 120)
|
|
1048
|
+
if truncated {
|
|
1049
|
+
title += "…"
|
|
1050
|
+
}
|
|
1051
|
+
label := fmt.Sprintf("#%d %s", issue.Number, title)
|
|
1052
|
+
if issue.HTMLURL != "" {
|
|
1053
|
+
return "[" + escapeMarkdownLinkText(label) + "](" + issue.HTMLURL + ")"
|
|
1054
|
+
}
|
|
1055
|
+
return label
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
func joinIssueRelationshipLinks(issues []githubIssue) string {
|
|
1059
|
+
parts := make([]string, 0, len(issues))
|
|
1060
|
+
for _, issue := range issues {
|
|
1061
|
+
parts = append(parts, issueRelationshipLink(issue))
|
|
1062
|
+
}
|
|
1063
|
+
return strings.Join(parts, ", ")
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
func renderIssueFieldValue(field githubIssueFieldValue) string {
|
|
1067
|
+
if field.SingleSelectOption != nil && field.SingleSelectOption.Name != "" {
|
|
1068
|
+
return field.SingleSelectOption.Name
|
|
1069
|
+
}
|
|
1070
|
+
if len(field.MultiSelectOptions) > 0 {
|
|
1071
|
+
names := make([]string, 0, len(field.MultiSelectOptions))
|
|
1072
|
+
for _, option := range field.MultiSelectOptions {
|
|
1073
|
+
if option.Name != "" {
|
|
1074
|
+
names = append(names, option.Name)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if len(names) > 0 {
|
|
1078
|
+
return strings.Join(names, ", ")
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
switch value := field.Value.(type) {
|
|
1082
|
+
case string:
|
|
1083
|
+
return value
|
|
1084
|
+
case float64:
|
|
1085
|
+
return strconv.FormatFloat(value, 'f', -1, 64)
|
|
1086
|
+
case nil:
|
|
1087
|
+
return "_empty_"
|
|
1088
|
+
default:
|
|
1089
|
+
encoded, _ := json.Marshal(value)
|
|
1090
|
+
return string(encoded)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
func decodePinnedIssueComment(raw json.RawMessage) *githubIssueComment {
|
|
1095
|
+
trimmed := strings.TrimSpace(string(raw))
|
|
1096
|
+
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
|
1097
|
+
return nil
|
|
1098
|
+
}
|
|
1099
|
+
var comment githubIssueComment
|
|
1100
|
+
if json.Unmarshal(raw, &comment) != nil || comment.ID == 0 {
|
|
1101
|
+
return nil
|
|
1102
|
+
}
|
|
1103
|
+
comment.IsPinned = true
|
|
1104
|
+
return &comment
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
func githubIssueTypeName(raw json.RawMessage) string {
|
|
1108
|
+
trimmed := strings.TrimSpace(string(raw))
|
|
1109
|
+
if trimmed == "" || trimmed == "null" {
|
|
1110
|
+
return ""
|
|
1111
|
+
}
|
|
1112
|
+
var name string
|
|
1113
|
+
if json.Unmarshal(raw, &name) == nil {
|
|
1114
|
+
return name
|
|
1115
|
+
}
|
|
1116
|
+
var issueType githubIssueType
|
|
1117
|
+
if json.Unmarshal(raw, &issueType) == nil {
|
|
1118
|
+
return issueType.Name
|
|
1119
|
+
}
|
|
1120
|
+
return ""
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
func issueLabelNames(labels []githubIssueLabel) []string {
|
|
1124
|
+
names := make([]string, 0, len(labels))
|
|
1125
|
+
for _, label := range labels {
|
|
1126
|
+
if strings.TrimSpace(label.Name) != "" {
|
|
1127
|
+
names = append(names, label.Name)
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
sort.Strings(names)
|
|
1131
|
+
return names
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
func githubUserLogins(users []githubUser) []string {
|
|
1135
|
+
logins := make([]string, 0, len(users))
|
|
1136
|
+
for _, user := range users {
|
|
1137
|
+
if strings.TrimSpace(user.Login) != "" {
|
|
1138
|
+
logins = append(logins, "@"+user.Login)
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
sort.Strings(logins)
|
|
1142
|
+
return logins
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
func readGitHubIssueList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1146
|
+
if target.Fragment != "" {
|
|
1147
|
+
return "", fmt.Errorf("GitHub Issue-list fragment %q is not a supported native selector", target.Fragment)
|
|
1148
|
+
}
|
|
1149
|
+
if strings.TrimSpace(target.Query.Get("q")) != "" {
|
|
1150
|
+
resource, err := githubSearchResourceQualifier(target.Query.Get("q"))
|
|
1151
|
+
if err != nil {
|
|
1152
|
+
return "", err
|
|
1153
|
+
}
|
|
1154
|
+
if resource == "pull_request" {
|
|
1155
|
+
return readGitHubPullSearch(ctx, client, target)
|
|
1156
|
+
}
|
|
1157
|
+
return readGitHubIssueSearch(ctx, client, target)
|
|
1158
|
+
}
|
|
1159
|
+
query := copySelectedQuery(target.Query, []string{"milestone", "state", "assignee", "type", "creator", "mentioned", "labels", "sort", "direction", "since", "page"})
|
|
1160
|
+
query.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
1161
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
|
|
1162
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1163
|
+
if err != nil {
|
|
1164
|
+
return "", err
|
|
1165
|
+
}
|
|
1166
|
+
var issues []githubIssue
|
|
1167
|
+
if err := json.Unmarshal(resp.Body, &issues); err != nil {
|
|
1168
|
+
return "", fmt.Errorf("decode GitHub Issue list: %w", err)
|
|
1169
|
+
}
|
|
1170
|
+
issues = filterPullRequestsFromIssues(issues)
|
|
1171
|
+
return renderGitHubIssueList(target, issues, resp.Links(), -1, false), nil
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
func readGitHubIssueSearch(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1175
|
+
q := strings.TrimSpace(target.Query.Get("q"))
|
|
1176
|
+
resource, err := githubSearchResourceQualifier(q)
|
|
1177
|
+
if err != nil {
|
|
1178
|
+
return "", err
|
|
1179
|
+
}
|
|
1180
|
+
if resource == "pull_request" {
|
|
1181
|
+
return readGitHubPullSearch(ctx, client, target)
|
|
1182
|
+
}
|
|
1183
|
+
if !hasGitHubSearchQualifierPrefix(q, "repo:") {
|
|
1184
|
+
q = "repo:" + target.Owner + "/" + target.Repo + " " + q
|
|
1185
|
+
}
|
|
1186
|
+
if resource == "" {
|
|
1187
|
+
q += " is:issue"
|
|
1188
|
+
}
|
|
1189
|
+
apiQuery := url.Values{"q": []string{strings.TrimSpace(q)}}
|
|
1190
|
+
for _, key := range []string{"sort", "order", "page"} {
|
|
1191
|
+
if value := target.Query.Get(key); value != "" {
|
|
1192
|
+
apiQuery.Set(key, value)
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
apiQuery.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
1196
|
+
endpoint := "/search/issues?" + apiQuery.Encode()
|
|
1197
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1198
|
+
if err != nil {
|
|
1199
|
+
return "", err
|
|
1200
|
+
}
|
|
1201
|
+
var result githubIssueSearchResponse
|
|
1202
|
+
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
|
1203
|
+
return "", fmt.Errorf("decode GitHub Issue search: %w", err)
|
|
1204
|
+
}
|
|
1205
|
+
result.Items = filterPullRequestsFromIssues(result.Items)
|
|
1206
|
+
return renderGitHubIssueList(target, result.Items, resp.Links(), result.TotalCount, result.IncompleteResults), nil
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
func githubSearchResourceQualifier(query string) (string, error) {
|
|
1210
|
+
hasIssue := false
|
|
1211
|
+
hasPR := false
|
|
1212
|
+
for _, token := range githubSearchTokens(query) {
|
|
1213
|
+
switch strings.ToLower(token) {
|
|
1214
|
+
case "is:issue":
|
|
1215
|
+
hasIssue = true
|
|
1216
|
+
case "is:pr":
|
|
1217
|
+
hasPR = true
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if hasIssue && hasPR {
|
|
1221
|
+
return "", fmt.Errorf("GitHub search query contains conflicting explicit is:issue and is:pr qualifiers")
|
|
1222
|
+
}
|
|
1223
|
+
if hasPR {
|
|
1224
|
+
return "pull_request", nil
|
|
1225
|
+
}
|
|
1226
|
+
if hasIssue {
|
|
1227
|
+
return "issue", nil
|
|
1228
|
+
}
|
|
1229
|
+
return "", nil
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
func hasGitHubSearchQualifierPrefix(query, prefix string) bool {
|
|
1233
|
+
prefix = strings.ToLower(prefix)
|
|
1234
|
+
for _, token := range githubSearchTokens(query) {
|
|
1235
|
+
if strings.HasPrefix(strings.ToLower(token), prefix) {
|
|
1236
|
+
return true
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return false
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
func githubSearchTokens(query string) []string {
|
|
1243
|
+
tokens := []string{}
|
|
1244
|
+
var current strings.Builder
|
|
1245
|
+
inQuote := false
|
|
1246
|
+
escaped := false
|
|
1247
|
+
flush := func() {
|
|
1248
|
+
if current.Len() == 0 {
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
tokens = append(tokens, current.String())
|
|
1252
|
+
current.Reset()
|
|
1253
|
+
}
|
|
1254
|
+
for _, r := range query {
|
|
1255
|
+
if escaped {
|
|
1256
|
+
current.WriteRune(r)
|
|
1257
|
+
escaped = false
|
|
1258
|
+
continue
|
|
1259
|
+
}
|
|
1260
|
+
if inQuote && r == '\\' {
|
|
1261
|
+
current.WriteRune(r)
|
|
1262
|
+
escaped = true
|
|
1263
|
+
continue
|
|
1264
|
+
}
|
|
1265
|
+
if r == '"' {
|
|
1266
|
+
inQuote = !inQuote
|
|
1267
|
+
current.WriteRune(r)
|
|
1268
|
+
continue
|
|
1269
|
+
}
|
|
1270
|
+
if !inQuote && (r == ' ' || r == '\t' || r == '\n' || r == '\r') {
|
|
1271
|
+
flush()
|
|
1272
|
+
continue
|
|
1273
|
+
}
|
|
1274
|
+
current.WriteRune(r)
|
|
1275
|
+
}
|
|
1276
|
+
flush()
|
|
1277
|
+
return tokens
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
func filterPullRequestsFromIssues(issues []githubIssue) []githubIssue {
|
|
1281
|
+
out := make([]githubIssue, 0, len(issues))
|
|
1282
|
+
for _, issue := range issues {
|
|
1283
|
+
if issue.PullRequest == nil {
|
|
1284
|
+
out = append(out, issue)
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return out
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
func renderGitHubIssueList(target *GitHubTarget, issues []githubIssue, links GitHubLinkRelations, total int, incomplete bool) string {
|
|
1291
|
+
return renderGitHubIssueListWithLimit(target, issues, links, total, incomplete, len(issues))
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
func renderGitHubIssueListWithLimit(target *GitHubTarget, issues []githubIssue, links GitHubLinkRelations, total int, incomplete bool, limit int) string {
|
|
1295
|
+
limit = minInt(limit, len(issues))
|
|
1296
|
+
lines := []string{
|
|
1297
|
+
"---",
|
|
1298
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
1299
|
+
"view: issues",
|
|
1300
|
+
fmt.Sprintf("results: %d", len(issues)),
|
|
1301
|
+
fmt.Sprintf("results_indexed: %d", limit),
|
|
1302
|
+
fmt.Sprintf("results_local_omitted: %d", len(issues)-limit),
|
|
1303
|
+
}
|
|
1304
|
+
if q := target.Query.Get("q"); q != "" {
|
|
1305
|
+
preview, truncated := githubOverviewInlinePreview(q, 300)
|
|
1306
|
+
lines = append(lines, "query: "+yamlScalar(preview))
|
|
1307
|
+
if truncated {
|
|
1308
|
+
lines = append(lines, "query_preview_truncated: true")
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if total >= 0 {
|
|
1312
|
+
lines = append(lines, fmt.Sprintf("total_matches: %d", total))
|
|
1313
|
+
}
|
|
1314
|
+
if incomplete {
|
|
1315
|
+
lines = append(lines, "complete: false")
|
|
1316
|
+
}
|
|
1317
|
+
if page := target.Query.Get("page"); page != "" {
|
|
1318
|
+
lines = append(lines, "page: "+yamlScalar(page))
|
|
1319
|
+
}
|
|
1320
|
+
lines = append(lines, "---", "", "# Issues", "")
|
|
1321
|
+
if incomplete {
|
|
1322
|
+
lines = append(lines, "> GitHub marked this search result set as incomplete.", "")
|
|
1323
|
+
}
|
|
1324
|
+
if len(issues) == 0 {
|
|
1325
|
+
lines = append(lines, "_No Issues on this page._")
|
|
1326
|
+
}
|
|
1327
|
+
for _, issue := range issues[:limit] {
|
|
1328
|
+
urlText := issue.HTMLURL
|
|
1329
|
+
if urlText == "" {
|
|
1330
|
+
urlText = fmt.Sprintf("https://github.com/%s/%s/issues/%d", target.Owner, target.Repo, issue.Number)
|
|
1331
|
+
}
|
|
1332
|
+
meta := []string{issue.State}
|
|
1333
|
+
if issue.User.Login != "" {
|
|
1334
|
+
meta = append(meta, "@"+issue.User.Login)
|
|
1335
|
+
}
|
|
1336
|
+
if issue.UpdatedAt != "" {
|
|
1337
|
+
meta = append(meta, "updated "+issue.UpdatedAt)
|
|
1338
|
+
}
|
|
1339
|
+
if labels := issueLabelNames(issue.Labels); len(labels) > 0 {
|
|
1340
|
+
meta = append(meta, "labels: "+githubOverviewLabelList(labels, 3))
|
|
1341
|
+
}
|
|
1342
|
+
title, truncated := githubOverviewInlinePreview(issue.Title, 140)
|
|
1343
|
+
if truncated {
|
|
1344
|
+
title += "…"
|
|
1345
|
+
}
|
|
1346
|
+
lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", issue.Number, escapeMarkdownLinkText(title), urlText, strings.Join(meta, " · ")))
|
|
1347
|
+
}
|
|
1348
|
+
if nav := renderGitHubUIPageNavigation(target, links); len(nav) > 0 {
|
|
1349
|
+
lines = append(lines, "", "## Navigation", "")
|
|
1350
|
+
lines = append(lines, nav...)
|
|
1351
|
+
}
|
|
1352
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
func readGitHubLabelList(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1356
|
+
if target.Fragment != "" {
|
|
1357
|
+
return "", fmt.Errorf("GitHub label-list fragment %q is not supported", target.Fragment)
|
|
1358
|
+
}
|
|
1359
|
+
query := copySelectedQuery(target.Query, []string{"page"})
|
|
1360
|
+
query.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
1361
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/labels?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
|
|
1362
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1363
|
+
if err != nil {
|
|
1364
|
+
return "", err
|
|
1365
|
+
}
|
|
1366
|
+
var labels []githubIssueLabel
|
|
1367
|
+
if err := json.Unmarshal(resp.Body, &labels); err != nil {
|
|
1368
|
+
return "", fmt.Errorf("decode GitHub labels: %w", err)
|
|
1369
|
+
}
|
|
1370
|
+
out := func(limit int) string {
|
|
1371
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: labels", fmt.Sprintf("results: %d", len(labels)), fmt.Sprintf("results_indexed: %d", limit), fmt.Sprintf("results_local_omitted: %d", len(labels)-limit), "---", "", "# Labels", ""}
|
|
1372
|
+
for _, label := range labels[:limit] {
|
|
1373
|
+
labelURL := fmt.Sprintf("https://github.com/%s/%s/labels/%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), url.PathEscape(label.Name))
|
|
1374
|
+
name, truncated := githubOverviewInlinePreview(label.Name, 100)
|
|
1375
|
+
if truncated {
|
|
1376
|
+
name += "…"
|
|
1377
|
+
}
|
|
1378
|
+
line := "- [" + escapeMarkdownLinkText(name) + "](" + labelURL + ")"
|
|
1379
|
+
if label.Description != "" {
|
|
1380
|
+
description, descriptionTruncated := githubOverviewInlinePreview(label.Description, 140)
|
|
1381
|
+
if descriptionTruncated {
|
|
1382
|
+
description += "…"
|
|
1383
|
+
}
|
|
1384
|
+
line += " — " + description
|
|
1385
|
+
}
|
|
1386
|
+
lines = append(lines, line)
|
|
1387
|
+
}
|
|
1388
|
+
if len(labels) == 0 {
|
|
1389
|
+
lines = append(lines, "_No labels on this page._")
|
|
1390
|
+
}
|
|
1391
|
+
if nav := renderGitHubUIPageNavigation(target, resp.Links()); len(nav) > 0 {
|
|
1392
|
+
lines = append(lines, "", "## Navigation", "")
|
|
1393
|
+
lines = append(lines, nav...)
|
|
1394
|
+
}
|
|
1395
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1396
|
+
}(len(labels))
|
|
1397
|
+
return out, nil
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
func readGitHubLabel(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1401
|
+
if target.Fragment != "" {
|
|
1402
|
+
return "", fmt.Errorf("GitHub label fragment %q is not supported", target.Fragment)
|
|
1403
|
+
}
|
|
1404
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/labels/%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), url.PathEscape(target.Name))
|
|
1405
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1406
|
+
if err != nil {
|
|
1407
|
+
return "", err
|
|
1408
|
+
}
|
|
1409
|
+
var label githubIssueLabel
|
|
1410
|
+
if err := json.Unmarshal(resp.Body, &label); err != nil {
|
|
1411
|
+
return "", fmt.Errorf("decode GitHub label: %w", err)
|
|
1412
|
+
}
|
|
1413
|
+
listQuery := url.Values{"labels": []string{target.Name}, "state": []string{"all"}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
|
|
1414
|
+
if page := target.Query.Get("page"); page != "" {
|
|
1415
|
+
listQuery.Set("page", page)
|
|
1416
|
+
}
|
|
1417
|
+
listEndpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), listQuery.Encode())
|
|
1418
|
+
listResp, err := client.REST(ctx, http.MethodGet, listEndpoint, "application/vnd.github+json")
|
|
1419
|
+
if err != nil {
|
|
1420
|
+
return "", err
|
|
1421
|
+
}
|
|
1422
|
+
var issues []githubIssue
|
|
1423
|
+
if err := json.Unmarshal(listResp.Body, &issues); err != nil {
|
|
1424
|
+
return "", fmt.Errorf("decode GitHub label Issues: %w", err)
|
|
1425
|
+
}
|
|
1426
|
+
issues = filterPullRequestsFromIssues(issues)
|
|
1427
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "label: " + yamlScalar(label.Name)}
|
|
1428
|
+
if label.Color != "" {
|
|
1429
|
+
lines = append(lines, "color: "+yamlScalar("#"+label.Color))
|
|
1430
|
+
}
|
|
1431
|
+
if label.Description != "" {
|
|
1432
|
+
description, truncated := githubOverviewInlinePreview(label.Description, 500)
|
|
1433
|
+
lines = append(lines, "description: "+yamlScalar(description))
|
|
1434
|
+
if truncated {
|
|
1435
|
+
lines = append(lines, "description_preview_truncated: true")
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
lines = append(lines, fmt.Sprintf("issues_on_page: %d", len(issues)), "---", "", "# Label: "+label.Name, "")
|
|
1439
|
+
lines = append(lines, renderIssueLinksCompact(issues)...)
|
|
1440
|
+
filteredURL := fmt.Sprintf("https://github.com/%s/%s/issues?q=%s", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), url.QueryEscape(`is:issue label:"`+label.Name+`"`))
|
|
1441
|
+
lines = append(lines, "", "## Useful GitHub URLs", "", "- Filtered Issues: "+filteredURL)
|
|
1442
|
+
return strings.TrimSpace(strings.Join(lines, "\n")), nil
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
func readGitHubMilestones(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1446
|
+
if target.Fragment != "" {
|
|
1447
|
+
return "", fmt.Errorf("GitHub milestone-list fragment %q is not supported", target.Fragment)
|
|
1448
|
+
}
|
|
1449
|
+
query := copySelectedQuery(target.Query, []string{"state", "sort", "direction", "page"})
|
|
1450
|
+
query.Set("per_page", strconv.Itoa(githubPageableListSize))
|
|
1451
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/milestones?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), query.Encode())
|
|
1452
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1453
|
+
if err != nil {
|
|
1454
|
+
return "", err
|
|
1455
|
+
}
|
|
1456
|
+
var milestones []githubIssueMilestone
|
|
1457
|
+
if err := json.Unmarshal(resp.Body, &milestones); err != nil {
|
|
1458
|
+
return "", fmt.Errorf("decode GitHub milestones: %w", err)
|
|
1459
|
+
}
|
|
1460
|
+
out := func(limit int) string {
|
|
1461
|
+
lines := []string{"---", "repository: " + yamlScalar(target.Owner+"/"+target.Repo), "view: milestones", fmt.Sprintf("results: %d", len(milestones)), fmt.Sprintf("results_indexed: %d", limit), fmt.Sprintf("results_local_omitted: %d", len(milestones)-limit), "---", "", "# Milestones", ""}
|
|
1462
|
+
if len(milestones) == 0 {
|
|
1463
|
+
lines = append(lines, "_No milestones on this page._")
|
|
1464
|
+
}
|
|
1465
|
+
for _, milestone := range milestones[:limit] {
|
|
1466
|
+
href := fmt.Sprintf("https://github.com/%s/%s/milestone/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), milestone.Number)
|
|
1467
|
+
meta := fmt.Sprintf("%s · %d open / %d closed", milestone.State, milestone.OpenIssues, milestone.ClosedIssues)
|
|
1468
|
+
if milestone.DueOn != "" {
|
|
1469
|
+
meta += " · due " + milestone.DueOn
|
|
1470
|
+
}
|
|
1471
|
+
title, truncated := githubOverviewInlinePreview(milestone.Title, 140)
|
|
1472
|
+
if truncated {
|
|
1473
|
+
title += "…"
|
|
1474
|
+
}
|
|
1475
|
+
lines = append(lines, fmt.Sprintf("- [%s](%s) — %s", escapeMarkdownLinkText(title), href, meta))
|
|
1476
|
+
}
|
|
1477
|
+
if nav := renderGitHubUIPageNavigation(target, resp.Links()); len(nav) > 0 {
|
|
1478
|
+
lines = append(lines, "", "## Navigation", "")
|
|
1479
|
+
lines = append(lines, nav...)
|
|
1480
|
+
}
|
|
1481
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
1482
|
+
}(len(milestones))
|
|
1483
|
+
return out, nil
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
func readGitHubMilestone(ctx context.Context, client *GitHubClient, target *GitHubTarget) (string, error) {
|
|
1487
|
+
if target.Fragment != "" {
|
|
1488
|
+
return "", fmt.Errorf("GitHub milestone fragment %q is not supported", target.Fragment)
|
|
1489
|
+
}
|
|
1490
|
+
endpoint := fmt.Sprintf("/repos/%s/%s/milestones/%d", url.PathEscape(target.Owner), url.PathEscape(target.Repo), target.Number)
|
|
1491
|
+
resp, err := client.REST(ctx, http.MethodGet, endpoint, "application/vnd.github+json")
|
|
1492
|
+
if err != nil {
|
|
1493
|
+
return "", err
|
|
1494
|
+
}
|
|
1495
|
+
var milestone githubIssueMilestone
|
|
1496
|
+
if err := json.Unmarshal(resp.Body, &milestone); err != nil {
|
|
1497
|
+
return "", fmt.Errorf("decode GitHub milestone: %w", err)
|
|
1498
|
+
}
|
|
1499
|
+
listQuery := url.Values{"milestone": []string{strconv.Itoa(target.Number)}, "state": []string{"all"}, "per_page": []string{strconv.Itoa(githubPageableListSize)}}
|
|
1500
|
+
if page := target.Query.Get("page"); page != "" {
|
|
1501
|
+
listQuery.Set("page", page)
|
|
1502
|
+
}
|
|
1503
|
+
listEndpoint := fmt.Sprintf("/repos/%s/%s/issues?%s", url.PathEscape(target.Owner), url.PathEscape(target.Repo), listQuery.Encode())
|
|
1504
|
+
listResp, err := client.REST(ctx, http.MethodGet, listEndpoint, "application/vnd.github+json")
|
|
1505
|
+
if err != nil {
|
|
1506
|
+
return "", err
|
|
1507
|
+
}
|
|
1508
|
+
var issues []githubIssue
|
|
1509
|
+
if err := json.Unmarshal(listResp.Body, &issues); err != nil {
|
|
1510
|
+
return "", fmt.Errorf("decode GitHub milestone Issues: %w", err)
|
|
1511
|
+
}
|
|
1512
|
+
issues = filterPullRequestsFromIssues(issues)
|
|
1513
|
+
titlePreview, titleTruncated := githubOverviewInlinePreview(milestone.Title, 180)
|
|
1514
|
+
if titleTruncated {
|
|
1515
|
+
titlePreview += "…"
|
|
1516
|
+
}
|
|
1517
|
+
lines := []string{
|
|
1518
|
+
"---",
|
|
1519
|
+
"repository: " + yamlScalar(target.Owner+"/"+target.Repo),
|
|
1520
|
+
fmt.Sprintf("milestone: %d", milestone.Number),
|
|
1521
|
+
"title: " + yamlScalar(titlePreview),
|
|
1522
|
+
"state: " + yamlScalar(milestone.State),
|
|
1523
|
+
fmt.Sprintf("open_issues: %d", milestone.OpenIssues),
|
|
1524
|
+
fmt.Sprintf("closed_issues: %d", milestone.ClosedIssues),
|
|
1525
|
+
}
|
|
1526
|
+
if milestone.DueOn != "" {
|
|
1527
|
+
lines = append(lines, "due: "+yamlScalar(milestone.DueOn))
|
|
1528
|
+
}
|
|
1529
|
+
lines = append(lines, "---", "", "# Milestone: "+titlePreview, "")
|
|
1530
|
+
if strings.TrimSpace(milestone.Description) != "" {
|
|
1531
|
+
preview, truncated := githubOverviewPreview(stripInvisibleHTMLComments(milestone.Description), 1000)
|
|
1532
|
+
lines = append(lines, preview, "")
|
|
1533
|
+
if truncated {
|
|
1534
|
+
href := fmt.Sprintf("https://github.com/%s/%s/milestone/%d", escapePathPreservingSlashes(target.Owner), escapePathPreservingSlashes(target.Repo), milestone.Number)
|
|
1535
|
+
lines = append(lines, "> Milestone description preview locally truncated. Full milestone: "+href, "")
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
lines = append(lines, "## Issues", "")
|
|
1539
|
+
lines = append(lines, renderIssueLinksCompact(issues)...)
|
|
1540
|
+
return strings.TrimSpace(strings.Join(lines, "\n")), nil
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
func renderIssueLinksCompact(issues []githubIssue) []string {
|
|
1544
|
+
if len(issues) == 0 {
|
|
1545
|
+
return []string{"_No Issues on this page._"}
|
|
1546
|
+
}
|
|
1547
|
+
limit := minInt(20, len(issues))
|
|
1548
|
+
lines := make([]string, 0, limit+2)
|
|
1549
|
+
for _, issue := range issues[:limit] {
|
|
1550
|
+
href := issue.HTMLURL
|
|
1551
|
+
if href == "" {
|
|
1552
|
+
href = "#"
|
|
1553
|
+
}
|
|
1554
|
+
title, truncated := githubOverviewInlinePreview(issue.Title, 140)
|
|
1555
|
+
if truncated {
|
|
1556
|
+
title += "…"
|
|
1557
|
+
}
|
|
1558
|
+
lines = append(lines, fmt.Sprintf("- [#%d %s](%s) — %s", issue.Number, escapeMarkdownLinkText(title), href, issue.State))
|
|
1559
|
+
}
|
|
1560
|
+
if note := githubLocalOmissionNote("Issues returned on this provider page", len(issues)-limit); note != "" {
|
|
1561
|
+
lines = append(lines, "", note)
|
|
1562
|
+
}
|
|
1563
|
+
return lines
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
func copySelectedQuery(source url.Values, keys []string) url.Values {
|
|
1567
|
+
out := url.Values{}
|
|
1568
|
+
for _, key := range keys {
|
|
1569
|
+
for _, value := range source[key] {
|
|
1570
|
+
out.Add(key, value)
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return out
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
func renderGitHubUIPageNavigation(target *GitHubTarget, links GitHubLinkRelations) []string {
|
|
1577
|
+
lines := []string{}
|
|
1578
|
+
if page, ok := pageFromGitHubLink(links["prev"]); ok {
|
|
1579
|
+
lines = append(lines, "- Previous: "+githubTargetPageURL(target, page))
|
|
1580
|
+
}
|
|
1581
|
+
if page, ok := pageFromGitHubLink(links["next"]); ok {
|
|
1582
|
+
lines = append(lines, "- Next: "+githubTargetPageURL(target, page))
|
|
1583
|
+
}
|
|
1584
|
+
return lines
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
func pageFromGitHubLink(raw string) (int, bool) {
|
|
1588
|
+
if strings.TrimSpace(raw) == "" {
|
|
1589
|
+
return 0, false
|
|
1590
|
+
}
|
|
1591
|
+
parsed, err := url.Parse(raw)
|
|
1592
|
+
if err != nil {
|
|
1593
|
+
return 0, false
|
|
1594
|
+
}
|
|
1595
|
+
page, err := strconv.Atoi(parsed.Query().Get("page"))
|
|
1596
|
+
if err != nil || page <= 0 {
|
|
1597
|
+
return 0, false
|
|
1598
|
+
}
|
|
1599
|
+
return page, true
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
func githubTargetPageURL(target *GitHubTarget, page int) string {
|
|
1603
|
+
parsed, err := url.Parse(target.OriginalURL)
|
|
1604
|
+
if err != nil {
|
|
1605
|
+
return target.OriginalURL
|
|
1606
|
+
}
|
|
1607
|
+
query := parsed.Query()
|
|
1608
|
+
query.Set("page", strconv.Itoa(page))
|
|
1609
|
+
parsed.RawQuery = query.Encode()
|
|
1610
|
+
return parsed.String()
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
func parseGitHubTime(raw string) time.Time {
|
|
1614
|
+
t, _ := time.Parse(time.RFC3339, raw)
|
|
1615
|
+
return t
|
|
1616
|
+
}
|