@@ -124,7 +124,7 @@ func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, err |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | // Render markdown for the cached body html. |
| 127 | | - html := renderBodyHTML(ctx, deps, p.Body) |
| 127 | + html, mentions := renderBody(ctx, deps, p.Body) |
| 128 | 128 | row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""} |
| 129 | 129 | |
| 130 | 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 | 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 | 156 | if err := tx.Commit(ctx); err != nil { |
| 142 | 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 | 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 | 205 | tx, err := deps.Pool.Begin(ctx) |
| 191 | 206 | if err != nil { |
@@ -212,6 +227,16 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb |
| 212 | 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 | 240 | if err := tx.Commit(ctx); err != nil { |
| 216 | 241 | return issuesdb.IssueComment{}, err |
| 217 | 242 | } |
@@ -270,6 +295,18 @@ func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newSta |
| 270 | 295 | }); err != nil { |
| 271 | 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 | 310 | if err := tx.Commit(ctx); err != nil { |
| 274 | 311 | return err |
| 275 | 312 | } |
@@ -340,10 +377,26 @@ func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked |
| 340 | 377 | // somewhere upstream regressed. The audit (S00-S25, M) flagged the |
| 341 | 378 | // `_`-discard pattern as the kind of slop where a real bug could hide. |
| 342 | 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 | 396 | if err != nil && deps.Logger != nil { |
| 345 | 397 | deps.Logger.WarnContext(ctx, "issues: markdown render failed", |
| 346 | 398 | "error", err, "body_bytes", len(body)) |
| 399 | + return "", nil |
| 347 | 400 | } |
| 348 | | - return html |
| 401 | + return string(html), mentions |
| 349 | 402 | } |