Go · 13729 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 "strconv"
18 "strings"
19 "time"
20
21 "github.com/jackc/pgx/v5"
22 "github.com/jackc/pgx/v5/pgtype"
23 "github.com/jackc/pgx/v5/pgxpool"
24
25 "github.com/tenseleyFlow/shithub/internal/auth/audit"
26 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
27 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
28 mdrender "github.com/tenseleyFlow/shithub/internal/markdown"
29 )
30
31 // Deps wires this package against the rest of the runtime. Pool is
32 // required; Limiter governs the comment-create rate limit; Logger is
33 // optional (falls back to discarding when nil).
34 type Deps struct {
35 Pool *pgxpool.Pool
36 Limiter *throttle.Limiter
37 Logger *slog.Logger
38 // Audit is optional; when non-nil, state-changing orchestrator
39 // calls (SetState, SetLock, AddComment) record an audit row. The
40 // repo lifecycle package writes audit rows directly via deps.Audit;
41 // this field ensures issues/PR mutations are equally traceable
42 // (S00-S25 audit, M).
43 Audit *audit.Recorder
44 }
45
46 // Errors returned by the orchestrator. Handlers map these to status
47 // codes + friendly user-facing messages.
48 var (
49 ErrEmptyTitle = errors.New("issues: title is required")
50 ErrTitleTooLong = errors.New("issues: title too long (max 256)")
51 ErrBodyTooLong = errors.New("issues: body too long")
52 ErrEmptyComment = errors.New("issues: comment body is required")
53 ErrCommentTooLong = errors.New("issues: comment too long")
54 ErrIssueLocked = errors.New("issues: issue is locked")
55 ErrCommentRateLimit = errors.New("issues: comment rate limit exceeded")
56 ErrLabelExists = errors.New("issues: label name already taken on this repo")
57 ErrLabelInvalidColor = errors.New("issues: label color must be 6 hex chars")
58 ErrMilestoneExists = errors.New("issues: milestone title already taken on this repo")
59 ErrIssueNotFound = errors.New("issues: issue not found")
60 )
61
62 // CreateParams describes a new-issue request.
63 type CreateParams struct {
64 RepoID int64
65 AuthorUserID int64 // 0 means anonymous (unsupported in S21; handler enforces)
66 Title string
67 Body string
68 // Kind defaults to "issue"; PR creation in S22 passes "pr".
69 Kind string
70 }
71
72 // Create validates inputs, allocates a per-repo number atomically,
73 // inserts the row, renders the body's markdown, and returns the
74 // fresh issue. Default labels live with repo create; per-issue
75 // label/assignee/milestone application is a separate call.
76 //
77 // Cross-reference indexing fires asynchronously inside the same tx
78 // via insertReferencesFromBody — refs to other issues create
79 // `issue_references` rows + a `referenced` event on the target.
80 func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, error) {
81 title := strings.TrimSpace(p.Title)
82 if title == "" {
83 return issuesdb.Issue{}, ErrEmptyTitle
84 }
85 if len(title) > 256 {
86 return issuesdb.Issue{}, ErrTitleTooLong
87 }
88 if len(p.Body) > 65535 {
89 return issuesdb.Issue{}, ErrBodyTooLong
90 }
91 kind := p.Kind
92 if kind == "" {
93 kind = "issue"
94 }
95
96 tx, err := deps.Pool.Begin(ctx)
97 if err != nil {
98 return issuesdb.Issue{}, fmt.Errorf("begin: %w", err)
99 }
100 committed := false
101 defer func() {
102 if !committed {
103 _ = tx.Rollback(ctx)
104 }
105 }()
106
107 q := issuesdb.New()
108 if err := q.EnsureRepoIssueCounter(ctx, tx, p.RepoID); err != nil {
109 return issuesdb.Issue{}, fmt.Errorf("counter init: %w", err)
110 }
111 num, err := q.AllocateIssueNumber(ctx, tx, p.RepoID)
112 if err != nil {
113 return issuesdb.Issue{}, fmt.Errorf("allocate number: %w", err)
114 }
115
116 row, err := q.CreateIssue(ctx, tx, issuesdb.CreateIssueParams{
117 RepoID: p.RepoID,
118 Number: num,
119 Kind: issuesdb.IssueKind(kind),
120 Title: title,
121 Body: p.Body,
122 AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
123 })
124 if err != nil {
125 return issuesdb.Issue{}, fmt.Errorf("insert: %w", err)
126 }
127
128 // Render markdown for the cached body html.
129 html, mentions := renderBody(ctx, deps, p.Body)
130 row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""}
131
132 if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{
133 ID: row.ID, Title: row.Title, Body: row.Body,
134 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
135 }); err != nil {
136 return issuesdb.Issue{}, fmt.Errorf("update html: %w", err)
137 }
138
139 if err := insertReferencesFromBody(ctx, tx, deps, row, p.Body, "issue_body", row.ID); err != nil {
140 return issuesdb.Issue{}, fmt.Errorf("refs: %w", err)
141 }
142
143 // Emit the domain event in the same tx as the issue row so a
144 // rollback drops both. Mention resolution happens *after* commit
145 // to avoid holding the row lock through user-id lookups; the
146 // fan-out worker reads payload.mentions to drive @-ping
147 // recipients.
148 mentionIDs := mentionUserIDs(ctx, deps.Pool, mentions)
149 repoVis, _ := repoVisibilityPublic(ctx, tx, p.RepoID)
150 eventKind := "issue_created"
151 if kind == "pr" {
152 eventKind = "pr_opened"
153 }
154 if err := emitIssueEventTx(ctx, tx, eventKind, row, p.AuthorUserID, repoVis, mentionIDs); err != nil {
155 return issuesdb.Issue{}, fmt.Errorf("emit event: %w", err)
156 }
157
158 if err := tx.Commit(ctx); err != nil {
159 return issuesdb.Issue{}, fmt.Errorf("commit: %w", err)
160 }
161 committed = true
162 return row, nil
163 }
164
165 // CommentCreateParams is the input for AddComment.
166 type CommentCreateParams struct {
167 IssueID int64
168 AuthorUserID int64
169 Body string
170 // IsCollab signals that the actor's policy.Can role is at least
171 // triage. Used to bypass the locked-issue gate.
172 IsCollab bool
173 }
174
175 // AddComment validates, applies the rate limit, inserts the comment,
176 // and indexes references. Returns the fresh comment.
177 func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) {
178 body := strings.TrimSpace(p.Body)
179 if body == "" {
180 return issuesdb.IssueComment{}, ErrEmptyComment
181 }
182 if len(body) > 65535 {
183 return issuesdb.IssueComment{}, ErrCommentTooLong
184 }
185 if deps.Limiter != nil && p.AuthorUserID != 0 {
186 if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
187 Scope: "issue_comment",
188 Identifier: fmt.Sprintf("user:%d", p.AuthorUserID),
189 Max: 20,
190 Window: time.Hour,
191 }); err != nil {
192 return issuesdb.IssueComment{}, ErrCommentRateLimit
193 }
194 }
195
196 q := issuesdb.New()
197 issue, err := q.GetIssueByID(ctx, deps.Pool, p.IssueID)
198 if err != nil {
199 if errors.Is(err, pgx.ErrNoRows) {
200 return issuesdb.IssueComment{}, ErrIssueNotFound
201 }
202 return issuesdb.IssueComment{}, err
203 }
204 if issue.Locked && !p.IsCollab {
205 return issuesdb.IssueComment{}, ErrIssueLocked
206 }
207
208 html, mentions := renderBody(ctx, deps, body)
209
210 tx, err := deps.Pool.Begin(ctx)
211 if err != nil {
212 return issuesdb.IssueComment{}, err
213 }
214 committed := false
215 defer func() {
216 if !committed {
217 _ = tx.Rollback(ctx)
218 }
219 }()
220
221 c, err := q.CreateIssueComment(ctx, tx, issuesdb.CreateIssueCommentParams{
222 IssueID: p.IssueID,
223 AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
224 Body: body,
225 BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
226 })
227 if err != nil {
228 return issuesdb.IssueComment{}, err
229 }
230
231 if err := insertReferencesFromBody(ctx, tx, deps, issue, body, "comment_body", c.ID); err != nil {
232 return issuesdb.IssueComment{}, err
233 }
234
235 mentionIDs := mentionUserIDs(ctx, deps.Pool, mentions)
236 repoVis, _ := repoVisibilityPublic(ctx, tx, issue.RepoID)
237 commentKind := "issue_comment_created"
238 if issue.Kind == issuesdb.IssueKindPr {
239 commentKind = "pr_comment_created"
240 }
241 if err := emitCommentEventTx(ctx, tx, commentKind, issue, c.ID, p.AuthorUserID, repoVis, mentionIDs); err != nil {
242 return issuesdb.IssueComment{}, fmt.Errorf("emit event: %w", err)
243 }
244
245 if err := tx.Commit(ctx); err != nil {
246 return issuesdb.IssueComment{}, err
247 }
248 committed = true
249 if deps.Audit != nil {
250 _ = deps.Audit.Record(ctx, deps.Pool, p.AuthorUserID,
251 audit.ActionIssueCommentCreated, audit.TargetIssue, p.IssueID,
252 map[string]any{"comment_id": c.ID})
253 }
254 return c, nil
255 }
256
257 // SetState closes or reopens an issue and emits an `closed` /
258 // `reopened` event.
259 func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error {
260 return setState(ctx, deps, actorUserID, issueID, newState, reason, 0)
261 }
262
263 // SetStateWithComment is used by the issue comment form when the submit
264 // button both creates a comment and changes state. The timeline event stores
265 // the comment id so the UI can keep the state badge adjacent to the comment
266 // that caused it.
267 func SetStateWithComment(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string, commentID int64) error {
268 return setState(ctx, deps, actorUserID, issueID, newState, reason, commentID)
269 }
270
271 func setState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string, commentID int64) error {
272 if newState != "open" && newState != "closed" {
273 return errors.New("issues: state must be open or closed")
274 }
275 tx, err := deps.Pool.Begin(ctx)
276 if err != nil {
277 return err
278 }
279 committed := false
280 defer func() {
281 if !committed {
282 _ = tx.Rollback(ctx)
283 }
284 }()
285
286 q := issuesdb.New()
287 closedBy := pgtype.Int8{}
288 if newState == "closed" && actorUserID != 0 {
289 closedBy = pgtype.Int8{Int64: actorUserID, Valid: true}
290 }
291 stateReason := pgtype.Text{}
292 if reason != "" {
293 stateReason = pgtype.Text{String: reason, Valid: true}
294 }
295 if err := q.SetIssueState(ctx, tx, issuesdb.SetIssueStateParams{
296 ID: issueID,
297 State: issuesdb.IssueState(newState),
298 StateReason: issuesdb.NullIssueStateReason{IssueStateReason: issuesdb.IssueStateReason(stateReason.String), Valid: stateReason.Valid},
299 ClosedByUserID: closedBy,
300 }); err != nil {
301 return err
302 }
303 kind := "closed"
304 if newState == "open" {
305 kind = "reopened"
306 }
307 meta := emptyMeta
308 if commentID != 0 {
309 meta = []byte(`{"comment_id":` + strconv.FormatInt(commentID, 10) + `}`)
310 }
311 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
312 IssueID: issueID,
313 ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
314 Kind: kind,
315 Meta: meta,
316 }); err != nil {
317 return err
318 }
319 // Lifecycle domain event so the notif fan-out + S33 webhook
320 // pipeline pick up state changes the same way they pick up
321 // comments.
322 issue, _ := q.GetIssueByID(ctx, tx, issueID)
323 repoVis, _ := repoVisibilityPublic(ctx, tx, issue.RepoID)
324 stateKind := "issue_" + kind // issue_closed | issue_reopened
325 if issue.Kind == issuesdb.IssueKindPr {
326 stateKind = "pr_" + kind
327 }
328 if err := emitIssueEventTx(ctx, tx, stateKind, issue, actorUserID, repoVis, nil); err != nil {
329 return fmt.Errorf("emit event: %w", err)
330 }
331 if err := tx.Commit(ctx); err != nil {
332 return err
333 }
334 committed = true
335 if deps.Audit != nil {
336 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
337 audit.ActionIssueStateChanged, audit.TargetIssue, issueID,
338 map[string]any{"state": newState, "reason": reason})
339 }
340 return nil
341 }
342
343 // emptyMeta is the JSON object passed when an event carries no metadata.
344 // The column is NOT NULL DEFAULT '{}'::jsonb, but binding nil from Go
345 // sends SQL NULL rather than letting the DEFAULT fire — so callers
346 // pass this explicitly.
347 var emptyMeta = []byte("{}")
348
349 // SetLock toggles the locked flag and emits an event.
350 func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked bool, reason string) error {
351 q := issuesdb.New()
352 tx, err := deps.Pool.Begin(ctx)
353 if err != nil {
354 return err
355 }
356 committed := false
357 defer func() {
358 if !committed {
359 _ = tx.Rollback(ctx)
360 }
361 }()
362 rsn := pgtype.Text{}
363 if reason != "" {
364 rsn = pgtype.Text{String: reason, Valid: true}
365 }
366 if err := q.SetIssueLock(ctx, tx, issuesdb.SetIssueLockParams{
367 ID: issueID, Locked: locked, LockReason: rsn,
368 }); err != nil {
369 return err
370 }
371 kind := "locked"
372 if !locked {
373 kind = "unlocked"
374 }
375 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
376 IssueID: issueID, ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
377 Kind: kind,
378 Meta: emptyMeta,
379 }); err != nil {
380 return err
381 }
382 if err := tx.Commit(ctx); err != nil {
383 return err
384 }
385 committed = true
386 if deps.Audit != nil {
387 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
388 audit.ActionIssueLockChanged, audit.TargetIssue, issueID,
389 map[string]any{"locked": locked, "reason": reason})
390 }
391 return nil
392 }
393
394 // renderBody renders markdown to sanitized HTML and returns the
395 // resolved mention list. Body length is bounded upstream
396 // (orchestrator validation + DB CHECK at 65535), so
397 // ErrInputTooLarge is structurally impossible here — but if it ever
398 // fires, log loudly: it means a precondition somewhere upstream
399 // regressed. Mentions feed the S29 fan-out worker via the event
400 // payload's `mentions` array.
401 func renderBody(ctx context.Context, deps Deps, body string) (string, []mdrender.Mention) {
402 if body == "" {
403 return "", nil
404 }
405 html, _, mentions, err := mdrender.Render(ctx, []byte(body), mdrender.Options{
406 SoftBreakAsBR: true,
407 })
408 if err != nil && deps.Logger != nil {
409 deps.Logger.WarnContext(ctx, "issues: markdown render failed",
410 "error", err, "body_bytes", len(body))
411 return "", nil
412 }
413 return string(html), mentions
414 }
415