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