Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/issues,pulls: create state records on PDS

Lewis: May this revision serve well! <lewis@tangled.org>

authored by

Lewis and committed by
Anirudh Oppiliappan
(Jul 3, 2026, 3:49 PM +0530) 6e3fc2d8 cc7a9dcf

+279 -47
+44
appview/issues/issues.go
··· 329 329 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 330 330 l := rp.logger.With("handler", "CloseIssue") 331 331 user := rp.oauth.GetMultiAccountUser(r) 332 + if user == nil { 333 + l.Error("nil user") 334 + rp.pages.Notice(w, "issue-action", "You must be logged in to close this issue.") 335 + return 336 + } 332 337 f, err := rp.repoResolver.Resolve(r) 333 338 if err != nil { 334 339 l.Error("failed to get repo and knot", "err", err) ··· 349 354 350 355 // TODO: make this more granular 351 356 if isIssueOwner || isRepoOwner || isCollaborator { 357 + if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateClosed); err != nil { 358 + l.Error("failed to write issue state record", "err", err) 359 + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 360 + return 361 + } 362 + 352 363 err = db.CloseIssues( 353 364 rp.db, 354 365 orm.FilterEq("id", issue.Id), ··· 377 388 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 378 389 l := rp.logger.With("handler", "ReopenIssue") 379 390 user := rp.oauth.GetMultiAccountUser(r) 391 + if user == nil { 392 + l.Error("nil user") 393 + rp.pages.Notice(w, "issue-action", "You must be logged in to reopen this issue.") 394 + return 395 + } 380 396 f, err := rp.repoResolver.Resolve(r) 381 397 if err != nil { 382 398 l.Error("failed to get repo and knot", "err", err) ··· 396 412 isIssueOwner := user.Did == issue.Did 397 413 398 414 if isCollaborator || isRepoOwner || isIssueOwner { 415 + if err := rp.writeIssueStateRecord(r, user.Did, issue.AtUri(), models.StateOpen); err != nil { 416 + l.Error("failed to write issue state record", "err", err) 417 + rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 418 + return 419 + } 420 + 399 421 err := db.ReopenIssues( 400 422 rp.db, 401 423 orm.FilterEq("id", issue.Id), ··· 419 441 http.Error(w, "forbidden", http.StatusUnauthorized) 420 442 return 421 443 } 444 + } 445 + 446 + func (rp *Issues) writeIssueStateRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error { 447 + client, err := rp.oauth.AuthorizedClient(r) 448 + if err != nil { 449 + return err 450 + } 451 + 452 + record, err := models.AsIssueStateRecord(subject, value, time.Now()) 453 + if err != nil { 454 + return err 455 + } 456 + 457 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 458 + Collection: tangled.RepoIssueStateNSID, 459 + Repo: actorDid, 460 + Rkey: tid.TID(), 461 + Record: &lexutil.LexiconTypeDecoder{ 462 + Val: &record, 463 + }, 464 + }) 465 + return err 422 466 } 423 467 424 468 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
+41
appview/models/entity_state.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "time" 5 6 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/samber/lo" 7 9 "tangled.org/core/api/tangled" 8 10 ) 9 11 ··· 70 72 return StateRecord{}, fmt.Errorf("unknown pull status variant: %q", record.Status) 71 73 } 72 74 } 75 + 76 + func AsIssueStateRecord(subject syntax.ATURI, value StateValue, createdAt time.Time) (tangled.RepoIssueState, error) { 77 + var variant string 78 + switch value { 79 + case StateOpen: 80 + variant = tangled.RepoIssueStateOpen 81 + case StateClosed: 82 + variant = tangled.RepoIssueStateClosed 83 + default: 84 + return tangled.RepoIssueState{}, fmt.Errorf("invalid issue state: %q", value) 85 + } 86 + return tangled.RepoIssueState{ 87 + Issue: subject.String(), 88 + State: variant, 89 + CreatedAt: createdAt.UTC().Format(syntax.AtprotoDatetimeLayout), 90 + }, nil 91 + } 92 + 93 + func AsPullStatusRecords(subjects []syntax.ATURI, value StateValue, createdAt time.Time) ([]tangled.RepoPullStatus, error) { 94 + var variant string 95 + switch value { 96 + case StateOpen: 97 + variant = tangled.RepoPullStatusOpen 98 + case StateClosed: 99 + variant = tangled.RepoPullStatusClosed 100 + case StateMerged: 101 + variant = tangled.RepoPullStatusMerged 102 + default: 103 + return nil, fmt.Errorf("invalid pull status: %q", value) 104 + } 105 + created := createdAt.UTC().Format(syntax.AtprotoDatetimeLayout) 106 + return lo.Map(subjects, func(subject syntax.ATURI, _ int) tangled.RepoPullStatus { 107 + return tangled.RepoPullStatus{ 108 + Pull: subject.String(), 109 + Status: variant, 110 + CreatedAt: created, 111 + } 112 + }), nil 113 + }
+66
appview/models/entity_state_test.go
··· 78 78 t.Fatal("unknown pull status variant must be rejected") 79 79 } 80 80 } 81 + 82 + func TestAsIssueStateRecord_RoundTrip(t *testing.T) { 83 + const rkey = "3jzfcijpj2z2a" 84 + subject := syntax.ATURI(testIssueSubject) 85 + created := time.Date(2026, 6, 30, 12, 30, 15, int(123*time.Millisecond), time.UTC) 86 + 87 + for _, value := range []StateValue{StateOpen, StateClosed} { 88 + rec, err := AsIssueStateRecord(subject, value, created) 89 + if err != nil { 90 + t.Fatalf("AsIssueStateRecord(%q): %v", value, err) 91 + } 92 + if rec.Issue != testIssueSubject { 93 + t.Fatalf("Issue = %q, want %q", rec.Issue, testIssueSubject) 94 + } 95 + 96 + back, err := IssueStateFromRecord("did:plc:akshay", rkey, rec) 97 + if err != nil { 98 + t.Fatalf("IssueStateFromRecord after AsIssueStateRecord(%q): %v", value, err) 99 + } 100 + if back.Value != value { 101 + t.Fatalf("round-trip value = %q, want %q", back.Value, value) 102 + } 103 + if back.SortMicros != created.UnixMicro() { 104 + t.Fatalf("round-trip SortMicros = %d, want %d", back.SortMicros, created.UnixMicro()) 105 + } 106 + } 107 + 108 + if _, err := AsIssueStateRecord(subject, StateMerged, created); err == nil { 109 + t.Fatal("merged is not a valid issue state and must be rejected") 110 + } 111 + } 112 + 113 + func TestAsPullStatusRecords_RoundTrip(t *testing.T) { 114 + const rkey = "3jzfcijpj2z2a" 115 + subject := syntax.ATURI("at://did:plc:limpet/sh.tangled.repo.pull/p1") 116 + created := time.Date(2026, 6, 30, 12, 30, 15, int(123*time.Millisecond), time.UTC) 117 + 118 + for _, value := range []StateValue{StateOpen, StateClosed, StateMerged} { 119 + recs, err := AsPullStatusRecords([]syntax.ATURI{subject}, value, created) 120 + if err != nil { 121 + t.Fatalf("AsPullStatusRecords(%q): %v", value, err) 122 + } 123 + if len(recs) != 1 { 124 + t.Fatalf("AsPullStatusRecords(%q) returned %d records, want 1", value, len(recs)) 125 + } 126 + rec := recs[0] 127 + if rec.Pull != subject.String() { 128 + t.Fatalf("Pull = %q, want %q", rec.Pull, subject) 129 + } 130 + 131 + back, err := PullStatusFromRecord("did:plc:akshay", rkey, rec) 132 + if err != nil { 133 + t.Fatalf("PullStatusFromRecord after AsPullStatusRecords(%q): %v", value, err) 134 + } 135 + if back.Value != value { 136 + t.Fatalf("round-trip value = %q, want %q", back.Value, value) 137 + } 138 + if back.SortMicros != created.UnixMicro() { 139 + t.Fatalf("round-trip SortMicros = %d, want %d", back.SortMicros, created.UnixMicro()) 140 + } 141 + } 142 + 143 + if _, err := AsPullStatusRecords([]syntax.ATURI{subject}, "bogus", created); err == nil { 144 + t.Fatal("invalid pull status value must be rejected") 145 + } 146 + }
+2
appview/oauth/scopes.go
··· 19 19 "repo:sh.tangled.repo.collaborator", 20 20 "repo:sh.tangled.repo.issue", 21 21 "repo:sh.tangled.repo.issue.comment", 22 + "repo:sh.tangled.repo.issue.state", 22 23 "repo:sh.tangled.repo.pull", 23 24 "repo:sh.tangled.repo.pull.comment", 25 + "repo:sh.tangled.repo.pull.status", 24 26 "repo:sh.tangled.spindle", 25 27 "repo:sh.tangled.spindle.member", 26 28 "repo:sh.tangled.string",
+1
appview/pages/templates/repo/pulls/pull.html
··· 595 595 {{ if eq $lastIdx $item.RoundNumber }} 596 596 {{ block "mergeStatus" $root }} {{ end }} 597 597 <div id="resubmit-banner"></div> 598 + <div id="pull-action-error" class="error"></div> 598 599 {{ end }} 599 600 </div> 600 601 <div hx-include="this" class="relative -ml-10 bg-gray-50 dark:bg-gray-900">
+51 -31
appview/pulls/lifecycle.go
··· 16 16 l := s.logger.With("handler", "ClosePull") 17 17 18 18 user := s.oauth.GetMultiAccountUser(r) 19 - if user != nil { 20 - l = l.With("user", user.Did) 19 + if user == nil { 20 + l.Error("nil user") 21 + s.pages.Notice(w, "pull-action-error", "You must be logged in to close this pull.") 22 + return 21 23 } 24 + l = l.With("user", user.Did) 22 25 23 26 f, err := s.repoResolver.Resolve(r) 24 27 if err != nil { ··· 29 32 pull, ok := r.Context().Value("pull").(*models.Pull) 30 33 if !ok { 31 34 l.Error("failed to get pull") 32 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 35 + s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 33 36 return 34 37 } 35 38 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) ··· 42 45 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 43 46 if !isCloseAllowed { 44 47 l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 45 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 48 + s.pages.Notice(w, "pull-action-error", "You are unauthorized to close this pull.") 46 49 return 47 50 } 48 51 49 - // Start a transaction 50 - tx, err := s.db.BeginTx(r.Context(), nil) 51 - if err != nil { 52 - l.Error("failed to start transaction", "err", err) 53 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 54 - return 55 - } 56 - defer tx.Rollback() 57 - 58 52 // if this PR is stacked, then we want to close all PRs above this one on the stack 59 53 stack := r.Context().Value("stack").(models.Stack) 60 54 pullsToClose := stack.Above(pull) ··· 63 57 atUris = append(atUris, p.AtUri()) 64 58 p.State = models.PullClosed 65 59 } 60 + 61 + if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateClosed); err != nil { 62 + l.Error("failed to write pull status records", "err", err) 63 + s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 64 + return 65 + } 66 + 67 + tx, err := s.db.BeginTx(r.Context(), nil) 68 + if err != nil { 69 + l.Error("failed to start transaction", "err", err) 70 + s.pages.Notice(w, "pull-action-error", "Failed to close pull.") 71 + return 72 + } 73 + defer tx.Rollback() 74 + 66 75 err = db.ClosePulls( 67 76 tx, 68 77 orm.FilterEq("repo_did", string(f.RepoDid)), ··· 70 79 ) 71 80 if err != nil { 72 81 l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 73 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 82 + s.pages.Notice(w, "pull-action-error", "Failed to close pull.") 83 + return 74 84 } 75 85 76 86 // Commit the transaction 77 87 if err = tx.Commit(); err != nil { 78 88 l.Error("failed to commit transaction", "err", err) 79 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 89 + s.pages.Notice(w, "pull-action-error", "Failed to close pull.") 80 90 return 81 91 } 82 92 ··· 92 102 l := s.logger.With("handler", "ReopenPull") 93 103 94 104 user := s.oauth.GetMultiAccountUser(r) 95 - if user != nil { 96 - l = l.With("user", user.Did) 105 + if user == nil { 106 + l.Error("nil user") 107 + s.pages.Notice(w, "pull-action-error", "You must be logged in to reopen this pull.") 108 + return 97 109 } 110 + l = l.With("user", user.Did) 98 111 99 112 f, err := s.repoResolver.Resolve(r) 100 113 if err != nil { 101 114 l.Error("failed to resolve repo", "err", err) 102 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 115 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 103 116 return 104 117 } 105 118 106 119 pull, ok := r.Context().Value("pull").(*models.Pull) 107 120 if !ok { 108 121 l.Error("failed to get pull") 109 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 122 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 110 123 return 111 124 } 112 125 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) ··· 119 132 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 120 133 if !isCloseAllowed { 121 134 l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 122 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 135 + s.pages.Notice(w, "pull-action-error", "You are unauthorized to reopen this pull.") 123 136 return 124 137 } 125 - 126 - // Start a transaction 127 - tx, err := s.db.BeginTx(r.Context(), nil) 128 - if err != nil { 129 - l.Error("failed to start transaction", "err", err) 130 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 131 - return 132 - } 133 - defer tx.Rollback() 134 138 135 139 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 136 140 stack := r.Context().Value("stack").(models.Stack) ··· 140 144 atUris = append(atUris, p.AtUri()) 141 145 p.State = models.PullOpen 142 146 } 147 + 148 + if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateOpen); err != nil { 149 + l.Error("failed to write pull status records", "err", err) 150 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 151 + return 152 + } 153 + 154 + tx, err := s.db.BeginTx(r.Context(), nil) 155 + if err != nil { 156 + l.Error("failed to start transaction", "err", err) 157 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 158 + return 159 + } 160 + defer tx.Rollback() 161 + 143 162 err = db.ReopenPulls( 144 163 tx, 145 164 orm.FilterEq("repo_did", string(f.RepoDid)), ··· 147 166 ) 148 167 if err != nil { 149 168 l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 150 - s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 169 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 170 + return 151 171 } 152 172 153 173 // Commit the transaction 154 174 if err = tx.Commit(); err != nil { 155 175 l.Error("failed to commit transaction", "err", err) 156 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 176 + s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 157 177 return 158 178 } 159 179
+23 -15
appview/pulls/merge.go
··· 20 20 l := s.logger.With("handler", "MergePull") 21 21 22 22 user := s.oauth.GetMultiAccountUser(r) 23 - if user != nil { 24 - l = l.With("user", user.Did) 23 + if user == nil { 24 + l.Error("nil user") 25 + s.pages.Notice(w, "pull-action-error", "You must be logged in to merge this pull.") 26 + return 25 27 } 28 + l = l.With("user", user.Did) 26 29 27 30 f, err := s.repoResolver.Resolve(r) 28 31 if err != nil { 29 32 l.Error("failed to resolve repo", "err", err) 30 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 33 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 31 34 return 32 35 } 33 36 l = l.With("repo_at", f.RepoAt().String()) ··· 35 38 pull, ok := r.Context().Value("pull").(*models.Pull) 36 39 if !ok { 37 40 l.Error("failed to get pull") 38 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 41 + s.pages.Notice(w, "pull-action-error", "Failed to merge patch. Try again later.") 39 42 return 40 43 } 41 44 l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) ··· 43 46 stack, ok := r.Context().Value("stack").(models.Stack) 44 47 if !ok { 45 48 l.Error("failed to get stack") 46 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 49 + s.pages.Notice(w, "pull-action-error", "Failed to merge patch. Try again later.") 47 50 return 48 51 } 49 52 ··· 94 97 ) 95 98 if err != nil { 96 99 l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 97 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 100 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 98 101 return 99 102 } 100 103 101 104 err = tangled.RepoMerge(r.Context(), client, mergeInput) 102 105 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 103 106 s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 104 - s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 107 + s.pages.Notice(w, "pull-action-error", xrpcerr.Error()) 105 108 return 106 109 } 107 110 111 + var atUris []syntax.ATURI 112 + for _, p := range pullsToMerge { 113 + atUris = append(atUris, p.AtUri()) 114 + p.State = models.PullMerged 115 + } 116 + 117 + if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateMerged); err != nil { 118 + l.Error("failed to write pull status records after merge", "err", err) 119 + } 120 + 108 121 tx, err := s.db.Begin() 109 122 if err != nil { 110 123 l.Error("failed to start transaction", "err", err) 111 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 124 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 112 125 return 113 126 } 114 127 defer tx.Rollback() 115 128 116 - var atUris []syntax.ATURI 117 - for _, p := range pullsToMerge { 118 - atUris = append(atUris, p.AtUri()) 119 - p.State = models.PullMerged 120 - } 121 129 err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 122 130 if err != nil { 123 131 l.Error("failed to update pull request status in database", "err", err) 124 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 132 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 125 133 return 126 134 } 127 135 ··· 129 137 if err != nil { 130 138 // TODO: this is unsound, we should also revert the merge from the knotserver here 131 139 l.Error("failed to commit merge transaction", "err", err) 132 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 140 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 133 141 return 134 142 } 135 143
+1 -1
appview/pulls/resubmit.go
··· 400 400 newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 401 401 if err != nil { 402 402 l.Error("failed to create resubmitted stack", "err", err) 403 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 403 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 404 404 return 405 405 } 406 406
+50
appview/pulls/state.go
··· 1 + package pulls 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + "github.com/samber/lo" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/tid" 15 + ) 16 + 17 + func (s *Pulls) writePullStatusRecords(r *http.Request, actorDid string, subjects []syntax.ATURI, value models.StateValue) error { 18 + if len(subjects) == 0 { 19 + return nil 20 + } 21 + 22 + client, err := s.oauth.AuthorizedClient(r) 23 + if err != nil { 24 + return err 25 + } 26 + 27 + records, err := models.AsPullStatusRecords(subjects, value, time.Now()) 28 + if err != nil { 29 + return err 30 + } 31 + 32 + writes := lo.Map(records, func(record tangled.RepoPullStatus, _ int) *comatproto.RepoApplyWrites_Input_Writes_Elem { 33 + rkey := tid.TID() 34 + return &comatproto.RepoApplyWrites_Input_Writes_Elem{ 35 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 36 + Collection: tangled.RepoPullStatusNSID, 37 + Rkey: &rkey, 38 + Value: &lexutil.LexiconTypeDecoder{ 39 + Val: &record, 40 + }, 41 + }, 42 + } 43 + }) 44 + 45 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 46 + Repo: actorDid, 47 + Writes: writes, 48 + }) 49 + return err 50 + }