detached 5278df2

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.IssuesEnabled() {
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	recordActivity(ctx, m.meta, repo, now)
89	m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
90	return issue, nil
91}
92
93// Reject deletes the pending issue. With ban, its submitter is first banned
94// for domain.BanDuration.
95func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
96	if !p.HasOwnerRights() {
97		return domain.ErrNotFound
98	}
99	pending, err := m.pending.Get(ctx, repo, id)
100	if err != nil {
101		return err
102	}
103	if ban {
104		until := m.clock.Now().UTC().Add(domain.BanDuration)
105		if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
106			return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
107		}
108	}
109	if err := m.pending.Delete(ctx, repo, id); err != nil {
110		if errors.Is(err, domain.ErrNotFound) {
111			return err
112		}
113		return fmt.Errorf("delete pending issue of %q: %w", repo, err)
114	}
115	log := zerowrap.FromCtx(ctx)
116	log.Info().Str("repo", repo.String()).Bool("ban", ban).
117		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
118	m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
119	return nil
120}

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 2

Tags 45

↑↓ moveenter openesc close