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