A vibe coded tangled fork which supports pijul.
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}