- commit
- 837fe8a
- parent
- bbadbe0
- author
- Eric Bower
- date
- 2024-10-23 14:44:41 -0400 EDT
feat: add multitenancy The goal of this change is to support the ability to let any user create repos, similar to popular code forges. Repos are created adhoc when a user uploads a patchset. As part of this change, we are deprecating the previous way to configure repos. Repos can be thought of as containers for patch requests and not much more. They don't need to strictly map to a git repo. Multitenancy is opt-in via the `git-pr.toml` field: ``` create_repo = "user" ```
24 files changed,
+990,
-461
M
Makefile
+4,
-0
1@@ -16,6 +16,10 @@ test:
2 go test ./...
3 .PHONY: test
4
5+snapshot:
6+ UPDATE_SNAPS=true go test ./...
7+.PHONY: snapshot
8+
9 build:
10 go build -o ./build/ssh ./cmd/ssh
11 go build -o ./build/web ./cmd/web
+49,
-0
1@@ -0,0 +1,49 @@
2+
3+[TestE2E - 1]
4+ID RepoID Name Status Patchsets User Date
5+1 test feat: lets build an rnn [open] 1 admin
6+
7+---
8+
9+[TestE2E - 2]
10+PR submitted! Use the ID for interacting with this PR.
11+Info
12+====
13+URL: https://localhost/prs/8
14+Repo: contributor/bin
15+
16+ID Name Status Date
17+8 feat: lets build an rnn [open]
18+
19+Patchsets
20+====
21+ID Type User Date
22+ps-12 contributor
23+
24+Patches from latest patchset
25+====
26+Idx Title Commit Author Date
27+0 feat: lets build an rnn 5945657 Eric Bower <me@erock.io>
28+
29+---
30+
31+[TestE2E - 3]
32+ID RepoID Name Status Patchsets User Date
33+8 contributor/bin feat: lets build an rnn [open] 1 contributor
34+7 admin/ai feat: lets build an rnn [accepted] 2 contributor
35+6 contributor/test Closed patch with review [closed] 2 contributor
36+5 contributor/test Accepted patch with review [accepted] 2 contributor
37+4 contributor/test Reviewed patch [reviewed] 2 contributor
38+3 contributor/test Closed patch (contributor) [closed] 1 contributor
39+2 contributor/test Closed patch (admin) [closed] 1 contributor
40+1 contributor/test Accepted patch [accepted] 1 contributor
41+
42+---
43+
44+[TestE2E - 4]
45+RepoID PrID PatchsetID Event Created Data
46+admin/ai 7 ps-10 pr_created
47+admin/ai 7 ps-11 pr_patchset_added
48+admin/ai 7 pr_status_changed {"status":"accepted"}
49+
50+---
+84,
-8
1@@ -4,9 +4,8 @@ import (
2 "encoding/base64"
3 "fmt"
4 "log/slog"
5- "path/filepath"
6+ "strings"
7
8- "github.com/charmbracelet/soft-serve/pkg/utils"
9 "github.com/charmbracelet/ssh"
10 "github.com/jmoiron/sqlx"
11 gossh "golang.org/x/crypto/ssh"
12@@ -18,16 +17,56 @@ type Backend struct {
13 Cfg *GitCfg
14 }
15
16-func (be *Backend) ReposDir() string {
17- return filepath.Join(be.Cfg.DataDir, "repos")
18+var ErrRepoNoNamespace = fmt.Errorf("repo must be namespaced by username")
19+
20+// Repo Namespace.
21+func (be *Backend) CreateRepoNs(userName, repoName string) string {
22+ if be.Cfg.CreateRepo == "admin" {
23+ return repoName
24+ }
25+ return fmt.Sprintf("%s/%s", userName, repoName)
26 }
27
28-func (be *Backend) RepoName(id string) string {
29- return utils.SanitizeRepo(id)
30+func (be *Backend) ValidateRepoNs(repoNs string) error {
31+ _, repoID := be.SplitRepoNs(repoNs)
32+ if strings.Contains(repoID, "/") {
33+ return fmt.Errorf("repo can only contain a single forward-slash")
34+ }
35+ return nil
36 }
37
38-func (be *Backend) RepoID(name string) string {
39- return name + ".git"
40+func (be *Backend) SplitRepoNs(repoNs string) (string, string) {
41+ results := strings.SplitN(repoNs, "/", 2)
42+ if len(results) == 1 {
43+ return "", results[0]
44+ }
45+
46+ return results[0], results[1]
47+}
48+
49+func (be *Backend) CanCreateRepo(repo *Repo, requester *User) error {
50+ pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
51+ if err != nil {
52+ return err
53+ }
54+ isAdmin := be.IsAdmin(pubkey)
55+ if isAdmin {
56+ return nil
57+ }
58+
59+ // can create repo is a misnomer since we are saying it's ok to create
60+ // a repo even though one already exists. this is a hack since this function
61+ // is used exclusively inside pr creation flow.
62+ if repo != nil {
63+ return nil
64+ }
65+
66+ if be.Cfg.CreateRepo == "user" {
67+ return nil
68+ }
69+
70+ // new repo with cfg indicating only admins can create prs/repos
71+ return fmt.Errorf("you are not authorized to create repo")
72 }
73
74 func (be *Backend) Pubkey(pk ssh.PublicKey) string {
75@@ -64,3 +103,40 @@ func (be *Backend) IsAdmin(pk ssh.PublicKey) bool {
76 func (be *Backend) IsPrOwner(pka, pkb int64) bool {
77 return pka == pkb
78 }
79+
80+type PrAcl struct {
81+ CanModify bool
82+ CanReview bool
83+ CanDelete bool
84+}
85+
86+func (be *Backend) GetPatchRequestAcl(prq *PatchRequest, requester *User) *PrAcl {
87+ acl := &PrAcl{}
88+ pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
89+ if err != nil {
90+ return acl
91+ }
92+
93+ isAdmin := be.IsAdmin(pubkey)
94+ // admin can do it all
95+ if isAdmin {
96+ acl.CanModify = true
97+ acl.CanReview = true
98+ acl.CanDelete = true
99+ return acl
100+ }
101+
102+ // pr creator have special priv
103+ if requester != nil && be.IsPrOwner(prq.UserID, requester.ID) {
104+ acl.CanModify = true
105+ acl.CanReview = false
106+ acl.CanDelete = true
107+ return acl
108+ }
109+
110+ acl.CanModify = false
111+ acl.CanReview = false
112+ acl.CanDelete = false
113+
114+ return acl
115+}
M
cfg.go
+11,
-24
1@@ -14,30 +14,23 @@ import (
2 "github.com/knadh/koanf/v2"
3 )
4
5-type Repo struct {
6- ID string `koanf:"id"`
7- Desc string `koanf:"desc"`
8- CloneAddr string `koanf:"clone_addr"`
9- DefaultBranch string `koanf:"default_branch"`
10-}
11-
12 var k = koanf.New(".")
13
14 type GitCfg struct {
15 DataDir string `koanf:"data_dir"`
16- Repos []*Repo `koanf:"repo"`
17 Url string `koanf:"url"`
18 Host string `koanf:"host"`
19 SshPort string `koanf:"ssh_port"`
20 WebPort string `koanf:"web_port"`
21 AdminsStr []string `koanf:"admins"`
22 Admins []ssh.PublicKey `koanf:"admins_pk"`
23+ CreateRepo string `koanf:"create_repo"`
24 Theme string `koanf:"theme"`
25 TimeFormat string `koanf:"time_format"`
26 Logger *slog.Logger
27 }
28
29-func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
30+func LoadConfigFile(fpath string, logger *slog.Logger) {
31 fpp, err := filepath.Abs(fpath)
32 if err != nil {
33 panic(err)
34@@ -47,8 +40,10 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
35 if err := k.Load(file.Provider(fpp), toml.Parser()); err != nil {
36 panic(fmt.Sprintf("error loading config: %v", err))
37 }
38+}
39
40- err = k.Load(env.Provider("GITPR_", ".", func(s string) string {
41+func NewGitCfg(logger *slog.Logger) *GitCfg {
42+ err := k.Load(env.Provider("GITPR_", ".", func(s string) string {
43 keyword := strings.ToLower(strings.TrimPrefix(s, "GITPR_"))
44 return keyword
45 }), nil)
46@@ -63,7 +58,7 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
47 }
48
49 if len(out.AdminsStr) > 0 {
50- keys, err := getAuthorizedKeys(out.AdminsStr)
51+ keys, err := GetAuthorizedKeys(out.AdminsStr)
52 if err == nil {
53 out.Admins = keys
54 } else {
55@@ -104,6 +99,10 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
56 out.TimeFormat = time.RFC3339
57 }
58
59+ if out.CreateRepo == "" {
60+ out.CreateRepo = "admin"
61+ }
62+
63 logger.Info(
64 "config",
65 "url", out.Url,
66@@ -113,25 +112,13 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
67 "web_port", out.WebPort,
68 "theme", out.Theme,
69 "time_format", out.TimeFormat,
70+ "create_repo", out.CreateRepo,
71 )
72
73 for _, pubkey := range out.AdminsStr {
74 logger.Info("admin", "pubkey", pubkey)
75 }
76
77- for _, repo := range out.Repos {
78- if repo.DefaultBranch == "" {
79- repo.DefaultBranch = "main"
80- }
81- logger.Info(
82- "repo",
83- "id", repo.ID,
84- "desc", repo.Desc,
85- "clone_addr", repo.CloneAddr,
86- "default_branch", repo.DefaultBranch,
87- )
88- }
89-
90 out.Logger = logger
91 return &out
92 }
M
cli.go
+157,
-89
1@@ -7,7 +7,6 @@ import (
2 "strings"
3 "text/tabwriter"
4
5- "github.com/charmbracelet/soft-serve/pkg/utils"
6 "github.com/charmbracelet/ssh"
7 "github.com/charmbracelet/wish"
8 "github.com/urfave/cli/v2"
9@@ -62,8 +61,19 @@ func prSummary(be *Backend, pr GitPatchRequest, sesh ssh.Session, prID int64) er
10 return err
11 }
12
13+ repo, err := pr.GetRepoByID(request.RepoID)
14+ if err != nil {
15+ return err
16+ }
17+
18+ repoUser, err := pr.GetUserByID(repo.UserID)
19+ if err != nil {
20+ return err
21+ }
22+
23 wish.Printf(sesh, "Info\n====\n")
24- wish.Printf(sesh, "URL: https://%s/prs/%d\n\n", be.Cfg.Url, prID)
25+ wish.Printf(sesh, "URL: https://%s/prs/%d\n", be.Cfg.Url, prID)
26+ wish.Printf(sesh, "Repo: %s\n\n", be.CreateRepoNs(repoUser.Name, repo.Name))
27
28 writer := NewTabWriter(sesh)
29 fmt.Fprintln(writer, "ID\tName\tStatus\tDate")
30@@ -175,48 +185,6 @@ Here's how it works:
31 return nil
32 },
33 Commands: []*cli.Command{
34- /* {
35- Name: "git-receive-pack",
36- Usage: "Receive what is pushed into the repository",
37- Action: func(cCtx *cli.Context) error {
38- repoName := cCtx.Args().First()
39- err := gitServiceCommands(sesh, be, "git-receive-patch", repoName)
40- return err
41- },
42- },
43- {
44- Name: "git-upload-pack",
45- Usage: "Send objects packed back to git-fetch-pack",
46- Action: func(cCtx *cli.Context) error {
47- repoName := cCtx.Args().First()
48- err := gitServiceCommands(sesh, be, "git-upload-patch", repoName)
49- return err
50- },
51- }, */
52- {
53- Name: "ls",
54- Usage: "List all git repos",
55- Action: func(cCtx *cli.Context) error {
56- repos, err := pr.GetRepos()
57- if err != nil {
58- return err
59- }
60- writer := NewTabWriter(sesh)
61- fmt.Fprintln(writer, "ID\tDefBranch\tClone\tDesc")
62- for _, repo := range repos {
63- fmt.Fprintf(
64- writer,
65- "%s\t%s\t%s\t%s\n",
66- utils.SanitizeRepo(repo.ID),
67- repo.DefaultBranch,
68- repo.CloneAddr,
69- repo.Desc,
70- )
71- }
72- writer.Flush()
73- return nil
74- },
75- },
76 {
77 Name: "logs",
78 Usage: "List event logs with filters",
79@@ -243,27 +211,45 @@ Here's how it works:
80 }
81 isPubkey := cCtx.Bool("pubkey")
82 prID := cCtx.Int64("pr")
83- repoID := cCtx.String("repo")
84+ repoNs := cCtx.String("repo")
85+ fmt.Println("ZZZZ", repoNs)
86 var eventLogs []*EventLog
87 if isPubkey {
88 eventLogs, err = pr.GetEventLogsByUserID(user.ID)
89 } else if prID != 0 {
90 eventLogs, err = pr.GetEventLogsByPrID(prID)
91- } else if repoID != "" {
92- eventLogs, err = pr.GetEventLogsByRepoID(repoID)
93+ } else if repoNs != "" {
94+ repoUsername, repoName := be.SplitRepoNs(repoNs)
95+ var repoUser *User
96+ repoUser, err = pr.GetUserByName(repoUsername)
97+ if err != nil {
98+ return nil
99+ }
100+ eventLogs, err = pr.GetEventLogsByRepoName(repoUser, repoName)
101 } else {
102 eventLogs, err = pr.GetEventLogs()
103 }
104 if err != nil {
105 return err
106 }
107+
108 writer := NewTabWriter(sesh)
109 fmt.Fprintln(writer, "RepoID\tPrID\tPatchsetID\tEvent\tCreated\tData")
110 for _, eventLog := range eventLogs {
111+ repo, err := pr.GetRepoByID(eventLog.RepoID.Int64)
112+ if err != nil {
113+ be.Logger.Error("repo not found", "repo", repo, "err", err)
114+ continue
115+ }
116+ repoUser, err := pr.GetUserByID(repo.UserID)
117+ if err != nil {
118+ be.Logger.Error("repo user not found", "repo", repo, "err", err)
119+ continue
120+ }
121 fmt.Fprintf(
122 writer,
123 "%s\t%d\t%s\t%s\t%s\t%s\n",
124- eventLog.RepoID,
125+ be.CreateRepoNs(repoUser.Name, repo.Name),
126 eventLog.PatchRequestID.Int64,
127 getFormattedPatchsetID(eventLog.PatchsetID.Int64),
128 eventLog.Event,
129@@ -322,6 +308,45 @@ Here's how it works:
130 },
131 },
132 },
133+ {
134+ Name: "repo",
135+ Usage: "Manage repos",
136+ Subcommands: []*cli.Command{
137+ {
138+ Name: "create",
139+ Usage: "Create a new repo",
140+ Args: true,
141+ ArgsUsage: "[repoName]",
142+ Action: func(cCtx *cli.Context) error {
143+ user, err := pr.UpsertUser(pubkey, userName)
144+ if err != nil {
145+ return err
146+ }
147+
148+ args := cCtx.Args()
149+ if !args.Present() {
150+ return fmt.Errorf("need repo name argument")
151+ }
152+ repoName := args.First()
153+ repo, _ := pr.GetRepoByName(user, repoName)
154+ err = be.CanCreateRepo(repo, user)
155+ if err != nil {
156+ return err
157+ }
158+
159+ if repo == nil {
160+ repo, err = pr.CreateRepo(user, repoName)
161+ if err != nil {
162+ return err
163+ }
164+ }
165+
166+ wish.Printf(sesh, "repo created: %s/%s", user.Name, repo.Name)
167+ return nil
168+ },
169+ },
170+ },
171+ },
172 {
173 Name: "pr",
174 Usage: "Manage Patch Requests (PR)",
175@@ -330,7 +355,7 @@ Here's how it works:
176 Name: "ls",
177 Usage: "List all PRs",
178 Args: true,
179- ArgsUsage: "[repoID]",
180+ ArgsUsage: "[repoName]",
181 Flags: []cli.Flag{
182 &cli.BoolFlag{
183 Name: "open",
184@@ -355,16 +380,28 @@ Here's how it works:
185 },
186 Action: func(cCtx *cli.Context) error {
187 args := cCtx.Args()
188- repoID := args.First()
189- var err error
190+ rawRepoNs := args.First()
191+ userName, repoName := be.SplitRepoNs(rawRepoNs)
192 var prs []*PatchRequest
193- if repoID == "" {
194+ var err error
195+ if repoName == "" {
196 prs, err = pr.GetPatchRequests()
197+ if err != nil {
198+ return err
199+ }
200 } else {
201- prs, err = pr.GetPatchRequestsByRepoID(repoID)
202- }
203- if err != nil {
204- return err
205+ user, err := pr.GetUserByName(userName)
206+ if err != nil {
207+ return err
208+ }
209+ repo, err := pr.GetRepoByName(user, repoName)
210+ if err != nil {
211+ return err
212+ }
213+ prs, err = pr.GetPatchRequestsByRepoID(repo.ID)
214+ if err != nil {
215+ return err
216+ }
217 }
218
219 onlyOpen := cCtx.Bool("open")
220@@ -408,11 +445,23 @@ Here's how it works:
221 continue
222 }
223
224+ repo, err := pr.GetRepoByID(req.RepoID)
225+ if err != nil {
226+ be.Logger.Error("could not get repo for pr", "err", err)
227+ continue
228+ }
229+
230+ repoUser, err := pr.GetUserByID(repo.UserID)
231+ if err != nil {
232+ be.Logger.Error("could not get repo user for pr", "err", err)
233+ continue
234+ }
235+
236 fmt.Fprintf(
237 writer,
238 "%d\t%s\t%s\t[%s]\t%d\t%s\t%s\n",
239 req.ID,
240- req.RepoID,
241+ be.CreateRepoNs(repoUser.Name, repo.Name),
242 req.Name,
243 req.Status,
244 len(patchsets),
245@@ -428,7 +477,7 @@ Here's how it works:
246 Name: "create",
247 Usage: "Submit a new PR",
248 Args: true,
249- ArgsUsage: "[repoID]",
250+ ArgsUsage: "[repoName]",
251 Action: func(cCtx *cli.Context) error {
252 user, err := pr.UpsertUser(pubkey, userName)
253 if err != nil {
254@@ -436,12 +485,32 @@ Here's how it works:
255 }
256
257 args := cCtx.Args()
258- if !args.Present() {
259- return fmt.Errorf("must provide a repo ID")
260+ rawRepoNs := "bin"
261+ if args.Present() {
262+ rawRepoNs = args.First()
263+ }
264+ repoUsername, repoName := be.SplitRepoNs(rawRepoNs)
265+ repoUser := user
266+ if repoUsername != "" {
267+ repoUser, err = pr.GetUserByName(repoUsername)
268+ if err != nil {
269+ return err
270+ }
271+ }
272+ repo, _ := pr.GetRepoByName(repoUser, repoName)
273+ err = be.CanCreateRepo(repo, user)
274+ if err != nil {
275+ return err
276+ }
277+
278+ if repo == nil {
279+ repo, err = pr.CreateRepo(user, repoName)
280+ if err != nil {
281+ return err
282+ }
283 }
284
285- repoID := args.First()
286- prq, err := pr.SubmitPatchRequest(repoID, user.ID, sesh)
287+ prq, err := pr.SubmitPatchRequest(repo.ID, user.ID, sesh)
288 if err != nil {
289 return err
290 }
291@@ -491,7 +560,7 @@ Here's how it works:
292 return err
293 }
294
295- wish.Println(sesh, rangeDiff)
296+ wish.Println(sesh, RangeDiffToStr(rangeDiff))
297 return nil
298 },
299 },
300@@ -571,30 +640,30 @@ Here's how it works:
301 return err
302 }
303
304- isAdmin := be.IsAdmin(sesh.PublicKey())
305- if !isAdmin {
306- return fmt.Errorf("you are not authorized to accept a PR")
307+ prq, err := pr.GetPatchRequestByID(prID)
308+ if err != nil {
309+ return err
310 }
311
312- patchReq, err := pr.GetPatchRequestByID(prID)
313+ user, err := pr.UpsertUser(pubkey, userName)
314 if err != nil {
315 return err
316 }
317
318- if patchReq.Status == "accepted" {
319- return fmt.Errorf("PR has already been accepted")
320+ acl := be.GetPatchRequestAcl(prq, user)
321+ if !acl.CanReview {
322+ return fmt.Errorf("you are not authorized to accept a PR")
323 }
324
325- user, err := pr.UpsertUser(pubkey, userName)
326- if err != nil {
327- return err
328+ if prq.Status == "accepted" {
329+ return fmt.Errorf("PR has already been accepted")
330 }
331
332 err = pr.UpdatePatchRequestStatus(prID, user.ID, "accepted")
333 if err != nil {
334 return err
335 }
336- wish.Printf(sesh, "Accepted PR %s (#%d)\n", patchReq.Name, patchReq.ID)
337+ wish.Printf(sesh, "Accepted PR %s (#%d)\n", prq.Name, prq.ID)
338 return prSummary(be, pr, sesh, prID)
339 },
340 },
341@@ -624,10 +693,8 @@ Here's how it works:
342 return err
343 }
344
345- pk := sesh.PublicKey()
346- isContrib := pubkey == patchUser.Pubkey
347- isAdmin := be.IsAdmin(pk)
348- if !isAdmin && !isContrib {
349+ acl := be.GetPatchRequestAcl(patchReq, patchUser)
350+ if !acl.CanModify {
351 return fmt.Errorf("you are not authorized to change PR status")
352 }
353
354@@ -674,10 +741,8 @@ Here's how it works:
355 return err
356 }
357
358- pk := sesh.PublicKey()
359- isContrib := pubkey == patchUser.Pubkey
360- isAdmin := be.IsAdmin(pk)
361- if !isAdmin && !isContrib {
362+ acl := be.GetPatchRequestAcl(patchReq, patchUser)
363+ if !acl.CanModify {
364 return fmt.Errorf("you are not authorized to change PR status")
365 }
366
367@@ -722,10 +787,9 @@ Here's how it works:
368 return err
369 }
370
371- isAdmin := be.IsAdmin(sesh.PublicKey())
372- isPrOwner := be.IsPrOwner(prq.UserID, user.ID)
373- if !isAdmin && !isPrOwner {
374- return fmt.Errorf("unauthorized, you are not the owner of this PR")
375+ acl := be.GetPatchRequestAcl(prq, user)
376+ if !acl.CanModify {
377+ return fmt.Errorf("you are not authorized to change PR")
378 }
379
380 tail := cCtx.Args().Tail()
381@@ -785,13 +849,17 @@ Here's how it works:
382 return err
383 }
384
385- isAdmin := be.IsAdmin(sesh.PublicKey())
386 isReview := cCtx.Bool("review")
387 isAccept := cCtx.Bool("accept")
388 isClose := cCtx.Bool("close")
389- isPrOwner := be.IsPrOwner(prq.UserID, user.ID)
390- if !isAdmin && !isPrOwner {
391- return fmt.Errorf("unauthorized, you are not the owner of this PR")
392+
393+ acl := be.GetPatchRequestAcl(prq, user)
394+ if !acl.CanModify {
395+ return fmt.Errorf("you are not authorized to add patchsets to pr")
396+ }
397+
398+ if isReview && !acl.CanReview {
399+ return fmt.Errorf("you are not authorized to submit a review to pr")
400 }
401
402 op := OpNormal
+2,
-1
1@@ -17,5 +17,6 @@ func main() {
2 logger := slog.New(
3 slog.NewTextHandler(os.Stdout, opts),
4 )
5- git.GitSshServer(git.NewGitCfg(*fpath, logger))
6+ git.LoadConfigFile(*fpath, logger)
7+ git.GitSshServer(git.NewGitCfg(logger), nil)
8 }
+2,
-1
1@@ -17,5 +17,6 @@ func main() {
2 logger := slog.New(
3 slog.NewTextHandler(os.Stdout, opts),
4 )
5- git.StartWebServer(git.NewGitCfg(*fpath, logger))
6+ git.LoadConfigFile(*fpath, logger)
7+ git.StartWebServer(git.NewGitCfg(logger))
8 }
+36,
-142
1@@ -1,60 +1,48 @@
2 package main
3
4 import (
5- "crypto/ed25519"
6- "crypto/rand"
7 "flag"
8 "fmt"
9 "log/slog"
10 "os"
11 "os/signal"
12- "path/filepath"
13 "syscall"
14 "time"
15
16 "github.com/picosh/git-pr"
17 "github.com/picosh/git-pr/fixtures"
18- "golang.org/x/crypto/ssh"
19+ "github.com/picosh/git-pr/util"
20 )
21
22 func main() {
23 cleanupFlag := flag.Bool("cleanup", true, "Clean up tmp dir after quitting (default: true)")
24 flag.Parse()
25
26- tmp, err := os.MkdirTemp(os.TempDir(), "git-pr*")
27- if err != nil {
28- panic(err)
29+ opts := &slog.HandlerOptions{
30+ AddSource: true,
31 }
32+ logger := slog.New(
33+ slog.NewTextHandler(os.Stdout, opts),
34+ )
35+
36+ dataDir := util.CreateTmpDir()
37 defer func() {
38 if *cleanupFlag {
39- os.RemoveAll(tmp)
40+ os.RemoveAll(dataDir)
41 }
42 }()
43- fmt.Println(tmp)
44
45- adminKey, userKey := generateKeys()
46+ adminKey, userKey := util.GenerateKeys()
47+ cfgPath := util.CreateCfgFile(dataDir, cfgTmpl, adminKey)
48+ git.LoadConfigFile(cfgPath, logger)
49+ cfg := git.NewGitCfg(logger)
50
51- cfgPath := filepath.Join(tmp, "git-pr.toml")
52- cfgFi, err := os.Create(cfgPath)
53- if err != nil {
54- panic(err)
55- }
56- _, _ = cfgFi.WriteString(fmt.Sprintf(cfgTmpl, tmp, adminKey.public()))
57- cfgFi.Close()
58-
59- opts := &slog.HandlerOptions{
60- AddSource: true,
61- }
62- logger := slog.New(
63- slog.NewTextHandler(os.Stdout, opts),
64- )
65- cfg := git.NewGitCfg(cfgPath, logger)
66- go git.GitSshServer(cfg)
67- time.Sleep(time.Second)
68+ go git.GitSshServer(cfg, nil)
69+ time.Sleep(time.Millisecond * 100)
70 go git.StartWebServer(cfg)
71
72 // Hack to wait for startup
73- time.Sleep(time.Second)
74+ time.Sleep(time.Millisecond * 100)
75
76 patch, err := fixtures.Fixtures.ReadFile("single.patch")
77 if err != nil {
78@@ -66,34 +54,34 @@ func main() {
79 }
80
81 // Accepted patch
82- userKey.cmd(patch, "pr create test")
83- userKey.cmd(nil, "pr edit 1 Accepted patch")
84- adminKey.cmd(nil, "pr accept 1")
85+ userKey.MustCmd(patch, "pr create test")
86+ userKey.MustCmd(nil, "pr edit 1 Accepted patch")
87+ adminKey.MustCmd(nil, "pr accept 1")
88
89 // Closed patch (admin)
90- userKey.cmd(patch, "pr create test")
91- userKey.cmd(nil, "pr edit 2 Closed patch (admin)")
92- adminKey.cmd(nil, "pr close 2")
93+ userKey.MustCmd(patch, "pr create test")
94+ userKey.MustCmd(nil, "pr edit 2 Closed patch (admin)")
95+ adminKey.MustCmd(nil, "pr close 2")
96
97 // Closed patch (contributor)
98- userKey.cmd(patch, "pr create test")
99- userKey.cmd(nil, "pr edit 3 Closed patch (contributor)")
100- userKey.cmd(nil, "pr close 3")
101+ userKey.MustCmd(patch, "pr create test")
102+ userKey.MustCmd(nil, "pr edit 3 Closed patch (contributor)")
103+ userKey.MustCmd(nil, "pr close 3")
104
105 // Reviewed patch
106- userKey.cmd(patch, "pr create test")
107- userKey.cmd(nil, "pr edit 4 Reviewed patch")
108- adminKey.cmd(otherPatch, "pr add --review 4")
109+ userKey.MustCmd(patch, "pr create test")
110+ userKey.MustCmd(nil, "pr edit 4 Reviewed patch")
111+ adminKey.MustCmd(otherPatch, "pr add --review 4")
112
113 // Accepted patch with review
114- userKey.cmd(patch, "pr create test")
115- userKey.cmd(nil, "pr edit 5 Accepted patch with review")
116- adminKey.cmd(otherPatch, "pr add --accept 5")
117+ userKey.MustCmd(patch, "pr create test")
118+ userKey.MustCmd(nil, "pr edit 5 Accepted patch with review")
119+ adminKey.MustCmd(otherPatch, "pr add --accept 5")
120
121 // Closed patch with review
122- userKey.cmd(patch, "pr create test")
123- userKey.cmd(nil, "pr edit 6 Closed patch with review")
124- adminKey.cmd(otherPatch, "pr add --close 6")
125+ userKey.MustCmd(patch, "pr create test")
126+ userKey.MustCmd(nil, "pr edit 6 Closed patch with review")
127+ adminKey.MustCmd(otherPatch, "pr add --close 6")
128
129 fmt.Println("time to do some testing...")
130 ch := make(chan os.Signal, 1)
131@@ -101,104 +89,10 @@ func main() {
132 <-ch
133 }
134
135-type sshKey struct {
136- username string
137- signer ssh.Signer
138-}
139-
140-func (s sshKey) public() string {
141- pubkey := s.signer.PublicKey()
142- return string(ssh.MarshalAuthorizedKey(pubkey))
143-}
144-
145-func (s sshKey) cmd(patch []byte, cmd string) {
146- host := "localhost:2222"
147-
148- config := &ssh.ClientConfig{
149- User: s.username,
150- Auth: []ssh.AuthMethod{
151- ssh.PublicKeys(s.signer),
152- },
153- HostKeyCallback: ssh.InsecureIgnoreHostKey(),
154- }
155-
156- client, err := ssh.Dial("tcp", host, config)
157- if err != nil {
158- panic(err)
159- }
160- defer client.Close()
161-
162- session, err := client.NewSession()
163- if err != nil {
164- panic(err)
165- }
166- defer session.Close()
167-
168- stdinPipe, err := session.StdinPipe()
169- if err != nil {
170- panic(err)
171- }
172-
173- if err := session.Start(cmd); err != nil {
174- panic(err)
175- }
176-
177- if patch != nil {
178- _, err = stdinPipe.Write(patch)
179- if err != nil {
180- panic(err)
181- }
182- }
183-
184- stdinPipe.Close()
185-
186- if err := session.Wait(); err != nil {
187- panic(err)
188- }
189-}
190-
191-func generateKeys() (sshKey, sshKey) {
192- _, adminKey, err := ed25519.GenerateKey(rand.Reader)
193- if err != nil {
194- panic(err)
195- }
196-
197- adminSigner, err := ssh.NewSignerFromKey(adminKey)
198- if err != nil {
199- panic(err)
200- }
201-
202- _, userKey, err := ed25519.GenerateKey(rand.Reader)
203- if err != nil {
204- panic(err)
205- }
206-
207- userSigner, err := ssh.NewSignerFromKey(userKey)
208- if err != nil {
209- panic(err)
210- }
211-
212- return sshKey{
213- username: "admin",
214- signer: adminSigner,
215- }, sshKey{
216- username: "contributor",
217- signer: userSigner,
218- }
219-}
220-
221 // args: tmpdir, adminKey
222-var cfgTmpl = `# url is used for help commands, exclude protocol
223+var cfgTmpl = `
224 url = "localhost"
225-# where we store the sqlite db, this toml file, git repos, and ssh host keys
226 data_dir = %q
227-# this gives users the ability to submit reviews and other admin permissions
228 admins = [%q]
229-# set datetime format for our clients
230 time_format = "01/02/2006 15:04:05 07:00"
231-
232-# add as many repos as you want
233-[[repo]]
234-id = "test"
235-clone_addr = "https://github.com/picosh/test.git"
236-desc = "Test repo"`
237+create_repo = "user"`
+161,
-0
1@@ -0,0 +1,161 @@
2+package git
3+
4+import (
5+ "log/slog"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ "github.com/gkampitakis/go-snaps/snaps"
11+ "github.com/picosh/git-pr/fixtures"
12+ "github.com/picosh/git-pr/util"
13+)
14+
15+func TestE2E(t *testing.T) {
16+ testSingleTenantE2E(t)
17+ testMultiTenantE2E(t)
18+}
19+
20+func testSingleTenantE2E(t *testing.T) {
21+ t.Log("single tenant end-to-end tests")
22+ dataDir := util.CreateTmpDir()
23+ defer func() {
24+ os.RemoveAll(dataDir)
25+ }()
26+ suite := setupTest(dataDir, cfgSingleTenantTmpl)
27+ done := make(chan error)
28+ go GitSshServer(suite.cfg, done)
29+ // Hack to wait for startup
30+ time.Sleep(time.Millisecond * 100)
31+ _, err := suite.userKey.Cmd(suite.patch, "pr create test")
32+ if err == nil {
33+ t.Error("user should not be able to create a PR")
34+ }
35+ suite.adminKey.MustCmd(suite.patch, "pr create test")
36+
37+ // Snapshot test ls command
38+ actual, err := suite.userKey.Cmd(nil, "pr ls")
39+ bail(err)
40+ snaps.MatchSnapshot(t, actual)
41+
42+ done <- nil
43+}
44+
45+func testMultiTenantE2E(t *testing.T) {
46+ t.Log("multi tenant end-to-end tests")
47+ dataDir := util.CreateTmpDir()
48+ defer func() {
49+ os.RemoveAll(dataDir)
50+ }()
51+ suite := setupTest(dataDir, cfgMultiTenantTmpl)
52+ done := make(chan error)
53+ go GitSshServer(suite.cfg, done)
54+
55+ time.Sleep(time.Millisecond * 100)
56+
57+ // Accepted pr
58+ suite.userKey.MustCmd(suite.patch, "pr create test")
59+ suite.userKey.MustCmd(nil, "pr edit 1 Accepted patch")
60+ _, err := suite.userKey.Cmd(nil, "pr accept 1")
61+ if err == nil {
62+ t.Error("contrib should not be able to accept their own PR")
63+ }
64+ suite.adminKey.MustCmd(nil, "pr accept 1")
65+
66+ // Closed pr (admin)
67+ suite.userKey.MustCmd(suite.patch, "pr create test")
68+ suite.userKey.MustCmd(nil, "pr edit 2 Closed patch (admin)")
69+ suite.adminKey.MustCmd(nil, "pr close 2")
70+
71+ // Closed pr (contributor)
72+ suite.userKey.MustCmd(suite.patch, "pr create test")
73+ suite.userKey.MustCmd(nil, "pr edit 3 Closed patch (contributor)")
74+ suite.userKey.MustCmd(nil, "pr close 3")
75+
76+ // Reviewed pr
77+ suite.userKey.MustCmd(suite.patch, "pr create test")
78+ suite.userKey.MustCmd(nil, "pr edit 4 Reviewed patch")
79+ suite.adminKey.MustCmd(suite.otherPatch, "pr add --review 4")
80+
81+ // Accepted pr with review
82+ suite.userKey.MustCmd(suite.patch, "pr create test")
83+ suite.userKey.MustCmd(nil, "pr edit 5 Accepted patch with review")
84+ suite.adminKey.MustCmd(suite.otherPatch, "pr add --accept 5")
85+
86+ // Closed pr with review
87+ suite.userKey.MustCmd(suite.patch, "pr create test")
88+ suite.userKey.MustCmd(nil, "pr edit 6 Closed patch with review")
89+ suite.adminKey.MustCmd(suite.otherPatch, "pr add --close 6")
90+
91+ // Create pr with user namespace
92+ suite.adminKey.MustCmd(nil, "repo create ai")
93+ suite.userKey.MustCmd(suite.patch, "pr create admin/ai")
94+ suite.adminKey.MustCmd(suite.otherPatch, "pr add --accept 7")
95+
96+ // Create pr with default `bin` repo
97+ actual, err := suite.userKey.Cmd(suite.patch, "pr create")
98+ bail(err)
99+ snaps.MatchSnapshot(t, actual)
100+
101+ // Snapshot test ls command
102+ actual, err = suite.userKey.Cmd(nil, "pr ls")
103+ bail(err)
104+ snaps.MatchSnapshot(t, actual)
105+
106+ // Snapshot test logs command
107+ actual, err = suite.userKey.Cmd(nil, "logs --repo admin/ai")
108+ bail(err)
109+ snaps.MatchSnapshot(t, actual)
110+
111+ done <- nil
112+}
113+
114+type TestSuite struct {
115+ cfg *GitCfg
116+ userKey util.UserSSH
117+ adminKey util.UserSSH
118+ patch []byte
119+ otherPatch []byte
120+}
121+
122+func setupTest(dataDir string, cfgTmpl string) TestSuite {
123+ opts := &slog.HandlerOptions{
124+ AddSource: true,
125+ }
126+ logger := slog.New(
127+ slog.NewTextHandler(os.Stdout, opts),
128+ )
129+
130+ adminKey, userKey := util.GenerateKeys()
131+ cfgPath := util.CreateCfgFile(dataDir, cfgTmpl, adminKey)
132+ LoadConfigFile(cfgPath, logger)
133+ cfg := NewGitCfg(logger)
134+
135+ // so outputs dont show dates
136+ cfg.TimeFormat = ""
137+
138+ patch, err := fixtures.Fixtures.ReadFile("single.patch")
139+ if err != nil {
140+ panic(err)
141+ }
142+ otherPatch, err := fixtures.Fixtures.ReadFile("with-cover.patch")
143+ if err != nil {
144+ panic(err)
145+ }
146+
147+ return TestSuite{cfg, userKey, adminKey, patch, otherPatch}
148+}
149+
150+var cfgSingleTenantTmpl = `
151+url = "localhost"
152+data_dir = %q
153+admins = [%q]
154+time_format = "01/02/2006 15:04:05 07:00"
155+create_repo = "admin"`
156+
157+var cfgMultiTenantTmpl = `
158+url = "localhost"
159+data_dir = %q
160+admins = [%q]
161+time_format = "01/02/2006 15:04:05 07:00"
162+create_repo = "user"`
+5,
-8
1@@ -1,16 +1,13 @@
2 # url is used for help commands, exclude protocol
3 url = "localhost"
4-# where we store the sqlite db, this toml file, git repos, and ssh host keys
5+# where we store the sqlite db, this toml file, and ssh host keys
6 data_dir = "./data"
7 # list of admin ssh pubkeys, authorized to submit review and other admin
8 # permissions
9 admins = []
10 # set datetime format for our clients
11 time_format = "2006-01-02"
12-
13-# add as many repos as you want
14-[[repo]]
15-id = "test"
16-default_branch = "main"
17-clone_addr = "https://github.com/picosh/test.git"
18-desc = "Test repo"
19+# who can create new repos?
20+# admin: only admins
21+# user: admins and users
22+create_repo = "user"
M
go.mod
+12,
-1
1@@ -5,9 +5,9 @@ go 1.22
2 require (
3 github.com/alecthomas/chroma/v2 v2.13.0
4 github.com/bluekeyes/go-gitdiff v0.8.0
5- github.com/charmbracelet/soft-serve v0.7.4
6 github.com/charmbracelet/ssh v0.0.0-20240301204039-e79ff702f5b3
7 github.com/charmbracelet/wish v1.3.2
8+ github.com/gkampitakis/go-snaps v0.5.7
9 github.com/gorilla/feeds v1.1.2
10 github.com/jmoiron/sqlx v1.3.5
11 github.com/knadh/koanf/parsers/toml v0.1.0
12@@ -36,12 +36,18 @@ require (
13 github.com/dlclark/regexp2 v1.11.0 // indirect
14 github.com/dustin/go-humanize v1.0.1 // indirect
15 github.com/fsnotify/fsnotify v1.7.0 // indirect
16+ github.com/gkampitakis/ciinfo v0.3.0 // indirect
17+ github.com/gkampitakis/go-diff v1.3.2 // indirect
18 github.com/go-logfmt/logfmt v0.6.0 // indirect
19 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
20 github.com/google/uuid v1.4.0 // indirect
21 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
22 github.com/knadh/koanf/maps v0.1.1 // indirect
23+ github.com/kr/pretty v0.3.1 // indirect
24+ github.com/kr/text v0.2.0 // indirect
25+ github.com/lib/pq v1.10.9 // indirect
26 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
27+ github.com/maruel/natural v1.1.1 // indirect
28 github.com/mattn/go-isatty v0.0.20 // indirect
29 github.com/mattn/go-localereader v0.0.1 // indirect
30 github.com/mattn/go-runewidth v0.0.15 // indirect
31@@ -55,7 +61,12 @@ require (
32 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
33 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
34 github.com/rivo/uniseg v0.4.7 // indirect
35+ github.com/rogpeppe/go-internal v1.12.0 // indirect
36 github.com/russross/blackfriday/v2 v2.1.0 // indirect
37+ github.com/tidwall/gjson v1.17.0 // indirect
38+ github.com/tidwall/match v1.1.1 // indirect
39+ github.com/tidwall/pretty v1.2.1 // indirect
40+ github.com/tidwall/sjson v1.2.5 // indirect
41 github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
42 golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
43 golang.org/x/mod v0.14.0 // indirect
M
go.sum
+23,
-6
1@@ -8,8 +8,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
2 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
3 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5-github.com/bluekeyes/go-gitdiff v0.7.4-0.20240715034416-0a4e55f9a190 h1:k6Ep4yQtmsoP/St4bf7ofXyWc6ITB/FyGy9ewaAn5os=
6-github.com/bluekeyes/go-gitdiff v0.7.4-0.20240715034416-0a4e55f9a190/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
7 github.com/bluekeyes/go-gitdiff v0.8.0 h1:Nn1wfw3/XeKoc3lWk+2bEXGUHIx36kj80FM1gVcBk+o=
8 github.com/bluekeyes/go-gitdiff v0.8.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
9 github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
10@@ -20,8 +18,6 @@ github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMt
11 github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
12 github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw=
13 github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g=
14-github.com/charmbracelet/soft-serve v0.7.4 h1:SghM4dzDztYdAN0NtdP5pl5g7Nxld9itizbvJuIS21Q=
15-github.com/charmbracelet/soft-serve v0.7.4/go.mod h1:oiAyFgP6LtR3AW3jmt6+6HQoKCgHhYY+DfsxBJKqo0c=
16 github.com/charmbracelet/ssh v0.0.0-20240301204039-e79ff702f5b3 h1:BI6Vno579jK/NKUwrvboHtMfF2On6kh6mU1cguf5+vQ=
17 github.com/charmbracelet/ssh v0.0.0-20240301204039-e79ff702f5b3/go.mod h1:wUZ0VTrLI5ixIbYOSRHrqrZnfj8EXgLZOOvQYAQ2f18=
18 github.com/charmbracelet/wish v1.3.2 h1:9+32OZnfebIw59Mcx0Yhsj6uke727bJVGJb6WolxsxQ=
19@@ -34,6 +30,7 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2
20 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
21 github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
22 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
23+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
24 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
25 github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
26 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
27@@ -45,6 +42,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
28 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
29 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
30 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
31+github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8=
32+github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
33+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
34+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
35+github.com/gkampitakis/go-snaps v0.5.7 h1:uVGjHR4t4pPHU944udMx7VKHpwepZXmvDMF+yDmI0rg=
36+github.com/gkampitakis/go-snaps v0.5.7/go.mod h1:ZABkO14uCuVxBHAXAfKG+bqNz+aa1bGPAg8jkI0Nk8Y=
37 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
38 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
39 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
40@@ -87,6 +90,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
41 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
42 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
43 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
44+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
45+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
46 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
47 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
48 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
49@@ -113,6 +118,7 @@ github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de h1:kuqx+Z
50 github.com/oddg/hungarian-algorithm v0.0.0-20170809162819-9567cbc363de/go.mod h1:dv3Q0yoeN8DwXGhZiv8Vi6/rr9mPtf4ylV60eLTGjUo=
51 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
52 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
53+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
54 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
55 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
56 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
57@@ -122,8 +128,9 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
58 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
59 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
60 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
61-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
62-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
63+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
64+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
65+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
66 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
67 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
68 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
69@@ -132,6 +139,16 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
70 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
71 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
72 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
73+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
74+github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
75+github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
76+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
77+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
78+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
79+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
80+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
81+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
82+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
83 github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
84 github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
85 github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
+11,
-2
1@@ -26,11 +26,20 @@ type Acl struct {
2 CreatedAt time.Time `db:"created_at"`
3 }
4
5+// Repo is a container for patch requests.
6+type Repo struct {
7+ ID int64 `db:"id"`
8+ Name string `db:"name"`
9+ UserID int64 `db:"user_id"`
10+ CreatedAt time.Time `db:"created_at"`
11+ UpdatedAt time.Time `db:"updated_at"`
12+}
13+
14 // PatchRequest is a database model for patches submitted to a Repo.
15 type PatchRequest struct {
16 ID int64 `db:"id"`
17 UserID int64 `db:"user_id"`
18- RepoID string `db:"repo_id"`
19+ RepoID int64 `db:"repo_id"`
20 Name string `db:"name"`
21 Text string `db:"text"`
22 Status string `db:"status"`
23@@ -76,7 +85,7 @@ func (p *Patch) CalcDiff() string {
24 type EventLog struct {
25 ID int64 `db:"id"`
26 UserID int64 `db:"user_id"`
27- RepoID string `db:"repo_id"`
28+ RepoID sql.NullInt64 `db:"repo_id"`
29 PatchRequestID sql.NullInt64 `db:"patch_request_id"`
30 PatchsetID sql.NullInt64 `db:"patchset_id"`
31 Event string `db:"event"`
M
pr.go
+97,
-104
1@@ -5,6 +5,7 @@ import (
2 "errors"
3 "fmt"
4 "io"
5+ "strings"
6 "time"
7
8 "github.com/jmoiron/sqlx"
9@@ -26,16 +27,17 @@ type GitPatchRequest interface {
10 GetUserByID(userID int64) (*User, error)
11 GetUserByName(name string) (*User, error)
12 GetUserByPubkey(pubkey string) (*User, error)
13+ GetRepos() ([]*Repo, error)
14+ GetRepoByID(repoID int64) (*Repo, error)
15+ GetRepoByName(user *User, repoName string) (*Repo, error)
16+ CreateRepo(user *User, repoName string) (*Repo, error)
17 UpsertUser(pubkey, name string) (*User, error)
18 IsBanned(pubkey, ipAddress string) error
19- GetRepos() ([]*Repo, error)
20- GetReposWithLatestPr() ([]RepoWithLatestPr, error)
21- GetRepoByID(repoID string) (*Repo, error)
22- SubmitPatchRequest(repoID string, userID int64, patchset io.Reader) (*PatchRequest, error)
23+ SubmitPatchRequest(repoID int64, userID int64, patchset io.Reader) (*PatchRequest, error)
24 SubmitPatchset(prID, userID int64, op PatchsetOp, patchset io.Reader) ([]*Patch, error)
25 GetPatchRequestByID(prID int64) (*PatchRequest, error)
26 GetPatchRequests() ([]*PatchRequest, error)
27- GetPatchRequestsByRepoID(repoID string) ([]*PatchRequest, error)
28+ GetPatchRequestsByRepoID(repoID int64) ([]*PatchRequest, error)
29 GetPatchsetsByPrID(prID int64) ([]*Patchset, error)
30 GetPatchsetByID(patchsetID int64) (*Patchset, error)
31 GetLatestPatchsetByPrID(prID int64) (*Patchset, error)
32@@ -45,7 +47,7 @@ type GitPatchRequest interface {
33 DeletePatchsetByID(userID, prID int64, patchsetID int64) error
34 CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error
35 GetEventLogs() ([]*EventLog, error)
36- GetEventLogsByRepoID(repoID string) ([]*EventLog, error)
37+ GetEventLogsByRepoName(user *User, repoName string) ([]*EventLog, error)
38 GetEventLogsByPrID(prID int64) ([]*EventLog, error)
39 GetEventLogsByUserID(userID int64) ([]*EventLog, error)
40 DiffPatchsets(aset *Patchset, bset *Patchset) ([]*RangeDiffOutput, error)
41@@ -108,6 +110,52 @@ func (pr PrCmd) computeUserName(name string) (string, error) {
42 return fmt.Sprintf("%s%s", name, randSeq(4)), nil
43 }
44
45+func (pr PrCmd) CreateRepo(user *User, repoName string) (*Repo, error) {
46+ var repoID int64
47+ row := pr.Backend.DB.QueryRow(
48+ "INSERT INTO repos (user_id, name) VALUES (?, ?) RETURNING id",
49+ user.ID,
50+ repoName,
51+ )
52+ err := row.Scan(&repoID)
53+ if err != nil {
54+ return nil, err
55+ }
56+
57+ return pr.GetRepoByID(repoID)
58+}
59+
60+func (pr PrCmd) GetRepoByID(repoID int64) (*Repo, error) {
61+ var repo Repo
62+ err := pr.Backend.DB.Get(&repo, "SELECT * FROM repos WHERE id=?", repoID)
63+ return &repo, err
64+}
65+
66+func (pr PrCmd) GetRepos() (repos []*Repo, err error) {
67+ err = pr.Backend.DB.Select(
68+ &repos,
69+ "SELECT * from repos",
70+ )
71+ if err != nil {
72+ return repos, err
73+ }
74+ if len(repos) == 0 {
75+ return repos, fmt.Errorf("no repos found")
76+ }
77+ return repos, nil
78+}
79+
80+func (pr PrCmd) GetRepoByName(user *User, repoName string) (*Repo, error) {
81+ var repo Repo
82+ fmt.Println(user.ID, repoName)
83+ err := pr.Backend.DB.Get(&repo, "SELECT * FROM repos WHERE user_id=? AND name=?", user.ID, repoName)
84+ if err != nil {
85+ return nil, fmt.Errorf("repo not found: %s/%s", user.Name, repoName)
86+ }
87+
88+ return &repo, nil
89+}
90+
91 func (pr PrCmd) createUser(pubkey, name string) (*User, error) {
92 if pubkey == "" {
93 return nil, fmt.Errorf("must provide pubkey when creating user")
94@@ -140,96 +188,17 @@ func (pr PrCmd) createUser(pubkey, name string) (*User, error) {
95 }
96
97 func (pr PrCmd) UpsertUser(pubkey, name string) (*User, error) {
98+ sanName := strings.ToLower(name)
99 if pubkey == "" {
100 return nil, fmt.Errorf("must provide pubkey during upsert")
101 }
102 user, err := pr.GetUserByPubkey(pubkey)
103 if err != nil {
104- user, err = pr.createUser(pubkey, name)
105+ user, err = pr.createUser(pubkey, sanName)
106 }
107 return user, err
108 }
109
110-type PrWithRepo struct {
111- LastUpdatedPrID int64
112- RepoID string
113-}
114-
115-type RepoWithLatestPr struct {
116- *Repo
117- User *User
118- PatchRequest *PatchRequest
119-}
120-
121-func (pr PrCmd) GetRepos() ([]*Repo, error) {
122- return pr.Backend.Cfg.Repos, nil
123-}
124-
125-func (pr PrCmd) GetReposWithLatestPr() ([]RepoWithLatestPr, error) {
126- repos := []RepoWithLatestPr{}
127- prs := []PatchRequest{}
128- err := pr.Backend.DB.Select(&prs, "SELECT *, max(updated_at) as last_updated FROM patch_requests GROUP BY repo_id")
129- if err != nil {
130- return repos, err
131- }
132-
133- users, err := pr.GetUsers()
134- if err != nil {
135- return repos, err
136- }
137-
138- // we want recently modified repos to be on top
139- for _, prq := range prs {
140- for _, repo := range pr.Backend.Cfg.Repos {
141- if prq.RepoID == repo.ID {
142- var user *User
143- for _, usr := range users {
144- if prq.UserID == usr.ID {
145- user = usr
146- break
147- }
148- }
149- repos = append(repos, RepoWithLatestPr{
150- User: user,
151- Repo: repo,
152- PatchRequest: &prq,
153- })
154- }
155- }
156- }
157-
158- for _, repo := range pr.Backend.Cfg.Repos {
159- found := false
160- for _, curRepo := range repos {
161- if curRepo.ID == repo.ID {
162- found = true
163- }
164- }
165- if !found {
166- repos = append(repos, RepoWithLatestPr{
167- Repo: repo,
168- })
169- }
170- }
171-
172- return repos, nil
173-}
174-
175-func (pr PrCmd) GetRepoByID(repoID string) (*Repo, error) {
176- repos, err := pr.GetRepos()
177- if err != nil {
178- return nil, err
179- }
180-
181- for _, repo := range repos {
182- if repo.ID == repoID {
183- return repo, nil
184- }
185- }
186-
187- return nil, fmt.Errorf("repo not found: %s", repoID)
188-}
189-
190 func (pr PrCmd) GetPatchsetsByPrID(prID int64) ([]*Patchset, error) {
191 patchsets := []*Patchset{}
192 err := pr.Backend.DB.Select(
193@@ -281,16 +250,16 @@ func (cmd PrCmd) GetPatchRequests() ([]*PatchRequest, error) {
194 prs := []*PatchRequest{}
195 err := cmd.Backend.DB.Select(
196 &prs,
197- "SELECT * FROM patch_requests ORDER BY created_at DESC",
198+ "SELECT * FROM patch_requests ORDER BY id DESC",
199 )
200 return prs, err
201 }
202
203-func (cmd PrCmd) GetPatchRequestsByRepoID(repoID string) ([]*PatchRequest, error) {
204+func (cmd PrCmd) GetPatchRequestsByRepoID(repoID int64) ([]*PatchRequest, error) {
205 prs := []*PatchRequest{}
206 err := cmd.Backend.DB.Select(
207 &prs,
208- "SELECT * FROM patch_requests WHERE repo_id=? ORDER BY created_at DESC",
209+ "SELECT * FROM patch_requests WHERE repo_id=? ORDER BY id DESC",
210 repoID,
211 )
212 return prs, err
213@@ -326,8 +295,14 @@ func (cmd PrCmd) UpdatePatchRequestStatus(prID int64, userID int64, status strin
214 return err
215 }
216
217+ pr, err := cmd.GetPatchRequestByID(prID)
218+ if err != nil {
219+ return err
220+ }
221+
222 err = cmd.CreateEventLog(tx, EventLog{
223 UserID: userID,
224+ RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
225 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
226 Event: "pr_status_changed",
227 Data: fmt.Sprintf(`{"status":"%s"}`, status),
228@@ -362,8 +337,14 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
229 return err
230 }
231
232+ pr, err := cmd.GetPatchRequestByID(prID)
233+ if err != nil {
234+ return err
235+ }
236+
237 err = cmd.CreateEventLog(tx, EventLog{
238 UserID: userID,
239+ RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
240 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
241 Event: "pr_name_changed",
242 Data: fmt.Sprintf(`{"name":"%s"}`, name),
243@@ -376,7 +357,7 @@ func (cmd PrCmd) UpdatePatchRequestName(prID int64, userID int64, name string) e
244 }
245
246 func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error {
247- if eventLog.RepoID == "" && eventLog.PatchRequestID.Valid {
248+ if eventLog.RepoID.Valid && eventLog.PatchRequestID.Valid {
249 var pr PatchRequest
250 err := tx.Get(
251 &pr,
252@@ -390,7 +371,7 @@ func (cmd PrCmd) CreateEventLog(tx *sqlx.Tx, eventLog EventLog) error {
253 )
254 return nil
255 }
256- eventLog.RepoID = pr.RepoID
257+ eventLog.RepoID = sql.NullInt64{Int64: pr.RepoID, Valid: true}
258 }
259
260 _, err := tx.Exec(
261@@ -444,7 +425,7 @@ func (cmd PrCmd) createPatch(tx *sqlx.Tx, patch *Patch) (int64, error) {
262 return patchID, err
263 }
264
265-func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Reader) (*PatchRequest, error) {
266+func (cmd PrCmd) SubmitPatchRequest(repoID int64, userID int64, patchset io.Reader) (*PatchRequest, error) {
267 tx, err := cmd.Backend.DB.Beginx()
268 if err != nil {
269 return nil, err
270@@ -463,11 +444,6 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
271 return nil, fmt.Errorf("after parsing patchset we did't find any patches, did you send us an empty patchset?")
272 }
273
274- _, err = cmd.GetRepoByID(repoID)
275- if err != nil {
276- return nil, fmt.Errorf("repo does not exist")
277- }
278-
279 prName := ""
280 prText := ""
281 if len(patches) > 0 {
282@@ -518,7 +494,7 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, userID int64, patchset io.Rea
283
284 err = cmd.CreateEventLog(tx, EventLog{
285 UserID: userID,
286- RepoID: repoID,
287+ RepoID: sql.NullInt64{Int64: repoID, Valid: true},
288 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
289 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
290 Event: "pr_created",
291@@ -589,8 +565,14 @@ func (cmd PrCmd) SubmitPatchset(prID int64, userID int64, op PatchsetOp, patchse
292 event = "pr_reviewed"
293 }
294
295+ pr, err := cmd.GetPatchRequestByID(prID)
296+ if err != nil {
297+ return fin, err
298+ }
299+
300 err = cmd.CreateEventLog(tx, EventLog{
301 UserID: userID,
302+ RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
303 PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
304 PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
305 Event: event,
306@@ -625,10 +607,16 @@ func (cmd PrCmd) DeletePatchsetByID(userID int64, prID int64, patchsetID int64)
307 return err
308 }
309
310+ pr, err := cmd.GetPatchRequestByID(prID)
311+ if err != nil {
312+ return err
313+ }
314+
315 err = cmd.CreateEventLog(tx, EventLog{
316 UserID: userID,
317- PatchRequestID: sql.NullInt64{Int64: prID},
318- PatchsetID: sql.NullInt64{Int64: patchsetID},
319+ RepoID: sql.NullInt64{Int64: pr.RepoID, Valid: true},
320+ PatchRequestID: sql.NullInt64{Int64: prID, Valid: true},
321+ PatchsetID: sql.NullInt64{Int64: patchsetID, Valid: true},
322 Event: "pr_patchset_deleted",
323 })
324 if err != nil {
325@@ -647,12 +635,17 @@ func (cmd PrCmd) GetEventLogs() ([]*EventLog, error) {
326 return eventLogs, err
327 }
328
329-func (cmd PrCmd) GetEventLogsByRepoID(repoID string) ([]*EventLog, error) {
330+func (cmd PrCmd) GetEventLogsByRepoName(user *User, repoName string) ([]*EventLog, error) {
331+ repo, err := cmd.GetRepoByName(user, repoName)
332+ if err != nil {
333+ return nil, err
334+ }
335+
336 eventLogs := []*EventLog{}
337- err := cmd.Backend.DB.Select(
338+ err = cmd.Backend.DB.Select(
339 &eventLogs,
340 "SELECT * FROM event_logs WHERE repo_id=? ORDER BY created_at DESC",
341- repoID,
342+ repo.ID,
343 )
344 return eventLogs, err
345 }
+85,
-0
1@@ -16,6 +16,18 @@ CREATE TABLE IF NOT EXISTS app_users (
2 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
3 );
4
5+CREATE TABLE IF NOT EXISTS repos (
6+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7+ user_id INTEGER NOT NULL,
8+ name TEXT NOT NULL UNIQUE,
9+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
11+ CONSTRAINT repo_user_id_fk
12+ FOREIGN KEY(user_id) REFERENCES app_users(id)
13+ ON DELETE CASCADE
14+ ON UPDATE CASCADE
15+);
16+
17 CREATE TABLE IF NOT EXISTS acl (
18 id INTEGER PRIMARY KEY AUTOINCREMENT,
19 pubkey string,
20@@ -109,6 +121,79 @@ var sqliteMigrations = []string{
21 "ALTER TABLE patches ADD COLUMN base_commit_sha TEXT",
22 // added this by accident
23 "",
24+ // create repos table
25+ `CREATE TABLE IF NOT EXISTS repos (
26+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27+ user_id INTEGER NOT NULL,
28+ name TEXT NOT NULL,
29+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
30+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
31+ UNIQUE (user_id, name),
32+ CONSTRAINT repo_user_id_fk
33+ FOREIGN KEY(user_id) REFERENCES app_users(id)
34+ ON DELETE CASCADE
35+ ON UPDATE CASCADE
36+ );`,
37+ // migrate existing repo info from patch_requests
38+ `INSERT INTO repos (user_id, name) SELECT user_id, repo_id from patch_requests group by repo_id;`,
39+ // convert patch_requests.repo_id to integer with FK constraint
40+ `CREATE TABLE IF NOT EXISTS tmp_patch_requests (
41+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42+ user_id INTEGER NOT NULL,
43+ repo_id INTEGER NOT NULL,
44+ name TEXT NOT NULL,
45+ text TEXT NOT NULL,
46+ status TEXT NOT NULL,
47+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
48+ updated_at DATETIME NOT NULL,
49+ CONSTRAINT pr_user_id_fk
50+ FOREIGN KEY(user_id) REFERENCES app_users(id)
51+ ON DELETE CASCADE
52+ ON UPDATE CASCADE,
53+ CONSTRAINT pr_repo_id_fk
54+ FOREIGN KEY(repo_id) REFERENCES repos(id)
55+ ON DELETE CASCADE
56+ ON UPDATE CASCADE
57+ );
58+ INSERT INTO tmp_patch_requests (user_id, repo_id, name, text, status, created_at, updated_at)
59+ SELECT pr.user_id, repos.id, pr.name, pr.text, pr.status, pr.created_at, pr.updated_at
60+ FROM patch_requests AS pr
61+ INNER JOIN repos ON repos.name = pr.repo_id;
62+ DROP TABLE patch_requests;
63+ ALTER TABLE tmp_patch_requests RENAME TO patch_requests;`,
64+ // convert event_logs.repo_id to integer with FK constraint
65+ `CREATE TABLE IF NOT EXISTS tmp_event_logs (
66+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67+ user_id INTEGER NOT NULL,
68+ repo_id INTEGER,
69+ patch_request_id INTEGER,
70+ patchset_id INTEGER,
71+ event TEXT NOT NULL,
72+ data TEXT,
73+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
74+ CONSTRAINT event_logs_pr_id_fk
75+ FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
76+ ON DELETE CASCADE
77+ ON UPDATE CASCADE,
78+ CONSTRAINT event_logs_patchset_id_fk
79+ FOREIGN KEY(patchset_id) REFERENCES patchsets(id)
80+ ON DELETE CASCADE
81+ ON UPDATE CASCADE,
82+ CONSTRAINT event_logs_user_id_fk
83+ FOREIGN KEY(user_id) REFERENCES app_users(id)
84+ ON DELETE CASCADE
85+ ON UPDATE CASCADE
86+ CONSTRAINT event_logs_repo_id_fk
87+ FOREIGN KEY(repo_id) REFERENCES repos(id)
88+ ON DELETE CASCADE
89+ ON UPDATE CASCADE
90+ );
91+ INSERT INTO tmp_event_logs (user_id, repo_id, patch_request_id, patchset_id, event, data, created_at)
92+ SELECT ev.user_id, repos.id, ev.patch_request_id, ev.patchset_id, ev.event, ev.data, ev.created_at
93+ FROM event_logs AS ev
94+ LEFT JOIN repos ON repos.name = ev.repo_id;
95+ DROP TABLE event_logs;
96+ ALTER TABLE tmp_event_logs RENAME TO event_logs;`,
97 }
98
99 // Open opens a database connection.
M
ssh.go
+7,
-4
1@@ -31,7 +31,7 @@ func authHandler(pr *PrCmd) func(ctx ssh.Context, key ssh.PublicKey) bool {
2 }
3 }
4
5-func GitSshServer(cfg *GitCfg) {
6+func GitSshServer(cfg *GitCfg, killCh chan error) {
7 dbpath := filepath.Join(cfg.DataDir, "pr.db")
8 dbh, err := SqliteOpen(dbpath, cfg.Logger)
9 if err != nil {
10@@ -71,16 +71,19 @@ func GitSshServer(cfg *GitCfg) {
11 go func() {
12 if err = s.ListenAndServe(); err != nil {
13 cfg.Logger.Error("serve error", "err", err)
14- os.Exit(1)
15+ // os.Exit(1)
16 }
17 }()
18
19- <-done
20+ select {
21+ case <-done:
22+ case <-killCh:
23+ }
24 cfg.Logger.Info("stopping SSH server")
25 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26 defer func() { cancel() }()
27 if err := s.Shutdown(ctx); err != nil {
28 cfg.Logger.Error("shutdown", "err", err)
29- os.Exit(1)
30+ // os.Exit(1)
31 }
32 }
+1,
-1
1@@ -1,6 +1,6 @@
2 {{template "base" .}}
3
4-{{define "title"}}repos{{end}}
5+{{define "title"}}git-pr{{end}}
6
7 {{define "meta"}}
8 <link rel="alternate" type="application/atom+xml"
+2,
-4
1@@ -4,8 +4,8 @@
2 <tr>
3 <th class="text-left">Repo</th>
4 <th class="text-left">Status</th>
5- <th class="text-left">Title</th>
6 <th class="text-left">User</th>
7+ <th class="text-left">Title</th>
8 <th class="text-left">Created At</th>
9 </tr>
10 </thead>
11@@ -17,13 +17,11 @@
12 <a href="{{.RepoLink.Url}}">{{.RepoLink.Text}}</a>
13 </td>
14 <td>{{template "pr-status" .Status}}</td>
15+ <td>{{template "user-pill" .UserData}}</td>
16 <td>
17 <code>#{{.ID}}</code>
18 <a href="{{.PrLink.Url}}">{{.PrLink.Text}}</a>
19 </td>
20- <td>
21- {{template "user-pill" .UserData}}
22- </td>
23 <td><date>{{.Date}}</date></td>
24 </tr>
25 {{end}}
+6,
-14
1@@ -1,32 +1,24 @@
2 {{template "base" .}}
3
4-{{define "title"}}{{.ID}} - repo{{end}}
5+{{define "title"}}{{.Name}} - repo{{end}}
6
7 {{define "meta"}}
8 <link rel="alternate" type="application/atom+xml"
9 title="RSS feed for git collaboration server"
10- href="/repos/{{.ID}}/rss" />
11+ href="/r/{{.Username}}/{{.Name}}/rss" />
12 {{end}}
13
14 {{define "body"}}
15 <header>
16- <h1 class="text-2xl mb"><a href="/">repos</a> / {{.ID}}</h1>
17+ <h1 class="text-2xl mb"><a href="/">repos</a> / <a href="/r/{{.Username}}">{{.Username}}</a> / {{.Name}}</h1>
18 <div class="group">
19- <div>
20- {{.Desc}}
21- </div>
22-
23- <div>
24- <code>git clone {{.CloneAddr}}</code>
25- </div>
26-
27 <details>
28 <summary>Help</summary>
29 <div class="group">
30 <pre class="m-0"># submit a new patch request
31-git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr create {{.ID}}</pre>
32+git format-patch {{.Branch}} --stdout | ssh {{.MetaData.URL}} pr create {{.Username}}/{{.Name}}</pre>
33 <pre class="m-0"># list prs for repo
34-ssh {{.MetaData.URL}} pr ls {{.ID}}</pre>
35+ssh {{.MetaData.URL}} pr ls {{.Username}}/{{.Name}}</pre>
36 </div>
37 </details>
38 </div>
39@@ -37,6 +29,6 @@ ssh {{.MetaData.URL}} pr ls {{.ID}}</pre>
40 </main>
41
42 <footer class="mt">
43- <a href="/repos/{{.ID}}/rss">rss</a>
44+ <a href="/r/{{.Username}}/{{.Name}}/rss">rss</a>
45 </footer>
46 {{end}}
+2,
-2
1@@ -5,7 +5,7 @@
2 {{define "meta"}}
3 <link rel="alternate" type="application/atom+xml"
4 title="RSS feed for git collaboration server"
5- href="/users/{{.UserData.Name}}/rss" />
6+ href="/rss/{{.UserData.Name}}" />
7 {{end}}
8
9 {{define "body"}}
10@@ -28,6 +28,6 @@
11 </main>
12
13 <footer class="mt">
14- <a href="/users/{{.UserData.Name}}/rss">rss</a>
15+ <a href="/rss/{{.UserData.Name}}">rss</a>
16 </footer>
17 {{end}}
+1,
-1
1@@ -1,5 +1,5 @@
2 {{define "user-pill"}}
3-<a href="/users/{{.Name}}">
4+<a href="/r/{{.Name}}">
5 <code class='pill{{if .IsAdmin}}-admin{{end}}' title="{{.Pubkey}}">{{.Name}}</code>
6 </a>
7 {{end}}
M
util.go
+2,
-2
1@@ -27,7 +27,7 @@ func randSeq(n int) string {
2 for i := range b {
3 b[i] = letters[rand.Intn(len(letters))]
4 }
5- return string(b)
6+ return strings.ToLower(string(b))
7 }
8
9 func truncateSha(sha string) string {
10@@ -37,7 +37,7 @@ func truncateSha(sha string) string {
11 return sha[:7]
12 }
13
14-func getAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) {
15+func GetAuthorizedKeys(pubkeys []string) ([]ssh.PublicKey, error) {
16 keys := []ssh.PublicKey{}
17 for _, pubkey := range pubkeys {
18 if strings.TrimSpace(pubkey) == "" {
+146,
-0
1@@ -0,0 +1,146 @@
2+package util
3+
4+import (
5+ "crypto/ed25519"
6+ "crypto/rand"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+
13+ "golang.org/x/crypto/ssh"
14+)
15+
16+func CreateTmpDir() string {
17+ tmp, err := os.MkdirTemp(os.TempDir(), "git-pr*")
18+ if err != nil {
19+ panic(err)
20+ }
21+ return tmp
22+}
23+
24+func CreateCfgFile(dataDir, cfgTmpl string, adminKey UserSSH) string {
25+ cfgPath := filepath.Join(dataDir, "git-pr.toml")
26+ cfgFi, err := os.Create(cfgPath)
27+ if err != nil {
28+ panic(err)
29+ }
30+ _, _ = cfgFi.WriteString(fmt.Sprintf(cfgTmpl, dataDir, adminKey.Public()))
31+ cfgFi.Close()
32+ return cfgPath
33+}
34+
35+type UserSSH struct {
36+ username string
37+ signer ssh.Signer
38+}
39+
40+func NewUserSSH(username string, signer ssh.Signer) *UserSSH {
41+ return &UserSSH{
42+ username: username,
43+ signer: signer,
44+ }
45+}
46+
47+func (s UserSSH) Public() string {
48+ pubkey := s.signer.PublicKey()
49+ return string(ssh.MarshalAuthorizedKey(pubkey))
50+}
51+
52+func (s UserSSH) MustCmd(patch []byte, cmd string) string {
53+ res, err := s.Cmd(patch, cmd)
54+ if err != nil {
55+ panic(err)
56+ }
57+ return res
58+}
59+
60+func (s UserSSH) Cmd(patch []byte, cmd string) (string, error) {
61+ host := "localhost:2222"
62+
63+ config := &ssh.ClientConfig{
64+ User: s.username,
65+ Auth: []ssh.AuthMethod{
66+ ssh.PublicKeys(s.signer),
67+ },
68+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
69+ }
70+
71+ client, err := ssh.Dial("tcp", host, config)
72+ if err != nil {
73+ return "", err
74+ }
75+ defer client.Close()
76+
77+ session, err := client.NewSession()
78+ if err != nil {
79+ return "", err
80+ }
81+ defer session.Close()
82+
83+ stdinPipe, err := session.StdinPipe()
84+ if err != nil {
85+ return "", err
86+ }
87+
88+ stdoutPipe, err := session.StdoutPipe()
89+ if err != nil {
90+ return "", err
91+ }
92+
93+ if err := session.Start(cmd); err != nil {
94+ return "", err
95+ }
96+
97+ if patch != nil {
98+ _, err = stdinPipe.Write(patch)
99+ if err != nil {
100+ return "", err
101+ }
102+ }
103+
104+ stdinPipe.Close()
105+
106+ if err := session.Wait(); err != nil {
107+ return "", err
108+ }
109+
110+ buf := new(strings.Builder)
111+ _, err = io.Copy(buf, stdoutPipe)
112+ if err != nil {
113+ return "", err
114+ }
115+
116+ return buf.String(), nil
117+}
118+
119+func GenerateKeys() (UserSSH, UserSSH) {
120+ _, adminKey, err := ed25519.GenerateKey(rand.Reader)
121+ if err != nil {
122+ panic(err)
123+ }
124+
125+ adminSigner, err := ssh.NewSignerFromKey(adminKey)
126+ if err != nil {
127+ panic(err)
128+ }
129+
130+ _, userKey, err := ed25519.GenerateKey(rand.Reader)
131+ if err != nil {
132+ panic(err)
133+ }
134+
135+ userSigner, err := ssh.NewSignerFromKey(userKey)
136+ if err != nil {
137+ panic(err)
138+ }
139+
140+ return UserSSH{
141+ username: "admin",
142+ signer: adminSigner,
143+ }, UserSSH{
144+ username: "contributor",
145+ signer: userSigner,
146+ }
147+}
M
web.go
+84,
-47
1@@ -132,8 +132,8 @@ func createPrDataSorter(sort, sortDir string) func(a, b *PrListData) int {
2 }
3
4 if sort == "repo" {
5- repoA := strings.ToLower(a.RepoID)
6- repoB := strings.ToLower(b.RepoID)
7+ repoA := strings.ToLower(a.RepoNs)
8+ repoB := strings.ToLower(b.RepoNs)
9 if sortDir == "asc" {
10 return strings.Compare(repoA, repoB)
11 } else {
12@@ -177,6 +177,18 @@ func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrLi
13 continue
14 }
15
16+ repo, err := web.Pr.GetRepoByID(curpr.RepoID)
17+ if err != nil {
18+ web.Logger.Error("cannot get repo", "err", err)
19+ continue
20+ }
21+
22+ repoUser, err := web.Pr.GetUserByID(repo.UserID)
23+ if err != nil {
24+ web.Logger.Error("cannot get repo user", "err", err)
25+ continue
26+ }
27+
28 if hasFilter {
29 if status != "" {
30 if status != curpr.Status {
31@@ -198,8 +210,9 @@ func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrLi
32 }
33
34 isAdmin := web.Backend.IsAdmin(pk)
35+ repoNs := web.Backend.CreateRepoNs(repoUser.Name, repo.Name)
36 prls := &PrListData{
37- RepoID: curpr.RepoID,
38+ RepoNs: repoNs,
39 ID: curpr.ID,
40 UserData: UserData{
41 Name: user.Name,
42@@ -207,8 +220,8 @@ func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrLi
43 Pubkey: user.Pubkey,
44 },
45 RepoLink: LinkData{
46- Url: template.URL(fmt.Sprintf("/repos/%s", curpr.RepoID)),
47- Text: curpr.RepoID,
48+ Url: template.URL(fmt.Sprintf("/r/%s/%s", repoUser.Name, repo.Name)),
49+ Text: repoNs,
50 },
51 PrLink: LinkData{
52 Url: template.URL(fmt.Sprintf("/prs/%d", curpr.ID)),
53@@ -279,7 +292,7 @@ type MetaData struct {
54
55 type PrListData struct {
56 UserData
57- RepoID string
58+ RepoNs string
59 RepoLink LinkData
60 PrLink LinkData
61 Title string
62@@ -290,7 +303,7 @@ type PrListData struct {
63 }
64
65 func userDetailHandler(w http.ResponseWriter, r *http.Request) {
66- userName := r.PathValue("name")
67+ userName := r.PathValue("user")
68
69 web, err := getWebCtx(r)
70 if err != nil {
71@@ -352,16 +365,17 @@ func userDetailHandler(w http.ResponseWriter, r *http.Request) {
72 }
73
74 type RepoDetailData struct {
75- ID string
76- CloneAddr string
77- Branch string
78- Desc string
79- Prs []*PrListData
80+ Name string
81+ UserID int64
82+ Username string
83+ Branch string
84+ Prs []*PrListData
85 MetaData
86 }
87
88 func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
89- repoID := r.PathValue("id")
90+ userName := r.PathValue("user")
91+ repoName := r.PathValue("repo")
92
93 web, err := getWebCtx(r)
94 if err != nil {
95@@ -370,14 +384,21 @@ func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
96 return
97 }
98
99- repo, err := web.Pr.GetRepoByID(repoID)
100+ user, err := web.Pr.GetUserByName(userName)
101 if err != nil {
102- web.Logger.Error("fetch repo", "err", err)
103- w.WriteHeader(http.StatusInternalServerError)
104+ web.Logger.Error("cannot find user", "user", user, "err", err)
105+ w.WriteHeader(http.StatusNotFound)
106 return
107 }
108
109- prs, err := web.Pr.GetPatchRequestsByRepoID(repoID)
110+ repo, err := web.Pr.GetRepoByName(user, repoName)
111+ if err != nil {
112+ web.Logger.Error("cannot find repo", "user", user, "err", err)
113+ w.WriteHeader(http.StatusNotFound)
114+ return
115+ }
116+
117+ prs, err := web.Pr.GetPatchRequestsByRepoID(repo.ID)
118 if err != nil {
119 web.Logger.Error("cannot get prs", "err", err)
120 w.WriteHeader(http.StatusInternalServerError)
121@@ -394,11 +415,10 @@ func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
122 w.Header().Set("content-type", "text/html")
123 tmpl := getTemplate("repo-detail.html")
124 err = tmpl.Execute(w, RepoDetailData{
125- ID: repo.ID,
126- CloneAddr: repo.CloneAddr,
127- Desc: repo.Desc,
128- Branch: repo.DefaultBranch,
129- Prs: prdata,
130+ Name: repo.Name,
131+ UserID: user.ID,
132+ Username: userName,
133+ Prs: prdata,
134 MetaData: MetaData{
135 URL: web.Backend.Cfg.Url,
136 },
137@@ -500,13 +520,6 @@ func createPrDetail(page string) http.HandlerFunc {
138 }
139 }
140
141- repo, err := web.Pr.GetRepoByID(pr.RepoID)
142- if err != nil {
143- web.Logger.Error("cannot get repo", "err", err)
144- w.WriteHeader(http.StatusInternalServerError)
145- return
146- }
147-
148 patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
149 if err != nil {
150 web.Logger.Error("cannot get latest patchset", "err", err)
151@@ -686,13 +699,22 @@ func createPrDetail(page string) http.HandlerFunc {
152 })
153 }
154
155+ repo, err := web.Pr.GetRepoByID(pr.RepoID)
156+ if err != nil {
157+ web.Logger.Error("cannot get repo for pr", "err", err)
158+ w.WriteHeader(http.StatusUnprocessableEntity)
159+ return
160+ }
161+
162+ repoNs := web.Backend.CreateRepoNs(user.Name, repo.Name)
163+ url := fmt.Sprintf("/r/%s/%s", user.Name, repo.Name)
164 err = tmpl.Execute(w, PrDetailData{
165 Page: "pr",
166 Repo: LinkData{
167- Url: template.URL("/repos/" + repo.ID),
168- Text: repo.ID,
169+ Url: template.URL(url),
170+ Text: repoNs,
171 },
172- Branch: repo.DefaultBranch,
173+ Branch: "main",
174 Patchset: ps,
175 Patches: patchesData,
176 Patchsets: patchsetsData,
177@@ -741,9 +763,9 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
178
179 var eventLogs []*EventLog
180 id := r.PathValue("id")
181- repoID := r.PathValue("repoid")
182 pubkey := r.URL.Query().Get("pubkey")
183 username := r.PathValue("user")
184+ repoName := r.PathValue("repo")
185
186 if id != "" {
187 var prID int64
188@@ -767,8 +789,13 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
189 return
190 }
191 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
192- } else if repoID != "" {
193- eventLogs, err = web.Pr.GetEventLogsByRepoID(repoID)
194+ } else if repoName != "" {
195+ user, perr := web.Pr.GetUserByName(username)
196+ if perr != nil {
197+ w.WriteHeader(http.StatusNotFound)
198+ return
199+ }
200+ eventLogs, err = web.Pr.GetEventLogsByRepoName(user, repoName)
201 } else {
202 eventLogs, err = web.Pr.GetEventLogs()
203 }
204@@ -781,10 +808,25 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
205
206 var feedItems []*feeds.Item
207 for _, eventLog := range eventLogs {
208+ user, err := web.Pr.GetUserByID(eventLog.UserID)
209+ if err != nil {
210+ web.Logger.Error("user not found for event log", "id", eventLog.ID, "err", err)
211+ continue
212+ }
213+
214+ repo := &Repo{Name: "unknown"}
215+ if eventLog.RepoID.Valid {
216+ repo, err = web.Pr.GetRepoByID(eventLog.RepoID.Int64)
217+ if err != nil {
218+ web.Logger.Error("repo not found for event log", "id", eventLog.ID, "err", err)
219+ continue
220+ }
221+ }
222+
223 realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
224 content := fmt.Sprintf(
225 "<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
226- eventLog.RepoID,
227+ web.Backend.CreateRepoNs(user.Name, repo.Name),
228 eventLog.PatchRequestID.Int64,
229 eventLog.Event,
230 eventLog.CreatedAt.Format(time.RFC3339Nano),
231@@ -795,15 +837,10 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
232 continue
233 }
234
235- user, err := web.Pr.GetUserByID(pr.UserID)
236- if err != nil {
237- continue
238- }
239-
240 title := fmt.Sprintf(
241 `%s in %s for PR "%s" (#%d)`,
242 eventLog.Event,
243- eventLog.RepoID,
244+ web.Backend.CreateRepoNs(user.Name, repo.Name),
245 pr.Name,
246 eventLog.PatchRequestID.Int64,
247 )
248@@ -952,13 +989,13 @@ func StartWebServer(cfg *GitCfg) {
249 http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
250 http.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
251 http.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
252- http.HandleFunc("GET /repos/{id}", ctxMdw(ctx, repoDetailHandler))
253- http.HandleFunc("GET /repos/{repoid}/rss", ctxMdw(ctx, rssHandler))
254- http.HandleFunc("GET /users/{name}", ctxMdw(ctx, userDetailHandler))
255- http.HandleFunc("GET /users/{name}/rss", ctxMdw(ctx, rssHandler))
256+ http.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
257+ http.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
258+ http.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
259+ http.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
260+ http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
261 http.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
262 http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
263- http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
264 embedFS, err := getEmbedFS(embedStaticFS, "static")
265 if err != nil {
266 panic(err)