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
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 >= 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 m := make(map[string]models.Pipeline)
155
156 var shas []string
157 for _, s := range pull.Submissions {
158 shas = append(shas, s.SourceRev)
159 }
160 for _, p := range stack {
161 shas = append(shas, p.LatestSha())
162 }
163
164 ps, err := db.GetPipelineStatuses(
165 s.db,
166 len(shas),
167 orm.FilterEq("p.repo_did", f.RepoDid),
168 orm.FilterIn("p.sha", shas),
169 )
170 if err != nil {
171 l.Error("failed to fetch pipeline statuses", "err", err)
172 // non-fatal
173 }
174
175 for _, p := range ps {
176 m[p.Sha] = p
177 }
178
179 entities := []syntax.ATURI{pull.AtUri()}
180 for _, s := range pull.Submissions {
181 for _, c := range s.Comments {
182 entities = append(entities, c.FeedCommentAtUri())
183 }
184 }
185 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
186 if err != nil {
187 l.Error("failed to get pull reactions", "err", err)
188 }
189
190 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
191 if user != nil {
192 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
193 if err != nil {
194 s.logger.Error("failed to get user reactions", "err", err)
195 }
196 }
197
198 labelDefs, err := db.GetLabelDefinitions(
199 s.db,
200 orm.FilterIn("at_uri", f.Labels),
201 orm.FilterContains("scope", tangled.RepoPullNSID),
202 )
203 if err != nil {
204 l.Error("failed to fetch labels", "err", err)
205 s.pages.Error503(w)
206 return
207 }
208
209 defs := make(map[string]*models.LabelDefinition)
210 for _, l := range labelDefs {
211 defs[l.AtUri().String()] = &l
212 }
213
214 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
215 vouchSkips := make(map[syntax.DID]bool)
216 if user != nil {
217 participants := pull.Participants()
218 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
219 if err != nil {
220 l.Error("failed to fetch vouch relationships", "err", err)
221 }
222 ownerDid := syntax.DID(pull.OwnerDid)
223 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid)
224 if err != nil {
225 l.Error("failed to check vouch skip", "err", err)
226 }
227 vouchSkips[ownerDid] = skipped
228 }
229
230 patch := pull.Submissions[roundIdInt].CombinedPatch()
231 var diff types.DiffRenderer
232 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch)
233
234 if interdiff {
235 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
236 if err != nil {
237 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
238 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
239 return
240 }
241
242 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
243 if err != nil {
244 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
245 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
246 return
247 }
248
249 diff = patchutil.Interdiff(previousPatch, currentPatch)
250 }
251
252 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
253 BaseParams: pages.BaseParamsFromContext(r.Context()),
254 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
255 Pull: pull,
256 Stack: stack,
257 Backlinks: backlinks,
258 BranchDeleteStatus: nil,
259 MergeCheck: types.MergeCheckResponse{},
260 ResubmitCheck: pages.Unknown,
261 Pipelines: m,
262 Diff: diff,
263 DiffOpts: diffOpts,
264 ActiveRound: roundIdInt,
265 IsInterdiff: interdiff,
266
267 Reactions: reactions,
268 UserReacted: userReactions,
269
270 LabelDefs: defs,
271 VouchRelationships: vouchRelationships,
272 VouchSkips: vouchSkips,
273 })
274 if err != nil {
275 l.Error("failed to render page", "err", err)
276 }
277}
278
279func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
280 l := s.logger.With("handler", "RepoSinglePull")
281
282 pull, ok := r.Context().Value("pull").(*models.Pull)
283 if !ok {
284 l.Error("failed to get pull")
285 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
286 return
287 }
288
289 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
290}
291
292func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
293 if pull.State == models.PullMerged {
294 return types.MergeCheckResponse{}
295 }
296
297 xrpcc := s.knotClient(f.Knot)
298
299 // combine patches of substack
300 subStack := stack.Below(pull)
301 // collect the portion of the stack that is mergeable
302 mergeable := subStack.Mergeable()
303 // combine each patch
304 patch := mergeable.CombinedPatch()
305
306 resp, err := tangled.RepoMergeCheck(
307 r.Context(),
308 xrpcc,
309 &tangled.RepoMergeCheck_Input{
310 Did: f.Did,
311 Name: f.Name,
312 Branch: pull.TargetBranch,
313 Patch: patch,
314 },
315 )
316 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
317 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
318 return types.MergeCheckResponse{
319 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
320 }
321 }
322
323 return mergeCheckResponseFrom(resp)
324}
325
326func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
327 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
328 for i, c := range resp.Conflicts {
329 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
330 }
331 out := types.MergeCheckResponse{
332 IsConflicted: resp.Is_conflicted,
333 Conflicts: conflicts,
334 }
335 if resp.Message != nil {
336 out.Message = *resp.Message
337 }
338 if resp.Error != nil {
339 out.Error = *resp.Error
340 }
341 return out
342}
343
344func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
345 if pull.State != models.PullMerged {
346 return nil
347 }
348
349 user := s.oauth.GetMultiAccountUser(r)
350 if user == nil {
351 return nil
352 }
353
354 var branch string
355 // check if the branch exists
356 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
357 if pull.IsBranchBased() {
358 branch = pull.PullSource.Branch
359 } else if pull.IsForkBased() {
360 branch = pull.PullSource.Branch
361 repo = pull.PullSource.Repo
362 } else {
363 return nil
364 }
365
366 // deleted fork
367 if repo == nil {
368 return nil
369 }
370
371 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
372 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") {
373 return nil
374 }
375
376 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
377 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid)
378 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
379 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
380 return nil
381 }
382
383 return &models.BranchDeleteStatus{
384 Repo: repo,
385 Branch: resp.Name,
386 }
387}
388
389func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
390 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
391 return pages.Unknown
392 }
393
394 var sourceRepoDid string
395 if pull.PullSource.RepoDid != nil {
396 sourceRepoDid = string(*pull.PullSource.RepoDid)
397 } else {
398 sourceRepoDid = repo.RepoDid
399 }
400
401 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
402 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
403 if err != nil {
404 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
405 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
406 return pages.Unknown
407 }
408 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
409 return pages.Unknown
410 }
411
412 targetBranch := branchResp
413
414 top := stack[0]
415 latestSourceRev := top.LatestSha()
416
417 if latestSourceRev != targetBranch.Hash {
418 return pages.ShouldResubmit
419 }
420
421 return pages.ShouldNotResubmit
422}
423
424func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
425 s.repoPullHelper(w, r, false)
426}
427
428func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
429 s.repoPullHelper(w, r, true)
430}
431
432func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
433 l := s.logger.With("handler", "RepoPullPatchRaw")
434
435 pull, ok := r.Context().Value("pull").(*models.Pull)
436 if !ok {
437 l.Error("failed to get pull")
438 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
439 return
440 }
441 l = l.With("pull_id", pull.PullId)
442
443 roundId := chi.URLParam(r, "round")
444 roundIdInt, err := strconv.Atoi(roundId)
445 if err != nil || roundIdInt >= len(pull.Submissions) {
446 http.Error(w, "bad round id", http.StatusBadRequest)
447 l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
448 return
449 }
450
451 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
452 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
453}