This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pulls / single.go
16 kB 572 lines
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}