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