Go · 23120 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package handlers
4
5 import (
6 "context"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/go-chi/chi/v5"
16 "github.com/jackc/pgx/v5/pgxpool"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/policy"
19 "github.com/tenseleyFlow/shithub/internal/web/middleware"
20 "github.com/tenseleyFlow/shithub/internal/web/render"
21 )
22
23 const globalDashboardLimit = 50
24
25 type globalNavHandler struct {
26 render *render.Renderer
27 logger *slog.Logger
28 pool *pgxpool.Pool
29 }
30
31 type globalNavTab struct {
32 Key string
33 Label string
34 Icon string
35 Href string
36 Selected bool
37 Count int64
38 Meta string
39 }
40
41 type globalIssueCounts struct {
42 Total int64
43 Open int64
44 Closed int64
45 }
46
47 type globalIssueRow struct {
48 ID int64
49 Owner string
50 RepoName string
51 Number int64
52 Title string
53 State string
54 Kind string
55 AuthorName string
56 URL string
57 RepoURL string
58 UpdatedAt time.Time
59 CommentCount int64
60 }
61
62 type globalRepoRow struct {
63 ID int64
64 Owner string
65 Name string
66 Description string
67 Visibility string
68 PrimaryLanguage string
69 LicenseKey string
70 StarCount int64
71 ForkCount int64
72 WatcherCount int64
73 IsFork bool
74 UpdatedAt time.Time
75 URL string
76 }
77
78 func (h globalNavHandler) RedirectIssues(w http.ResponseWriter, r *http.Request) {
79 http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther)
80 }
81
82 func (h globalNavHandler) ServeIssues(w http.ResponseWriter, r *http.Request) {
83 view := chi.URLParam(r, "view")
84 if !validIssueView(view) {
85 http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther)
86 return
87 }
88 viewer := middleware.CurrentUserFromContext(r.Context())
89 rawQuery := strings.TrimSpace(r.URL.Query().Get("q"))
90 state := globalStateFromRequest(r, rawQuery)
91 displayQuery := rawQuery
92 if displayQuery == "" {
93 displayQuery = defaultIssueQuery(view, state)
94 }
95 searchText := globalIssueSearchText(rawQuery)
96
97 items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "issue", view, state, searchText)
98 if err != nil {
99 h.logError(r.Context(), "global issues list", err)
100 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
101 return
102 }
103
104 data := map[string]any{
105 "Title": "Issues",
106 "Heading": issueViewLabel(view),
107 "Query": displayQuery,
108 "State": state,
109 "StateTabs": stateTabs(r.URL.Path, rawQuery, state, counts),
110 "Views": issueViewTabs(view, rawQuery, state),
111 "Issues": items,
112 "Counts": counts,
113 "ResultCount": countForState(counts, state),
114 "EmptyTitle": "No issues matched this view",
115 "EmptyMessage": "Try another filter or open a repository to create a new issue.",
116 "NewHref": "/issues/new",
117 }
118 if err := h.render.RenderPage(w, r, "dashboard/issues", data); err != nil {
119 h.logError(r.Context(), "render global issues", err)
120 http.Error(w, "internal server error", http.StatusInternalServerError)
121 }
122 }
123
124 func (h globalNavHandler) ServeNewIssue(w http.ResponseWriter, r *http.Request) {
125 viewer := middleware.CurrentUserFromContext(r.Context())
126 searchText := strings.TrimSpace(r.URL.Query().Get("q"))
127 repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, "contributions", searchText, true)
128 if err != nil {
129 h.logError(r.Context(), "global new issue repos", err)
130 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
131 return
132 }
133 data := map[string]any{
134 "Title": "New issue",
135 "Heading": "New issue",
136 "Query": searchText,
137 "Repos": repos,
138 "TotalCount": total,
139 "EmptyTitle": "No repositories with issues enabled",
140 "EmptyBody": "Create a repository or choose one with issues enabled before opening an issue.",
141 "ChooseIssue": true,
142 }
143 if err := h.render.RenderPage(w, r, "dashboard/new_issue", data); err != nil {
144 h.logError(r.Context(), "render global new issue", err)
145 http.Error(w, "internal server error", http.StatusInternalServerError)
146 }
147 }
148
149 func (h globalNavHandler) ServePulls(w http.ResponseWriter, r *http.Request) {
150 view := normalizePullView(r.URL.Query().Get("view"))
151 viewer := middleware.CurrentUserFromContext(r.Context())
152 rawQuery := strings.TrimSpace(r.URL.Query().Get("q"))
153 state := globalStateFromRequest(r, rawQuery)
154 displayQuery := rawQuery
155 if displayQuery == "" {
156 displayQuery = defaultPullQuery(view, state, viewer.Username)
157 }
158 searchText := globalIssueSearchText(rawQuery)
159
160 items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "pr", view, state, searchText)
161 if err != nil {
162 h.logError(r.Context(), "global pulls list", err)
163 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
164 return
165 }
166
167 data := map[string]any{
168 "Title": "Pull requests",
169 "Heading": "Pull requests",
170 "Query": displayQuery,
171 "State": state,
172 "StateTabs": stateTabs("/pulls", rawQuery, state, counts),
173 "Views": pullViewTabs(view, rawQuery, state),
174 "Pulls": items,
175 "Counts": counts,
176 "ResultCount": countForState(counts, state),
177 "EmptyTitle": "No pull requests matched this view",
178 }
179 if err := h.render.RenderPage(w, r, "dashboard/pulls", data); err != nil {
180 h.logError(r.Context(), "render global pulls", err)
181 http.Error(w, "internal server error", http.StatusInternalServerError)
182 }
183 }
184
185 func (h globalNavHandler) ServeRepos(w http.ResponseWriter, r *http.Request) {
186 view := normalizeRepoView(r.URL.Query().Get("view"))
187 viewer := middleware.CurrentUserFromContext(r.Context())
188 searchText := strings.TrimSpace(r.URL.Query().Get("q"))
189 repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, view, searchText, false)
190 if err != nil {
191 h.logError(r.Context(), "global repos list", err)
192 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
193 return
194 }
195
196 data := map[string]any{
197 "Title": "Repositories",
198 "Heading": repoViewLabel(view),
199 "Query": searchText,
200 "Views": repoViewTabs(view, searchText),
201 "Repos": repos,
202 "TotalCount": total,
203 "EmptyTitle": "No repositories matched this view",
204 "EmptyBody": "Create a repository or adjust your filters.",
205 "NewRepoHref": "/new",
206 }
207 if err := h.render.RenderPage(w, r, "dashboard/repos", data); err != nil {
208 h.logError(r.Context(), "render global repos", err)
209 http.Error(w, "internal server error", http.StatusInternalServerError)
210 }
211 }
212
213 func (h globalNavHandler) listIssues(ctx context.Context, actor policy.Actor, viewerID int64, kind, view, state, searchText string) ([]globalIssueRow, globalIssueCounts, error) {
214 if h.pool == nil {
215 return nil, globalIssueCounts{}, fmt.Errorf("database unavailable")
216 }
217
218 countWhere, countArgs := buildGlobalIssueWhere(actor, viewerID, kind, view, "", searchText)
219 countSQL := fmt.Sprintf(`
220 SELECT
221 COUNT(*)::bigint,
222 COUNT(*) FILTER (WHERE i.state = 'open')::bigint,
223 COUNT(*) FILTER (WHERE i.state = 'closed')::bigint
224 FROM issues i
225 JOIN repos r ON r.id = i.repo_id
226 LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
227 LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
228 WHERE %s`, countWhere)
229 var counts globalIssueCounts
230 if err := h.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&counts.Total, &counts.Open, &counts.Closed); err != nil {
231 return nil, counts, err
232 }
233
234 where, args := buildGlobalIssueWhere(actor, viewerID, kind, view, state, searchText)
235 limitPlaceholder := nextPlaceholder(&args, globalDashboardLimit)
236 ownerExpr := globalRepoOwnerExpr()
237 listSQL := fmt.Sprintf(`
238 SELECT
239 i.id,
240 %s AS owner,
241 r.name::text AS repo_name,
242 i.number,
243 i.title,
244 i.state::text AS state,
245 i.kind::text AS kind,
246 COALESCE(author.username::text, '') AS author_name,
247 i.updated_at,
248 (SELECT COUNT(*)::bigint FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count
249 FROM issues i
250 JOIN repos r ON r.id = i.repo_id
251 LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
252 LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
253 LEFT JOIN users author ON author.id = i.author_user_id
254 WHERE %s
255 ORDER BY i.updated_at DESC, i.id DESC
256 LIMIT %s`, ownerExpr, where, limitPlaceholder)
257
258 rows, err := h.pool.Query(ctx, listSQL, args...)
259 if err != nil {
260 return nil, counts, err
261 }
262 defer rows.Close()
263
264 var out []globalIssueRow
265 for rows.Next() {
266 var row globalIssueRow
267 if err := rows.Scan(
268 &row.ID,
269 &row.Owner,
270 &row.RepoName,
271 &row.Number,
272 &row.Title,
273 &row.State,
274 &row.Kind,
275 &row.AuthorName,
276 &row.UpdatedAt,
277 &row.CommentCount,
278 ); err != nil {
279 return nil, counts, err
280 }
281 row.RepoURL = "/" + row.Owner + "/" + row.RepoName
282 threadPath := "issues"
283 if row.Kind == "pr" {
284 threadPath = "pulls"
285 }
286 row.URL = row.RepoURL + "/" + threadPath + "/" + strconv.FormatInt(row.Number, 10)
287 out = append(out, row)
288 }
289 if err := rows.Err(); err != nil {
290 return nil, counts, err
291 }
292 return out, counts, nil
293 }
294
295 func (h globalNavHandler) listRepos(ctx context.Context, actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) ([]globalRepoRow, int64, error) {
296 if h.pool == nil {
297 return nil, 0, fmt.Errorf("database unavailable")
298 }
299 where, args := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues)
300 countSQL := fmt.Sprintf(`
301 SELECT COUNT(*)::bigint
302 FROM repos r
303 LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
304 LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
305 WHERE %s`, where)
306 var total int64
307 if err := h.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
308 return nil, 0, err
309 }
310
311 listWhere, listArgs := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues)
312 limitPlaceholder := nextPlaceholder(&listArgs, globalDashboardLimit)
313 ownerExpr := globalRepoOwnerExpr()
314 listSQL := fmt.Sprintf(`
315 SELECT
316 r.id,
317 %s AS owner,
318 r.name::text AS name,
319 r.description,
320 r.visibility::text AS visibility,
321 COALESCE(r.primary_language, '') AS primary_language,
322 COALESCE(r.license_key, '') AS license_key,
323 r.star_count,
324 r.fork_count,
325 r.watcher_count,
326 (r.fork_of_repo_id IS NOT NULL) AS is_fork,
327 r.updated_at
328 FROM repos r
329 LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
330 LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
331 WHERE %s
332 ORDER BY r.updated_at DESC, r.id DESC
333 LIMIT %s`, ownerExpr, listWhere, limitPlaceholder)
334
335 rows, err := h.pool.Query(ctx, listSQL, listArgs...)
336 if err != nil {
337 return nil, total, err
338 }
339 defer rows.Close()
340
341 var out []globalRepoRow
342 for rows.Next() {
343 var row globalRepoRow
344 if err := rows.Scan(
345 &row.ID,
346 &row.Owner,
347 &row.Name,
348 &row.Description,
349 &row.Visibility,
350 &row.PrimaryLanguage,
351 &row.LicenseKey,
352 &row.StarCount,
353 &row.ForkCount,
354 &row.WatcherCount,
355 &row.IsFork,
356 &row.UpdatedAt,
357 ); err != nil {
358 return nil, total, err
359 }
360 row.URL = "/" + row.Owner + "/" + row.Name
361 out = append(out, row)
362 }
363 if err := rows.Err(); err != nil {
364 return nil, total, err
365 }
366 return out, total, nil
367 }
368
369 func buildGlobalIssueWhere(actor policy.Actor, viewerID int64, kind, view, state, searchText string) (string, []any) {
370 visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1)
371 args := append([]any{}, visArgs...)
372 clauses := []string{
373 visClause,
374 "((" + "r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL" + ") OR (" + "r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL" + "))",
375 }
376
377 kindPlaceholder := nextPlaceholder(&args, kind)
378 clauses = append(clauses, "i.kind = "+kindPlaceholder+"::issue_kind")
379 if state != "" && state != "all" {
380 clauses = append(clauses, "i.state = "+nextPlaceholder(&args, state)+"::issue_state")
381 }
382 if searchText != "" {
383 pattern := "%" + strings.ToLower(searchText) + "%"
384 p := nextPlaceholder(&args, pattern)
385 ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")"
386 clauses = append(clauses, "(LOWER(i.title) LIKE "+p+" OR LOWER(i.body) LIKE "+p+" OR LOWER(r.name::text) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")")
387 }
388
389 uid := nextPlaceholder(&args, viewerID)
390 threadKind := func() string {
391 return nextPlaceholder(&args, kind) + "::notification_thread_kind"
392 }
393 switch view {
394 case "assigned":
395 clauses = append(clauses, "EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+")")
396 case "created":
397 clauses = append(clauses, "i.author_user_id = "+uid)
398 case "mentioned":
399 tk := threadKind()
400 clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'mention') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'mention'))")
401 case "recent":
402 tk := threadKind()
403 clauses = append(clauses, "(i.author_user_id = "+uid+" OR EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+") OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.subscribed = true))")
404 case "review-requests":
405 tk := threadKind()
406 clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'review_requested') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'review_requested'))")
407 default:
408 clauses = append(clauses, "i.author_user_id = "+uid)
409 }
410 return strings.Join(clauses, " AND "), args
411 }
412
413 func buildGlobalRepoWhere(actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) (string, []any) {
414 visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1)
415 args := append([]any{}, visArgs...)
416 clauses := []string{
417 visClause,
418 "((r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL) OR (r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL))",
419 }
420 if requireIssues {
421 clauses = append(clauses, "r.has_issues = true")
422 }
423 if searchText != "" {
424 pattern := "%" + strings.ToLower(searchText) + "%"
425 p := nextPlaceholder(&args, pattern)
426 ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")"
427 clauses = append(clauses, "(LOWER(r.name::text) LIKE "+p+" OR LOWER(r.description) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")")
428 }
429 uid := nextPlaceholder(&args, viewerID)
430 contributionClause := "(r.owner_user_id = " + uid + " OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = " + uid + ") OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = " + uid + "))"
431 switch view {
432 case "mine":
433 clauses = append(clauses, "r.owner_user_id = "+uid)
434 case "forks":
435 clauses = append(clauses, "r.fork_of_repo_id IS NOT NULL", contributionClause)
436 case "admin":
437 clauses = append(clauses, "(r.owner_user_id = "+uid+" OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = "+uid+" AND role = 'owner') OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = "+uid+" AND rc.role = 'admin'))")
438 default:
439 clauses = append(clauses, contributionClause)
440 }
441 return strings.Join(clauses, " AND "), args
442 }
443
444 func nextPlaceholder(args *[]any, v any) string {
445 *args = append(*args, v)
446 return "$" + strconv.Itoa(len(*args))
447 }
448
449 func globalRepoOwnerExpr() string {
450 return "COALESCE(owner_user.username::text, owner_org.slug::text, '')"
451 }
452
453 func validIssueView(view string) bool {
454 switch view {
455 case "assigned", "created", "mentioned", "recent":
456 return true
457 default:
458 return false
459 }
460 }
461
462 func normalizePullView(view string) string {
463 switch view {
464 case "assigned", "mentioned", "review-requests":
465 return view
466 default:
467 return "created"
468 }
469 }
470
471 func normalizeRepoView(view string) string {
472 switch view {
473 case "mine", "forks", "admin":
474 return view
475 default:
476 return "contributions"
477 }
478 }
479
480 func globalStateFromRequest(r *http.Request, rawQuery string) string {
481 state := normalizeGlobalState(r.URL.Query().Get("state"))
482 lower := strings.ToLower(rawQuery)
483 switch {
484 case strings.Contains(lower, "state:closed"), strings.Contains(lower, "is:closed"):
485 return "closed"
486 case strings.Contains(lower, "state:open"), strings.Contains(lower, "is:open"):
487 return "open"
488 default:
489 return state
490 }
491 }
492
493 func normalizeGlobalState(raw string) string {
494 switch raw {
495 case "closed", "all":
496 return raw
497 default:
498 return "open"
499 }
500 }
501
502 func globalIssueSearchText(raw string) string {
503 if raw == "" {
504 return ""
505 }
506 parts := strings.Fields(raw)
507 keep := make([]string, 0, len(parts))
508 for _, part := range parts {
509 token := strings.ToLower(strings.TrimSpace(part))
510 switch {
511 case token == "":
512 continue
513 case strings.HasPrefix(token, "is:"),
514 strings.HasPrefix(token, "state:"),
515 strings.HasPrefix(token, "archived:"),
516 strings.HasPrefix(token, "assignee:"),
517 strings.HasPrefix(token, "author:"),
518 strings.HasPrefix(token, "mentions:"),
519 strings.HasPrefix(token, "involves:"),
520 strings.HasPrefix(token, "review-requested:"),
521 strings.HasPrefix(token, "sort:"):
522 continue
523 default:
524 keep = append(keep, part)
525 }
526 }
527 return strings.Join(keep, " ")
528 }
529
530 func globalQueryWithoutStateOperators(raw string) string {
531 if raw == "" {
532 return ""
533 }
534 parts := strings.Fields(raw)
535 keep := make([]string, 0, len(parts))
536 for _, part := range parts {
537 token := strings.ToLower(strings.TrimSpace(part))
538 switch {
539 case token == "":
540 continue
541 case strings.HasPrefix(token, "state:"),
542 token == "is:open",
543 token == "is:closed":
544 continue
545 default:
546 keep = append(keep, part)
547 }
548 }
549 return strings.Join(keep, " ")
550 }
551
552 func issueViewLabel(view string) string {
553 switch view {
554 case "created":
555 return "Created by me"
556 case "mentioned":
557 return "Mentioned"
558 case "recent":
559 return "Recent activity"
560 default:
561 return "Assigned to me"
562 }
563 }
564
565 func repoViewLabel(view string) string {
566 switch view {
567 case "mine":
568 return "My repositories"
569 case "forks":
570 return "My forks"
571 case "admin":
572 return "Admin access"
573 default:
574 return "My contributions"
575 }
576 }
577
578 func defaultIssueQuery(view, state string) string {
579 switch view {
580 case "created":
581 return "is:issue state:" + state + " archived:false author:@me sort:updated-desc"
582 case "mentioned":
583 return "is:issue state:" + state + " archived:false mentions:@me sort:updated-desc"
584 case "recent":
585 return "is:issue state:" + state + " archived:false involves:@me sort:updated-desc"
586 default:
587 return "is:issue state:" + state + " archived:false assignee:@me sort:updated-desc"
588 }
589 }
590
591 func defaultPullQuery(view, state, username string) string {
592 if username == "" {
593 username = "@me"
594 } else {
595 username = "@" + username
596 }
597 switch view {
598 case "assigned":
599 return "is:pr state:" + state + " archived:false assignee:@me sort:updated-desc"
600 case "mentioned":
601 return "is:pr state:" + state + " archived:false mentions:@me sort:updated-desc"
602 case "review-requests":
603 return "is:pr state:" + state + " archived:false review-requested:@me sort:updated-desc"
604 default:
605 return "is:pr state:" + state + " archived:false author:" + username
606 }
607 }
608
609 func issueViewTabs(active, query, state string) []globalNavTab {
610 return []globalNavTab{
611 {Key: "assigned", Label: "Assigned to me", Icon: "people", Href: dashboardHref("/issues/assigned", queryValues(query, state, "")), Selected: active == "assigned"},
612 {Key: "created", Label: "Created by me", Icon: "smiley", Href: dashboardHref("/issues/created", queryValues(query, state, "")), Selected: active == "created"},
613 {Key: "mentioned", Label: "Mentioned", Icon: "mention", Href: dashboardHref("/issues/mentioned", queryValues(query, state, "")), Selected: active == "mentioned"},
614 {Key: "recent", Label: "Recent activity", Icon: "history", Href: dashboardHref("/issues/recent", queryValues(query, state, "")), Selected: active == "recent"},
615 }
616 }
617
618 func pullViewTabs(active, query, state string) []globalNavTab {
619 return []globalNavTab{
620 {Key: "created", Label: "Created", Href: dashboardHref("/pulls", queryValues(query, state, "created")), Selected: active == "created"},
621 {Key: "assigned", Label: "Assigned", Href: dashboardHref("/pulls", queryValues(query, state, "assigned")), Selected: active == "assigned"},
622 {Key: "mentioned", Label: "Mentioned", Href: dashboardHref("/pulls", queryValues(query, state, "mentioned")), Selected: active == "mentioned"},
623 {Key: "review-requests", Label: "Review requests", Href: dashboardHref("/pulls", queryValues(query, state, "review-requests")), Selected: active == "review-requests"},
624 }
625 }
626
627 func repoViewTabs(active, query string) []globalNavTab {
628 return []globalNavTab{
629 {Key: "contributions", Label: "My contributions", Icon: "people", Href: dashboardHref("/repos", repoQueryValues(query, "contributions")), Selected: active == "contributions"},
630 {Key: "mine", Label: "My repositories", Icon: "repo", Href: dashboardHref("/repos", repoQueryValues(query, "mine")), Selected: active == "mine"},
631 {Key: "forks", Label: "My forks", Icon: "repo-forked", Href: dashboardHref("/repos", repoQueryValues(query, "forks")), Selected: active == "forks"},
632 {Key: "admin", Label: "Admin access", Icon: "gear", Href: dashboardHref("/repos", repoQueryValues(query, "admin")), Selected: active == "admin"},
633 }
634 }
635
636 func stateTabs(path, query, active string, counts globalIssueCounts) []globalNavTab {
637 query = globalQueryWithoutStateOperators(query)
638 return []globalNavTab{
639 {Key: "open", Label: "Open", Icon: "issue-opened", Count: counts.Open, Href: dashboardHref(path, queryValues(query, "open", "")), Selected: active == "open"},
640 {Key: "closed", Label: "Closed", Icon: "check", Count: counts.Closed, Href: dashboardHref(path, queryValues(query, "closed", "")), Selected: active == "closed"},
641 {Key: "all", Label: "All", Icon: "list-unordered", Count: counts.Total, Href: dashboardHref(path, queryValues(query, "all", "")), Selected: active == "all"},
642 }
643 }
644
645 func queryValues(query, state, view string) url.Values {
646 values := url.Values{}
647 if query != "" {
648 values.Set("q", query)
649 }
650 if state != "" && state != "open" {
651 values.Set("state", state)
652 }
653 if view != "" && view != "created" {
654 values.Set("view", view)
655 }
656 return values
657 }
658
659 func repoQueryValues(query, view string) url.Values {
660 values := url.Values{}
661 if query != "" {
662 values.Set("q", query)
663 }
664 if view != "" && view != "contributions" {
665 values.Set("view", view)
666 }
667 return values
668 }
669
670 func dashboardHref(path string, values url.Values) string {
671 if encoded := values.Encode(); encoded != "" {
672 return path + "?" + encoded
673 }
674 return path
675 }
676
677 func countForState(counts globalIssueCounts, state string) int64 {
678 switch state {
679 case "closed":
680 return counts.Closed
681 case "all":
682 return counts.Total
683 default:
684 return counts.Open
685 }
686 }
687
688 func (h globalNavHandler) logError(ctx context.Context, msg string, err error) {
689 if h.logger != nil {
690 h.logger.ErrorContext(ctx, msg, "error", err)
691 }
692 }
693