@@ -254,6 +254,73 @@ func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb |
| 254 | 254 | return c, nil |
| 255 | 255 | } |
| 256 | 256 | |
| 257 | +// EditParams describes a title/body update applied to an existing |
| 258 | +// issue. Both fields are optional pointers: nil means "leave as-is". |
| 259 | +type EditParams struct { |
| 260 | + IssueID int64 |
| 261 | + Title *string |
| 262 | + Body *string |
| 263 | +} |
| 264 | + |
| 265 | +// Edit updates an issue's title and/or body, re-rendering the cached |
| 266 | +// markdown body and re-indexing cross-references. Pure plumbing: the |
| 267 | +// caller is responsible for the policy check before invoking. |
| 268 | +func Edit(ctx context.Context, deps Deps, p EditParams) (issuesdb.Issue, error) { |
| 269 | + q := issuesdb.New() |
| 270 | + tx, err := deps.Pool.Begin(ctx) |
| 271 | + if err != nil { |
| 272 | + return issuesdb.Issue{}, fmt.Errorf("begin: %w", err) |
| 273 | + } |
| 274 | + committed := false |
| 275 | + defer func() { |
| 276 | + if !committed { |
| 277 | + _ = tx.Rollback(ctx) |
| 278 | + } |
| 279 | + }() |
| 280 | + cur, err := q.GetIssueByID(ctx, tx, p.IssueID) |
| 281 | + if err != nil { |
| 282 | + return issuesdb.Issue{}, err |
| 283 | + } |
| 284 | + title := cur.Title |
| 285 | + if p.Title != nil { |
| 286 | + title = strings.TrimSpace(*p.Title) |
| 287 | + if title == "" { |
| 288 | + return issuesdb.Issue{}, ErrEmptyTitle |
| 289 | + } |
| 290 | + if len(title) > 256 { |
| 291 | + return issuesdb.Issue{}, ErrTitleTooLong |
| 292 | + } |
| 293 | + } |
| 294 | + body := cur.Body |
| 295 | + if p.Body != nil { |
| 296 | + body = *p.Body |
| 297 | + if len(body) > 65535 { |
| 298 | + return issuesdb.Issue{}, ErrBodyTooLong |
| 299 | + } |
| 300 | + } |
| 301 | + html, _ := renderBody(ctx, deps, body) |
| 302 | + if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{ |
| 303 | + ID: p.IssueID, Title: title, Body: body, |
| 304 | + BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""}, |
| 305 | + }); err != nil { |
| 306 | + return issuesdb.Issue{}, fmt.Errorf("update: %w", err) |
| 307 | + } |
| 308 | + // Re-index cross-references when the body changed; new refs land, |
| 309 | + // stale ones are no longer indexed (existing rows are left alone, |
| 310 | + // matching how GitHub leaves prior references in place). |
| 311 | + if p.Body != nil { |
| 312 | + fresh, _ := q.GetIssueByID(ctx, tx, p.IssueID) |
| 313 | + if err := insertReferencesFromBody(ctx, tx, deps, fresh, body, "issue_body", fresh.ID); err != nil { |
| 314 | + return issuesdb.Issue{}, fmt.Errorf("refs: %w", err) |
| 315 | + } |
| 316 | + } |
| 317 | + if err := tx.Commit(ctx); err != nil { |
| 318 | + return issuesdb.Issue{}, fmt.Errorf("commit: %w", err) |
| 319 | + } |
| 320 | + committed = true |
| 321 | + return q.GetIssueByID(ctx, deps.Pool, p.IssueID) |
| 322 | +} |
| 323 | + |
| 257 | 324 | // SetState closes or reopens an issue and emits an `closed` / |
| 258 | 325 | // `reopened` event. |
| 259 | 326 | func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error { |