@@ -124,7 +124,7 @@ func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, err |
| 124 | } | 124 | } |
| 125 | | 125 | |
| 126 | // Render markdown for the cached body html. | 126 | // Render markdown for the cached body html. |
| 127 | - html := renderBodyHTML(ctx, deps, p.Body) | 127 | + html, mentions := renderBody(ctx, deps, p.Body) |
| 128 | row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""} | 128 | row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""} |
| 129 | | 129 | |
| 130 | if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{ | 130 | if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{ |
@@ -138,6 +138,21 @@ func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, err |
| 138 | return issuesdb.Issue{}, fmt.Errorf("refs: %w", err) | 138 | return issuesdb.Issue{}, fmt.Errorf("refs: %w", err) |
| 139 | } | 139 | } |
| 140 | | 140 | |
| | 141 | + // Emit the domain event in the same tx as the issue row so a |
| | 142 | + // rollback drops both. Mention resolution happens *after* commit |
| | 143 | + // to avoid holding the row lock through user-id lookups; the |
| | 144 | + // fan-out worker reads payload.mentions to drive @-ping |
| | 145 | + // recipients. |
| | 146 | + mentionIDs := mentionUserIDs(ctx, deps.Pool, mentions) |
| | 147 | + repoVis, _ := repoVisibilityPublic(ctx, tx, p.RepoID) |
| | 148 | + eventKind := "issue_created" |
| | 149 | + if kind == "pr" { |
| | 150 | + eventKind = "pr_opened" |
| | 151 | + } |
| | 152 | + if err := emitIssueEventTx(ctx, tx, eventKind, row, p.AuthorUserID, repoVis, mentionIDs); err != nil { |
| | 153 | + return issuesdb.Issue{}, fmt.Errorf("emit event: %w", err) |
| | 154 | + } |
| | 155 | + |
| 141 | if err := tx.Commit(ctx); err != nil { | 156 | if err := tx.Commit(ctx); err != nil { |
| 142 | return issuesdb.Issue{}, fmt.Errorf("commit: %w", err) | 157 | return issuesdb.Issue{}, fmt.Errorf("commit: %w", err) |
| 143 | } | 158 | } |
@@ -185,7 +200,7 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb |
| 185 | return issuesdb.IssueComment{}, ErrIssueLocked | 200 | return issuesdb.IssueComment{}, ErrIssueLocked |
| 186 | } | 201 | } |
| 187 | | 202 | |
| 188 | - html := renderBodyHTML(ctx, deps, body) | 203 | + html, mentions := renderBody(ctx, deps, body) |
| 189 | | 204 | |
| 190 | tx, err := deps.Pool.Begin(ctx) | 205 | tx, err := deps.Pool.Begin(ctx) |
| 191 | if err != nil { | 206 | if err != nil { |
@@ -212,6 +227,16 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb |
| 212 | return issuesdb.IssueComment{}, err | 227 | return issuesdb.IssueComment{}, err |
| 213 | } | 228 | } |
| 214 | | 229 | |
| | 230 | + mentionIDs := mentionUserIDs(ctx, deps.Pool, mentions) |
| | 231 | + repoVis, _ := repoVisibilityPublic(ctx, tx, issue.RepoID) |
| | 232 | + commentKind := "issue_comment_created" |
| | 233 | + if issue.Kind == issuesdb.IssueKindPr { |
| | 234 | + commentKind = "pr_comment_created" |
| | 235 | + } |
| | 236 | + if err := emitCommentEventTx(ctx, tx, commentKind, issue, c.ID, p.AuthorUserID, repoVis, mentionIDs); err != nil { |
| | 237 | + return issuesdb.IssueComment{}, fmt.Errorf("emit event: %w", err) |
| | 238 | + } |
| | 239 | + |
| 215 | if err := tx.Commit(ctx); err != nil { | 240 | if err := tx.Commit(ctx); err != nil { |
| 216 | return issuesdb.IssueComment{}, err | 241 | return issuesdb.IssueComment{}, err |
| 217 | } | 242 | } |
@@ -270,6 +295,18 @@ func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newSta |
| 270 | }); err != nil { | 295 | }); err != nil { |
| 271 | return err | 296 | return err |
| 272 | } | 297 | } |
| | 298 | + // Lifecycle domain event so the notif fan-out + S33 webhook |
| | 299 | + // pipeline pick up state changes the same way they pick up |
| | 300 | + // comments. |
| | 301 | + issue, _ := q.GetIssueByID(ctx, tx, issueID) |
| | 302 | + repoVis, _ := repoVisibilityPublic(ctx, tx, issue.RepoID) |
| | 303 | + stateKind := "issue_" + kind // issue_closed | issue_reopened |
| | 304 | + if issue.Kind == issuesdb.IssueKindPr { |
| | 305 | + stateKind = "pr_" + kind |
| | 306 | + } |
| | 307 | + if err := emitIssueEventTx(ctx, tx, stateKind, issue, actorUserID, repoVis, nil); err != nil { |
| | 308 | + return fmt.Errorf("emit event: %w", err) |
| | 309 | + } |
| 273 | if err := tx.Commit(ctx); err != nil { | 310 | if err := tx.Commit(ctx); err != nil { |
| 274 | return err | 311 | return err |
| 275 | } | 312 | } |
@@ -340,10 +377,26 @@ func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked |
| 340 | // somewhere upstream regressed. The audit (S00-S25, M) flagged the | 377 | // somewhere upstream regressed. The audit (S00-S25, M) flagged the |
| 341 | // `_`-discard pattern as the kind of slop where a real bug could hide. | 378 | // `_`-discard pattern as the kind of slop where a real bug could hide. |
| 342 | func renderBodyHTML(ctx context.Context, deps Deps, body string) string { | 379 | func renderBodyHTML(ctx context.Context, deps Deps, body string) string { |
| 343 | - html, err := mdrender.RenderHTML([]byte(body)) | 380 | + html, _ := renderBody(ctx, deps, body) |
| | 381 | + return html |
| | 382 | +} |
| | 383 | + |
| | 384 | +// renderBody is the mention-aware variant. Returns the cleaned HTML |
| | 385 | +// plus the resolved mentions list — callers that emit notification |
| | 386 | +// events use the mentions to fan out @-pings. The `_` shimming under |
| | 387 | +// renderBodyHTML keeps existing call sites that don't care about |
| | 388 | +// mentions untouched. |
| | 389 | +func renderBody(ctx context.Context, deps Deps, body string) (string, []mdrender.Mention) { |
| | 390 | + if body == "" { |
| | 391 | + return "", nil |
| | 392 | + } |
| | 393 | + html, _, mentions, err := mdrender.Render(ctx, []byte(body), mdrender.Options{ |
| | 394 | + SoftBreakAsBR: true, |
| | 395 | + }) |
| 344 | if err != nil && deps.Logger != nil { | 396 | if err != nil && deps.Logger != nil { |
| 345 | deps.Logger.WarnContext(ctx, "issues: markdown render failed", | 397 | deps.Logger.WarnContext(ctx, "issues: markdown render failed", |
| 346 | "error", err, "body_bytes", len(body)) | 398 | "error", err, "body_bytes", len(body)) |
| | 399 | + return "", nil |
| 347 | } | 400 | } |
| 348 | - return html | 401 | + return string(html), mentions |
| 349 | } | 402 | } |