internal/control/search.go
121 lines · 3689 bytes
1package control
2
3import (
4 "fmt"
5 "io"
6 "slices"
7
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"search"},
15 Summary: "find repositories, issues and merge requests across the instance",
16 Usage: "search <query> [--kind repo|issue|mr]",
17 ReadOnly: true, Run: runSearch})
18}
19
20// searchLimit caps each kind of result. A query that hits more than this
21// wants narrowing, not a longer page.
22const searchLimit = 50
23
24// SearchResult is one match, in the shape all three kinds share.
25type SearchResult struct {
26 Kind string `json:"kind"` // repo, issue, or mr
27 Repo string `json:"repo"`
28 Number int64 `json:"number,omitempty"`
29 Title string `json:"title"`
30 Author string `json:"author,omitempty"`
31 State string `json:"state,omitempty"`
32 UpdatedAt string `json:"updated_at,omitempty"`
33}
34
35// Search runs a query for a user, or for an anonymous visitor when userID
36// is 0, in which case only public repositories are reached. kinds names
37// which of repo, issue and mr to look at; empty means all three. It is
38// exported because the web's /search page renders it for readers who have
39// no session to dispatch a command as.
40func Search(st *store.Store, root string, userID int64, q string, kinds []string) ([]SearchResult, error) {
41 want := func(kind string) bool { return len(kinds) == 0 || slices.Contains(kinds, kind) }
42 out := []SearchResult{}
43 if want("repo") {
44 repos, err := st.VisibleRepos(userID)
45 if err != nil {
46 return nil, err
47 }
48 for _, r := range repos {
49 desc := gitutil.ReadDescription(RepoDir(root, r.OwnerName, r.Name))
50 topics, _ := st.ListTopics(r.ID)
51 if !MatchesRepo(q, r.Path(), desc, topics) {
52 continue
53 }
54 out = append(out, SearchResult{Kind: "repo", Repo: r.Path(), Title: desc})
55 if len(out) == searchLimit {
56 break
57 }
58 }
59 }
60 for _, k := range []struct {
61 kind string
62 query func(int64, string, int) ([]store.DashboardItem, error)
63 }{{"issue", st.SearchIssues}, {"mr", st.SearchMRs}} {
64 if !want(k.kind) {
65 continue
66 }
67 items, err := k.query(userID, q, searchLimit)
68 if err != nil {
69 return nil, err
70 }
71 for _, it := range items {
72 out = append(out, SearchResult{Kind: k.kind, Repo: it.RepoPath, Number: it.Number,
73 Title: it.Title, Author: it.Author, State: it.State, UpdatedAt: it.UpdatedAt})
74 }
75 }
76 return out, nil
77}
78
79func runSearch(c *Ctx, args []string) int {
80 const usage = "search <query> [--kind repo|issue|mr]"
81 f, err := parseFlags(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: usage})
82 if err != nil {
83 return c.fail(protocol.ExitUsage, "%v", err)
84 }
85 if len(f.Pos) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: %s", usage)
87 }
88 if err := validQuery(f.Pos[0]); err != nil {
89 return c.failInput(err)
90 }
91 kinds := f.List("--kind")
92 for _, k := range kinds {
93 if k != "repo" && k != "issue" && k != "mr" {
94 return c.fail(protocol.ExitUsage, "--kind must be repo, issue or mr")
95 }
96 }
97 results, err := Search(c.Store, c.Cfg.Server.Root, c.User.ID, f.Pos[0], kinds)
98 if err != nil {
99 return c.fail(protocol.ExitFailure, "%v", err)
100 }
101 return c.emit(results, func(w io.Writer) {
102 for _, r := range results {
103 switch r.Kind {
104 case "repo":
105 fmt.Fprintf(w, "repo\t%s\t%s\n", r.Repo, r.Title)
106 default:
107 fmt.Fprintf(w, "%s\t%s%s%d\t%s\t%s\n", r.Kind, r.Repo,
108 SearchMarker(r.Kind), r.Number, r.State, r.Title)
109 }
110 }
111 })
112}
113
114// SearchMarker is the sigil a result's number carries, shared with the web
115// so a hit reads the same in both places.
116func SearchMarker(kind string) string {
117 if kind == "mr" {
118 return "!"
119 }
120 return "#"
121}