internal/control/admin.go

353f68a2e57ac692964b73ae1f9acd91fcdcae20
gitbay/internal/control/admin.go history · blame · raw

620 lines · 21188 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"slices"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"gitbay.org/gitbay/internal/gitutil"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17func init() {
 18	register(Command{Path: []string{"admin", "user", "list"},
 19		Summary:  "list accounts (instance admins)",
 20		Usage:    "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
 21		ReadOnly: true, SSHOnly: true, Run: runAdminUserList})
 22	register(Command{Path: []string{"admin", "user", "show"},
 23		Summary:  "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
 24		Usage:    "admin user show <username>",
 25		ReadOnly: true, SSHOnly: true, Run: runAdminUserShow})
 26	register(Command{Path: []string{"admin", "user", "promote"},
 27		Summary: "make an account an instance admin",
 28		Usage:   "admin user promote <username>",
 29		SSHOnly: true, Run: runAdminUserPromote})
 30	register(Command{Path: []string{"admin", "user", "demote"},
 31		Summary: "remove instance admin from an account (never the last one)",
 32		Usage:   "admin user demote <username>",
 33		SSHOnly: true, Run: runAdminUserDemote})
 34	register(Command{Path: []string{"admin", "runners"},
 35		Summary:  "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
 36		Usage:    "admin runners",
 37		ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
 38	register(Command{Path: []string{"admin", "runners", "remove"},
 39		Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
 40		Usage:   "admin runners remove <fingerprint>",
 41		SSHOnly: true, Run: runAdminRunnersForget})
 42	// forget is the name this shipped under in v1.18; remove is the verb
 43	// every other noun uses. Both stay for one release.
 44	register(Command{Path: []string{"admin", "runners", "forget"},
 45		Summary: "alias of admin runners remove",
 46		Usage:   "admin runners forget <fingerprint>",
 47		SSHOnly: true, Run: runAdminRunnersForget})
 48	register(Command{Path: []string{"admin", "repo", "list"},
 49		Summary:  "list every repository with size and last push (instance admins)",
 50		Usage:    "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
 51		ReadOnly: true, SSHOnly: true, Run: runAdminRepoList})
 52	register(Command{Path: []string{"admin", "repo", "archive"},
 53		Summary: "archive any repository (instance admins; audited)",
 54		Usage:   "admin repo archive <owner/name>",
 55		SSHOnly: true, Run: runAdminRepoArchive})
 56	register(Command{Path: []string{"admin", "repo", "unarchive"},
 57		Summary: "unarchive any repository (instance admins; audited)",
 58		Usage:   "admin repo unarchive <owner/name>",
 59		SSHOnly: true, Run: runAdminRepoUnarchive})
 60	register(Command{Path: []string{"admin", "repo", "visibility"},
 61		Summary: "set any repository's visibility (instance admins; audited)",
 62		Usage:   "admin repo visibility <owner/name> public|private",
 63		SSHOnly: true, Run: runAdminRepoVisibility})
 64	register(Command{Path: []string{"admin", "repo", "delete"},
 65		Summary: "delete any repository (instance admins; audited)",
 66		Usage:   "admin repo delete <owner/name> --yes",
 67		SSHOnly: true, Run: runAdminRepoDelete})
 68	register(Command{Path: []string{"admin", "mr", "prune"},
 69		Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
 70		Usage:   "admin mr prune <owner/name> <n> [<n>...] --yes",
 71		SSHOnly: true, Run: runAdminMRPrune})
 72}
 73
 74// requireInstanceAdmin gates the admin noun. -1 means proceed.
 75func requireInstanceAdmin(c *Ctx) int {
 76	if !c.User.IsAdmin {
 77		return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
 78	}
 79	return -1
 80}
 81
 82// adminUserOut is one account row, shared by list and show.
 83type adminUserOut struct {
 84	Username  string `json:"username"`
 85	State     string `json:"state"` // active | pending | disabled
 86	Admin     bool   `json:"admin"`
 87	CreatedAt string `json:"created_at"`
 88	LastSeen  string `json:"last_seen,omitempty"`
 89}
 90
 91func adminUserRow(u store.AdminUser) adminUserOut {
 92	state := "active"
 93	switch {
 94	case u.Disabled:
 95		state = "disabled"
 96	case u.Pending:
 97		state = "pending"
 98	}
 99	return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen}
100}
101
102func runAdminUserList(c *Ctx, args []string) int {
103	if code := requireInstanceAdmin(c); code >= 0 {
104		return code
105	}
106	args, p, code := parsePageFlags(c, args, "admin-user", false)
107	if code >= 0 {
108		return code
109	}
110	f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
111		Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
112	if err != nil {
113		return c.fail(protocol.ExitUsage, "%v", err)
114	}
115	state := f.Value("--state")
116	switch state {
117	case "", "active", "pending", "disabled", "admin":
118	default:
119		return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
120	}
121	users, err := c.Store.ListUsers(state, p.queryLimit(), p.key)
122	if err != nil {
123		return c.fail(protocol.ExitFailure, "%v", err)
124	}
125	users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username })
126	var ds []adminUserOut
127	for _, u := range users {
128		ds = append(ds, adminUserRow(u))
129	}
130	return c.emitPage(p, ds, next, func(w io.Writer) {
131		for _, d := range ds {
132			mark := ""
133			if d.Admin {
134				mark = "admin"
135			}
136			fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen)
137		}
138	})
139}
140
141func runAdminUserShow(c *Ctx, args []string) int {
142	if code := requireInstanceAdmin(c); code >= 0 {
143		return code
144	}
145	if len(args) != 1 {
146		return c.usage()
147	}
148	name := args[0]
149	u, err := c.Store.UserByUsername(name)
150	if errors.Is(err, store.ErrNotFound) {
151		return c.fail(protocol.ExitNotFound, "no user %q", name)
152	} else if err != nil {
153		return c.fail(protocol.ExitFailure, "%v", err)
154	}
155	row, err := c.Store.AdminUserByName(name)
156	if err != nil {
157		return c.fail(protocol.ExitFailure, "%v", err)
158	}
159
160	type keyOut struct {
161		Fingerprint string `json:"fingerprint"`
162		Algo        string `json:"algo"`
163		Scope       string `json:"scope"`
164		Label       string `json:"label"`
165		CreatedAt   string `json:"created_at"`
166		LastUsedAt  string `json:"last_used_at,omitempty"`
167	}
168	type emailOut struct {
169		Address    string `json:"address"`
170		Verified   bool   `json:"verified"`
171		VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin
172		Primary    bool   `json:"primary"`
173	}
174	type pgpOut struct {
175		Fingerprint string     `json:"fingerprint"`
176		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
177		RevokedAt   *time.Time `json:"revoked_at,omitempty"`
178	}
179	type orgOut struct {
180		Org  string `json:"org"`
181		Role string `json:"role"`
182	}
183	type tokenOut struct {
184		Name       string     `json:"name"`
185		Scope      string     `json:"scope"`
186		CreatedAt  string     `json:"created_at"`
187		ExpiresAt  *time.Time `json:"expires_at,omitempty"`
188		LastUsedAt *time.Time `json:"last_used_at,omitempty"`
189	}
190	type out struct {
191		adminUserOut
192		Keys        []keyOut   `json:"keys"`
193		Emails      []emailOut `json:"emails"`
194		PGPKeys     []pgpOut   `json:"pgp_keys"`
195		Orgs        []orgOut   `json:"orgs"`
196		Repos       int64      `json:"repos"`
197		RepoLimit   int64      `json:"repo_limit"` // 0 unlimited
198		ByteLimit   int64      `json:"byte_limit"` // 0 unlimited
199		APITokens   []tokenOut `json:"api_tokens"`
200		WebSessions int64      `json:"web_sessions"`
201	}
202	d := out{adminUserOut: adminUserRow(row),
203		Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
204
205	keys, err := c.Store.ListSSHKeys(u.ID)
206	if err != nil {
207		return c.fail(protocol.ExitFailure, "%v", err)
208	}
209	for _, k := range keys {
210		d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedAt, k.LastUsedAt})
211	}
212	emails, err := c.Store.ListEmails(u.ID)
213	if err != nil {
214		return c.fail(protocol.ExitFailure, "%v", err)
215	}
216	for _, e := range emails {
217		d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
218	}
219	pgp, err := c.Store.ListPGPKeys(u.ID)
220	if err != nil {
221		return c.fail(protocol.ExitFailure, "%v", err)
222	}
223	for _, k := range pgp {
224		d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
225	}
226	orgs, err := c.Store.ListOrgsForUser(u.ID)
227	if err != nil {
228		return c.fail(protocol.ExitFailure, "%v", err)
229	}
230	for _, m := range orgs {
231		d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
232	}
233	if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
234		return c.fail(protocol.ExitFailure, "%v", err)
235	}
236	d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
237	d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
238	tokens, err := c.Store.ListAPITokens(u.ID)
239	if err != nil {
240		return c.fail(protocol.ExitFailure, "%v", err)
241	}
242	for _, t := range tokens {
243		d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
244	}
245	if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
246		return c.fail(protocol.ExitFailure, "%v", err)
247	}
248
249	return c.emit(d, func(w io.Writer) {
250		fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
251		if d.Admin {
252			fmt.Fprint(w, "\tadmin")
253		}
254		fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
255		if d.LastSeen != "" {
256			fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
257		}
258		fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
259		fmt.Fprintln(w, "keys:")
260		for _, k := range d.Keys {
261			fmt.Fprintf(w, "  %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
262		}
263		fmt.Fprintln(w, "emails:")
264		for _, e := range d.Emails {
265			state := "unverified"
266			if e.Verified {
267				state = "verified by " + e.VerifiedBy
268			}
269			mark := ""
270			if e.Primary {
271				mark = "\tprimary"
272			}
273			fmt.Fprintf(w, "  %s\t%s%s\n", e.Address, state, mark)
274		}
275		fmt.Fprintln(w, "pgp keys:")
276		for _, k := range d.PGPKeys {
277			fmt.Fprintf(w, "  %s\n", k.Fingerprint)
278		}
279		fmt.Fprintln(w, "orgs:")
280		for _, o := range d.Orgs {
281			fmt.Fprintf(w, "  %s\t%s\n", o.Org, o.Role)
282		}
283		fmt.Fprintln(w, "api tokens:")
284		for _, t := range d.APITokens {
285			used := ""
286			if t.LastUsedAt != nil {
287				used = t.LastUsedAt.UTC().Format(time.RFC3339)
288			}
289			fmt.Fprintf(w, "  %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
290		}
291	})
292}
293
294func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
295func runAdminUserDemote(c *Ctx, args []string) int  { return setAdmin(c, args, false) }
296
297func setAdmin(c *Ctx, args []string, admin bool) int {
298	if code := requireInstanceAdmin(c); code >= 0 {
299		return code
300	}
301	verb := "demote"
302	if admin {
303		verb = "promote"
304	}
305	if len(args) != 1 {
306		return c.usage()
307	}
308	u, err := c.Store.UserByUsername(args[0])
309	if errors.Is(err, store.ErrNotFound) {
310		return c.fail(protocol.ExitNotFound, "no user %q", args[0])
311	} else if err != nil {
312		return c.fail(protocol.ExitFailure, "%v", err)
313	}
314	if u.IsAdmin == admin {
315		return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
316	}
317	if admin && (u.Pending || u.Disabled) {
318		return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
319			map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
320	}
321	if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
322		if errors.Is(err, store.ErrLastAdmin) {
323			return c.failErr(err)
324		}
325		return c.fail(protocol.ExitFailure, "%v", err)
326	}
327	c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
328	return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
329		fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
330	})
331}
332
333// adminRepo loads a repository for an admin override. Instance admin
334// carries no implicit read right, so policy is not consulted; the only
335// refusal is a path that does not exist. Every caller audits what it does.
336func adminRepo(c *Ctx, path string) (store.Repo, int) {
337	if code := requireInstanceAdmin(c); code >= 0 {
338		return store.Repo{}, code
339	}
340	repo, err := c.Store.RepoByPath(path)
341	if errors.Is(err, store.ErrNotFound) {
342		return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
343	} else if err != nil {
344		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
345	}
346	return repo, -1
347}
348
349func runAdminRepoList(c *Ctx, args []string) int {
350	if code := requireInstanceAdmin(c); code >= 0 {
351		return code
352	}
353	args, p, code := parsePageFlags(c, args, "admin-repo", false)
354	if code >= 0 {
355		return code
356	}
357	f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
358		Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
359	if err != nil {
360		return c.fail(protocol.ExitUsage, "%v", err)
361	}
362	owner, visibility := f.Value("--owner"), f.Value("--visibility")
363	if visibility != "" && visibility != "public" && visibility != "private" {
364		return c.fail(protocol.ExitUsage, "--visibility requires public|private")
365	}
366	repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
367	if err != nil {
368		return c.fail(protocol.ExitFailure, "%v", err)
369	}
370	repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path })
371	type out struct {
372		Path       string `json:"path"`
373		Visibility string `json:"visibility"`
374		Archived   bool   `json:"archived,omitempty"`
375		CreatedAt  string `json:"created_at"`
376		LastPush   string `json:"last_push,omitempty"`
377		Bytes      int64  `json:"bytes"`
378	}
379	var ds []out
380	for _, r := range repos {
381		size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
382		ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
383	}
384	return c.emitPage(p, ds, next, func(w io.Writer) {
385		for _, d := range ds {
386			mark := ""
387			if d.Archived {
388				mark = "\t[archived]"
389			}
390			fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark)
391		}
392	})
393}
394
395func runAdminRepoArchive(c *Ctx, args []string) int   { return adminArchive(c, args, true) }
396func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
397
398func adminArchive(c *Ctx, args []string, archived bool) int {
399	verb := "archive"
400	if !archived {
401		verb = "unarchive"
402	}
403	if len(args) != 1 {
404		return c.usage()
405	}
406	repo, code := adminRepo(c, args[0])
407	if code >= 0 {
408		return code
409	}
410	if code := archiveRepo(c, repo, archived); code != protocol.ExitOK {
411		return code
412	}
413	c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()})
414	return protocol.ExitOK
415}
416
417func runAdminRepoVisibility(c *Ctx, args []string) int {
418	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
419		return c.usage()
420	}
421	repo, code := adminRepo(c, args[0])
422	if code >= 0 {
423		return code
424	}
425	if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK {
426		return code
427	}
428	c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]})
429	return protocol.ExitOK
430}
431
432func runAdminRepoDelete(c *Ctx, args []string) int {
433	var path string
434	var yes bool
435	for _, a := range args {
436		if a == "--yes" {
437			yes = true
438		} else if path == "" {
439			path = a
440		} else {
441			return c.usage()
442		}
443	}
444	if path == "" {
445		return c.usage()
446	}
447	repo, code := adminRepo(c, path)
448	if code >= 0 {
449		return code
450	}
451	if !yes {
452		return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes")
453	}
454	if code := deleteRepo(c, repo); code != protocol.ExitOK {
455		return code
456	}
457	c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
458	return protocol.ExitOK
459}
460
461func runAdminRunnersForget(c *Ctx, args []string) int {
462	if code := requireInstanceAdmin(c); code >= 0 {
463		return code
464	}
465	if len(args) != 1 {
466		return c.usage()
467	}
468	if err := c.Store.ForgetRunner(args[0]); err != nil {
469		if errors.Is(err, store.ErrNotFound) {
470			return c.fail(protocol.ExitNotFound, "no runner has polled with %s", args[0])
471		}
472		return c.fail(protocol.ExitFailure, "%v", err)
473	}
474	c.Store.Audit(c.User.ID, "admin runners.forget", map[string]any{"fingerprint": args[0]})
475	return c.emit(map[string]string{"forgot": args[0]}, func(w io.Writer) {
476		fmt.Fprintf(w, "forgot runner %s\n", args[0])
477	})
478}
479
480func runAdminRunners(c *Ctx, args []string) int {
481	if code := requireInstanceAdmin(c); code >= 0 {
482		return code
483	}
484	if len(args) != 0 {
485		return c.usage()
486	}
487	runners, err := c.Store.ListRunners()
488	if err != nil {
489		return c.fail(protocol.ExitFailure, "%v", err)
490	}
491	queue, err := c.Store.QueueStats()
492	if err != nil {
493		return c.fail(protocol.ExitFailure, "%v", err)
494	}
495	if runners == nil {
496		runners = []store.Runner{}
497	}
498	// The scope column is what the key may claim, not what it asked for. A
499	// runner key is confined to its attachments, so they replace whatever
500	// -repos it polled with, and none of them means none. Any other key
501	// keeps the repositories it asked for, or the whole instance.
502	for i := range runners {
503		key, err := c.Store.SSHKeyByID(runners[i].KeyID)
504		if err != nil || key.Scope != "runner" {
505			continue
506		}
507		paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
508		if err != nil {
509			return c.fail(protocol.ExitFailure, "%v", err)
510		}
511		runners[i].Scope = "none"
512		if len(paths) > 0 {
513			runners[i].Scope = strings.Join(paths, ",")
514		}
515	}
516	d := map[string]any{"queue": queue, "runners": runners}
517	return c.emit(d, func(w io.Writer) {
518		fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
519			queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
520		for _, r := range runners {
521			scope := r.Scope
522			if scope == "" {
523				scope = "any"
524			}
525			held := "idle"
526			if r.BuildNumber != 0 {
527				held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
528			}
529			fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
530		}
531	})
532}
533
534type mrPruneOut struct {
535	Number int64  `json:"number"`
536	Head   string `json:"head_sha"` // what the ref pointed at; empty if it was already gone
537}
538
539// runAdminMRPrune deletes refs/merge-requests/<n>/head for the named MRs
540// and prunes the repository at once, so commits a history rewrite left
541// reachable only through them stop being fetchable. Nothing drops a head
542// ref on its own: an open or source-gone MR is merged through it, and a
543// merged or closed one keeps its diff readable through it. Every check
544// runs before the first write.
545func runAdminMRPrune(c *Ctx, args []string) int {
546	var path string
547	var yes bool
548	var numbers []int64
549	for _, a := range args {
550		switch {
551		case a == "--yes":
552			yes = true
553		case path == "":
554			path = a
555		default:
556			n, err := strconv.ParseInt(a, 10, 64)
557			if err != nil || n <= 0 {
558				return c.usage()
559			}
560			if !slices.Contains(numbers, n) {
561				numbers = append(numbers, n)
562			}
563		}
564	}
565	if path == "" || len(numbers) == 0 {
566		return c.usage()
567	}
568	repo, code := adminRepo(c, path)
569	if code >= 0 {
570		return code
571	}
572	if !yes {
573		return c.fail(protocol.ExitUsage, "admin mr prune drops the commits for good; re-run with --yes")
574	}
575	mrs := make([]store.MR, 0, len(numbers))
576	for _, n := range numbers {
577		mr, err := c.Store.MRByNumber(repo.ID, n)
578		if errors.Is(err, store.ErrNotFound) {
579			return c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
580		} else if err != nil {
581			return c.fail(protocol.ExitFailure, "%v", err)
582		}
583		if mr.State != "merged" && mr.State != "closed" {
584			return c.fail(protocol.ExitFailure, "!%d is still mergeable and its head is what makes it so; merge or close it first", n)
585		}
586		mrs = append(mrs, mr)
587	}
588
589	// The record is written as each ref goes, not after the gc: a failure
590	// past this point leaves refs deleted, and the audit log and the MR
591	// thread must say so. Re-running the same command finishes the job.
592	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
593	rows := make([]mrPruneOut, 0, len(mrs))
594	for _, mr := range mrs {
595		ref := mrHeadRef(mr.Number)
596		row := mrPruneOut{Number: mr.Number}
597		if gitutil.RefExists(dir, ref) {
598			row.Head, _ = gitutil.ResolveRef(dir, ref)
599			if err := gitutil.DeleteRef(dir, ref); err != nil {
600				c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers, "failed": err.Error()})
601				return c.fail(protocol.ExitFailure, "%v; the refs before !%d are deleted and not yet pruned; re-run the same command", err, mr.Number)
602			}
603		}
604		c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("head ref pruned by %s; the diff is no longer available", c.User.Username))
605		rows = append(rows, row)
606	}
607	c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers})
608	if err := gitutil.PruneNow(dir); err != nil {
609		return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
610	}
611	return c.emit(rows, func(w io.Writer) {
612		for _, r := range rows {
613			if r.Head == "" {
614				fmt.Fprintf(w, "!%d\talready gone\n", r.Number)
615				continue
616			}
617			fmt.Fprintf(w, "!%d\t%s\n", r.Number, r.Head)
618		}
619	})
620}