forked from
tangled.org/core
Monorepo for Tangled
15 kB
497 lines
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 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
268 BaseParams: pages.BaseParamsFromContext(r.Context()),
269 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
270 Pull: pull,
271 Stack: stack,
272 Backlinks: backlinks,
273 BranchDeleteStatus: nil,
274 MergeCheck: types.MergeCheckResponse{},
275 ResubmitCheck: pages.Unknown,
276 Pipelines: pipelines,
277 Diff: diff,
278 DiffOpts: diffOpts,
279 ActiveRound: roundIdInt,
280 IsInterdiff: interdiff,
281
282 Reactions: reactions,
283 UserReacted: userReactions,
284
285 LabelDefs: defs,
286 VouchRelationships: vouchRelationships,
287 VouchSkips: vouchSkips,
288 })
289 if err != nil {
290 l.Error("failed to render page", "err", err)
291 }
292}
293
294func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer {
295 submission := pull.Submissions[round]
296 key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev)
297 if cached, ok := s.diffCache.Get(key); ok {
298 return cached
299 }
300
301 diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch)
302 s.diffCache.Add(key, diff)
303 return diff
304}
305
306func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) {
307 if spindle == "" {
308 return nil, nil
309 }
310 spindleUrl, err := hostutil.EnsureHttpScheme(spindle)
311 if err != nil {
312 return nil, err
313 }
314 xrpcc := &indigoxrpc.Client{Host: spindleUrl}
315 out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repoDid)
316 if err != nil {
317 return nil, err
318 }
319 return types.PipelinesByCommit(out.Pipelines), nil
320}
321
322func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
323 l := s.logger.With("handler", "RepoSinglePull")
324
325 pull, ok := r.Context().Value("pull").(*models.Pull)
326 if !ok {
327 l.Error("failed to get pull")
328 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
329 return
330 }
331
332 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
333}
334
335func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
336 if pull.State == models.PullMerged {
337 return types.MergeCheckResponse{}
338 }
339
340 xrpcc := s.knotClient(f.Knot)
341
342 // combine patches of substack
343 subStack := stack.Below(pull)
344 // collect the portion of the stack that is mergeable
345 mergeable := subStack.Mergeable()
346 // combine each patch
347 patch := mergeable.CombinedPatch()
348
349 resp, err := tangled.RepoMergeCheck(
350 r.Context(),
351 xrpcc,
352 &tangled.RepoMergeCheck_Input{
353 Did: f.Did,
354 Name: f.Name,
355 Repo: f.RepoDidPtr(),
356 Branch: pull.TargetBranch,
357 Patch: patch,
358 },
359 )
360 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
361 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
362 return types.MergeCheckResponse{
363 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
364 }
365 }
366
367 return mergeCheckResponseFrom(resp)
368}
369
370func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
371 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
372 for i, c := range resp.Conflicts {
373 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
374 }
375 out := types.MergeCheckResponse{
376 IsConflicted: resp.Is_conflicted,
377 Conflicts: conflicts,
378 }
379 if resp.Message != nil {
380 out.Message = *resp.Message
381 }
382 if resp.Error != nil {
383 out.Error = *resp.Error
384 }
385 return out
386}
387
388func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
389 if pull.State != models.PullMerged {
390 return nil
391 }
392
393 user := s.oauth.GetMultiAccountUser(r)
394 if user == nil {
395 return nil
396 }
397
398 var branch string
399 // check if the branch exists
400 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
401 if pull.IsBranchBased() {
402 branch = pull.PullSource.Branch
403 } else if pull.IsForkBased() {
404 branch = pull.PullSource.Branch
405 repo = pull.PullSource.Repo
406 } else {
407 return nil
408 }
409
410 // deleted fork
411 if repo == nil {
412 return nil
413 }
414
415 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
416 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") {
417 return nil
418 }
419
420 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
421 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
422 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
423 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
424 return nil
425 }
426
427 return &models.BranchDeleteStatus{
428 Repo: repo,
429 Branch: resp.Name,
430 }
431}
432
433func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
434 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
435 return pages.Unknown
436 }
437
438 var sourceRepoDid string
439 if pull.PullSource.RepoDid != nil {
440 sourceRepoDid = string(*pull.PullSource.RepoDid)
441 } else {
442 sourceRepoDid = repo.RepoDid
443 }
444
445 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
446 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
447 if err != nil {
448 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
449 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
450 return pages.Unknown
451 }
452 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
453 return pages.Unknown
454 }
455
456 targetBranch := branchResp
457
458 top := stack[0]
459 latestSourceRev := top.LatestSha()
460
461 if latestSourceRev != targetBranch.Hash {
462 return pages.ShouldResubmit
463 }
464
465 return pages.ShouldNotResubmit
466}
467
468func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
469 s.repoPullHelper(w, r, false)
470}
471
472func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
473 s.repoPullHelper(w, r, true)
474}
475
476func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
477 l := s.logger.With("handler", "RepoPullPatchRaw")
478
479 pull, ok := r.Context().Value("pull").(*models.Pull)
480 if !ok {
481 l.Error("failed to get pull")
482 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
483 return
484 }
485 l = l.With("pull_id", pull.PullId)
486
487 roundId := chi.URLParam(r, "round")
488 roundIdInt, err := strconv.Atoi(roundId)
489 if err != nil || roundIdInt >= len(pull.Submissions) {
490 http.Error(w, "bad round id", http.StatusBadRequest)
491 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
492 return
493 }
494
495 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
496 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
497}