Go · 9452 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package issues owns the issue + comment + label + milestone domain
4 // logic. Web handlers call into this package; the package owns
5 // transactions, cross-reference parsing, and event emission.
6 //
7 // PR-specific behavior lives in the future internal/pulls/ package
8 // (S22) — but PRs reuse the `issues` and `issue_comments` tables, so
9 // the queries here are kind-discriminated to keep the surface clean.
10 package issues
11
12 import (
13 "context"
14 "errors"
15 "fmt"
16 "log/slog"
17 "strings"
18 "time"
19
20 "github.com/jackc/pgx/v5"
21 "github.com/jackc/pgx/v5/pgtype"
22 "github.com/jackc/pgx/v5/pgxpool"
23
24 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
25 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
26 mdrender "github.com/tenseleyFlow/shithub/internal/markdown"
27 )
28
29 // Deps wires this package against the rest of the runtime. Pool is
30 // required; Limiter governs the comment-create rate limit; Logger is
31 // optional (falls back to discarding when nil).
32 type Deps struct {
33 Pool *pgxpool.Pool
34 Limiter *throttle.Limiter
35 Logger *slog.Logger
36 }
37
38 // Errors returned by the orchestrator. Handlers map these to status
39 // codes + friendly user-facing messages.
40 var (
41 ErrEmptyTitle = errors.New("issues: title is required")
42 ErrTitleTooLong = errors.New("issues: title too long (max 256)")
43 ErrBodyTooLong = errors.New("issues: body too long")
44 ErrCommentTooLong = errors.New("issues: comment too long")
45 ErrIssueLocked = errors.New("issues: issue is locked")
46 ErrCommentRateLimit = errors.New("issues: comment rate limit exceeded")
47 ErrLabelExists = errors.New("issues: label name already taken on this repo")
48 ErrLabelInvalidColor = errors.New("issues: label color must be 6 hex chars")
49 ErrMilestoneExists = errors.New("issues: milestone title already taken on this repo")
50 ErrIssueNotFound = errors.New("issues: issue not found")
51 )
52
53 // CreateParams describes a new-issue request.
54 type CreateParams struct {
55 RepoID int64
56 AuthorUserID int64 // 0 means anonymous (unsupported in S21; handler enforces)
57 Title string
58 Body string
59 // Kind defaults to "issue"; PR creation in S22 passes "pr".
60 Kind string
61 }
62
63 // Create validates inputs, allocates a per-repo number atomically,
64 // inserts the row, renders the body's markdown, and returns the
65 // fresh issue. Default labels live with repo create; per-issue
66 // label/assignee/milestone application is a separate call.
67 //
68 // Cross-reference indexing fires asynchronously inside the same tx
69 // via insertReferencesFromBody — refs to other issues create
70 // `issue_references` rows + a `referenced` event on the target.
71 func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, error) {
72 title := strings.TrimSpace(p.Title)
73 if title == "" {
74 return issuesdb.Issue{}, ErrEmptyTitle
75 }
76 if len(title) > 256 {
77 return issuesdb.Issue{}, ErrTitleTooLong
78 }
79 if len(p.Body) > 65535 {
80 return issuesdb.Issue{}, ErrBodyTooLong
81 }
82 kind := p.Kind
83 if kind == "" {
84 kind = "issue"
85 }
86
87 tx, err := deps.Pool.Begin(ctx)
88 if err != nil {
89 return issuesdb.Issue{}, fmt.Errorf("begin: %w", err)
90 }
91 committed := false
92 defer func() {
93 if !committed {
94 _ = tx.Rollback(ctx)
95 }
96 }()
97
98 q := issuesdb.New()
99 if err := q.EnsureRepoIssueCounter(ctx, tx, p.RepoID); err != nil {
100 return issuesdb.Issue{}, fmt.Errorf("counter init: %w", err)
101 }
102 num, err := q.AllocateIssueNumber(ctx, tx, p.RepoID)
103 if err != nil {
104 return issuesdb.Issue{}, fmt.Errorf("allocate number: %w", err)
105 }
106
107 row, err := q.CreateIssue(ctx, tx, issuesdb.CreateIssueParams{
108 RepoID: p.RepoID,
109 Number: num,
110 Kind: issuesdb.IssueKind(kind),
111 Title: title,
112 Body: p.Body,
113 AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
114 })
115 if err != nil {
116 return issuesdb.Issue{}, fmt.Errorf("insert: %w", err)
117 }
118
119 // Render markdown for the cached body html.
120 html, _ := mdrender.RenderHTML([]byte(p.Body))
121 row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""}
122
123 if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{
124 ID: row.ID, Title: row.Title, Body: row.Body,
125 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
126 }); err != nil {
127 return issuesdb.Issue{}, fmt.Errorf("update html: %w", err)
128 }
129
130 if err := insertReferencesFromBody(ctx, tx, deps, row, p.Body, "issue_body", row.ID); err != nil {
131 return issuesdb.Issue{}, fmt.Errorf("refs: %w", err)
132 }
133
134 if err := tx.Commit(ctx); err != nil {
135 return issuesdb.Issue{}, fmt.Errorf("commit: %w", err)
136 }
137 committed = true
138 return row, nil
139 }
140
141 // CommentCreateParams is the input for AddComment.
142 type CommentCreateParams struct {
143 IssueID int64
144 AuthorUserID int64
145 Body string
146 // IsCollab signals that the actor's policy.Can role is at least
147 // triage. Used to bypass the locked-issue gate.
148 IsCollab bool
149 }
150
151 // AddComment validates, applies the rate limit, inserts the comment,
152 // and indexes references. Returns the fresh comment.
153 func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) {
154 body := strings.TrimSpace(p.Body)
155 if body == "" || len(body) > 65535 {
156 return issuesdb.IssueComment{}, ErrCommentTooLong
157 }
158 if deps.Limiter != nil && p.AuthorUserID != 0 {
159 if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
160 Scope: "issue_comment",
161 Identifier: fmt.Sprintf("user:%d", p.AuthorUserID),
162 Max: 20,
163 Window: time.Hour,
164 }); err != nil {
165 return issuesdb.IssueComment{}, ErrCommentRateLimit
166 }
167 }
168
169 q := issuesdb.New()
170 issue, err := q.GetIssueByID(ctx, deps.Pool, p.IssueID)
171 if err != nil {
172 if errors.Is(err, pgx.ErrNoRows) {
173 return issuesdb.IssueComment{}, ErrIssueNotFound
174 }
175 return issuesdb.IssueComment{}, err
176 }
177 if issue.Locked && !p.IsCollab {
178 return issuesdb.IssueComment{}, ErrIssueLocked
179 }
180
181 html, _ := mdrender.RenderHTML([]byte(body))
182
183 tx, err := deps.Pool.Begin(ctx)
184 if err != nil {
185 return issuesdb.IssueComment{}, err
186 }
187 committed := false
188 defer func() {
189 if !committed {
190 _ = tx.Rollback(ctx)
191 }
192 }()
193
194 c, err := q.CreateIssueComment(ctx, tx, issuesdb.CreateIssueCommentParams{
195 IssueID: p.IssueID,
196 AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
197 Body: body,
198 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
199 })
200 if err != nil {
201 return issuesdb.IssueComment{}, err
202 }
203
204 if err := insertReferencesFromBody(ctx, tx, deps, issue, body, "comment_body", c.ID); err != nil {
205 return issuesdb.IssueComment{}, err
206 }
207
208 if err := tx.Commit(ctx); err != nil {
209 return issuesdb.IssueComment{}, err
210 }
211 committed = true
212 return c, nil
213 }
214
215 // SetState closes or reopens an issue and emits an `closed` /
216 // `reopened` event.
217 func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error {
218 if newState != "open" && newState != "closed" {
219 return errors.New("issues: state must be open or closed")
220 }
221 tx, err := deps.Pool.Begin(ctx)
222 if err != nil {
223 return err
224 }
225 committed := false
226 defer func() {
227 if !committed {
228 _ = tx.Rollback(ctx)
229 }
230 }()
231
232 q := issuesdb.New()
233 closedBy := pgtype.Int8{}
234 if newState == "closed" && actorUserID != 0 {
235 closedBy = pgtype.Int8{Int64: actorUserID, Valid: true}
236 }
237 stateReason := pgtype.Text{}
238 if reason != "" {
239 stateReason = pgtype.Text{String: reason, Valid: true}
240 }
241 if err := q.SetIssueState(ctx, tx, issuesdb.SetIssueStateParams{
242 ID: issueID,
243 State: issuesdb.IssueState(newState),
244 StateReason: issuesdb.NullIssueStateReason{IssueStateReason: issuesdb.IssueStateReason(stateReason.String), Valid: stateReason.Valid},
245 ClosedByUserID: closedBy,
246 }); err != nil {
247 return err
248 }
249 kind := "closed"
250 if newState == "open" {
251 kind = "reopened"
252 }
253 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
254 IssueID: issueID,
255 ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
256 Kind: kind,
257 Meta: emptyMeta,
258 }); err != nil {
259 return err
260 }
261 if err := tx.Commit(ctx); err != nil {
262 return err
263 }
264 committed = true
265 return nil
266 }
267
268 // emptyMeta is the JSON object passed when an event carries no metadata.
269 // The column is NOT NULL DEFAULT '{}'::jsonb, but binding nil from Go
270 // sends SQL NULL rather than letting the DEFAULT fire — so callers
271 // pass this explicitly.
272 var emptyMeta = []byte("{}")
273
274 // SetLock toggles the locked flag and emits an event.
275 func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked bool, reason string) error {
276 q := issuesdb.New()
277 tx, err := deps.Pool.Begin(ctx)
278 if err != nil {
279 return err
280 }
281 committed := false
282 defer func() {
283 if !committed {
284 _ = tx.Rollback(ctx)
285 }
286 }()
287 rsn := pgtype.Text{}
288 if reason != "" {
289 rsn = pgtype.Text{String: reason, Valid: true}
290 }
291 if err := q.SetIssueLock(ctx, tx, issuesdb.SetIssueLockParams{
292 ID: issueID, Locked: locked, LockReason: rsn,
293 }); err != nil {
294 return err
295 }
296 kind := "locked"
297 if !locked {
298 kind = "unlocked"
299 }
300 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
301 IssueID: issueID, ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
302 Kind: kind,
303 Meta: emptyMeta,
304 }); err != nil {
305 return err
306 }
307 if err := tx.Commit(ctx); err != nil {
308 return err
309 }
310 committed = true
311 return nil
312 }
313