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