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