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