Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "fmt"
5 "net/http"
6 "slices"
7 "strconv"
8
9 "tangled.org/core/api/tangled"
10 "tangled.org/core/appview/db"
11 "tangled.org/core/appview/models"
12 "tangled.org/core/appview/pages"
13 "tangled.org/core/appview/xrpcclient"
14 "tangled.org/core/orm"
15 "tangled.org/core/patchutil"
16 "tangled.org/core/types"
17
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
20 "github.com/go-chi/chi/v5"
21)
22
23// htmx fragment
24func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
25 l := s.logger.With("handler", "PullActions")
26
27 switch r.Method {
28 case http.MethodGet:
29 user := s.oauth.GetMultiAccountUser(r)
30 if user != nil {
31 l = l.With("user", user.Did)
32 }
33
34 f, err := s.repoResolver.Resolve(r)
35 if err != nil {
36 l.Error("failed to get repo and knot", "err", err)
37 return
38 }
39
40 pull, ok := r.Context().Value("pull").(*models.Pull)
41 if !ok {
42 l.Error("failed to get pull")
43 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
44 return
45 }
46 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
47
48 // can be nil if this pull is not stacked
49 stack, _ := r.Context().Value("stack").(models.Stack)
50
51 roundNumberStr := chi.URLParam(r, "round")
52 roundNumber, err := strconv.Atoi(roundNumberStr)
53 if err != nil {
54 roundNumber = pull.LastRoundNumber()
55 }
56 if roundNumber >= len(pull.Submissions) {
57 http.Error(w, "bad round id", http.StatusBadRequest)
58 l.Error("failed to parse round id", "err", err, "round_number", roundNumber)
59 return
60 }
61
62 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
63 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
64 resubmitResult := pages.Unknown
65 if user.Did == pull.OwnerDid {
66 resubmitResult = s.resubmitCheck(r, f, pull, stack)
67 }
68
69 s.pages.PullActionsFragment(w, pages.PullActionsParams{
70 LoggedInUser: user,
71 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
72 Pull: pull,
73 RoundNumber: roundNumber,
74 MergeCheck: mergeCheckResponse,
75 ResubmitCheck: resubmitResult,
76 BranchDeleteStatus: branchDeleteStatus,
77 Stack: stack,
78 })
79 return
80 }
81}
82
83func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) {
84 l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff)
85
86 user := s.oauth.GetMultiAccountUser(r)
87 if user != nil {
88 l = l.With("user", user.Did)
89 }
90
91 f, err := s.repoResolver.Resolve(r)
92 if err != nil {
93 l.Error("failed to get repo and knot", "err", err)
94 return
95 }
96
97 pull, ok := r.Context().Value("pull").(*models.Pull)
98 if !ok {
99 l.Error("failed to get pull")
100 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
101 return
102 }
103 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
104
105 backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
106 if err != nil {
107 l.Error("failed to get pull backlinks", "err", err)
108 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
109 return
110 }
111
112 roundId := chi.URLParam(r, "round")
113 roundIdInt := pull.LastRoundNumber()
114 if r, err := strconv.Atoi(roundId); err == nil {
115 roundIdInt = r
116 }
117 if roundIdInt >= len(pull.Submissions) {
118 http.Error(w, "bad round id", http.StatusBadRequest)
119 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt)
120 return
121 }
122
123 var diffOpts types.DiffOpts
124 if d := r.URL.Query().Get("diff"); d == "split" {
125 diffOpts.Split = true
126 }
127
128 // can be nil if this pull is not stacked
129 stack, _ := r.Context().Value("stack").(models.Stack)
130
131 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
132 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
133 resubmitResult := pages.Unknown
134 if user != nil && user.Did == pull.OwnerDid {
135 resubmitResult = s.resubmitCheck(r, f, pull, stack)
136 }
137
138 m := make(map[string]models.Pipeline)
139
140 var shas []string
141 for _, s := range pull.Submissions {
142 shas = append(shas, s.SourceRev)
143 }
144 for _, p := range stack {
145 shas = append(shas, p.LatestSha())
146 }
147
148 ps, err := db.GetPipelineStatuses(
149 s.db,
150 len(shas),
151 orm.FilterEq("p.repo_owner", f.Did),
152 orm.FilterEq("p.repo_name", f.Rkey),
153 orm.FilterEq("p.knot", f.Knot),
154 orm.FilterIn("p.sha", shas),
155 )
156 if err != nil {
157 l.Error("failed to fetch pipeline statuses", "err", err)
158 // non-fatal
159 }
160
161 for _, p := range ps {
162 m[p.Sha] = p
163 }
164
165 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
166 if err != nil {
167 l.Error("failed to get pull reactions", "err", err)
168 }
169
170 userReactions := map[models.ReactionKind]bool{}
171 if user != nil {
172 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
173 }
174
175 labelDefs, err := db.GetLabelDefinitions(
176 s.db,
177 orm.FilterIn("at_uri", f.Labels),
178 orm.FilterContains("scope", tangled.RepoPullNSID),
179 )
180 if err != nil {
181 l.Error("failed to fetch labels", "err", err)
182 s.pages.Error503(w)
183 return
184 }
185
186 defs := make(map[string]*models.LabelDefinition)
187 for _, l := range labelDefs {
188 defs[l.AtUri().String()] = &l
189 }
190
191 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
192 vouchSkips := make(map[syntax.DID]bool)
193 if user != nil {
194 participants := pull.Participants()
195 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
196 if err != nil {
197 l.Error("failed to fetch vouch relationships", "err", err)
198 }
199 ownerDid := syntax.DID(pull.OwnerDid)
200 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid)
201 if err != nil {
202 l.Error("failed to check vouch skip", "err", err)
203 }
204 vouchSkips[ownerDid] = skipped
205 }
206
207 patch := pull.Submissions[roundIdInt].CombinedPatch()
208 var diff types.DiffRenderer
209 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch)
210
211 if interdiff {
212 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
213 if err != nil {
214 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
215 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
216 return
217 }
218
219 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
220 if err != nil {
221 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
222 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
223 return
224 }
225
226 diff = patchutil.Interdiff(previousPatch, currentPatch)
227 }
228
229 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
230 LoggedInUser: user,
231 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
232 Pull: pull,
233 Stack: stack,
234 Backlinks: backlinks,
235 BranchDeleteStatus: branchDeleteStatus,
236 MergeCheck: mergeCheckResponse,
237 ResubmitCheck: resubmitResult,
238 Pipelines: m,
239 Diff: diff,
240 DiffOpts: diffOpts,
241 ActiveRound: roundIdInt,
242 IsInterdiff: interdiff,
243
244 Reactions: reactionMap,
245 UserReacted: userReactions,
246
247 LabelDefs: defs,
248 VouchRelationships: vouchRelationships,
249 VouchSkips: vouchSkips,
250 })
251 if err != nil {
252 l.Error("failed to render page", "err", err)
253 }
254}
255
256func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
257 l := s.logger.With("handler", "RepoSinglePull")
258
259 pull, ok := r.Context().Value("pull").(*models.Pull)
260 if !ok {
261 l.Error("failed to get pull")
262 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
263 return
264 }
265
266 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
267}
268
269func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
270 if pull.State == models.PullMerged {
271 return types.MergeCheckResponse{}
272 }
273
274 xrpcc := s.knotClient(f.Knot)
275
276 // combine patches of substack
277 subStack := stack.Below(pull)
278 // collect the portion of the stack that is mergeable
279 mergeable := subStack.Mergeable()
280 // combine each patch
281 patch := mergeable.CombinedPatch()
282
283 resp, err := tangled.RepoMergeCheck(
284 r.Context(),
285 xrpcc,
286 &tangled.RepoMergeCheck_Input{
287 Did: f.Did,
288 Name: f.Name,
289 Branch: pull.TargetBranch,
290 Patch: patch,
291 },
292 )
293 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
294 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
295 return types.MergeCheckResponse{
296 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
297 }
298 }
299
300 return mergeCheckResponseFrom(resp)
301}
302
303func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
304 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
305 for i, c := range resp.Conflicts {
306 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
307 }
308 out := types.MergeCheckResponse{
309 IsConflicted: resp.Is_conflicted,
310 Conflicts: conflicts,
311 }
312 if resp.Message != nil {
313 out.Message = *resp.Message
314 }
315 if resp.Error != nil {
316 out.Error = *resp.Error
317 }
318 return out
319}
320
321func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
322 if pull.State != models.PullMerged {
323 return nil
324 }
325
326 user := s.oauth.GetMultiAccountUser(r)
327 if user == nil {
328 return nil
329 }
330
331 var branch string
332 // check if the branch exists
333 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
334 if pull.IsBranchBased() {
335 branch = pull.PullSource.Branch
336 } else if pull.IsForkBased() {
337 branch = pull.PullSource.Branch
338 repo = pull.PullSource.Repo
339 } else {
340 return nil
341 }
342
343 // deleted fork
344 if repo == nil {
345 return nil
346 }
347
348 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
349 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier())
350 if !slices.Contains(perms, "repo:push") {
351 return nil
352 }
353
354 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
355 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String())
356 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
357 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
358 return nil
359 }
360
361 return &models.BranchDeleteStatus{
362 Repo: repo,
363 Branch: resp.Name,
364 }
365}
366
367func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
368 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
369 return pages.Unknown
370 }
371
372 var sourceRepoDid string
373 if pull.PullSource.RepoDid != nil {
374 sourceRepoDid = string(*pull.PullSource.RepoDid)
375 } else {
376 sourceRepoDid = repo.RepoDid
377 }
378
379 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
380 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
381 if err != nil {
382 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
383 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
384 return pages.Unknown
385 }
386 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
387 return pages.Unknown
388 }
389
390 targetBranch := branchResp
391
392 top := stack[0]
393 latestSourceRev := top.LatestSha()
394
395 if latestSourceRev != targetBranch.Hash {
396 return pages.ShouldResubmit
397 }
398
399 return pages.ShouldNotResubmit
400}
401
402func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
403 s.repoPullHelper(w, r, false)
404}
405
406func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
407 s.repoPullHelper(w, r, true)
408}
409
410func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
411 l := s.logger.With("handler", "RepoPullPatchRaw")
412
413 pull, ok := r.Context().Value("pull").(*models.Pull)
414 if !ok {
415 l.Error("failed to get pull")
416 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
417 return
418 }
419 l = l.With("pull_id", pull.PullId)
420
421 roundId := chi.URLParam(r, "round")
422 roundIdInt, err := strconv.Atoi(roundId)
423 if err != nil || roundIdInt >= len(pull.Submissions) {
424 http.Error(w, "bad round id", http.StatusBadRequest)
425 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
426 return
427 }
428
429 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
430 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
431}