internal/control/search.go
139 lines · 4436 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 Flags: []Flag{
18 {"--kind", "repo|issue|mr", "only this kind of result, may repeat", ""},
19 },
20 Examples: []string{
21 `search "auth bug"`,
22 `search "auth bug" --kind issue`,
23 },
24 ReadOnly: true, Run: runSearch})
25}
26
27// searchLimit caps each kind of result. A query that hits more than this
28// wants narrowing, not a longer page.
29const searchLimit = 50
30
31// SearchResult is one match, in the shape all three kinds share.
32type SearchResult struct {
33 Kind string `json:"kind"` // repo, issue, or mr
34 Repo string `json:"repo"`
35 Number int64 `json:"number,omitempty"`
36 Title string `json:"title"`
37 Author string `json:"author,omitempty"`
38 State string `json:"state,omitempty"`
39 UpdatedAt string `json:"updated_at,omitempty"`
40}
41
42// Search runs a query for a user, or for an anonymous visitor when userID
43// is 0, in which case only public repositories are reached. kinds names
44// which of repo, issue and mr to look at; empty means all three. It is
45// exported because the web's /search page renders it for readers who have
46// no session to dispatch a command as.
47func Search(st *store.Store, root string, userID int64, q string, kinds []string) ([]SearchResult, error) {
48 want := func(kind string) bool { return len(kinds) == 0 || slices.Contains(kinds, kind) }
49 out := []SearchResult{}
50 if want("repo") {
51 repos, err := st.VisibleRepos(userID)
52 if err != nil {
53 return nil, err
54 }
55 for _, r := range repos {
56 desc := gitutil.ReadDescription(RepoDir(root, r.OwnerName, r.Name))
57 topics, _ := st.ListTopics(r.ID)
58 if !MatchesRepo(q, r.Path(), desc, topics) {
59 continue
60 }
61 out = append(out, SearchResult{Kind: "repo", Repo: r.Path(), Title: desc})
62 if len(out) == searchLimit {
63 break
64 }
65 }
66 }
67 for _, k := range []struct {
68 kind string
69 query func(int64, string, int) ([]store.DashboardItem, error)
70 }{{"issue", st.SearchIssues}, {"mr", st.SearchMRs}} {
71 if !want(k.kind) {
72 continue
73 }
74 items, err := k.query(userID, q, searchLimit)
75 if err != nil {
76 return nil, err
77 }
78 for _, it := range items {
79 out = append(out, SearchResult{Kind: k.kind, Repo: it.RepoPath, Number: it.Number,
80 Title: it.Title, Author: it.Author, State: it.State, UpdatedAt: it.UpdatedAt})
81 }
82 }
83 return out, nil
84}
85
86func runSearch(c *Ctx, args []string) int {
87 f, err := c.parseArgs(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage})
88 if err != nil {
89 return c.fail(protocol.ExitUsage, "%v", err)
90 }
91 if len(f.Pos) != 1 {
92 return c.usage()
93 }
94 if err := validQuery(f.Pos[0]); err != nil {
95 return c.failInput(err)
96 }
97 kinds := f.List("--kind")
98 for _, k := range kinds {
99 if k != "repo" && k != "issue" && k != "mr" {
100 return c.fail(protocol.ExitUsage, "--kind must be repo, issue or mr")
101 }
102 }
103 results, err := Search(c.Store, c.Cfg.Server.Root, c.User.ID, f.Pos[0], kinds)
104 if err != nil {
105 return c.fail(protocol.ExitFailure, "%v", err)
106 }
107 return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
108}
109
110// writeSearchTable renders search results: a repo hit has no ref number or
111// state, an issue/mr hit has both. Plain output keeps a repo row at 3
112// cells, matching what it always printed. At a terminal the header still
113// reserves a STATE column, so a repo row gets an empty state cell there —
114// otherwise its title would render under STATE instead of TITLE.
115func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
116 tb := c.table(w, "KIND", "REF", "STATE", "TITLE")
117 for _, r := range results {
118 switch r.Kind {
119 case "repo":
120 if c.Term.Cols > 0 {
121 tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title))
122 } else {
123 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
124 }
125 default:
126 tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
127 }
128 }
129 tb.flush()
130}
131
132// SearchMarker is the sigil a result's number carries, shared with the web
133// so a hit reads the same in both places.
134func SearchMarker(kind string) string {
135 if kind == "mr" {
136 return "!"
137 }
138 return "#"
139}