A vibe coded tangled fork which supports pijul.
1package xrpc
2
3import (
4 "encoding/json"
5 "net/http"
6 "strings"
7
8 "github.com/bluesky-social/indigo/atproto/syntax"
9 securejoin "github.com/cyphar/filepath-securejoin"
10 "tangled.org/core/knotserver/pijul"
11 "tangled.org/core/rbac"
12 xrpcerr "tangled.org/core/xrpc/errors"
13)
14
15// ApplyChangesRequest is the request body for applying changes
16type ApplyChangesRequest struct {
17 Repo string `json:"repo"`
18 Channel string `json:"channel"`
19 Changes []string `json:"changes"`
20}
21
22// ApplyChangesResponse is the response for applying changes
23type ApplyChangesResponse struct {
24 Applied []string `json:"applied"`
25 Failed []ApplyChangeFailure `json:"failed,omitempty"`
26}
27
28// ApplyChangeFailure represents a failed change application
29type ApplyChangeFailure struct {
30 Hash string `json:"hash"`
31 Error string `json:"error"`
32}
33
34// RepoApplyChanges handles the sh.tangled.repo.applyChanges endpoint
35// Applies Pijul changes to a repository channel (used for merging discussions)
36func (x *Xrpc) RepoApplyChanges(w http.ResponseWriter, r *http.Request) {
37 if r.Method != http.MethodPost {
38 writeError(w, xrpcerr.NewXrpcError(
39 xrpcerr.WithTag("InvalidRequest"),
40 xrpcerr.WithMessage("method not allowed"),
41 ), http.StatusMethodNotAllowed)
42 return
43 }
44
45 var req ApplyChangesRequest
46 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
47 writeError(w, xrpcerr.NewXrpcError(
48 xrpcerr.WithTag("InvalidRequest"),
49 xrpcerr.WithMessage("invalid request body"),
50 ), http.StatusBadRequest)
51 return
52 }
53
54 if req.Repo == "" || req.Channel == "" || len(req.Changes) == 0 {
55 writeError(w, xrpcerr.NewXrpcError(
56 xrpcerr.WithTag("InvalidRequest"),
57 xrpcerr.WithMessage("repo, channel, and changes are required"),
58 ), http.StatusBadRequest)
59 return
60 }
61
62 // Authorization: verify the caller has push permission
63 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
64 if !ok {
65 writeError(w, xrpcerr.MissingActorDidError, http.StatusBadRequest)
66 return
67 }
68
69 repoPath, err := x.parseRepoParam(req.Repo)
70 if err != nil {
71 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
72 return
73 }
74
75 repoParts := strings.SplitN(req.Repo, "/", 2)
76 if len(repoParts) != 2 {
77 writeError(w, xrpcerr.NewXrpcError(
78 xrpcerr.WithTag("InvalidRequest"),
79 xrpcerr.WithMessage("invalid repo format"),
80 ), http.StatusBadRequest)
81 return
82 }
83 qualifiedRepo, _ := securejoin.SecureJoin(repoParts[0], repoParts[1])
84 pushOk, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, qualifiedRepo)
85 if err != nil || !pushOk {
86 writeError(w, xrpcerr.NewXrpcError(
87 xrpcerr.WithTag("Forbidden"),
88 xrpcerr.WithMessage("push permission required to apply changes"),
89 ), http.StatusForbidden)
90 return
91 }
92
93 // Open the repository with the target channel
94 pr, err := pijul.Open(repoPath, req.Channel)
95 if err != nil {
96 writeError(w, xrpcerr.NewXrpcError(
97 xrpcerr.WithTag("RepoNotFound"),
98 xrpcerr.WithMessage("failed to open pijul repository"),
99 ), http.StatusNotFound)
100 return
101 }
102
103 // Verify the channel exists
104 channels, err := pr.Channels()
105 if err != nil {
106 writeError(w, xrpcerr.NewXrpcError(
107 xrpcerr.WithTag("InternalServerError"),
108 xrpcerr.WithMessage("failed to list channels"),
109 ), http.StatusInternalServerError)
110 return
111 }
112
113 channelExists := false
114 for _, ch := range channels {
115 if ch.Name == req.Channel {
116 channelExists = true
117 break
118 }
119 }
120
121 if !channelExists {
122 writeError(w, xrpcerr.NewXrpcError(
123 xrpcerr.WithTag("ChannelNotFound"),
124 xrpcerr.WithMessage("target channel not found"),
125 ), http.StatusNotFound)
126 return
127 }
128
129 // Apply each change in order
130 response := ApplyChangesResponse{
131 Applied: make([]string, 0),
132 Failed: make([]ApplyChangeFailure, 0),
133 }
134
135 for _, changeHash := range req.Changes {
136 if err := pr.Apply(changeHash); err != nil {
137 x.Logger.Error("failed to apply change", "hash", changeHash, "error", err.Error())
138 response.Failed = append(response.Failed, ApplyChangeFailure{
139 Hash: changeHash,
140 Error: err.Error(),
141 })
142 } else {
143 response.Applied = append(response.Applied, changeHash)
144 x.Logger.Info("applied change", "hash", changeHash, "channel", req.Channel)
145 }
146 }
147
148 // If any changes failed, return partial success
149 if len(response.Failed) > 0 && len(response.Applied) == 0 {
150 writeError(w, xrpcerr.NewXrpcError(
151 xrpcerr.WithTag("ApplyFailed"),
152 xrpcerr.WithMessage("all changes failed to apply"),
153 ), http.StatusInternalServerError)
154 return
155 }
156
157 writeJson(w, response)
158}