repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

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 cfg.go
M cli.go
M go.mod
M go.sum
M pr.go
M ssh.go
M web.go
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
A __snapshots__/e2e_test.snap
+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+---
M backend.go
+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
M cmd/ssh/main.go
+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 }
M cmd/web/main.go
+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 }
M contrib/dev/main.go
+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"`
A e2e_test.go
+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"`
M git-pr.toml
+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=
M models.go
+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 }
M sqlite.go
+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 }
M tmpl/index.html
+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"
M tmpl/pr-table.html
+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}}
M tmpl/repo-detail.html
+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}}
M tmpl/user-detail.html
+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}}
M tmpl/user-pill.html
+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) == "" {
A util/util.go
+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)