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 if user != nil {
106 repoDid := f.RepoDid
107 userDid := user.Did
108 pullId := pull.PullId
109 go func() {
110 if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil {
111 l.Error("failed to mark pull notifications as read", "err", err)
112 }
113 }()
114 }
115
116 backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
117 if err != nil {
118 l.Error("failed to get pull backlinks", "err", err)
119 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
120 return
121 }
122
123 roundId := chi.URLParam(r, "round")
124 roundIdInt := pull.LastRoundNumber()
125 if r, err := strconv.Atoi(roundId); err == nil {
126 roundIdInt = r
127 }
128 if roundIdInt >= len(pull.Submissions) {
129 http.Error(w, "bad round id", http.StatusBadRequest)
130 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt)
131 return
132 }
133
134 var diffOpts types.DiffOpts
135 if d := r.URL.Query().Get("diff"); d == "split" {
136 diffOpts.Split = true
137 }
138
139 // can be nil if this pull is not stacked
140 stack, _ := r.Context().Value("stack").(models.Stack)
141
142 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
143 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
144 resubmitResult := pages.Unknown
145 if user != nil && user.Did == pull.OwnerDid {
146 resubmitResult = s.resubmitCheck(r, f, pull, stack)
147 }
148
149 m := make(map[string]models.Pipeline)
150
151 var shas []string
152 for _, s := range pull.Submissions {
153 shas = append(shas, s.SourceRev)
154 }
155 for _, p := range stack {
156 shas = append(shas, p.LatestSha())
157 }
158
159 ps, err := db.GetPipelineStatuses(
160 s.db,
161 len(shas),
162 orm.FilterEq("p.repo_did", f.RepoDid),
163 orm.FilterIn("p.sha", shas),
164 )
165 if err != nil {
166 l.Error("failed to fetch pipeline statuses", "err", err)
167 // non-fatal
168 }
169
170 for _, p := range ps {
171 m[p.Sha] = p
172 }
173
174 entities := []syntax.ATURI{pull.AtUri()}
175 for _, s := range pull.Submissions {
176 for _, c := range s.Comments {
177 entities = append(entities, c.FeedCommentAtUri())
178 }
179 }
180 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
181 if err != nil {
182 l.Error("failed to get pull reactions", "err", err)
183 }
184
185 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
186 if user != nil {
187 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
188 if err != nil {
189 s.logger.Error("failed to get user reactions", "err", err)
190 }
191 }
192
193 labelDefs, err := db.GetLabelDefinitions(
194 s.db,
195 orm.FilterIn("at_uri", f.Labels),
196 orm.FilterContains("scope", tangled.RepoPullNSID),
197 )
198 if err != nil {
199 l.Error("failed to fetch labels", "err", err)
200 s.pages.Error503(w)
201 return
202 }
203
204 defs := make(map[string]*models.LabelDefinition)
205 for _, l := range labelDefs {
206 defs[l.AtUri().String()] = &l
207 }
208
209 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
210 vouchSkips := make(map[syntax.DID]bool)
211 if user != nil {
212 participants := pull.Participants()
213 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
214 if err != nil {
215 l.Error("failed to fetch vouch relationships", "err", err)
216 }
217 ownerDid := syntax.DID(pull.OwnerDid)
218 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid)
219 if err != nil {
220 l.Error("failed to check vouch skip", "err", err)
221 }
222 vouchSkips[ownerDid] = skipped
223 }
224
225 patch := pull.Submissions[roundIdInt].CombinedPatch()
226 var diff types.DiffRenderer
227 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch)
228
229 if interdiff {
230 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
231 if err != nil {
232 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
233 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
234 return
235 }
236
237 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
238 if err != nil {
239 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
240 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
241 return
242 }
243
244 diff = patchutil.Interdiff(previousPatch, currentPatch)
245 }
246
247 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
248 LoggedInUser: user,
249 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
250 Pull: pull,
251 Stack: stack,
252 Backlinks: backlinks,
253 BranchDeleteStatus: branchDeleteStatus,
254 MergeCheck: mergeCheckResponse,
255 ResubmitCheck: resubmitResult,
256 Pipelines: m,
257 Diff: diff,
258 DiffOpts: diffOpts,
259 ActiveRound: roundIdInt,
260 IsInterdiff: interdiff,
261
262 Reactions: reactions,
263 UserReacted: userReactions,
264
265 LabelDefs: defs,
266 VouchRelationships: vouchRelationships,
267 VouchSkips: vouchSkips,
268 })
269 if err != nil {
270 l.Error("failed to render page", "err", err)
271 }
272}
273
274func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
275 l := s.logger.With("handler", "RepoSinglePull")
276
277 pull, ok := r.Context().Value("pull").(*models.Pull)
278 if !ok {
279 l.Error("failed to get pull")
280 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
281 return
282 }
283
284 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
285}
286
287func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
288 if pull.State == models.PullMerged {
289 return types.MergeCheckResponse{}
290 }
291
292 xrpcc := s.knotClient(f.Knot)
293
294 // combine patches of substack
295 subStack := stack.Below(pull)
296 // collect the portion of the stack that is mergeable
297 mergeable := subStack.Mergeable()
298 // combine each patch
299 patch := mergeable.CombinedPatch()
300
301 resp, err := tangled.RepoMergeCheck(
302 r.Context(),
303 xrpcc,
304 &tangled.RepoMergeCheck_Input{
305 Did: f.Did,
306 Name: f.Name,
307 Branch: pull.TargetBranch,
308 Patch: patch,
309 },
310 )
311 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
312 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
313 return types.MergeCheckResponse{
314 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
315 }
316 }
317
318 return mergeCheckResponseFrom(resp)
319}
320
321func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
322 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
323 for i, c := range resp.Conflicts {
324 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
325 }
326 out := types.MergeCheckResponse{
327 IsConflicted: resp.Is_conflicted,
328 Conflicts: conflicts,
329 }
330 if resp.Message != nil {
331 out.Message = *resp.Message
332 }
333 if resp.Error != nil {
334 out.Error = *resp.Error
335 }
336 return out
337}
338
339func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
340 if pull.State != models.PullMerged {
341 return nil
342 }
343
344 user := s.oauth.GetMultiAccountUser(r)
345 if user == nil {
346 return nil
347 }
348
349 var branch string
350 // check if the branch exists
351 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
352 if pull.IsBranchBased() {
353 branch = pull.PullSource.Branch
354 } else if pull.IsForkBased() {
355 branch = pull.PullSource.Branch
356 repo = pull.PullSource.Repo
357 } else {
358 return nil
359 }
360
361 // deleted fork
362 if repo == nil {
363 return nil
364 }
365
366 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
367 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier())
368 if !slices.Contains(perms, "repo:push") {
369 return nil
370 }
371
372 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
373 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
374 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
375 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
376 return nil
377 }
378
379 return &models.BranchDeleteStatus{
380 Repo: repo,
381 Branch: resp.Name,
382 }
383}
384
385func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
386 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
387 return pages.Unknown
388 }
389
390 var sourceRepoDid string
391 if pull.PullSource.RepoDid != nil {
392 sourceRepoDid = string(*pull.PullSource.RepoDid)
393 } else {
394 sourceRepoDid = repo.RepoDid
395 }
396
397 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
398 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
399 if err != nil {
400 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
401 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
402 return pages.Unknown
403 }
404 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
405 return pages.Unknown
406 }
407
408 targetBranch := branchResp
409
410 top := stack[0]
411 latestSourceRev := top.LatestSha()
412
413 if latestSourceRev != targetBranch.Hash {
414 return pages.ShouldResubmit
415 }
416
417 return pages.ShouldNotResubmit
418}
419
420func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
421 s.repoPullHelper(w, r, false)
422}
423
424func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
425 s.repoPullHelper(w, r, true)
426}
427
428func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
429 l := s.logger.With("handler", "RepoPullPatchRaw")
430
431 pull, ok := r.Context().Value("pull").(*models.Pull)
432 if !ok {
433 l.Error("failed to get pull")
434 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
435 return
436 }
437 l = l.With("pull_id", pull.PullId)
438
439 roundId := chi.URLParam(r, "round")
440 roundIdInt, err := strconv.Atoi(roundId)
441 if err != nil || roundIdInt >= len(pull.Submissions) {
442 http.Error(w, "bad round id", http.StatusBadRequest)
443 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
444 return
445 }
446
447 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
448 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
449}