This repository has no description
1package pulls
2
3import (
4 "cmp"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "strconv"
12 "strings"
13
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16 "github.com/go-chi/chi/v5"
17 "github.com/go-git/go-git/v5/plumbing"
18 "github.com/go-git/go-git/v5/plumbing/object"
19 "golang.org/x/sync/errgroup"
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/db"
22 "tangled.org/core/appview/models"
23 "tangled.org/core/appview/oauth"
24 "tangled.org/core/appview/pages"
25 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
26 "tangled.org/core/hostutil"
27 "tangled.org/core/orm"
28 "tangled.org/core/types"
29)
30
31func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) {
32 pull, ok := r.Context().Value("pull").(*models.Pull)
33 if !ok {
34 s.logger.Error("failed to get pull")
35 s.pages.Error500(w)
36 return
37 }
38 u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber()))
39 http.Redirect(w, r, u.String(), http.StatusFound)
40}
41
42func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) {
43 if strings.Contains(chi.URLParam(r, "version"), "..") {
44 s.PullInterDiff(w, r)
45 } else {
46 s.PullDiff(w, r)
47 }
48}
49
50// PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit}
51//
52// Examples:
53// - /pulls/123/latest
54// - /pulls/123/2/head
55// - /pulls/123/2/base..head
56// - /pulls/123/2/a53ab251e..d8add468c
57// - /pulls/123/2/d8add468c
58func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) {
59 l := s.logger.With("handler", "PullDiff")
60 ctx := r.Context()
61
62 user := s.oauth.GetMultiAccountUser(r)
63 if user != nil {
64 l = l.With("user", user.Did)
65 }
66
67 pull, ok := r.Context().Value("pull").(*models.Pull)
68 if !ok {
69 l.Error("failed to get pull")
70 http.Error(w, "failed to get PR", http.StatusInternalServerError)
71 return
72 }
73
74 var version models.PullVersion
75 var versionIdRaw = chi.URLParam(r, "version")
76 if versionIdRaw == "latest" {
77 version = pull.LatestVersion()
78 } else {
79 versionId, err := strconv.Atoi(versionIdRaw)
80 if err != nil {
81 // invalid version number. redirect
82 http.Redirect(w, r,
83 fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
84 http.StatusSeeOther,
85 )
86 return
87 }
88 var ok bool
89 version, ok = pull.GetVersion(versionId)
90 if !ok {
91 // invalid version number. redirect
92 http.Redirect(w, r,
93 fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
94 http.StatusSeeOther,
95 )
96 return
97 }
98 }
99
100 diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec"))
101 if err != nil {
102 http.Redirect(w, r,
103 fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw),
104 http.StatusSeeOther,
105 )
106 return
107 }
108
109 // defer render
110 var params pages.PullDiffParams
111 params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull)
112 params.VersionId = version.ID
113 defer func() {
114 if err := s.pages.PullDiff(w, params); err != nil {
115 l.Error("Failed to render", "err", err)
116 }
117 }()
118
119 // special cases
120 // default to {current.base}..{current.head}
121 if diffBase == "" || diffBase == "base" {
122 params.IsDiffBase = true
123 // NOTE: We fallback to target-branch for legacy reason.
124 // Old PRs before ref-based-PR refactor doesn't have `version.base`.
125 diffBase = cmp.Or(version.Base, pull.TargetBranch)
126 }
127 if diffHead == "" || diffHead == "head" {
128 params.IsDiffHead = true
129 diffHead = version.Head
130 }
131 params.DiffParams.Base = diffBase
132 params.DiffParams.Head = diffHead
133
134 commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head)
135 if err != nil {
136 l.Error("failed to list commits", "err", err)
137 params.ErrorMsg = "Failed to list commits. Try again later."
138 return
139 }
140 params.Commits = commits
141
142 // commitId -> latest pipeline
143 shas := make([]string, len(params.Commits))
144 for i, commit := range params.Commits {
145 shas[i] = commit.Hash.String()
146 }
147 params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas)
148}
149
150// PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change}
151//
152// Examples:
153// - /pulls/123/0..2/all
154// - /pulls/123/0..2/nrpytyzw
155func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) {
156 l := s.logger.With("handler", "PullInterDiff")
157 ctx := r.Context()
158
159 user := s.oauth.GetMultiAccountUser(r)
160 if user != nil {
161 l = l.With("user", user.Did)
162 }
163
164 pull, ok := r.Context().Value("pull").(*models.Pull)
165 if !ok {
166 s.logger.Error("failed to get pull")
167 s.pages.Error500(w)
168 return
169 }
170
171 version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version"))
172 if err != nil {
173 http.Redirect(w, r,
174 fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId),
175 http.StatusSeeOther,
176 )
177 return
178 }
179 version1, err := strconv.Atoi(version1Raw)
180 version2, err := strconv.Atoi(version2Raw)
181 if err != nil {
182 http.Redirect(w, r,
183 fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
184 http.StatusSeeOther,
185 )
186 return
187 }
188
189 changeId := chi.URLParam(r, "revspec")
190 if changeId == "all" {
191 changeId = ""
192 }
193
194 // defer render
195 var params pages.PullInterdiffParams
196 params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull)
197 params.Version1 = version1
198 params.Version2 = version2
199 params.ChangeId = changeId
200 defer func() {
201 if err := s.pages.PullInterdiff(w, params); err != nil {
202 l.Error("Failed to render", "err", err)
203 }
204 }()
205
206 var commits1, commits2 []types.Commit
207 g, gctx := errgroup.WithContext(ctx)
208 if changeId != "" {
209 g.Go(func() error {
210 commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head)
211 return err
212 })
213 }
214 g.Go(func() error {
215 commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head)
216 return err
217 })
218 if err := g.Wait(); err != nil {
219 l.Error("failed to list commits", "err", err)
220 params.ErrorMsg = "Failed to list commits. Try again later."
221 return
222 }
223 params.Commits = commits2
224
225 // commitId -> latest pipeline
226 shas := make([]string, len(params.Commits))
227 for i, commit := range params.Commits {
228 shas[i] = commit.Hash.String()
229 }
230 params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas)
231
232 if changeId != "" {
233 // interdiff by change-id
234 var from, to *types.Commit
235 for _, commit := range commits1 {
236 if commit.ChangeId == changeId {
237 from = &commit
238 break
239 }
240 }
241 for _, commit := range commits2 {
242 if commit.ChangeId == changeId {
243 to = &commit
244 break
245 }
246 }
247 l.Debug("commits", "old", from, "new", to)
248
249 switch {
250 case to == nil:
251 // can't find change-id from v2 branch.
252 // NOTE: This can't happen because user selected from v2's commits
253 params.ErrorMsg = "Can't find commit with given change-id."
254 case from == nil:
255 // new commit -> diff <parent1>..<new>
256 params.ActiveCommitId = to.Hash.String()
257 params.DiffParams.Diff = &pages.DiffParams_Diff{
258 Base: to.FirstParentHash().String(),
259 Head: to.Hash.String(),
260 }
261 default:
262 // interdiff
263 params.ActiveCommitId = to.Hash.String()
264 // TODO: use merged tree of all parents
265 params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{
266 From: pages.DiffParams_Diff{
267 Base: from.FirstParentHash().String(),
268 Head: from.Hash.String(),
269 },
270 To: pages.DiffParams_Diff{
271 Base: to.FirstParentHash().String(),
272 Head: to.Hash.String(),
273 },
274 }
275 }
276 } else {
277 // interdiff of two versions
278 params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{
279 From: pages.DiffParams_Diff{
280 Base: pull.Versions[version1].Base,
281 Head: pull.Versions[version1].Head,
282 },
283 To: pages.DiffParams_Diff{
284 Base: pull.Versions[version2].Base,
285 Head: pull.Versions[version2].Head,
286 },
287 }
288
289 // TODO: if any of them is "", show error message
290 }
291}
292
293func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) {
294 l := s.logger.With("handler", "RepoPullPatchRaw")
295
296 pull, ok := r.Context().Value("pull").(*models.Pull)
297 if !ok {
298 l.Error("failed to get pull")
299 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
300 return
301 }
302 l = l.With("pull_id", pull.PullId)
303
304 var version models.PullVersion
305 var versionIdRaw = chi.URLParam(r, "version")
306 if versionIdRaw == "latest" {
307 version = pull.LatestVersion()
308 } else {
309 versionId, err := strconv.Atoi(versionIdRaw)
310 if err != nil {
311 http.Error(w, "bad version id", http.StatusBadRequest)
312 return
313 }
314 var ok bool
315 version, ok = pull.GetVersion(versionId)
316 if !ok {
317 http.Error(w, "unknown version", http.StatusNotFound)
318 return
319 }
320 }
321
322 xrpcc := s.knotMirrorXRPC
323 rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head)
324 if err != nil {
325 http.Error(w, "Failed to compute patch", http.StatusInternalServerError)
326 return
327 }
328
329 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
330 w.Write(rawOut)
331}
332
333func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, pull *models.Pull) pages.PullPageBaseParams {
334 l := s.logger
335 ctx := r.Context()
336
337 entities := []syntax.ATURI{pull.AtUri()}
338 for _, v := range pull.Versions {
339 for _, c := range v.Comments {
340 entities = append(entities, c.FeedCommentAtUri())
341 }
342 }
343 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
344 if err != nil {
345 l.Error("failed to get reactions", "err", err)
346 }
347
348 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
349 if user != nil {
350 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
351 if err != nil {
352 s.logger.Error("failed to get user reactions", "err", err)
353 }
354 }
355
356 labelDefs, err := db.GetLabelDefinitions(
357 s.db,
358 orm.FilterIn("at_uri", pull.Repo.Labels),
359 orm.FilterContains("scope", tangled.RepoPullNSID),
360 )
361 if err != nil {
362 l.Error("failed to fetch labels", "err", err)
363 }
364 defs := make(map[string]*models.LabelDefinition)
365 for _, l := range labelDefs {
366 defs[l.AtUri().String()] = &l
367 }
368
369 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
370 vouchSkips := make(map[syntax.DID]bool)
371 if user != nil {
372 participants := pull.Participants()
373 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
374 if err != nil {
375 l.Error("failed to fetch vouch relationships", "err", err)
376 }
377 ownerDid := syntax.DID(pull.OwnerDid)
378 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String())
379 if err != nil {
380 l.Error("failed to check vouch skip", "err", err)
381 }
382 vouchSkips[ownerDid] = skipped
383 }
384
385 var isSubscribed *bool
386 if user != nil {
387 pullDbId := int64(pull.ID)
388 sub, found, err2 := db.GetPullSubscription(s.db, user.Did, pullDbId)
389 if err2 == nil {
390 if found {
391 isSubscribed = &sub
392 } else {
393 // Implicitly subscribed if author or participant.
394 isAuthorOrParticipant := pull.OwnerDid == syntax.DID(user.Did)
395 if !isAuthorOrParticipant {
396 for _, p := range pull.Participants() {
397 if p.String() == user.Did {
398 isAuthorOrParticipant = true
399 break
400 }
401 }
402 }
403 if isAuthorOrParticipant {
404 t := true
405 isSubscribed = &t
406 }
407 }
408 }
409 }
410
411 params := pages.PullPageBaseParams{}
412 params.BaseParams = pages.BaseParamsFromContext(ctx)
413 params.RepoInfo = s.repoResolver.GetRepoInfo(r, user)
414 params.Pull = pull
415 params.Backlinks = nil
416 params.LabelDefs = defs
417 params.Reactions = reactions
418 params.UserReacted = userReactions
419 params.VouchRelationships = vouchRelationships
420 params.VouchSkips = vouchSkips
421 params.IsSubscribed = isSubscribed
422 return params
423}
424
425func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) {
426 s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head)
427 stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{
428 Repo: repo.String(),
429 Ranges: [][]byte{fmt.Appendf(nil, "%s..%s", base, head)},
430 AllRefs: false,
431 })
432 if err != nil {
433 return nil, err
434 }
435 var commits []types.Commit
436 for {
437 res, err := stream.Recv()
438 if errors.Is(err, io.EOF) {
439 break
440 }
441 if err != nil {
442 return nil, err
443 }
444 for _, commit := range res.Commits {
445 commits = append(commits, types.Commit{
446 Hash: plumbing.NewHash(commit.Oid),
447 Author: object.Signature{
448 Name: string(commit.Author.GetName()),
449 Email: string(commit.Author.GetEmail()),
450 When: commit.Author.Date.AsTime(),
451 },
452 Committer: object.Signature{
453 Name: string(commit.Committer.GetName()),
454 Email: string(commit.Committer.GetEmail()),
455 When: commit.Committer.Date.AsTime(),
456 },
457 Message: string(commit.Message),
458 ParentHashes: func() []plumbing.Hash {
459 var parents []plumbing.Hash
460 for _, hash := range commit.Parents {
461 parents = append(parents, plumbing.NewHash(hash))
462 }
463 return parents
464 }(),
465 ChangeId: commit.ExtraHeaders["change-id"],
466 })
467 }
468 }
469 return commits, err
470}
471
472// SubscribePull handles subscribe/unsubscribe for a specific pull request.
473func (s *Pulls) SubscribePull(w http.ResponseWriter, r *http.Request) {
474 l := s.logger.With("handler", "SubscribePull")
475 user := s.oauth.GetMultiAccountUser(r)
476 if user == nil {
477 w.WriteHeader(http.StatusUnauthorized)
478 return
479 }
480
481 pull, ok := r.Context().Value("pull").(*models.Pull)
482 if !ok {
483 l.Error("failed to get pull from context")
484 w.WriteHeader(http.StatusNotFound)
485 return
486 }
487
488 subscribe := r.FormValue("subscribe") != "false"
489 pullDbId := int64(pull.ID)
490
491 if err := db.UpsertPullSubscription(s.db, user.Did, pullDbId, subscribe); err != nil {
492 l.Error("failed to update pull subscription", "err", err)
493 w.WriteHeader(http.StatusInternalServerError)
494 return
495 }
496
497 repoInfo := s.repoResolver.GetRepoInfo(r, user)
498 s.pages.PullSubscribeFragment(w, pages.PullSubscribeParams{
499 RepoInfo: repoInfo,
500 PullId: pull.PullId,
501 IsSubscribed: &subscribe,
502 })
503}
504
505func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) {
506 if spindle == "" || len(shas) == 0 {
507 return nil, nil
508 }
509 spindleUrl, err := hostutil.EnsureHttpScheme(spindle)
510 if err != nil {
511 return nil, err
512 }
513 xrpcc := &indigoxrpc.Client{Host: spindleUrl}
514 out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repoDid)
515 if err != nil {
516 return nil, err
517 }
518 return types.PipelinesByCommit(out.Pipelines), nil
519}
520
521func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline {
522 m := make(map[string]types.Pipeline)
523 if f.Spindle == "" || len(shas) == 0 {
524 return m
525 }
526 spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle)
527 if err != nil {
528 l.Error("invalid spindle host", "host", f.Spindle, "err", err)
529 return m
530 }
531 xrpcc := &indigoxrpc.Client{Host: spindleUrl}
532 out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid)
533 if err != nil {
534 l.Error("failed to fetch pipelines", "err", err)
535 return m
536 }
537
538 for _, pipeline := range out.Pipelines {
539 if pipeline == nil {
540 continue
541 }
542 m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline}
543 }
544 return m
545}
546
547// parseRange parses <base>..<base> string
548func parseRange(input string) (base string, head string, err error) {
549 input = strings.TrimSpace(input)
550 if input == "" {
551 return "", "", nil
552 }
553
554 if strings.Count(input, "..") > 1 || strings.Contains(input, "...") {
555 return "", "", fmt.Errorf("invalid revspec format: %q", input)
556 }
557
558 // /{head}
559 if !strings.Contains(input, "..") {
560 return "", input, nil
561 }
562
563 // /{base}..{head}
564 parts := strings.SplitN(input, "..", 2)
565 base = strings.TrimSpace(parts[0])
566 head = strings.TrimSpace(parts[1])
567 if base == "" && head == "" {
568 return "", "", fmt.Errorf("invalid empty range: \"..\"")
569 }
570
571 return base, head, nil
572}