Monorepo for Tangled
tangled.org
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, "", 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 Branch: pull.TargetBranch,
348 Patch: patch,
349 },
350 )
351 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
352 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
353 return types.MergeCheckResponse{
354 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
355 }
356 }
357
358 return mergeCheckResponseFrom(resp)
359}
360
361func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
362 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
363 for i, c := range resp.Conflicts {
364 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
365 }
366 out := types.MergeCheckResponse{
367 IsConflicted: resp.Is_conflicted,
368 Conflicts: conflicts,
369 }
370 if resp.Message != nil {
371 out.Message = *resp.Message
372 }
373 if resp.Error != nil {
374 out.Error = *resp.Error
375 }
376 return out
377}
378
379func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
380 if pull.State != models.PullMerged {
381 return nil
382 }
383
384 user := s.oauth.GetMultiAccountUser(r)
385 if user == nil {
386 return nil
387 }
388
389 var branch string
390 // check if the branch exists
391 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
392 if pull.IsBranchBased() {
393 branch = pull.PullSource.Branch
394 } else if pull.IsForkBased() {
395 branch = pull.PullSource.Branch
396 repo = pull.PullSource.Repo
397 } else {
398 return nil
399 }
400
401 // deleted fork
402 if repo == nil {
403 return nil
404 }
405
406 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
407 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") {
408 return nil
409 }
410
411 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
412 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
413 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
414 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
415 return nil
416 }
417
418 return &models.BranchDeleteStatus{
419 Repo: repo,
420 Branch: resp.Name,
421 }
422}
423
424func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
425 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
426 return pages.Unknown
427 }
428
429 var sourceRepoDid string
430 if pull.PullSource.RepoDid != nil {
431 sourceRepoDid = string(*pull.PullSource.RepoDid)
432 } else {
433 sourceRepoDid = repo.RepoDid
434 }
435
436 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
437 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
438 if err != nil {
439 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
440 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
441 return pages.Unknown
442 }
443 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
444 return pages.Unknown
445 }
446
447 targetBranch := branchResp
448
449 top := stack[0]
450 latestSourceRev := top.LatestSha()
451
452 if latestSourceRev != targetBranch.Hash {
453 return pages.ShouldResubmit
454 }
455
456 return pages.ShouldNotResubmit
457}
458
459func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
460 s.repoPullHelper(w, r, false)
461}
462
463func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
464 s.repoPullHelper(w, r, true)
465}
466
467func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
468 l := s.logger.With("handler", "RepoPullPatchRaw")
469
470 pull, ok := r.Context().Value("pull").(*models.Pull)
471 if !ok {
472 l.Error("failed to get pull")
473 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
474 return
475 }
476 l = l.With("pull_id", pull.PullId)
477
478 roundId := chi.URLParam(r, "round")
479 roundIdInt, err := strconv.Atoi(roundId)
480 if err != nil || roundIdInt >= len(pull.Submissions) {
481 http.Error(w, "bad round id", http.StatusBadRequest)
482 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
483 return
484 }
485
486 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
487 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
488}