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