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 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, "", nil, 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{CiPipeline: pipeline}
186 }
187 return m
188 }(r.Context())
189
190 var workflowsChanged bool
191 var changedWorkflows []string
192 if _, hasPipeline := pipelines[pull.LatestSha()]; pull.IsForkBased() && !hasPipeline {
193 changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch())
194 if err != nil {
195 l.Error("failed to inspect latest round's patch for workflow changes", "err", err)
196 }
197 workflowsChanged = len(changedWorkflows) > 0
198 }
199
200 entities := []syntax.ATURI{pull.AtUri()}
201 for _, s := range pull.Submissions {
202 for _, c := range s.Comments {
203 entities = append(entities, c.FeedCommentAtUri())
204 }
205 }
206 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
207 if err != nil {
208 l.Error("failed to get pull reactions", "err", err)
209 }
210
211 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
212 if user != nil {
213 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
214 if err != nil {
215 s.logger.Error("failed to get user reactions", "err", err)
216 }
217 }
218
219 labelDefs, err := db.GetLabelDefinitions(
220 s.db,
221 orm.FilterIn("at_uri", f.Labels),
222 orm.FilterContains("scope", tangled.RepoPullNSID),
223 )
224 if err != nil {
225 l.Error("failed to fetch labels", "err", err)
226 s.pages.Error503(w)
227 return
228 }
229
230 defs := make(map[string]*models.LabelDefinition)
231 for _, l := range labelDefs {
232 defs[l.AtUri().String()] = &l
233 }
234
235 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
236 vouchSkips := make(map[syntax.DID]bool)
237 if user != nil {
238 participants := pull.Participants()
239 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
240 if err != nil {
241 l.Error("failed to fetch vouch relationships", "err", err)
242 }
243 ownerDid := syntax.DID(pull.OwnerDid)
244 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid)
245 if err != nil {
246 l.Error("failed to check vouch skip", "err", err)
247 }
248 vouchSkips[ownerDid] = skipped
249 }
250
251 var diff types.DiffRenderer
252 if interdiff {
253 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
254 if err != nil {
255 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
256 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
257 return
258 }
259
260 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
261 if err != nil {
262 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
263 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
264 return
265 }
266
267 diff = patchutil.Interdiff(previousPatch, currentPatch)
268 } else {
269 diff = s.combinedDiff(pull, roundIdInt)
270 }
271
272 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
273 BaseParams: pages.BaseParamsFromContext(r.Context()),
274 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
275 Pull: pull,
276 Stack: stack,
277 Backlinks: backlinks,
278 BranchDeleteStatus: nil,
279 MergeCheck: types.MergeCheckResponse{},
280 ResubmitCheck: pages.Unknown,
281 Pipelines: pipelines,
282 Diff: diff,
283 DiffOpts: diffOpts,
284 ActiveRound: roundIdInt,
285 IsInterdiff: interdiff,
286
287 WorkflowsChanged: workflowsChanged,
288 ChangedWorkflowFiles: changedWorkflows,
289
290 Reactions: reactions,
291 UserReacted: userReactions,
292
293 LabelDefs: defs,
294 VouchRelationships: vouchRelationships,
295 VouchSkips: vouchSkips,
296 })
297 if err != nil {
298 l.Error("failed to render page", "err", err)
299 }
300}
301
302func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer {
303 submission := pull.Submissions[round]
304 key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev)
305 if cached, ok := s.diffCache.Get(key); ok {
306 return cached
307 }
308
309 diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch)
310 s.diffCache.Add(key, diff)
311 return diff
312}
313
314func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
315 l := s.logger.With("handler", "RepoSinglePull")
316
317 pull, ok := r.Context().Value("pull").(*models.Pull)
318 if !ok {
319 l.Error("failed to get pull")
320 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
321 return
322 }
323
324 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
325}
326
327func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
328 if pull.State == models.PullMerged {
329 return types.MergeCheckResponse{}
330 }
331
332 xrpcc := s.knotClient(f.Knot)
333
334 // combine patches of substack
335 subStack := stack.Below(pull)
336 // collect the portion of the stack that is mergeable
337 mergeable := subStack.Mergeable()
338 // combine each patch
339 patch := mergeable.CombinedPatch()
340
341 resp, err := tangled.RepoMergeCheck(
342 r.Context(),
343 xrpcc,
344 &tangled.RepoMergeCheck_Input{
345 Did: f.Did,
346 Name: f.Name,
347 Repo: f.RepoDidPtr(),
348 Branch: pull.TargetBranch,
349 Patch: patch,
350 },
351 )
352 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
353 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
354 return types.MergeCheckResponse{
355 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
356 }
357 }
358
359 return mergeCheckResponseFrom(resp)
360}
361
362func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
363 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
364 for i, c := range resp.Conflicts {
365 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
366 }
367 out := types.MergeCheckResponse{
368 IsConflicted: resp.Is_conflicted,
369 Conflicts: conflicts,
370 }
371 if resp.Message != nil {
372 out.Message = *resp.Message
373 }
374 if resp.Error != nil {
375 out.Error = *resp.Error
376 }
377 return out
378}
379
380func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
381 if pull.State != models.PullMerged {
382 return nil
383 }
384
385 user := s.oauth.GetMultiAccountUser(r)
386 if user == nil {
387 return nil
388 }
389
390 var branch string
391 // check if the branch exists
392 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
393 if pull.IsBranchBased() {
394 branch = pull.PullSource.Branch
395 } else if pull.IsForkBased() {
396 branch = pull.PullSource.Branch
397 repo = pull.PullSource.Repo
398 } else {
399 return nil
400 }
401
402 // deleted fork
403 if repo == nil {
404 return nil
405 }
406
407 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
408 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") {
409 return nil
410 }
411
412 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
413 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
414 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
415 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
416 return nil
417 }
418
419 return &models.BranchDeleteStatus{
420 Repo: repo,
421 Branch: resp.Name,
422 }
423}
424
425func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
426 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
427 return pages.Unknown
428 }
429
430 var sourceRepoDid string
431 if pull.PullSource.RepoDid != nil {
432 sourceRepoDid = string(*pull.PullSource.RepoDid)
433 } else {
434 sourceRepoDid = repo.RepoDid
435 }
436
437 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
438 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
439 if err != nil {
440 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
441 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
442 return pages.Unknown
443 }
444 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
445 return pages.Unknown
446 }
447
448 targetBranch := branchResp
449
450 top := stack[0]
451 latestSourceRev := top.LatestSha()
452
453 if latestSourceRev != targetBranch.Hash {
454 return pages.ShouldResubmit
455 }
456
457 return pages.ShouldNotResubmit
458}
459
460func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
461 s.repoPullHelper(w, r, false)
462}
463
464func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
465 s.repoPullHelper(w, r, true)
466}
467
468func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
469 l := s.logger.With("handler", "RepoPullPatchRaw")
470
471 pull, ok := r.Context().Value("pull").(*models.Pull)
472 if !ok {
473 l.Error("failed to get pull")
474 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
475 return
476 }
477 l = l.With("pull_id", pull.PullId)
478
479 roundId := chi.URLParam(r, "round")
480 roundIdInt, err := strconv.Atoi(roundId)
481 if err != nil || roundIdInt >= len(pull.Submissions) {
482 http.Error(w, "bad round id", http.StatusBadRequest)
483 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
484 return
485 }
486
487 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
488 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
489}