Commit d298215429
Verified · cmc
cmd/gitbay/main.go +7
| @@ -216,6 +216,8 @@ func repoCmd() *cobra.Command { | ||
| 216 | 216 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), |
| 217 | 217 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), |
| 218 | 218 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 219 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), | |
| 220 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), | |
| 219 | 221 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
| 220 | 222 | importCmd(), |
| 221 | 223 | group("deploy-key", "repository-bound CI keys", |
| @@ -223,6 +225,11 @@ func repoCmd() *cobra.Command { | ||
| 223 | 225 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
| 224 | 226 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), |
| 225 | 227 | ), |
| 228 | group("topics", "free-form repository tags", | |
| 229 | pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}), | |
| 230 | pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}), | |
| 231 | pass("remove", "remove topics: <topic>...", passOpts{server: []string{"repo", "topics", "remove"}, needsRepo: true}), | |
| 232 | ), | |
| 226 | 233 | group("access", "manage access grants", |
| 227 | 234 | pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}), |
| 228 | 235 | pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}), |
e2e/archive_test.go added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func TestArchiveAndTopics(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | bobKey := inst.newKey(t, "bob") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", | |
| 15 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 16 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 17 | ||
| 18 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 19 | t.Fatalf("repo create: %s", errOut) | |
| 20 | } | |
| 21 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 { | |
| 22 | t.Fatal("grant failed") | |
| 23 | } | |
| 24 | work := t.TempDir() | |
| 25 | env := inst.gitEnv(aliceKey) | |
| 26 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 27 | dir := filepath.Join(work, "w") | |
| 28 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 29 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 30 | mustGit(t, dir, env, "add", ".") | |
| 31 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 32 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 33 | ||
| 34 | // Topics: admin-only edits, validation, idempotent add, listing. | |
| 35 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "topics", "add", "alice/app", "go"); code != 4 { | |
| 36 | t.Fatalf("non-admin added topics: exit %d, %s", code, errOut) | |
| 37 | } | |
| 38 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/app", "Bad_Topic"); code != 2 || !strings.Contains(errOut, "invalid topic") { | |
| 39 | t.Fatalf("bad topic accepted: exit %d, %s", code, errOut) | |
| 40 | } | |
| 41 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/app", "go", "cli", "go"); code != 0 { | |
| 42 | t.Fatalf("topics add: %s", errOut) | |
| 43 | } | |
| 44 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "topics", "alice/app", "--json") | |
| 45 | if !strings.Contains(out, `["cli","go"]`) { | |
| 46 | t.Fatalf("topics list: %s", out) | |
| 47 | } | |
| 48 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") | |
| 49 | if !strings.Contains(out, `"topics":["cli","go"]`) || strings.Contains(out, `"archived"`) { | |
| 50 | t.Fatalf("repo show topics: %s", out) | |
| 51 | } | |
| 52 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "remove", "alice/app", "cli"); code != 0 { | |
| 53 | t.Fatal("topics remove failed") | |
| 54 | } | |
| 55 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "remove", "alice/app", "cli"); code != 3 { | |
| 56 | t.Fatal("removing an absent topic should be not-found") | |
| 57 | } | |
| 58 | ||
| 59 | // An MR and an issue that predate archiving. | |
| 60 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 61 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | |
| 62 | mustGit(t, dir, env, "add", ".") | |
| 63 | mustGit(t, dir, env, "commit", "-q", "-m", "feat") | |
| 64 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 65 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | |
| 66 | "--source", "feat", "--target", "main", "--title", "'feat'"); code != 0 { | |
| 67 | t.Fatalf("mr create: %s", errOut) | |
| 68 | } | |
| 69 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo'"); code != 0 { | |
| 70 | t.Fatalf("issue create: %s", errOut) | |
| 71 | } | |
| 72 | ||
| 73 | // Archive: admin-only, then everything content-mutating is refused. | |
| 74 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "archive", "alice/app"); code != 4 { | |
| 75 | t.Fatal("non-admin archived the repo") | |
| 76 | } | |
| 77 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/app"); code != 0 { | |
| 78 | t.Fatalf("archive: %s", errOut) | |
| 79 | } | |
| 80 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/app"); code != 2 { | |
| 81 | t.Fatal("double archive not refused") | |
| 82 | } | |
| 83 | ||
| 84 | os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644) | |
| 85 | mustGit(t, dir, env, "checkout", "-q", "main") | |
| 86 | mustGit(t, dir, env, "add", ".") | |
| 87 | mustGit(t, dir, env, "commit", "-q", "-m", "more") | |
| 88 | if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "archived and read-only") { | |
| 89 | t.Fatalf("push to archived repo: exit %d\n%s", code, out) | |
| 90 | } | |
| 91 | for _, cmd := range [][]string{ | |
| 92 | {"issue", "create", "alice/app", "--title", "'x'"}, | |
| 93 | {"issue", "comment", "alice/app", "1", "--message", "'x'"}, | |
| 94 | {"issue", "close", "alice/app", "1"}, | |
| 95 | {"mr", "comment", "alice/app", "1", "--message", "'x'"}, | |
| 96 | {"mr", "review", "alice/app", "1", "--approve"}, | |
| 97 | {"mr", "merge", "alice/app", "1"}, | |
| 98 | {"mr", "close", "alice/app", "1"}, | |
| 99 | {"status", "set", "alice/app", "HEAD", "--context", "ci", "--state", "success"}, | |
| 100 | } { | |
| 101 | if _, errOut, code := inst.ssh(t, bobKey, "", cmd...); code != 4 || !strings.Contains(errOut, "archived and read-only") { | |
| 102 | t.Fatalf("%v on archived repo: exit %d, %s", cmd, code, errOut) | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | // Reading stays untouched: clone, listings, show, web. | |
| 107 | work2 := t.TempDir() | |
| 108 | mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/app"), "r") | |
| 109 | if out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app"); code != 0 || !strings.Contains(out, "todo") { | |
| 110 | t.Fatalf("issue list on archived repo: %s", out) | |
| 111 | } | |
| 112 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") | |
| 113 | if !strings.Contains(out, `"archived":true`) { | |
| 114 | t.Fatalf("repo show archived flag: %s", out) | |
| 115 | } | |
| 116 | if status, _ := inst.get(t, "/alice/app"); status != 200 { | |
| 117 | t.Fatalf("web browse on archived repo: %d", status) | |
| 118 | } | |
| 119 | ||
| 120 | // Settings and unarchive still work; writes come back. | |
| 121 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/app", "main"); code != 0 { | |
| 122 | t.Fatal("settings on archived repo should still work") | |
| 123 | } | |
| 124 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect", "alice/app", "main"); code != 0 { | |
| 125 | t.Fatal("unprotect failed") | |
| 126 | } | |
| 127 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "unarchive", "alice/app"); code != 0 { | |
| 128 | t.Fatalf("unarchive: %s", errOut) | |
| 129 | } | |
| 130 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 131 | if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "comment", "alice/app", "1", "--message", "'back'"); code != 0 { | |
| 132 | t.Fatalf("comment after unarchive: %s", errOut) | |
| 133 | } | |
| 134 | } | |
internal/control/diffcomment.go +6
| @@ -69,6 +69,9 @@ func runDiffComment(c *Ctx, args []string) int { | ||
| 69 | 69 | if code >= 0 { |
| 70 | 70 | return code |
| 71 | 71 | } |
| 72 | if code := refuseArchived(c, repo); code >= 0 { | |
| 73 | return code | |
| 74 | } | |
| 72 | 75 | if replyTo == 0 && (path == "" || line == 0) { |
| 73 | 76 | return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)") |
| 74 | 77 | } |
| @@ -203,6 +206,9 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int { | ||
| 203 | 206 | if code >= 0 { |
| 204 | 207 | return code |
| 205 | 208 | } |
| 209 | if code := refuseArchived(c, repo); code >= 0 { | |
| 210 | return code | |
| 211 | } | |
| 206 | 212 | threadID, err := strconv.ParseInt(args[2], 10, 64) |
| 207 | 213 | if err != nil { |
| 208 | 214 | return c.fail(protocol.ExitUsage, "bad thread id %q", args[2]) |
internal/control/issue.go +15
| @@ -130,6 +130,9 @@ func runIssueCreate(c *Ctx, args []string) int { | ||
| 130 | 130 | if code >= 0 { |
| 131 | 131 | return code |
| 132 | 132 | } |
| 133 | if code := refuseArchived(c, repo); code >= 0 { | |
| 134 | return code | |
| 135 | } | |
| 133 | 136 | b, err := bodyFrom(c, body, file) |
| 134 | 137 | if err != nil { |
| 135 | 138 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -256,6 +259,9 @@ func runIssueComment(c *Ctx, args []string) int { | ||
| 256 | 259 | if code >= 0 { |
| 257 | 260 | return code |
| 258 | 261 | } |
| 262 | if code := refuseArchived(c, repo); code >= 0 { | |
| 263 | return code | |
| 264 | } | |
| 259 | 265 | body, err := bodyFrom(c, message, file) |
| 260 | 266 | if err != nil { |
| 261 | 267 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -282,6 +288,9 @@ func setIssueState(c *Ctx, args []string, state string) int { | ||
| 282 | 288 | if code >= 0 { |
| 283 | 289 | return code |
| 284 | 290 | } |
| 291 | if code := refuseArchived(c, repo); code >= 0 { | |
| 292 | return code | |
| 293 | } | |
| 285 | 294 | if len(args) != 2 { |
| 286 | 295 | return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state) |
| 287 | 296 | } |
| @@ -348,6 +357,9 @@ func runIssueLabel(c *Ctx, args []string) int { | ||
| 348 | 357 | if code >= 0 { |
| 349 | 358 | return code |
| 350 | 359 | } |
| 360 | if code := refuseArchived(c, repo); code >= 0 { | |
| 361 | return code | |
| 362 | } | |
| 351 | 363 | for _, l := range adds { |
| 352 | 364 | if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil { |
| 353 | 365 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -382,6 +394,9 @@ func runIssueAssign(c *Ctx, args []string) int { | ||
| 382 | 394 | if code >= 0 { |
| 383 | 395 | return code |
| 384 | 396 | } |
| 397 | if code := refuseArchived(c, repo); code >= 0 { | |
| 398 | return code | |
| 399 | } | |
| 385 | 400 | resolve := func(name string) (store.User, int) { |
| 386 | 401 | u, err := c.Store.UserByUsername(name) |
| 387 | 402 | if errors.Is(err, store.ErrNotFound) { |
internal/control/mr.go +15
| @@ -241,6 +241,9 @@ func runMRCreate(c *Ctx, args []string) int { | ||
| 241 | 241 | if code >= 0 { |
| 242 | 242 | return code |
| 243 | 243 | } |
| 244 | if code := refuseArchived(c, repo); code >= 0 { | |
| 245 | return code | |
| 246 | } | |
| 244 | 247 | if target == "" { |
| 245 | 248 | target = repo.DefaultBranch |
| 246 | 249 | } |
| @@ -488,6 +491,9 @@ func runMRComment(c *Ctx, args []string) int { | ||
| 488 | 491 | if code >= 0 { |
| 489 | 492 | return code |
| 490 | 493 | } |
| 494 | if code := refuseArchived(c, repo); code >= 0 { | |
| 495 | return code | |
| 496 | } | |
| 491 | 497 | body, err := bodyFrom(c, message, file) |
| 492 | 498 | if err != nil { |
| 493 | 499 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -529,6 +535,9 @@ func runMRReview(c *Ctx, args []string) int { | ||
| 529 | 535 | if code >= 0 { |
| 530 | 536 | return code |
| 531 | 537 | } |
| 538 | if code := refuseArchived(c, repo); code >= 0 { | |
| 539 | return code | |
| 540 | } | |
| 532 | 541 | if mr.State != "open" { |
| 533 | 542 | return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) |
| 534 | 543 | } |
| @@ -566,6 +575,9 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 566 | 575 | if code >= 0 { |
| 567 | 576 | return code |
| 568 | 577 | } |
| 578 | if code := refuseArchived(c, repo); code >= 0 { | |
| 579 | return code | |
| 580 | } | |
| 569 | 581 | if mr.State != "open" && mr.State != "source_gone" { |
| 570 | 582 | return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) |
| 571 | 583 | } |
| @@ -926,6 +938,9 @@ func runMRClose(c *Ctx, args []string) int { | ||
| 926 | 938 | if code >= 0 { |
| 927 | 939 | return code |
| 928 | 940 | } |
| 941 | if code := refuseArchived(c, repo); code >= 0 { | |
| 942 | return code | |
| 943 | } | |
| 929 | 944 | if len(args) != 2 { |
| 930 | 945 | return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>") |
| 931 | 946 | } |
internal/control/repo.go +150 −6
| @@ -50,6 +50,26 @@ func init() { | ||
| 50 | 50 | Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) |
| 51 | 51 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, |
| 52 | 52 | Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) |
| 53 | register(Command{Path: []string{"repo", "archive"}, | |
| 54 | Summary: "archive a repository (read-only: pushes and issue/MR writes refused): repo archive <owner/name>", Run: runArchive}) | |
| 55 | register(Command{Path: []string{"repo", "unarchive"}, | |
| 56 | Summary: "unarchive a repository: repo unarchive <owner/name>", Run: runUnarchive}) | |
| 57 | register(Command{Path: []string{"repo", "topics"}, | |
| 58 | Summary: "list topics: repo topics <owner/name>", ReadOnly: true, Run: runTopicsList}) | |
| 59 | register(Command{Path: []string{"repo", "topics", "add"}, | |
| 60 | Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd}) | |
| 61 | register(Command{Path: []string{"repo", "topics", "remove"}, | |
| 62 | Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove}) | |
| 63 | } | |
| 64 | ||
| 65 | // refuseArchived blocks content writes (pushes are refused in the transport | |
| 66 | // layer) on archived repositories. Settings, access, and lifecycle commands | |
| 67 | // stay available so an archived repo can be managed and unarchived. | |
| 68 | func refuseArchived(c *Ctx, repo store.Repo) int { | |
| 69 | if repo.Settings.Archived { | |
| 70 | return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path()) | |
| 71 | } | |
| 72 | return -1 | |
| 53 | 73 | } |
| 54 | 74 | |
| 55 | 75 | // resolveRepo loads a repo and checks the given permission for c.User. |
| @@ -159,15 +179,20 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 159 | 179 | Path string `json:"path"` |
| 160 | 180 | Visibility string `json:"visibility"` |
| 161 | 181 | Description string `json:"description,omitempty"` |
| 182 | Archived bool `json:"archived,omitempty"` | |
| 162 | 183 | } |
| 163 | 184 | var ds []out |
| 164 | 185 | for _, r := range repos { |
| 165 | 186 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) |
| 166 | ds = append(ds, out{r.Path(), r.Visibility, desc}) | |
| 187 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) | |
| 167 | 188 | } |
| 168 | 189 | return c.emit(ds, func(w io.Writer) { |
| 169 | 190 | for _, d := range ds { |
| 170 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description) | |
| 191 | mark := "" | |
| 192 | if d.Archived { | |
| 193 | mark = "\t[archived]" | |
| 194 | } | |
| 195 | fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark) | |
| 171 | 196 | } |
| 172 | 197 | }) |
| 173 | 198 | } |
| @@ -186,14 +211,28 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 186 | 211 | Visibility string `json:"visibility"` |
| 187 | 212 | DefaultBranch string `json:"default_branch"` |
| 188 | 213 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 214 | Archived bool `json:"archived,omitempty"` | |
| 215 | Topics []string `json:"topics,omitempty"` | |
| 189 | 216 | } |
| 190 | 217 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) |
| 191 | d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches} | |
| 218 | topics, err := c.Store.ListTopics(repo.ID) | |
| 219 | if err != nil { | |
| 220 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 221 | } | |
| 222 | d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches, | |
| 223 | repo.Settings.Archived, topics} | |
| 192 | 224 | return c.emit(d, func(w io.Writer) { |
| 193 | fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch) | |
| 225 | line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch) | |
| 226 | if d.Archived { | |
| 227 | line += "\t[archived]" | |
| 228 | } | |
| 229 | fmt.Fprintln(w, line) | |
| 194 | 230 | if d.Description != "" { |
| 195 | 231 | fmt.Fprintf(w, "%s\n", d.Description) |
| 196 | 232 | } |
| 233 | if len(d.Topics) > 0 { | |
| 234 | fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", ")) | |
| 235 | } | |
| 197 | 236 | if len(d.ProtectedBranches) > 0 { |
| 198 | 237 | fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) |
| 199 | 238 | } |
| @@ -368,8 +407,8 @@ func runSettingsShow(c *Ctx, args []string) int { | ||
| 368 | 407 | return code |
| 369 | 408 | } |
| 370 | 409 | return c.emit(repo.Settings, func(w io.Writer) { |
| 371 | fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n", | |
| 372 | strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon) | |
| 410 | fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n", | |
| 411 | strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived) | |
| 373 | 412 | }) |
| 374 | 413 | } |
| 375 | 414 | |
| @@ -413,6 +452,111 @@ func runGitDaemon(c *Ctx, args []string) int { | ||
| 413 | 452 | return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) }) |
| 414 | 453 | } |
| 415 | 454 | |
| 455 | func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) } | |
| 456 | func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) } | |
| 457 | ||
| 458 | func setArchived(c *Ctx, args []string, archived bool) int { | |
| 459 | verb := "archive" | |
| 460 | if !archived { | |
| 461 | verb = "unarchive" | |
| 462 | } | |
| 463 | if len(args) != 1 { | |
| 464 | return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb) | |
| 465 | } | |
| 466 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 467 | if code >= 0 { | |
| 468 | return code | |
| 469 | } | |
| 470 | if repo.Settings.Archived == archived { | |
| 471 | return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb) | |
| 472 | } | |
| 473 | s := repo.Settings | |
| 474 | s.Archived = archived | |
| 475 | if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { | |
| 476 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 477 | } | |
| 478 | c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}") | |
| 479 | return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) }) | |
| 480 | } | |
| 481 | ||
| 482 | func runTopicsList(c *Ctx, args []string) int { | |
| 483 | if len(args) != 1 { | |
| 484 | return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>") | |
| 485 | } | |
| 486 | repo, code := resolveRepo(c, args[0], policy.CanRead) | |
| 487 | if code >= 0 { | |
| 488 | return code | |
| 489 | } | |
| 490 | topics, err := c.Store.ListTopics(repo.ID) | |
| 491 | if err != nil { | |
| 492 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 493 | } | |
| 494 | return c.emit(topics, func(w io.Writer) { | |
| 495 | for _, t := range topics { | |
| 496 | fmt.Fprintln(w, t) | |
| 497 | } | |
| 498 | }) | |
| 499 | } | |
| 500 | ||
| 501 | func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) } | |
| 502 | func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) } | |
| 503 | ||
| 504 | func editTopics(c *Ctx, args []string, add bool) int { | |
| 505 | verb := "add" | |
| 506 | if !add { | |
| 507 | verb = "remove" | |
| 508 | } | |
| 509 | if len(args) < 2 { | |
| 510 | return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb) | |
| 511 | } | |
| 512 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 513 | if code >= 0 { | |
| 514 | return code | |
| 515 | } | |
| 516 | topics := args[1:] | |
| 517 | if add { | |
| 518 | for _, t := range topics { | |
| 519 | if err := policy.ValidateTopic(t); err != nil { | |
| 520 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 521 | } | |
| 522 | } | |
| 523 | have, err := c.Store.ListTopics(repo.ID) | |
| 524 | if err != nil { | |
| 525 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 526 | } | |
| 527 | added := 0 | |
| 528 | for _, t := range topics { | |
| 529 | if !slices.Contains(have, t) { | |
| 530 | added++ | |
| 531 | } | |
| 532 | } | |
| 533 | if len(have)+added > policy.MaxTopics { | |
| 534 | return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics) | |
| 535 | } | |
| 536 | for _, t := range topics { | |
| 537 | if err := c.Store.AddTopic(repo.ID, t); err != nil { | |
| 538 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 539 | } | |
| 540 | } | |
| 541 | } else { | |
| 542 | for _, t := range topics { | |
| 543 | if err := c.Store.RemoveTopic(repo.ID, t); err != nil { | |
| 544 | if errors.Is(err, store.ErrNotFound) { | |
| 545 | return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t) | |
| 546 | } | |
| 547 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 548 | } | |
| 549 | } | |
| 550 | } | |
| 551 | now, err := c.Store.ListTopics(repo.ID) | |
| 552 | if err != nil { | |
| 553 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 554 | } | |
| 555 | return c.emit(now, func(w io.Writer) { | |
| 556 | fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", ")) | |
| 557 | }) | |
| 558 | } | |
| 559 | ||
| 416 | 560 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 417 | 561 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } |
| 418 | 562 | |
internal/control/status.go +3
| @@ -64,6 +64,9 @@ func runStatusSet(c *Ctx, args []string) int { | ||
| 64 | 64 | if code >= 0 { |
| 65 | 65 | return code |
| 66 | 66 | } |
| 67 | if code := refuseArchived(c, repo); code >= 0 { | |
| 68 | return code | |
| 69 | } | |
| 67 | 70 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 68 | 71 | full, err := gitutil.ResolveRef(dir, sha) |
| 69 | 72 | if err != nil { |
internal/policy/topic.go added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | package policy | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "regexp" | |
| 6 | ) | |
| 7 | ||
| 8 | // MaxTopics caps topics per repository. | |
| 9 | const MaxTopics = 20 | |
| 10 | ||
| 11 | var topicPat = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,34}$`) | |
| 12 | ||
| 13 | // ValidateTopic checks a repository topic: lowercase alphanumerics and | |
| 14 | // dashes, must start with an alphanumeric, max 35 chars. | |
| 15 | func ValidateTopic(topic string) error { | |
| 16 | if !topicPat.MatchString(topic) { | |
| 17 | return fmt.Errorf("invalid topic %q: lowercase letters, digits, and '-' only; must start with a letter or digit; max 35 chars", topic) | |
| 18 | } | |
| 19 | return nil | |
| 20 | } | |
internal/sshd/sshd.go +4
| @@ -298,6 +298,10 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 298 | 298 | return protocol.ExitDenied |
| 299 | 299 | } |
| 300 | 300 | } |
| 301 | if write && repo.Settings.Archived { | |
| 302 | fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path()) | |
| 303 | return protocol.ExitDenied | |
| 304 | } | |
| 301 | 305 | |
| 302 | 306 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) |
| 303 | 307 | env := []string{ |
internal/store/migrations/0011_topics.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP TABLE repo_topics; | |
internal/store/migrations/0011_topics.up.sql added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | CREATE TABLE repo_topics ( | |
| 2 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 3 | topic TEXT NOT NULL, | |
| 4 | PRIMARY KEY (repo_id, topic) | |
| 5 | ); | |
| 6 | CREATE INDEX repo_topics_topic ON repo_topics(topic); | |
internal/store/repos.go +1
| @@ -27,6 +27,7 @@ type RepoSettings struct { | ||
| 27 | 27 | RequireApprovals int `json:"require_approvals,omitempty"` |
| 28 | 28 | RequireResolved bool `json:"require_resolved,omitempty"` |
| 29 | 29 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 30 | Archived bool `json:"archived,omitempty"` | |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | // Path returns the canonical owner/name form. |
internal/store/topics.go added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | func (s *Store) ListTopics(repoID int64) ([]string, error) { | |
| 4 | rows, err := s.DB.Query("SELECT topic FROM repo_topics WHERE repo_id = ? ORDER BY topic", repoID) | |
| 5 | if err != nil { | |
| 6 | return nil, err | |
| 7 | } | |
| 8 | defer rows.Close() | |
| 9 | var out []string | |
| 10 | for rows.Next() { | |
| 11 | var t string | |
| 12 | if err := rows.Scan(&t); err != nil { | |
| 13 | return nil, err | |
| 14 | } | |
| 15 | out = append(out, t) | |
| 16 | } | |
| 17 | return out, rows.Err() | |
| 18 | } | |
| 19 | ||
| 20 | // AddTopic is idempotent: adding an existing topic is not an error. | |
| 21 | func (s *Store) AddTopic(repoID int64, topic string) error { | |
| 22 | _, err := s.DB.Exec( | |
| 23 | "INSERT INTO repo_topics (repo_id, topic) VALUES (?, ?) ON CONFLICT DO NOTHING", | |
| 24 | repoID, topic) | |
| 25 | return err | |
| 26 | } | |
| 27 | ||
| 28 | func (s *Store) RemoveTopic(repoID int64, topic string) error { | |
| 29 | res, err := s.DB.Exec( | |
| 30 | "DELETE FROM repo_topics WHERE repo_id = ? AND topic = ?", repoID, topic) | |
| 31 | if err != nil { | |
| 32 | return err | |
| 33 | } | |
| 34 | if n, _ := res.RowsAffected(); n == 0 { | |
| 35 | return ErrNotFound | |
| 36 | } | |
| 37 | return nil | |
| 38 | } | |