A vibe coded tangled fork which supports pijul.
at master 757 lines 23 kB view raw
1package discussions 2 3import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "strconv" 9 "time" 10 11 "github.com/bluesky-social/indigo/xrpc" 12 "github.com/go-chi/chi/v5" 13 14 tangled "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/mentions" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/repoinfo" 23 "tangled.org/core/appview/pagination" 24 "tangled.org/core/appview/reporesolver" 25 "tangled.org/core/appview/validator" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/orm" 28 "tangled.org/core/rbac" 29 "tangled.org/core/tid" 30) 31 32// Discussions handles the discussions feature for Pijul repositories 33type Discussions struct { 34 oauth *oauth.OAuth 35 repoResolver *reporesolver.RepoResolver 36 enforcer *rbac.Enforcer 37 pages *pages.Pages 38 idResolver *idresolver.Resolver 39 mentionsResolver *mentions.Resolver 40 db *db.DB 41 config *config.Config 42 notifier notify.Notifier 43 logger *slog.Logger 44 validator *validator.Validator 45} 46 47func New( 48 oauth *oauth.OAuth, 49 repoResolver *reporesolver.RepoResolver, 50 enforcer *rbac.Enforcer, 51 pages *pages.Pages, 52 idResolver *idresolver.Resolver, 53 mentionsResolver *mentions.Resolver, 54 db *db.DB, 55 config *config.Config, 56 notifier notify.Notifier, 57 validator *validator.Validator, 58 logger *slog.Logger, 59) *Discussions { 60 return &Discussions{ 61 oauth: oauth, 62 repoResolver: repoResolver, 63 enforcer: enforcer, 64 pages: pages, 65 idResolver: idResolver, 66 mentionsResolver: mentionsResolver, 67 db: db, 68 config: config, 69 notifier: notifier, 70 logger: logger, 71 validator: validator, 72 } 73} 74 75// rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 76// rolesFor returns the RolesInRepo for the given user in the repo described by repoInfo. 77func (d *Discussions) rolesFor(userDid string, ri repoinfo.RepoInfo) repoinfo.RolesInRepo { 78 return repoinfo.RolesInRepo{ 79 Roles: d.enforcer.GetPermissionsInRepo(userDid, ri.Knot, ri.OwnerDid+"/"+ri.Name), 80 } 81} 82 83// RepoDiscussionsList shows all discussions for a Pijul repository 84func (d *Discussions) RepoDiscussionsList(w http.ResponseWriter, r *http.Request) { 85 l := d.logger.With("handler", "RepoDiscussionsList") 86 user := d.oauth.GetMultiAccountUser(r) 87 88 repo, err := d.repoResolver.Resolve(r) 89 if err != nil { 90 l.Error("failed to get repo", "err", err) 91 d.pages.Error404(w) 92 return 93 } 94 95 // Only allow discussions for Pijul repos 96 if !repo.IsPijul() { 97 l.Info("discussions only available for pijul repos") 98 d.pages.Error404(w) 99 return 100 } 101 102 repoAt := repo.RepoAt() 103 page := pagination.Page{Limit: 50} 104 105 // Filter by state 106 filter := r.URL.Query().Get("filter") 107 filters := []orm.Filter{orm.FilterEq("repo_at", repoAt)} 108 switch filter { 109 case "closed": 110 filters = append(filters, orm.FilterEq("state", models.DiscussionClosed)) 111 case "merged": 112 filters = append(filters, orm.FilterEq("state", models.DiscussionMerged)) 113 default: 114 // Default to open 115 filters = append(filters, orm.FilterEq("state", models.DiscussionOpen)) 116 filter = "open" 117 } 118 119 discussions, err := db.GetDiscussionsPaginated(d.db, page, filters...) 120 if err != nil { 121 l.Error("failed to fetch discussions", "err", err) 122 d.pages.Error503(w) 123 return 124 } 125 126 count, err := db.GetDiscussionCount(d.db, repoAt) 127 if err != nil { 128 l.Error("failed to get discussion count", "err", err) 129 } 130 131 d.pages.RepoDiscussionsList(w, pages.RepoDiscussionsListParams{ 132 LoggedInUser: user, 133 RepoInfo: d.repoResolver.GetRepoInfo(r, user), 134 Discussions: discussions, 135 Filter: filter, 136 DiscussionCount: count, 137 }) 138} 139 140// NewDiscussion creates a new discussion 141func (d *Discussions) NewDiscussion(w http.ResponseWriter, r *http.Request) { 142 l := d.logger.With("handler", "NewDiscussion") 143 user := d.oauth.GetMultiAccountUser(r) 144 145 repo, err := d.repoResolver.Resolve(r) 146 if err != nil { 147 l.Error("failed to get repo", "err", err) 148 d.pages.Error404(w) 149 return 150 } 151 152 if !repo.IsPijul() { 153 l.Info("discussions only available for pijul repos") 154 d.pages.Error404(w) 155 return 156 } 157 158 repoInfo := d.repoResolver.GetRepoInfo(r, user) 159 160 switch r.Method { 161 case http.MethodGet: 162 d.pages.NewDiscussion(w, pages.NewDiscussionParams{ 163 LoggedInUser: user, 164 RepoInfo: repoInfo, 165 }) 166 167 case http.MethodPost: 168 noticeId := "discussion" 169 170 title := r.FormValue("title") 171 body := r.FormValue("body") 172 targetChannel := r.FormValue("target_channel") 173 if targetChannel == "" { 174 targetChannel = "main" 175 } 176 177 if title == "" { 178 d.pages.Notice(w, noticeId, "Title is required") 179 return 180 } 181 182 discussion := &models.Discussion{ 183 Did: user.Active.Did, 184 Rkey: tid.TID(), 185 RepoAt: repo.RepoAt(), 186 Title: title, 187 Body: body, 188 TargetChannel: targetChannel, 189 State: models.DiscussionOpen, 190 Created: time.Now(), 191 } 192 193 tx, err := d.db.BeginTx(r.Context(), nil) 194 if err != nil { 195 l.Error("failed to begin transaction", "err", err) 196 d.pages.Notice(w, noticeId, "Failed to create discussion") 197 return 198 } 199 defer tx.Rollback() 200 201 if err := db.NewDiscussion(tx, discussion); err != nil { 202 l.Error("failed to create discussion", "err", err) 203 d.pages.Notice(w, noticeId, "Failed to create discussion") 204 return 205 } 206 207 if err := tx.Commit(); err != nil { 208 l.Error("failed to commit transaction", "err", err) 209 d.pages.Notice(w, noticeId, "Failed to create discussion") 210 return 211 } 212 213 // Subscribe the creator to the discussion 214 db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 215 216 l.Info("discussion created", "discussion_id", discussion.DiscussionId) 217 218 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 219 repo.Did, repo.Name, discussion.DiscussionId)) 220 } 221} 222 223// RepoSingleDiscussion shows a single discussion 224func (d *Discussions) RepoSingleDiscussion(w http.ResponseWriter, r *http.Request) { 225 l := d.logger.With("handler", "RepoSingleDiscussion") 226 user := d.oauth.GetMultiAccountUser(r) 227 228 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 229 if !ok { 230 l.Error("failed to get discussion from context") 231 d.pages.Error404(w) 232 return 233 } 234 235 repoInfo := d.repoResolver.GetRepoInfo(r, user) 236 237 canManage := user != nil && d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() 238 239 d.pages.RepoSingleDiscussion(w, pages.RepoSingleDiscussionParams{ 240 LoggedInUser: user, 241 RepoInfo: repoInfo, 242 Discussion: discussion, 243 CommentList: discussion.CommentList(), 244 CanManage: canManage, 245 ActivePatches: discussion.ActivePatches(), 246 }) 247} 248 249// AddPatch allows anyone to add a patch to a discussion 250func (d *Discussions) AddPatch(w http.ResponseWriter, r *http.Request) { 251 l := d.logger.With("handler", "AddPatch") 252 user := d.oauth.GetMultiAccountUser(r) 253 noticeId := "patch" 254 255 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 256 if !ok { 257 l.Error("failed to get discussion from context") 258 d.pages.Notice(w, noticeId, "Discussion not found") 259 return 260 } 261 262 if discussion.State != models.DiscussionOpen { 263 d.pages.Notice(w, noticeId, "Cannot add patches to a closed or merged discussion") 264 return 265 } 266 267 patchHash := r.FormValue("patch_hash") 268 patch := r.FormValue("patch") 269 270 if patchHash == "" || patch == "" { 271 d.pages.Notice(w, noticeId, "Patch hash and content are required") 272 return 273 } 274 275 // Check if patch already exists 276 exists, err := db.PatchExists(d.db, discussion.AtUri(), patchHash) 277 if err != nil { 278 l.Error("failed to check patch existence", "err", err) 279 d.pages.Notice(w, noticeId, "Failed to add patch") 280 return 281 } 282 if exists { 283 d.pages.Notice(w, noticeId, "This patch has already been added to the discussion") 284 return 285 } 286 287 // Get repo info for verification and dependency checking 288 repo, err := d.repoResolver.Resolve(r) 289 if err != nil { 290 l.Error("failed to resolve repo", "err", err) 291 d.pages.Notice(w, noticeId, "Failed to add patch") 292 return 293 } 294 295 repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 296 297 // Verify the change exists in the Pijul repository 298 change, err := d.getChangeFromKnot(r.Context(), repo.Knot, repoIdentifier, patchHash) 299 if err != nil { 300 l.Info("change verification failed", "hash", patchHash, "err", err) 301 d.pages.Notice(w, noticeId, "Change not found in repository. Please ensure the change hash is correct and exists in the repo.") 302 return 303 } 304 305 l.Debug("change verified", "hash", patchHash, "message", change.Message) 306 307 // Check dependencies - ensure the patch doesn't depend on removed patches 308 if err := d.canAddPatchWithChange(discussion, change); err != nil { 309 l.Info("dependency check failed", "err", err) 310 d.pages.Notice(w, noticeId, err.Error()) 311 return 312 } 313 314 discussionPatch := &models.DiscussionPatch{ 315 DiscussionAt: discussion.AtUri(), 316 PushedByDid: user.Active.Did, 317 PatchHash: patchHash, 318 Patch: patch, 319 Added: time.Now(), 320 } 321 322 tx, err := d.db.BeginTx(r.Context(), nil) 323 if err != nil { 324 l.Error("failed to begin transaction", "err", err) 325 d.pages.Notice(w, noticeId, "Failed to add patch") 326 return 327 } 328 defer tx.Rollback() 329 330 if err := db.AddDiscussionPatch(tx, discussionPatch); err != nil { 331 l.Error("failed to add patch", "err", err) 332 d.pages.Notice(w, noticeId, "Failed to add patch") 333 return 334 } 335 336 if err := tx.Commit(); err != nil { 337 l.Error("failed to commit transaction", "err", err) 338 d.pages.Notice(w, noticeId, "Failed to add patch") 339 return 340 } 341 342 // Subscribe the patch contributor to the discussion 343 db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 344 345 l.Info("patch added", "patch_hash", patchHash, "pushed_by", user.Active.Did) 346 347 // Reload the page to show the new patch 348 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 349 repo.Did, repo.Name, discussion.DiscussionId)) 350} 351 352// RemovePatch removes a patch from a discussion (soft delete) 353func (d *Discussions) RemovePatch(w http.ResponseWriter, r *http.Request) { 354 l := d.logger.With("handler", "RemovePatch") 355 user := d.oauth.GetMultiAccountUser(r) 356 noticeId := "patch" 357 358 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 359 if !ok { 360 l.Error("failed to get discussion from context") 361 d.pages.Notice(w, noticeId, "Discussion not found") 362 return 363 } 364 365 patchIdStr := chi.URLParam(r, "patchId") 366 patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 367 if err != nil { 368 d.pages.Notice(w, noticeId, "Invalid patch ID") 369 return 370 } 371 372 patch, err := db.GetDiscussionPatch(d.db, patchId) 373 if err != nil { 374 l.Error("failed to get patch", "err", err) 375 d.pages.Notice(w, noticeId, "Patch not found") 376 return 377 } 378 379 // Check permission: patch pusher or repo collaborator 380 repoInfo := d.repoResolver.GetRepoInfo(r, user) 381 if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 382 d.pages.Notice(w, noticeId, "You don't have permission to remove this patch") 383 return 384 } 385 386 // Get repo for dependency checking 387 repo, err := d.repoResolver.Resolve(r) 388 if err != nil { 389 l.Error("failed to resolve repo", "err", err) 390 d.pages.Notice(w, noticeId, "Failed to remove patch") 391 return 392 } 393 394 // Check if other active patches depend on this one 395 repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 396 if err := d.canRemovePatch(r.Context(), discussion, repo.Knot, repoIdentifier, patch.PatchHash); err != nil { 397 l.Info("dependency check failed", "err", err) 398 d.pages.Notice(w, noticeId, err.Error()) 399 return 400 } 401 402 if err := db.RemovePatch(d.db, patchId); err != nil { 403 l.Error("failed to remove patch", "err", err) 404 d.pages.Notice(w, noticeId, "Failed to remove patch") 405 return 406 } 407 408 l.Info("patch removed", "patch_id", patchId) 409 410 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 411 repo.Did, repo.Name, discussion.DiscussionId)) 412} 413 414// ReaddPatch re-adds a previously removed patch 415func (d *Discussions) ReaddPatch(w http.ResponseWriter, r *http.Request) { 416 l := d.logger.With("handler", "ReaddPatch") 417 user := d.oauth.GetMultiAccountUser(r) 418 noticeId := "patch" 419 420 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 421 if !ok { 422 l.Error("failed to get discussion from context") 423 d.pages.Notice(w, noticeId, "Discussion not found") 424 return 425 } 426 427 patchIdStr := chi.URLParam(r, "patchId") 428 patchId, err := strconv.ParseInt(patchIdStr, 10, 64) 429 if err != nil { 430 d.pages.Notice(w, noticeId, "Invalid patch ID") 431 return 432 } 433 434 patch, err := db.GetDiscussionPatch(d.db, patchId) 435 if err != nil { 436 l.Error("failed to get patch", "err", err) 437 d.pages.Notice(w, noticeId, "Patch not found") 438 return 439 } 440 441 // Check permission 442 repoInfo := d.repoResolver.GetRepoInfo(r, user) 443 if patch.PushedByDid != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 444 d.pages.Notice(w, noticeId, "You don't have permission to re-add this patch") 445 return 446 } 447 448 if err := db.ReaddPatch(d.db, patchId); err != nil { 449 l.Error("failed to re-add patch", "err", err) 450 d.pages.Notice(w, noticeId, "Failed to re-add patch") 451 return 452 } 453 454 l.Info("patch re-added", "patch_id", patchId) 455 456 repo, _ := d.repoResolver.Resolve(r) 457 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 458 repo.Did, repo.Name, discussion.DiscussionId)) 459} 460 461// NewComment adds a comment to a discussion 462func (d *Discussions) NewComment(w http.ResponseWriter, r *http.Request) { 463 l := d.logger.With("handler", "NewComment") 464 user := d.oauth.GetMultiAccountUser(r) 465 noticeId := "comment" 466 467 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 468 if !ok { 469 l.Error("failed to get discussion from context") 470 d.pages.Notice(w, noticeId, "Discussion not found") 471 return 472 } 473 474 body := r.FormValue("body") 475 replyTo := r.FormValue("reply_to") 476 477 if body == "" { 478 d.pages.Notice(w, noticeId, "Comment body is required") 479 return 480 } 481 482 comment := models.DiscussionComment{ 483 Did: user.Active.Did, 484 Rkey: tid.TID(), 485 DiscussionAt: discussion.AtUri().String(), 486 Body: body, 487 Created: time.Now(), 488 } 489 490 if replyTo != "" { 491 comment.ReplyTo = &replyTo 492 } 493 494 tx, err := d.db.BeginTx(r.Context(), nil) 495 if err != nil { 496 l.Error("failed to begin transaction", "err", err) 497 d.pages.Notice(w, noticeId, "Failed to add comment") 498 return 499 } 500 defer tx.Rollback() 501 502 if _, err := db.AddDiscussionComment(tx, comment); err != nil { 503 l.Error("failed to add comment", "err", err) 504 d.pages.Notice(w, noticeId, "Failed to add comment") 505 return 506 } 507 508 if err := tx.Commit(); err != nil { 509 l.Error("failed to commit transaction", "err", err) 510 d.pages.Notice(w, noticeId, "Failed to add comment") 511 return 512 } 513 514 // Subscribe the commenter to the discussion 515 db.SubscribeToDiscussion(d.db, discussion.AtUri(), user.Active.Did) 516 517 l.Info("comment added", "discussion_id", discussion.DiscussionId) 518 519 repo, _ := d.repoResolver.Resolve(r) 520 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 521 repo.Did, repo.Name, discussion.DiscussionId)) 522} 523 524// CloseDiscussion closes a discussion 525func (d *Discussions) CloseDiscussion(w http.ResponseWriter, r *http.Request) { 526 l := d.logger.With("handler", "CloseDiscussion") 527 user := d.oauth.GetMultiAccountUser(r) 528 noticeId := "discussion" 529 530 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 531 if !ok { 532 l.Error("failed to get discussion from context") 533 d.pages.Notice(w, noticeId, "Discussion not found") 534 return 535 } 536 537 // Check permission: discussion creator or repo manager 538 repoInfo := d.repoResolver.GetRepoInfo(r, user) 539 if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 540 d.pages.Notice(w, noticeId, "You don't have permission to close this discussion") 541 return 542 } 543 544 if err := db.CloseDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 545 l.Error("failed to close discussion", "err", err) 546 d.pages.Notice(w, noticeId, "Failed to close discussion") 547 return 548 } 549 550 l.Info("discussion closed", "discussion_id", discussion.DiscussionId) 551 552 repo, _ := d.repoResolver.Resolve(r) 553 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 554 repo.Did, repo.Name, discussion.DiscussionId)) 555} 556 557// ReopenDiscussion reopens a discussion 558func (d *Discussions) ReopenDiscussion(w http.ResponseWriter, r *http.Request) { 559 l := d.logger.With("handler", "ReopenDiscussion") 560 user := d.oauth.GetMultiAccountUser(r) 561 noticeId := "discussion" 562 563 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 564 if !ok { 565 l.Error("failed to get discussion from context") 566 d.pages.Notice(w, noticeId, "Discussion not found") 567 return 568 } 569 570 // Check permission: discussion creator or repo manager 571 repoInfo := d.repoResolver.GetRepoInfo(r, user) 572 if discussion.Did != user.Active.Did && !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 573 d.pages.Notice(w, noticeId, "You don't have permission to reopen this discussion") 574 return 575 } 576 577 if err := db.ReopenDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 578 l.Error("failed to reopen discussion", "err", err) 579 d.pages.Notice(w, noticeId, "Failed to reopen discussion") 580 return 581 } 582 583 l.Info("discussion reopened", "discussion_id", discussion.DiscussionId) 584 585 repo, _ := d.repoResolver.Resolve(r) 586 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 587 repo.Did, repo.Name, discussion.DiscussionId)) 588} 589 590// MergeDiscussion applies patches and marks a discussion as merged 591func (d *Discussions) MergeDiscussion(w http.ResponseWriter, r *http.Request) { 592 l := d.logger.With("handler", "MergeDiscussion") 593 user := d.oauth.GetMultiAccountUser(r) 594 noticeId := "discussion" 595 596 discussion, ok := r.Context().Value("discussion").(*models.Discussion) 597 if !ok { 598 l.Error("failed to get discussion from context") 599 d.pages.Notice(w, noticeId, "Discussion not found") 600 return 601 } 602 603 // Only collaborators can merge 604 repoInfo := d.repoResolver.GetRepoInfo(r, user) 605 if !d.rolesFor(user.Active.Did, repoInfo).CanManageRepo() { 606 d.pages.Notice(w, noticeId, "You don't have permission to merge this discussion") 607 return 608 } 609 610 // Get all active patches to apply 611 activePatches := discussion.ActivePatches() 612 if len(activePatches) == 0 { 613 d.pages.Notice(w, noticeId, "No patches to merge") 614 return 615 } 616 617 // Get repo for API call 618 repo, err := d.repoResolver.Resolve(r) 619 if err != nil { 620 l.Error("failed to resolve repo", "err", err) 621 d.pages.Notice(w, noticeId, "Failed to merge discussion") 622 return 623 } 624 625 // Apply patches via knotserver (needs authenticated client since endpoint requires service auth) 626 xrpcc, err := d.oauth.ServiceClient( 627 r, 628 oauth.WithService(repo.Knot), 629 oauth.WithLxm(tangled.RepoApplyChangesNSID), 630 oauth.WithDev(d.config.Core.Dev), 631 ) 632 if err != nil { 633 l.Error("failed to create service client", "err", err) 634 d.pages.Notice(w, noticeId, "Failed to authenticate with knotserver") 635 return 636 } 637 638 // Collect patch hashes in order 639 changeHashes := make([]string, len(activePatches)) 640 for i, patch := range activePatches { 641 changeHashes[i] = patch.PatchHash 642 } 643 644 repoIdentifier := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 645 applyInput := &tangled.RepoApplyChanges_Input{ 646 Repo: repoIdentifier, 647 Channel: discussion.TargetChannel, 648 Changes: changeHashes, 649 } 650 651 applyResult, err := tangled.RepoApplyChanges(r.Context(), xrpcc, applyInput) 652 if err != nil { 653 l.Error("failed to apply changes", "err", err) 654 d.pages.Notice(w, noticeId, "Failed to apply patches: "+err.Error()) 655 return 656 } 657 658 // Check if all patches were applied 659 if len(applyResult.Failed) > 0 { 660 failedHashes := make([]string, len(applyResult.Failed)) 661 for i, f := range applyResult.Failed { 662 failedHashes[i] = f.Hash[:12] 663 } 664 l.Warn("some patches failed to apply", "failed", failedHashes) 665 d.pages.Notice(w, noticeId, fmt.Sprintf("Some patches failed to apply: %v", failedHashes)) 666 return 667 } 668 669 l.Info("patches applied successfully", "count", len(applyResult.Applied)) 670 671 // Mark discussion as merged 672 if err := db.MergeDiscussion(d.db, discussion.RepoAt, discussion.DiscussionId); err != nil { 673 l.Error("failed to merge discussion", "err", err) 674 d.pages.Notice(w, noticeId, "Failed to merge discussion") 675 return 676 } 677 678 l.Info("discussion merged", "discussion_id", discussion.DiscussionId) 679 680 repo, _ = d.repoResolver.Resolve(r) 681 d.pages.HxLocation(w, fmt.Sprintf("/%s/%s/discussions/%d", 682 repo.Did, repo.Name, discussion.DiscussionId)) 683} 684 685// getChangeFromKnot fetches change details (including dependencies) from knotserver 686func (d *Discussions) getChangeFromKnot(ctx context.Context, knot, repo, hash string) (*tangled.RepoChangeGet_Output, error) { 687 scheme := "http" 688 if d.config.Core.UseTLS() { 689 scheme = "https" 690 } 691 host := fmt.Sprintf("%s://%s", scheme, knot) 692 693 xrpcc := &xrpc.Client{ 694 Host: host, 695 } 696 697 return tangled.RepoChangeGet(ctx, xrpcc, hash, repo) 698} 699 700// canAddPatchWithChange checks if a patch can be added to the discussion 701// Uses the already-fetched change object to avoid duplicate API calls 702// Returns error if the patch depends on a removed patch 703func (d *Discussions) canAddPatchWithChange(discussion *models.Discussion, change *tangled.RepoChangeGet_Output) error { 704 705 if len(change.Dependencies) == 0 { 706 return nil // No dependencies, can always add 707 } 708 709 // Get all patches in this discussion 710 patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 711 if err != nil { 712 return fmt.Errorf("failed to get discussion patches: %w", err) 713 } 714 715 // Check if any dependency is a removed patch in this discussion 716 for _, dep := range change.Dependencies { 717 for _, patch := range patches { 718 if patch.PatchHash == dep && !patch.IsActive() { 719 return fmt.Errorf("cannot add patch: it depends on removed patch %s", dep[:12]) 720 } 721 } 722 } 723 724 return nil 725} 726 727// canRemovePatch checks if a patch can be removed from the discussion 728// Returns error if other active patches depend on this patch 729func (d *Discussions) canRemovePatch(ctx context.Context, discussion *models.Discussion, knot, repo, patchHashToRemove string) error { 730 // Get all active patches in this discussion 731 patches, err := db.GetDiscussionPatches(d.db, orm.FilterEq("discussion_at", discussion.AtUri())) 732 if err != nil { 733 return fmt.Errorf("failed to get discussion patches: %w", err) 734 } 735 736 // For each active patch, check if it depends on the patch we want to remove 737 for _, patch := range patches { 738 if !patch.IsActive() || patch.PatchHash == patchHashToRemove { 739 continue 740 } 741 742 // Get the change details to check its dependencies 743 change, err := d.getChangeFromKnot(ctx, knot, repo, patch.PatchHash) 744 if err != nil { 745 d.logger.Warn("failed to get change dependencies", "hash", patch.PatchHash, "err", err) 746 continue // Skip if we can't get the change, but don't block removal 747 } 748 749 for _, dep := range change.Dependencies { 750 if dep == patchHashToRemove { 751 return fmt.Errorf("cannot remove patch: patch %s depends on it", patch.PatchHash[:12]) 752 } 753 } 754 } 755 756 return nil 757}