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