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/orm"
14 "tangled.org/core/patchutil"
15 "tangled.org/core/types"
16 "tangled.org/core/xrpc/xrpcclient"
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
67 var workflowsChanged bool
68 var changedWorkflows []string
69 hasPipeline := false
70 if isLastRound && f.Spindle != "" {
71 pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, []string{pull.LatestSha()})
72 if err != nil {
73 l.Error("failed to fetch latest pipeline", "err", err)
74 } else if pipelines != nil {
75 _, hasPipeline = pipelines[pull.LatestSha()]
76 }
77
78 if pull.IsForkBased() && !hasPipeline {
79 changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch())
80 if err != nil {
81 l.Error("failed to inspect latest round's patch for workflow changes", "err", err)
82 }
83 workflowsChanged = len(changedWorkflows) > 0
84 }
85 }
86
87 mergeCheckResponse := types.MergeCheckResponse{}
88 resubmitResult := pages.Unknown
89 if isLastRound {
90 mergeCheckResponse = s.mergeCheck(r, f, pull, stack)
91 if user != nil && user.Did == pull.OwnerDid {
92 resubmitResult = s.resubmitCheck(r, f, pull, stack)
93 }
94 }
95
96 s.pages.PullActionsFragment(w, pages.PullActionsParams{
97 BaseParams: pages.BaseParamsFromContext(r.Context()),
98 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
99 Pull: pull,
100 RoundNumber: roundNumber,
101 MergeCheck: mergeCheckResponse,
102 ResubmitCheck: resubmitResult,
103 BranchDeleteStatus: branchDeleteStatus,
104 Stack: stack,
105 WorkflowsChanged: workflowsChanged,
106 ChangedWorkflowFiles: changedWorkflows,
107 HasPipeline: hasPipeline,
108 })
109 return
110 }
111}
112
113func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) {
114 l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff)
115
116 user := s.oauth.GetMultiAccountUser(r)
117 if user != nil {
118 l = l.With("user", user.Did)
119 }
120
121 f, err := s.repoResolver.Resolve(r)
122 if err != nil {
123 l.Error("failed to get repo and knot", "err", err)
124 return
125 }
126
127 pull, ok := r.Context().Value("pull").(*models.Pull)
128 if !ok {
129 l.Error("failed to get pull")
130 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
131 return
132 }
133 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
134
135 if user != nil {
136 userDid := user.Did
137 repoDid := f.RepoDid
138 pullId := pull.PullId
139 atUri := pull.AtUri().String()
140 focusing := pages.BaseParamsFromContext(r.Context()).FocusParams.Focusing
141 go func() {
142 if !focusing {
143 if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil {
144 l.Error("failed to mark pull notifications as read", "err", err)
145 }
146 }
147 if err := db.UpsertRecentLink(s.db, userDid, models.RecentLinkTypePull, atUri); err != nil {
148 l.Error("failed to upsert recent link", "err", err)
149 }
150 }()
151 }
152
153 backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
154 if err != nil {
155 l.Error("failed to get pull backlinks", "err", err)
156 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
157 return
158 }
159
160 roundId := chi.URLParam(r, "round")
161 roundIdInt := pull.LastRoundNumber()
162 if r, err := strconv.Atoi(roundId); err == nil {
163 roundIdInt = r
164 }
165 if roundIdInt < 0 || roundIdInt >= len(pull.Submissions) {
166 http.Error(w, "bad round id", http.StatusBadRequest)
167 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt)
168 return
169 }
170
171 var diffOpts types.DiffOpts
172 if d := r.URL.Query().Get("diff"); d == "split" {
173 diffOpts.Split = true
174 }
175
176 // can be nil if this pull is not stacked
177 stack, _ := r.Context().Value("stack").(models.Stack)
178
179 var shas []string
180 for _, s := range pull.Submissions {
181 shas = append(shas, s.SourceRev)
182 }
183 for _, p := range stack {
184 shas = append(shas, p.LatestSha())
185 }
186
187 pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, shas)
188 if err != nil {
189 l.Error("failed to fetch pipelines", "err", err)
190 }
191 if pipelines == nil {
192 pipelines = make(map[string]types.Pipeline)
193 }
194
195 entities := []syntax.ATURI{pull.AtUri()}
196 for _, s := range pull.Submissions {
197 for _, c := range s.Comments {
198 entities = append(entities, c.FeedCommentAtUri())
199 }
200 }
201 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
202 if err != nil {
203 l.Error("failed to get pull reactions", "err", err)
204 }
205
206 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
207 if user != nil {
208 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
209 if err != nil {
210 s.logger.Error("failed to get user reactions", "err", err)
211 }
212 }
213
214 labelDefs, err := db.GetLabelDefinitions(
215 s.db,
216 orm.FilterIn("at_uri", f.Labels),
217 orm.FilterContains("scope", tangled.RepoPullNSID),
218 )
219 if err != nil {
220 l.Error("failed to fetch labels", "err", err)
221 s.pages.Error503(w)
222 return
223 }
224
225 defs := make(map[string]*models.LabelDefinition)
226 for _, l := range labelDefs {
227 defs[l.AtUri().String()] = &l
228 }
229
230 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
231 vouchSkips := make(map[syntax.DID]bool)
232 if user != nil {
233 participants := pull.Participants()
234 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
235 if err != nil {
236 l.Error("failed to fetch vouch relationships", "err", err)
237 }
238 ownerDid := syntax.DID(pull.OwnerDid)
239 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid)
240 if err != nil {
241 l.Error("failed to check vouch skip", "err", err)
242 }
243 vouchSkips[ownerDid] = skipped
244 }
245
246 var diff types.DiffRenderer
247 if interdiff {
248 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
249 if err != nil {
250 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
251 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
252 return
253 }
254
255 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
256 if err != nil {
257 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
258 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
259 return
260 }
261
262 diff = patchutil.Interdiff(previousPatch, currentPatch)
263 } else {
264 diff = s.combinedDiff(pull, roundIdInt)
265 }
266
267 var isSubscribed *bool
268 if user != nil {
269 pullDbId := int64(pull.ID)
270 sub, found, err2 := db.GetPullSubscription(s.db, user.Did, pullDbId)
271 if err2 == nil {
272 if found {
273 isSubscribed = &sub
274 } else {
275 // Implicitly subscribed if author or participant.
276 isAuthorOrParticipant := pull.OwnerDid == user.Did
277 if !isAuthorOrParticipant {
278 for _, p := range pull.Participants() {
279 if p.String() == user.Did {
280 isAuthorOrParticipant = true
281 break
282 }
283 }
284 }
285 if isAuthorOrParticipant {
286 t := true
287 isSubscribed = &t
288 }
289 }
290 }
291 }
292
293 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
294 BaseParams: pages.BaseParamsFromContext(r.Context()),
295 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
296 Pull: pull,
297 Stack: stack,
298 Backlinks: backlinks,
299 BranchDeleteStatus: nil,
300 MergeCheck: types.MergeCheckResponse{},
301 ResubmitCheck: pages.Unknown,
302 Pipelines: pipelines,
303 Diff: diff,
304 DiffOpts: diffOpts,
305 ActiveRound: roundIdInt,
306 IsInterdiff: interdiff,
307
308 Reactions: reactions,
309 UserReacted: userReactions,
310
311 LabelDefs: defs,
312 VouchRelationships: vouchRelationships,
313 VouchSkips: vouchSkips,
314 IsSubscribed: isSubscribed,
315 })
316 if err != nil {
317 l.Error("failed to render page", "err", err)
318 }
319}
320
321// SubscribePull handles subscribe/unsubscribe for a specific pull request.
322func (s *Pulls) SubscribePull(w http.ResponseWriter, r *http.Request) {
323 l := s.logger.With("handler", "SubscribePull")
324 user := s.oauth.GetMultiAccountUser(r)
325 if user == nil {
326 w.WriteHeader(http.StatusUnauthorized)
327 return
328 }
329
330 pull, ok := r.Context().Value("pull").(*models.Pull)
331 if !ok {
332 l.Error("failed to get pull from context")
333 w.WriteHeader(http.StatusNotFound)
334 return
335 }
336
337 subscribe := r.FormValue("subscribe") != "false"
338 pullDbId := int64(pull.ID)
339
340 if err := db.UpsertPullSubscription(s.db, user.Did, pullDbId, subscribe); err != nil {
341 l.Error("failed to update pull subscription", "err", err)
342 w.WriteHeader(http.StatusInternalServerError)
343 return
344 }
345
346 repoInfo := s.repoResolver.GetRepoInfo(r, user)
347 s.pages.PullSubscribeFragment(w, pages.PullSubscribeParams{
348 RepoInfo: repoInfo,
349 PullId: pull.PullId,
350 IsSubscribed: &subscribe,
351 })
352}
353
354func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer {
355 submission := pull.Submissions[round]
356 key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev)
357 if cached, ok := s.diffCache.Get(key); ok {
358 return cached
359 }
360
361 diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch)
362 s.diffCache.Add(key, diff)
363 return diff
364}
365
366func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) {
367 if spindle == "" {
368 return nil, nil
369 }
370 spindleUrl, err := hostutil.EnsureHttpScheme(spindle)
371 if err != nil {
372 return nil, err
373 }
374 xrpcc := &indigoxrpc.Client{Host: spindleUrl}
375 out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repoDid)
376 if err != nil {
377 return nil, err
378 }
379 return types.PipelinesByCommit(out.Pipelines), nil
380}
381
382func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
383 l := s.logger.With("handler", "RepoSinglePull")
384
385 pull, ok := r.Context().Value("pull").(*models.Pull)
386 if !ok {
387 l.Error("failed to get pull")
388 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
389 return
390 }
391
392 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
393}
394
395func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
396 if pull.State == models.PullMerged {
397 return types.MergeCheckResponse{}
398 }
399
400 xrpcc := s.knotClient(f.Knot)
401
402 // combine patches of substack
403 subStack := stack.Below(pull)
404 // collect the portion of the stack that is mergeable
405 mergeable := subStack.Mergeable()
406 // combine each patch
407 patch := mergeable.CombinedPatch()
408
409 resp, err := tangled.RepoMergeCheck(
410 r.Context(),
411 xrpcc,
412 &tangled.RepoMergeCheck_Input{
413 Did: f.Did,
414 Name: f.Name,
415 Repo: f.RepoDidPtr(),
416 Branch: pull.TargetBranch,
417 Patch: patch,
418 },
419 )
420 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
421 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
422 return types.MergeCheckResponse{
423 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
424 }
425 }
426
427 return mergeCheckResponseFrom(resp)
428}
429
430func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
431 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
432 for i, c := range resp.Conflicts {
433 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
434 }
435 out := types.MergeCheckResponse{
436 IsConflicted: resp.Is_conflicted,
437 Conflicts: conflicts,
438 }
439 if resp.Message != nil {
440 out.Message = *resp.Message
441 }
442 if resp.Error != nil {
443 out.Error = *resp.Error
444 }
445 return out
446}
447
448func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
449 if pull.State != models.PullMerged {
450 return nil
451 }
452
453 user := s.oauth.GetMultiAccountUser(r)
454 if user == nil {
455 return nil
456 }
457
458 var branch string
459 // check if the branch exists
460 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
461 if pull.IsBranchBased() {
462 branch = pull.PullSource.Branch
463 } else if pull.IsForkBased() {
464 branch = pull.PullSource.Branch
465 repo = pull.PullSource.Repo
466 } else {
467 return nil
468 }
469
470 // deleted fork
471 if repo == nil {
472 return nil
473 }
474
475 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
476 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") {
477 return nil
478 }
479
480 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
481 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
482 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
483 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
484 return nil
485 }
486
487 return &models.BranchDeleteStatus{
488 Repo: repo,
489 Branch: resp.Name,
490 }
491}
492
493func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
494 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
495 return pages.Unknown
496 }
497
498 var sourceRepoDid string
499 if pull.PullSource.RepoDid != nil {
500 sourceRepoDid = string(*pull.PullSource.RepoDid)
501 } else {
502 sourceRepoDid = repo.RepoDid
503 }
504
505 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
506 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
507 if err != nil {
508 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
509 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
510 return pages.Unknown
511 }
512 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
513 return pages.Unknown
514 }
515
516 targetBranch := branchResp
517
518 top := stack[0]
519 latestSourceRev := top.LatestSha()
520
521 if latestSourceRev != targetBranch.Hash {
522 return pages.ShouldResubmit
523 }
524
525 return pages.ShouldNotResubmit
526}
527
528func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
529 s.repoPullHelper(w, r, false)
530}
531
532func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
533 s.repoPullHelper(w, r, true)
534}
535
536func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
537 l := s.logger.With("handler", "RepoPullPatchRaw")
538
539 pull, ok := r.Context().Value("pull").(*models.Pull)
540 if !ok {
541 l.Error("failed to get pull")
542 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
543 return
544 }
545 l = l.With("pull_id", pull.PullId)
546
547 roundId := chi.URLParam(r, "round")
548 roundIdInt, err := strconv.Atoi(roundId)
549 if err != nil || roundIdInt >= len(pull.Submissions) {
550 http.Error(w, "bad round id", http.StatusBadRequest)
551 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
552 return
553 }
554
555 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
556 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
557}