detached aa7340c

issue_moderation.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9
10	"github.com/bnema/zerowrap"
11
12	"bnema.dev/ermite/internal/domain"
13	"bnema.dev/ermite/internal/ports"
14)
15
16// IssueModeration is the owner's moderation of the issues submitted through
17// the public form (ADR 0031): list, approve and reject, with an optional ban of
18// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19// for a page that does not exist.
20type IssueModeration struct {
21	meta    ports.RepoMetaStore
22	pending ports.PendingIssueStore
23	bans    ports.IssueBanStore
24	bus     ports.EventBus
25	clock   ports.Clock
26}
27
28var _ ports.IssueModerationService = (*IssueModeration)(nil)
29
30// IssueModerationConfig holds what the moderation needs. Every field is
31// required.
32type IssueModerationConfig struct {
33	Meta    ports.RepoMetaStore
34	Pending ports.PendingIssueStore
35	Bans    ports.IssueBanStore
36	Bus     ports.EventBus
37	Clock   ports.Clock
38}
39
40// NewIssueModeration builds the moderation use case.
41func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42	return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43}
44
45// ListPending lists the pending issues of a repository, or of every repository
46// when repo is zero, oldest first.
47func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48	if !p.HasOwnerRights() {
49		return ports.PendingIssuePage{}, domain.ErrNotFound
50	}
51	size, err := pageSize(page.PageSize)
52	if err != nil {
53		return ports.PendingIssuePage{}, err
54	}
55	page.PageSize = size
56	out, err := m.pending.List(ctx, repo, page)
57	if err != nil {
58		return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59	}
60	return out, nil
61}
62
63// Approve gives the pending issue the next number of its repository and opens
64// it, then publishes IssueOpened. The repository must exist with issues on
65// (else domain.ErrNotFound), and so must the pending issue.
66func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67	if !p.HasOwnerRights() {
68		return domain.Issue{}, domain.ErrNotFound
69	}
70	r, err := lookupRepo(ctx, m.meta, repo)
71	if err != nil {
72		return domain.Issue{}, err
73	}
74	if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75		return domain.Issue{}, domain.ErrNotFound
76	}
77	now := m.clock.Now().UTC()
78	issue, err := m.pending.Approve(ctx, repo, id, now)
79	if err != nil {
80		if errors.Is(err, domain.ErrNotFound) {
81			return domain.Issue{}, err
82		}
83		return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
84	}
85	log := zerowrap.FromCtx(ctx)
86	log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88	m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89	return issue, nil
90}
91
92// Reject deletes the pending issue. With ban, its submitter is first banned
93// for domain.BanDuration.
94func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95	if !p.HasOwnerRights() {
96		return domain.ErrNotFound
97	}
98	pending, err := m.pending.Get(ctx, repo, id)
99	if err != nil {
100		return err
101	}
102	if ban {
103		until := m.clock.Now().UTC().Add(domain.BanDuration)
104		if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105			return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106		}
107	}
108	if err := m.pending.Delete(ctx, repo, id); err != nil {
109		if errors.Is(err, domain.ErrNotFound) {
110			return err
111		}
112		return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113	}
114	log := zerowrap.FromCtx(ctx)
115	log.Info().Str("repo", repo.String()).Bool("ban", ban).
116		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117	m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
118	return nil
119}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close