Monorepo for Tangled
0
fork

Configure Feed

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

appview/pulls: split pulls.go file out

Lewis: May this revision serve well! <lewis@tangled.org>

authored by

Lewis and committed by
Tangled
19e03757 84ae132b

+3177 -3031
+136
appview/pulls/comment.go
··· 1 + package pulls 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + "time" 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/reporesolver" 14 + "tangled.org/core/tid" 15 + 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + lexutil "github.com/bluesky-social/indigo/lex/util" 18 + "github.com/go-chi/chi/v5" 19 + ) 20 + 21 + func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 22 + l := s.logger.With("handler", "PullComment") 23 + 24 + user := s.oauth.GetMultiAccountUser(r) 25 + if user != nil { 26 + l = l.With("user", user.Did) 27 + } 28 + 29 + f, err := s.repoResolver.Resolve(r) 30 + if err != nil { 31 + l.Error("failed to get repo and knot", "err", err) 32 + return 33 + } 34 + 35 + pull, ok := r.Context().Value("pull").(*models.Pull) 36 + if !ok { 37 + l.Error("failed to get pull") 38 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 39 + return 40 + } 41 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 42 + 43 + roundNumberStr := chi.URLParam(r, "round") 44 + roundNumber, err := strconv.Atoi(roundNumberStr) 45 + if err != nil || roundNumber >= len(pull.Submissions) { 46 + http.Error(w, "bad round id", http.StatusBadRequest) 47 + l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr) 48 + return 49 + } 50 + 51 + switch r.Method { 52 + case http.MethodGet: 53 + s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 54 + LoggedInUser: user, 55 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 56 + Pull: pull, 57 + RoundNumber: roundNumber, 58 + }) 59 + return 60 + case http.MethodPost: 61 + body := r.FormValue("body") 62 + if body == "" { 63 + s.pages.Notice(w, "pull", "Comment body is required") 64 + return 65 + } 66 + 67 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 68 + 69 + // Start a transaction 70 + tx, err := s.db.BeginTx(r.Context(), nil) 71 + if err != nil { 72 + l.Error("failed to start transaction", "err", err) 73 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 74 + return 75 + } 76 + defer tx.Rollback() 77 + 78 + createdAt := time.Now().Format(time.RFC3339) 79 + 80 + client, err := s.oauth.AuthorizedClient(r) 81 + if err != nil { 82 + l.Error("failed to get authorized client", "err", err) 83 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 84 + return 85 + } 86 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 87 + Collection: tangled.RepoPullCommentNSID, 88 + Repo: user.Did, 89 + Rkey: tid.TID(), 90 + Record: &lexutil.LexiconTypeDecoder{ 91 + Val: &tangled.RepoPullComment{ 92 + Pull: pull.AtUri().String(), 93 + Body: body, 94 + CreatedAt: createdAt, 95 + }, 96 + }, 97 + }) 98 + if err != nil { 99 + l.Error("failed to create pull comment", "err", err) 100 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 101 + return 102 + } 103 + 104 + comment := &models.PullComment{ 105 + OwnerDid: user.Did, 106 + RepoAt: f.RepoAt().String(), 107 + PullId: pull.PullId, 108 + Body: body, 109 + CommentAt: atResp.Uri, 110 + SubmissionId: pull.Submissions[roundNumber].ID, 111 + Mentions: mentions, 112 + References: references, 113 + } 114 + 115 + // Create the pull comment in the database with the commentAt field 116 + commentId, err := db.NewPullComment(tx, comment) 117 + if err != nil { 118 + l.Error("failed to create pull comment in database", "err", err) 119 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 120 + return 121 + } 122 + 123 + // Commit the transaction 124 + if err = tx.Commit(); err != nil { 125 + l.Error("failed to commit transaction", "err", err) 126 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 127 + return 128 + } 129 + 130 + s.notifier.NewPullComment(r.Context(), comment, mentions) 131 + 132 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 133 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 134 + return 135 + } 136 + }
+644
appview/pulls/compose.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "net/http" 9 + "net/url" 10 + "slices" 11 + "sort" 12 + "strings" 13 + 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/pages/markup" 20 + "tangled.org/core/appview/pages/repoinfo" 21 + "tangled.org/core/appview/xrpcclient" 22 + "tangled.org/core/orm" 23 + "tangled.org/core/patchutil" 24 + "tangled.org/core/types" 25 + 26 + "github.com/bluesky-social/indigo/atproto/syntax" 27 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 28 + ) 29 + 30 + func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 31 + l := s.logger.With("handler", "NewPull") 32 + 33 + user := s.oauth.GetMultiAccountUser(r) 34 + if user != nil { 35 + l = l.With("user", user.Did) 36 + } 37 + 38 + f, err := s.repoResolver.Resolve(r) 39 + if err != nil { 40 + l.Error("failed to get repo and knot", "err", err) 41 + return 42 + } 43 + l = l.With("repo_at", f.RepoAt().String()) 44 + 45 + switch r.Method { 46 + case http.MethodGet: 47 + params, err := s.composeParams(r, f) 48 + if err != nil { 49 + l.Error("failed to build compose params", "err", err) 50 + s.pages.Error503(w) 51 + return 52 + } 53 + s.pages.RepoNewPull(w, params) 54 + 55 + case http.MethodPost: 56 + title := r.FormValue("title") 57 + body := r.FormValue("body") 58 + targetBranch := r.FormValue("targetBranch") 59 + fromFork := r.FormValue("fork") 60 + sourceBranch := r.FormValue("sourceBranch") 61 + patch := r.FormValue("patch") 62 + userDid := syntax.DID(user.Did) 63 + 64 + if targetBranch == "" { 65 + s.pages.Notice(w, "pull", "Target branch is required.") 66 + return 67 + } 68 + 69 + // Determine PR type based on input parameters 70 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 71 + isPushAllowed := roles.IsPushAllowed() 72 + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 73 + isForkBased := fromFork != "" && sourceBranch != "" 74 + isPatchBased := patch != "" && !isBranchBased && !isForkBased 75 + isStacked := r.FormValue("mode") == "stack" && !isPatchBased 76 + 77 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 78 + if title == "" { 79 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 80 + return 81 + } 82 + sanitizer := markup.NewSanitizer() 83 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 84 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 85 + return 86 + } 87 + } 88 + 89 + // Validate we have at least one valid PR creation method 90 + if !isBranchBased && !isPatchBased && !isForkBased { 91 + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 92 + return 93 + } 94 + 95 + // Can't mix branch-based and patch-based approaches 96 + if isBranchBased && patch != "" { 97 + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 98 + return 99 + } 100 + 101 + if isBranchBased && sourceBranch == targetBranch { 102 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 103 + return 104 + } 105 + 106 + // TODO: make capabilities an xrpc call 107 + caps := struct { 108 + PullRequests struct { 109 + FormatPatch bool 110 + BranchSubmissions bool 111 + ForkSubmissions bool 112 + PatchSubmissions bool 113 + } 114 + }{ 115 + PullRequests: struct { 116 + FormatPatch bool 117 + BranchSubmissions bool 118 + ForkSubmissions bool 119 + PatchSubmissions bool 120 + }{ 121 + FormatPatch: true, 122 + BranchSubmissions: true, 123 + ForkSubmissions: true, 124 + PatchSubmissions: true, 125 + }, 126 + } 127 + 128 + if !caps.PullRequests.FormatPatch { 129 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 130 + return 131 + } 132 + 133 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 134 + stackBodies := parseBracketedForm(r.Form, "stackBody") 135 + 136 + // Handle the PR creation based on the type 137 + if isBranchBased { 138 + if !caps.PullRequests.BranchSubmissions { 139 + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 140 + return 141 + } 142 + s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 143 + } else if isForkBased { 144 + if !caps.PullRequests.ForkSubmissions { 145 + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 146 + return 147 + } 148 + s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 149 + } else if isPatchBased { 150 + if !caps.PullRequests.PatchSubmissions { 151 + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 152 + return 153 + } 154 + s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 155 + } 156 + return 157 + } 158 + } 159 + 160 + func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 161 + body := r.FormValue("body") 162 + s.pages.MarkdownPreviewFragment(w, body) 163 + } 164 + 165 + func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 166 + l := s.logger.With("handler", "RefreshCompose") 167 + 168 + f, err := s.repoResolver.Resolve(r) 169 + if err != nil { 170 + l.Error("failed to resolve repo", "err", err) 171 + s.pages.Error503(w) 172 + return 173 + } 174 + 175 + params, err := s.composeParams(r, f) 176 + if err != nil { 177 + l.Error("failed to build compose params", "err", err) 178 + s.pages.Error503(w) 179 + return 180 + } 181 + w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 182 + s.pages.PullComposeHostFragment(w, params) 183 + } 184 + 185 + func composeCanonicalURL(params pages.RepoNewPullParams) string { 186 + base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 187 + q := url.Values{} 188 + if params.IsStacked { 189 + q.Set("mode", "stack") 190 + } 191 + if params.Source != "" && params.Source != pages.SourceBranch { 192 + q.Set("source", string(params.Source)) 193 + } 194 + if params.SourceBranch != "" { 195 + q.Set("sourceBranch", params.SourceBranch) 196 + } 197 + if params.TargetBranch != "" { 198 + q.Set("targetBranch", params.TargetBranch) 199 + } 200 + if params.Source == pages.SourceFork && params.Fork != "" { 201 + q.Set("fork", params.Fork) 202 + } 203 + if len(q) == 0 { 204 + return base 205 + } 206 + return base + "?" + q.Encode() 207 + } 208 + 209 + func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 210 + l := s.logger.With("handler", "composeParams") 211 + user := s.oauth.GetMultiAccountUser(r) 212 + 213 + branches, err := s.listBranches(r.Context(), repo) 214 + if err != nil { 215 + return pages.RepoNewPullParams{}, err 216 + } 217 + 218 + var forks []models.Repo 219 + if user != nil { 220 + forks, err = db.GetForksByDid(s.db, user.Did) 221 + if err != nil { 222 + l.Warn("failed to list user forks", "err", err, "user", user.Did) 223 + } 224 + } 225 + 226 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 227 + source, ok := pages.ParseSource(r.FormValue("source")) 228 + if !ok { 229 + source = pages.SourceBranch 230 + if !repoInfo.Roles.IsPushAllowed() { 231 + source = pages.SourceFork 232 + } 233 + } 234 + 235 + sourceBranch := r.FormValue("sourceBranch") 236 + targetBranch := r.FormValue("targetBranch") 237 + fork := r.FormValue("fork") 238 + patch := r.FormValue("patch") 239 + 240 + if source == pages.SourceFork && fork == "" && len(forks) == 1 { 241 + fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 242 + } 243 + 244 + var forkBranches []types.Branch 245 + var forkBranchesErr error 246 + if source == pages.SourceFork && fork != "" { 247 + forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 248 + if forkBranchesErr != nil { 249 + l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 250 + } 251 + } 252 + 253 + sourceBranchList := sourceBranchChoices(branches) 254 + targetBranch = defaultTargetBranch(branches, targetBranch) 255 + sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 256 + 257 + comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 258 + var prefillErr string 259 + if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 260 + prefillErr = joined.Error() 261 + } 262 + 263 + mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 264 + 265 + refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 266 + var diffOpts types.DiffOpts 267 + if r.FormValue("diff") == "split" { 268 + diffOpts.Split = true 269 + } 270 + diffOpts.RefreshUrl = refreshUrl 271 + diffOpts.Target = "#diff-area" 272 + 273 + labelDefs, err := s.pullLabelDefs(repo) 274 + if err != nil { 275 + l.Warn("failed to load label definitions", "err", err) 276 + } 277 + labelState := labelStateFromForm(r.Form, labelDefs) 278 + perCidLabelForms := parseStackLabelForms(r.Form) 279 + stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 280 + for cid, perForm := range perCidLabelForms { 281 + stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 282 + } 283 + 284 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 285 + stackBodies := parseBracketedForm(r.Form, "stackBody") 286 + stackSplits := parseBracketedForm(r.Form, "stackSplit") 287 + 288 + title := r.FormValue("title") 289 + body := r.FormValue("body") 290 + if comparison != nil && len(comparison.FormatPatch) > 0 { 291 + first := comparison.FormatPatch[0] 292 + if title == "" && first.PatchHeader != nil { 293 + title = first.Title 294 + } 295 + if body == "" && first.PatchHeader != nil { 296 + body = first.Body 297 + } 298 + } 299 + 300 + isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 301 + var stackedDiffs []pages.StackedDiff 302 + if isStacked { 303 + stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 304 + } 305 + 306 + return pages.RepoNewPullParams{ 307 + LoggedInUser: user, 308 + RepoInfo: repoInfo, 309 + Branches: branches, 310 + SourceBranches: sourceBranchList, 311 + ForkBranches: forkBranches, 312 + Forks: forks, 313 + Source: source, 314 + SourceBranch: sourceBranch, 315 + TargetBranch: targetBranch, 316 + Fork: fork, 317 + Patch: patch, 318 + Title: title, 319 + Body: body, 320 + IsStacked: isStacked, 321 + Comparison: comparison, 322 + Diff: diff, 323 + DiffOpts: diffOpts, 324 + StackedDiffs: stackedDiffs, 325 + MergeCheck: mergeCheck, 326 + StackTitles: stackTitles, 327 + StackBodies: stackBodies, 328 + PrefillError: prefillErr, 329 + LabelDefs: labelDefs, 330 + LabelState: labelState, 331 + StackLabelStates: stackLabelStates, 332 + }, nil 333 + } 334 + 335 + func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 336 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 337 + xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 338 + if err != nil { 339 + return nil, err 340 + } 341 + var result types.RepoBranchesResponse 342 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 343 + return nil, err 344 + } 345 + return result.Branches, nil 346 + } 347 + 348 + func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 349 + parts := strings.SplitN(forkIdent, "/", 2) 350 + if len(parts) != 2 { 351 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 352 + } 353 + forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 354 + if err != nil { 355 + return nil, err 356 + } 357 + branches, err := s.listBranches(ctx, forkRepo) 358 + if err != nil { 359 + return nil, err 360 + } 361 + return sortBranchesByRecency(branches), nil 362 + } 363 + 364 + func sourceBranchChoices(branches []types.Branch) []types.Branch { 365 + withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 366 + return b.IsDefault 367 + }) 368 + return sortBranchesByRecency(withoutDefault) 369 + } 370 + 371 + func defaultTargetBranch(branches []types.Branch, current string) string { 372 + if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 373 + return current 374 + } 375 + if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 376 + return branches[idx].Reference.Name 377 + } 378 + return "" 379 + } 380 + 381 + func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 382 + var candidates []types.Branch 383 + switch source { 384 + case pages.SourceFork: 385 + candidates = forkBranches 386 + case pages.SourceBranch: 387 + candidates = branchChoices 388 + default: 389 + return current 390 + } 391 + if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 392 + return current 393 + } 394 + if len(candidates) == 0 { 395 + return "" 396 + } 397 + return candidates[0].Reference.Name 398 + } 399 + 400 + func sortBranchesByRecency(branches []types.Branch) []types.Branch { 401 + out := slices.Clone(branches) 402 + sort.SliceStable(out, func(i, j int) bool { 403 + if out[i].Commit == nil || out[j].Commit == nil { 404 + return out[i].Commit != nil 405 + } 406 + return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 407 + }) 408 + return out 409 + } 410 + 411 + func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 412 + var ( 413 + comparison *types.RepoFormatPatchResponse 414 + err error 415 + ) 416 + switch source { 417 + case pages.SourcePatch: 418 + if strings.TrimSpace(patch) == "" { 419 + return nil, nil, nil 420 + } 421 + if verr := s.validator.ValidatePatch(&patch); verr != nil { 422 + return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 423 + } 424 + comparison = parsePastedPatch(patch) 425 + case pages.SourceBranch: 426 + if targetBranch == "" || sourceBranch == "" { 427 + return nil, nil, nil 428 + } 429 + comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 430 + case pages.SourceFork: 431 + if fork == "" || targetBranch == "" || sourceBranch == "" { 432 + return nil, nil, nil 433 + } 434 + comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 435 + default: 436 + return nil, nil, nil 437 + } 438 + if err != nil { 439 + s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 440 + return nil, nil, err 441 + } 442 + 443 + return comparison, deriveDiff(comparison, targetBranch), nil 444 + } 445 + 446 + func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 447 + if comparison == nil || targetBranch == "" { 448 + return nil 449 + } 450 + patch := comparison.CombinedPatchRaw 451 + if patch == "" { 452 + patch = comparison.FormatPatchRaw 453 + } 454 + if patch == "" { 455 + return nil 456 + } 457 + 458 + xrpcc := s.knotClient(repo.Knot) 459 + 460 + resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 461 + Did: repo.Did, 462 + Name: repo.Name, 463 + Branch: targetBranch, 464 + Patch: patch, 465 + }) 466 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 467 + s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 468 + return &types.MergeCheckResponse{Error: xrpcerr.Error()} 469 + } 470 + 471 + out := mergeCheckResponseFrom(resp) 472 + return &out 473 + } 474 + 475 + func bracketComponents(key, prefix string) ([]string, bool) { 476 + if !strings.HasPrefix(key, prefix) { 477 + return nil, false 478 + } 479 + rest := key[len(prefix):] 480 + var parts []string 481 + for len(rest) > 0 { 482 + if !strings.HasPrefix(rest, "[") { 483 + return nil, false 484 + } 485 + end := strings.Index(rest, "]") 486 + if end <= 0 { 487 + return nil, false 488 + } 489 + parts = append(parts, rest[1:end]) 490 + rest = rest[end+1:] 491 + } 492 + if len(parts) == 0 { 493 + return nil, false 494 + } 495 + return parts, true 496 + } 497 + 498 + func parseBracketedForm(form url.Values, prefix string) map[string]string { 499 + out := make(map[string]string) 500 + for key, vals := range form { 501 + parts, ok := bracketComponents(key, prefix) 502 + if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 503 + continue 504 + } 505 + out[parts[0]] = vals[0] 506 + } 507 + return out 508 + } 509 + 510 + func parseStackLabelForms(form url.Values) map[string]url.Values { 511 + out := make(map[string]url.Values) 512 + for key, vals := range form { 513 + parts, ok := bracketComponents(key, "stackLabel") 514 + if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 515 + continue 516 + } 517 + cid, atUri := parts[0], parts[1] 518 + if _, ok := out[cid]; !ok { 519 + out[cid] = make(url.Values) 520 + } 521 + out[cid][atUri] = append(out[cid][atUri], vals...) 522 + } 523 + return out 524 + } 525 + 526 + func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 527 + if patch == "" { 528 + return nil 529 + } 530 + response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 531 + if patchutil.IsFormatPatch(patch) { 532 + if patches, err := patchutil.ExtractPatches(patch); err == nil { 533 + response.FormatPatch = patches 534 + } 535 + } 536 + return response 537 + } 538 + 539 + func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 540 + xrpcc := s.knotClient(repo.Knot) 541 + 542 + xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 543 + if err != nil { 544 + return nil, err 545 + } 546 + 547 + var comparison types.RepoFormatPatchResponse 548 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 549 + return nil, err 550 + } 551 + return &comparison, nil 552 + } 553 + 554 + func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 555 + parts := strings.SplitN(forkIdent, "/", 2) 556 + if len(parts) != 2 { 557 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 558 + } 559 + fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 560 + if err != nil { 561 + return nil, err 562 + } 563 + 564 + client, err := s.oauth.ServiceClient( 565 + r, 566 + oauth.WithService(fork.Knot), 567 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 568 + oauth.WithDev(s.config.Core.Dev), 569 + ) 570 + if err != nil { 571 + return nil, err 572 + } 573 + 574 + resp, err := tangled.RepoHiddenRef( 575 + r.Context(), 576 + client, 577 + &tangled.RepoHiddenRef_Input{ 578 + ForkRef: sourceBranch, 579 + RemoteRef: targetBranch, 580 + Repo: fork.RepoAt().String(), 581 + }, 582 + ) 583 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 584 + return nil, xrpcerr 585 + } 586 + if !resp.Success { 587 + if resp.Error != nil { 588 + return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 589 + } 590 + return nil, fmt.Errorf("hidden ref failed") 591 + } 592 + 593 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 594 + forkXrpcc := s.knotClient(fork.Knot) 595 + 596 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 597 + if err != nil { 598 + return nil, err 599 + } 600 + 601 + var comparison types.RepoFormatPatchResponse 602 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 603 + return nil, err 604 + } 605 + return &comparison, nil 606 + } 607 + 608 + func stackPerCommitDiffs( 609 + comparison *types.RepoFormatPatchResponse, 610 + targetBranch, refreshUrl string, 611 + stackSplits map[string]string, 612 + ) []pages.StackedDiff { 613 + if comparison == nil { 614 + return nil 615 + } 616 + out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 617 + for i, p := range comparison.FormatPatch { 618 + nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 619 + out[i].Diff = &nd 620 + cid := p.ChangeIdOrEmpty() 621 + if cid == "" { 622 + continue 623 + } 624 + out[i].Opts = types.DiffOpts{ 625 + Split: stackSplits[cid] == "split", 626 + RefreshUrl: refreshUrl, 627 + Target: fmt.Sprintf("#stack-diff-%s", cid), 628 + Field: fmt.Sprintf("stackSplit[%s]", cid), 629 + } 630 + } 631 + return out 632 + } 633 + 634 + func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 635 + if comparison == nil { 636 + return nil 637 + } 638 + raw := comparison.CombinedPatchRaw 639 + if raw == "" { 640 + raw = comparison.FormatPatchRaw 641 + } 642 + d := patchutil.AsNiceDiff(raw, targetBranch) 643 + return &d 644 + }
+532
appview/pulls/create.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/reporesolver" 18 + "tangled.org/core/appview/xrpcclient" 19 + "tangled.org/core/patchutil" 20 + "tangled.org/core/tid" 21 + "tangled.org/core/types" 22 + "tangled.org/core/xrpc" 23 + 24 + comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + ) 28 + 29 + func (s *Pulls) handleBranchBasedPull( 30 + w http.ResponseWriter, 31 + r *http.Request, 32 + repo *models.Repo, 33 + userDid syntax.DID, 34 + title, 35 + body, 36 + targetBranch, 37 + sourceBranch string, 38 + isStacked bool, 39 + stackTitles, stackBodies map[string]string, 40 + ) { 41 + l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 42 + 43 + xrpcc := s.knotClient(repo.Knot) 44 + 45 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 46 + if err != nil { 47 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 48 + l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 49 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 50 + return 51 + } 52 + l.Error("failed to compare", "err", err) 53 + s.pages.Notice(w, "pull", err.Error()) 54 + return 55 + } 56 + 57 + var comparison types.RepoFormatPatchResponse 58 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 59 + l.Error("failed to decode XRPC compare response", "err", err) 60 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 61 + return 62 + } 63 + 64 + if len(comparison.FormatPatch) == 0 { 65 + s.pages.Notice(w, "pull", "No commits between target and source.") 66 + return 67 + } 68 + 69 + sourceRev := comparison.Rev2 70 + patch := comparison.FormatPatchRaw 71 + combined := comparison.CombinedPatchRaw 72 + 73 + if err := s.validator.ValidatePatch(&patch); err != nil { 74 + s.logger.Error("failed to validate patch", "err", err) 75 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 76 + return 77 + } 78 + 79 + pullSource := &models.PullSource{ 80 + Branch: sourceBranch, 81 + } 82 + 83 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 84 + } 85 + 86 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 87 + if err := s.validator.ValidatePatch(&patch); err != nil { 88 + s.logger.Error("patch validation failed", "err", err) 89 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 90 + return 91 + } 92 + 93 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 94 + } 95 + 96 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 97 + l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 98 + 99 + repoString := strings.SplitN(forkRepo, "/", 2) 100 + forkOwnerDid := repoString[0] 101 + repoName := repoString[1] 102 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 103 + if errors.Is(err, sql.ErrNoRows) { 104 + s.pages.Notice(w, "pull", "No such fork.") 105 + return 106 + } else if err != nil { 107 + l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName) 108 + s.pages.Notice(w, "pull", "Failed to fetch fork.") 109 + return 110 + } 111 + 112 + client, err := s.oauth.ServiceClient( 113 + r, 114 + oauth.WithService(fork.Knot), 115 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 116 + oauth.WithDev(s.config.Core.Dev), 117 + ) 118 + 119 + resp, err := tangled.RepoHiddenRef( 120 + r.Context(), 121 + client, 122 + &tangled.RepoHiddenRef_Input{ 123 + ForkRef: sourceBranch, 124 + RemoteRef: targetBranch, 125 + Repo: fork.RepoAt().String(), 126 + }, 127 + ) 128 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 129 + s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 130 + s.pages.Notice(w, "pull", xrpcerr.Error()) 131 + return 132 + } 133 + 134 + if !resp.Success { 135 + errorMsg := "Failed to create pull request" 136 + if resp.Error != nil { 137 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 138 + } 139 + s.pages.Notice(w, "pull", errorMsg) 140 + return 141 + } 142 + 143 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 144 + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 145 + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 146 + // hiddenRef: hidden/feature-1/main (on repo-fork) 147 + // targetBranch: main (on repo-1) 148 + // sourceBranch: feature-1 (on repo-fork) 149 + forkXrpcc := s.knotClient(fork.Knot) 150 + 151 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 152 + if err != nil { 153 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 154 + l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 155 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 156 + return 157 + } 158 + l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 159 + s.pages.Notice(w, "pull", err.Error()) 160 + return 161 + } 162 + 163 + var comparison types.RepoFormatPatchResponse 164 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 165 + l.Error("failed to decode XRPC compare response for fork", "err", err) 166 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 167 + return 168 + } 169 + 170 + if len(comparison.FormatPatch) == 0 { 171 + s.pages.Notice(w, "pull", "No commits between target and source.") 172 + return 173 + } 174 + 175 + sourceRev := comparison.Rev2 176 + patch := comparison.FormatPatchRaw 177 + combined := comparison.CombinedPatchRaw 178 + 179 + if err := s.validator.ValidatePatch(&patch); err != nil { 180 + s.logger.Error("failed to validate patch", "err", err) 181 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 182 + return 183 + } 184 + 185 + forkAtUri := fork.RepoAt() 186 + var forkDid *syntax.DID 187 + if fork.RepoDid != "" { 188 + forkDid = new(syntax.DID) 189 + *forkDid = syntax.DID(fork.RepoDid) 190 + } 191 + 192 + pullSource := &models.PullSource{ 193 + Branch: sourceBranch, 194 + RepoAt: &forkAtUri, 195 + RepoDid: forkDid, 196 + } 197 + 198 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 199 + } 200 + 201 + func (s *Pulls) createPullRequest( 202 + w http.ResponseWriter, 203 + r *http.Request, 204 + repo *models.Repo, 205 + userDid syntax.DID, 206 + title, body, targetBranch string, 207 + patch string, 208 + combined string, 209 + sourceRev string, 210 + pullSource *models.PullSource, 211 + isStacked bool, 212 + stackTitles, stackBodies map[string]string, 213 + ) { 214 + l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 215 + 216 + if isStacked { 217 + // creates a series of PRs, each linking to the previous, identified by jj's change-id 218 + s.createStackedPullRequest( 219 + w, 220 + r, 221 + repo, 222 + userDid, 223 + targetBranch, 224 + patch, 225 + sourceRev, 226 + pullSource, 227 + stackTitles, 228 + stackBodies, 229 + ) 230 + return 231 + } 232 + 233 + client, err := s.oauth.AuthorizedClient(r) 234 + if err != nil { 235 + l.Error("failed to get authorized client", "err", err) 236 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 237 + return 238 + } 239 + 240 + tx, err := s.db.BeginTx(r.Context(), nil) 241 + if err != nil { 242 + l.Error("failed to start tx", "err", err) 243 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 244 + return 245 + } 246 + defer tx.Rollback() 247 + 248 + // We've already checked earlier if it's diff-based and title is empty, 249 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 250 + if title == "" || body == "" { 251 + formatPatches, err := patchutil.ExtractPatches(patch) 252 + if err != nil { 253 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 254 + return 255 + } 256 + if len(formatPatches) == 0 { 257 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 258 + return 259 + } 260 + 261 + if title == "" { 262 + title = formatPatches[0].Title 263 + } 264 + if body == "" { 265 + body = formatPatches[0].Body 266 + } 267 + } 268 + 269 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 270 + 271 + rkey := tid.TID() 272 + 273 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 274 + if err != nil { 275 + l.Error("failed to upload patch", "err", err) 276 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 277 + return 278 + } 279 + 280 + now := time.Now() 281 + 282 + pull := &models.Pull{ 283 + Title: title, 284 + Body: body, 285 + TargetBranch: targetBranch, 286 + OwnerDid: userDid.String(), 287 + RepoAt: repo.RepoAt(), 288 + Rkey: rkey, 289 + Mentions: mentions, 290 + References: references, 291 + Submissions: []*models.PullSubmission{ 292 + { 293 + Patch: patch, 294 + Combined: combined, 295 + SourceRev: sourceRev, 296 + Blob: *blob.Blob, 297 + Created: now, 298 + }, 299 + }, 300 + PullSource: pullSource, 301 + State: models.PullOpen, 302 + Created: now, 303 + } 304 + 305 + record := pull.AsRecord() 306 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 307 + Collection: tangled.RepoPullNSID, 308 + Repo: userDid.String(), 309 + Rkey: rkey, 310 + Record: &lexutil.LexiconTypeDecoder{ 311 + Val: &record, 312 + }, 313 + }) 314 + if err != nil { 315 + l.Error("failed to create pull request", "err", err) 316 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 317 + return 318 + } 319 + 320 + err = db.PutPull(tx, pull) 321 + if err != nil { 322 + l.Error("failed to create pull request in database", "err", err) 323 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 324 + return 325 + } 326 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 327 + if err != nil { 328 + s.logger.Error("failed to get pull id", "err", err) 329 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 330 + return 331 + } 332 + 333 + if err = tx.Commit(); err != nil { 334 + l.Error("failed to commit transaction for pull request", "err", err) 335 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 336 + return 337 + } 338 + 339 + s.notifier.NewPull(r.Context(), pull) 340 + 341 + s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 342 + 343 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 344 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 345 + } 346 + 347 + func (s *Pulls) createStackedPullRequest( 348 + w http.ResponseWriter, 349 + r *http.Request, 350 + repo *models.Repo, 351 + userDid syntax.DID, 352 + targetBranch string, 353 + patch string, 354 + sourceRev string, 355 + pullSource *models.PullSource, 356 + stackTitles, stackBodies map[string]string, 357 + ) { 358 + l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 359 + 360 + // run some necessary checks for stacked-prs first 361 + 362 + formatPatches, err := patchutil.ExtractPatches(patch) 363 + if err != nil { 364 + l.Error("failed to extract patches", "err", err) 365 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 366 + return 367 + } 368 + 369 + // must have atleast 1 patch to begin with 370 + if len(formatPatches) == 0 { 371 + l.Error("empty patches") 372 + s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 373 + return 374 + } 375 + 376 + client, err := s.oauth.AuthorizedClient(r) 377 + if err != nil { 378 + l.Error("failed to get authorized client", "err", err) 379 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 380 + return 381 + } 382 + 383 + // first upload all blobs 384 + blobs := make([]*lexutil.LexBlob, len(formatPatches)) 385 + for i, p := range formatPatches { 386 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 387 + if err != nil { 388 + l.Error("failed to upload patch blob", "err", err, "patch_index", i) 389 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 390 + return 391 + } 392 + l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 393 + blobs[i] = blob.Blob 394 + } 395 + 396 + // build a stack out of this patch 397 + stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 398 + if err != nil { 399 + l.Error("failed to create stack", "err", err) 400 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 401 + return 402 + } 403 + 404 + // apply all record creations at once 405 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 406 + for _, p := range stack { 407 + record := p.AsRecord() 408 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 409 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 410 + Collection: tangled.RepoPullNSID, 411 + Rkey: &p.Rkey, 412 + Value: &lexutil.LexiconTypeDecoder{ 413 + Val: &record, 414 + }, 415 + }, 416 + }) 417 + } 418 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 419 + Repo: userDid.String(), 420 + Writes: writes, 421 + }) 422 + if err != nil { 423 + l.Error("failed to create stacked pull request", "err", err) 424 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 425 + return 426 + } 427 + 428 + // create all pulls at once 429 + tx, err := s.db.BeginTx(r.Context(), nil) 430 + if err != nil { 431 + l.Error("failed to start tx", "err", err) 432 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 433 + return 434 + } 435 + defer tx.Rollback() 436 + 437 + for _, p := range stack { 438 + err = db.PutPull(tx, p) 439 + if err != nil { 440 + l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 441 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 442 + return 443 + } 444 + 445 + } 446 + 447 + if err = tx.Commit(); err != nil { 448 + l.Error("failed to commit transaction for pull requests", "err", err) 449 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 450 + return 451 + } 452 + 453 + // notify about each pull 454 + // 455 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 456 + for _, p := range stack { 457 + s.notifier.NewPull(r.Context(), p) 458 + } 459 + 460 + s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 461 + 462 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 463 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 464 + } 465 + 466 + func (s *Pulls) newStack( 467 + ctx context.Context, 468 + repo *models.Repo, 469 + userDid syntax.DID, 470 + targetBranch string, 471 + pullSource *models.PullSource, 472 + formatPatches []types.FormatPatch, 473 + blobs []*lexutil.LexBlob, 474 + stackTitles, stackBodies map[string]string, 475 + ) (models.Stack, error) { 476 + var stack models.Stack 477 + var parentAtUri *syntax.ATURI 478 + for i, fp := range formatPatches { 479 + // all patches must have a jj change-id 480 + cid, err := fp.ChangeId() 481 + if err != nil { 482 + return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 483 + } 484 + 485 + title := fp.Title 486 + body := fp.Body 487 + if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 488 + title = override 489 + } 490 + if override, ok := stackBodies[cid]; ok { 491 + body = override 492 + } 493 + rkey := tid.TID() 494 + 495 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 496 + 497 + now := time.Now() 498 + 499 + pull := models.Pull{ 500 + Title: title, 501 + Body: body, 502 + TargetBranch: targetBranch, 503 + OwnerDid: userDid.String(), 504 + RepoAt: repo.RepoAt(), 505 + Rkey: rkey, 506 + Mentions: mentions, 507 + References: references, 508 + Submissions: []*models.PullSubmission{ 509 + { 510 + Patch: fp.Raw, 511 + SourceRev: fp.SHA, 512 + Combined: fp.Raw, 513 + Blob: *blobs[i], 514 + Created: now, 515 + }, 516 + }, 517 + PullSource: pullSource, 518 + Created: now, 519 + State: models.PullOpen, 520 + 521 + DependentOn: parentAtUri, 522 + Repo: repo, 523 + } 524 + 525 + stack = append(stack, &pull) 526 + 527 + parent := pull.AtUri() 528 + parentAtUri = &parent 529 + } 530 + 531 + return stack, nil 532 + }
+192
appview/pulls/labels.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "iter" 6 + "net/url" 7 + "slices" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/orm" 14 + "tangled.org/core/tid" 15 + 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/atclient" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 + ) 21 + 22 + func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 23 + defs, err := db.GetLabelDefinitions( 24 + s.db, 25 + orm.FilterIn("at_uri", repo.Labels), 26 + orm.FilterContains("scope", tangled.RepoPullNSID), 27 + ) 28 + if err != nil { 29 + return nil, err 30 + } 31 + 32 + out := make(map[string]*models.LabelDefinition, len(defs)) 33 + for i := range defs { 34 + d := defs[i] 35 + if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 36 + continue 37 + } 38 + out[d.AtUri().String()] = &d 39 + } 40 + return out, nil 41 + } 42 + 43 + func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 44 + return func(yield func(string, string) bool) { 45 + for key := range defs { 46 + for _, v := range form[key] { 47 + if v == "" { 48 + continue 49 + } 50 + if !yield(key, v) { 51 + return 52 + } 53 + } 54 + } 55 + } 56 + } 57 + 58 + func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 59 + state := models.NewLabelState() 60 + actx := &models.LabelApplicationCtx{Defs: defs} 61 + for key, val := range formLabelEntries(form, defs) { 62 + _ = actx.ApplyLabelOp(state, models.LabelOp{ 63 + Operation: models.LabelOperationAdd, 64 + OperandKey: key, 65 + OperandValue: val, 66 + }) 67 + } 68 + return state 69 + } 70 + 71 + func buildCreationLabelOps( 72 + userDid syntax.DID, 73 + subject syntax.ATURI, 74 + rkey string, 75 + form url.Values, 76 + defs map[string]*models.LabelDefinition, 77 + performedAt time.Time, 78 + ) []models.LabelOp { 79 + var ops []models.LabelOp 80 + for key, val := range formLabelEntries(form, defs) { 81 + ops = append(ops, models.LabelOp{ 82 + Did: userDid.String(), 83 + Rkey: rkey, 84 + Subject: subject, 85 + Operation: models.LabelOperationAdd, 86 + OperandKey: key, 87 + OperandValue: val, 88 + PerformedAt: performedAt, 89 + }) 90 + } 91 + return ops 92 + } 93 + 94 + func (s *Pulls) applyCreationLabels( 95 + ctx context.Context, 96 + client *atclient.APIClient, 97 + userDid syntax.DID, 98 + pulls []*models.Pull, 99 + form url.Values, 100 + repo *models.Repo, 101 + ) { 102 + l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 103 + 104 + defs, err := s.pullLabelDefs(repo) 105 + if err != nil { 106 + l.Warn("failed to fetch label defs", "err", err) 107 + return 108 + } 109 + if len(defs) == 0 { 110 + return 111 + } 112 + 113 + perCidForms := parseStackLabelForms(form) 114 + 115 + applyAll := form.Get("applyLabelsToAll") == "on" 116 + var firstStackForm url.Values 117 + if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 118 + if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 119 + if f, ok := perCidForms[firstCid]; ok { 120 + firstStackForm = f 121 + } 122 + } 123 + } 124 + 125 + performedAt := time.Now() 126 + for _, pull := range pulls { 127 + labelForm := form 128 + if firstStackForm != nil { 129 + labelForm = firstStackForm 130 + } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 131 + if cid := pull.Submissions[0].ChangeId(); cid != "" { 132 + if perForm, ok := perCidForms[cid]; ok { 133 + labelForm = perForm 134 + } 135 + } 136 + } 137 + rkey := tid.TID() 138 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 139 + 140 + valid := make([]models.LabelOp, 0, len(raw)) 141 + for _, op := range raw { 142 + def := defs[op.OperandKey] 143 + if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 144 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 145 + continue 146 + } 147 + valid = append(valid, op) 148 + } 149 + if len(valid) == 0 { 150 + continue 151 + } 152 + 153 + record := models.LabelOpsAsRecord(valid) 154 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 155 + Collection: tangled.LabelOpNSID, 156 + Repo: userDid.String(), 157 + Rkey: rkey, 158 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 159 + }); err != nil { 160 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 161 + continue 162 + } 163 + 164 + if err := s.indexLabelOps(ctx, valid); err != nil { 165 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 166 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 167 + Collection: tangled.LabelOpNSID, 168 + Repo: userDid.String(), 169 + Rkey: rkey, 170 + }); err != nil { 171 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 172 + } 173 + continue 174 + } 175 + 176 + s.notifier.NewPullLabelOp(ctx, pull) 177 + } 178 + } 179 + 180 + func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 181 + tx, err := s.db.BeginTx(ctx, nil) 182 + if err != nil { 183 + return err 184 + } 185 + defer tx.Rollback() 186 + for _, op := range ops { 187 + if _, err := db.AddLabelOp(tx, &op); err != nil { 188 + return err 189 + } 190 + } 191 + return tx.Commit() 192 + }
+167
appview/pulls/lifecycle.go
··· 1 + package pulls 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/pages/repoinfo" 10 + "tangled.org/core/appview/reporesolver" 11 + "tangled.org/core/orm" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 17 + l := s.logger.With("handler", "ClosePull") 18 + 19 + user := s.oauth.GetMultiAccountUser(r) 20 + if user != nil { 21 + l = l.With("user", user.Did) 22 + } 23 + 24 + f, err := s.repoResolver.Resolve(r) 25 + if err != nil { 26 + l.Error("failed to resolve repo", "err", err) 27 + return 28 + } 29 + 30 + pull, ok := r.Context().Value("pull").(*models.Pull) 31 + if !ok { 32 + l.Error("failed to get pull") 33 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 34 + return 35 + } 36 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 37 + 38 + // auth filter: only owner or collaborators can close 39 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 40 + isOwner := roles.IsOwner() 41 + isCollaborator := roles.IsCollaborator() 42 + isPullAuthor := user.Did == pull.OwnerDid 43 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 44 + if !isCloseAllowed { 45 + l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 46 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 47 + return 48 + } 49 + 50 + // Start a transaction 51 + tx, err := s.db.BeginTx(r.Context(), nil) 52 + if err != nil { 53 + l.Error("failed to start transaction", "err", err) 54 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 55 + return 56 + } 57 + defer tx.Rollback() 58 + 59 + // if this PR is stacked, then we want to close all PRs above this one on the stack 60 + stack := r.Context().Value("stack").(models.Stack) 61 + pullsToClose := stack.Above(pull) 62 + var atUris []syntax.ATURI 63 + for _, p := range pullsToClose { 64 + atUris = append(atUris, p.AtUri()) 65 + p.State = models.PullClosed 66 + } 67 + err = db.ClosePulls( 68 + tx, 69 + orm.FilterEq("repo_at", f.RepoAt()), 70 + orm.FilterIn("at_uri", atUris), 71 + ) 72 + if err != nil { 73 + l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 74 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 75 + } 76 + 77 + // Commit the transaction 78 + if err = tx.Commit(); err != nil { 79 + l.Error("failed to commit transaction", "err", err) 80 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 81 + return 82 + } 83 + 84 + for _, p := range pullsToClose { 85 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 86 + } 87 + 88 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 89 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 90 + } 91 + 92 + func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 93 + l := s.logger.With("handler", "ReopenPull") 94 + 95 + user := s.oauth.GetMultiAccountUser(r) 96 + if user != nil { 97 + l = l.With("user", user.Did) 98 + } 99 + 100 + f, err := s.repoResolver.Resolve(r) 101 + if err != nil { 102 + l.Error("failed to resolve repo", "err", err) 103 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 104 + return 105 + } 106 + 107 + pull, ok := r.Context().Value("pull").(*models.Pull) 108 + if !ok { 109 + l.Error("failed to get pull") 110 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 111 + return 112 + } 113 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 114 + 115 + // auth filter: only owner or collaborators can close 116 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 117 + isOwner := roles.IsOwner() 118 + isCollaborator := roles.IsCollaborator() 119 + isPullAuthor := user.Did == pull.OwnerDid 120 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 121 + if !isCloseAllowed { 122 + l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 123 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 124 + return 125 + } 126 + 127 + // Start a transaction 128 + tx, err := s.db.BeginTx(r.Context(), nil) 129 + if err != nil { 130 + l.Error("failed to start transaction", "err", err) 131 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 132 + return 133 + } 134 + defer tx.Rollback() 135 + 136 + // if this PR is stacked, then we want to reopen all PRs above this one on the stack 137 + stack := r.Context().Value("stack").(models.Stack) 138 + pullsToReopen := stack.Below(pull) 139 + var atUris []syntax.ATURI 140 + for _, p := range pullsToReopen { 141 + atUris = append(atUris, p.AtUri()) 142 + p.State = models.PullOpen 143 + } 144 + err = db.ReopenPulls( 145 + tx, 146 + orm.FilterEq("repo_at", f.RepoAt()), 147 + orm.FilterIn("at_uri", atUris), 148 + ) 149 + if err != nil { 150 + l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 151 + s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 152 + } 153 + 154 + // Commit the transaction 155 + if err = tx.Commit(); err != nil { 156 + l.Error("failed to commit transaction", "err", err) 157 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 158 + return 159 + } 160 + 161 + for _, p := range pullsToReopen { 162 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 163 + } 164 + 165 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 166 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 167 + }
+330
appview/pulls/list.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "slices" 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/pagination" 13 + "tangled.org/core/appview/searchquery" 14 + "tangled.org/core/orm" 15 + 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + ) 18 + 19 + func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 20 + l := s.logger.With("handler", "RepoPulls") 21 + 22 + user := s.oauth.GetMultiAccountUser(r) 23 + if user != nil { 24 + l = l.With("user", user.Did) 25 + } 26 + 27 + params := r.URL.Query() 28 + page := pagination.FromContext(r.Context()) 29 + 30 + f, err := s.repoResolver.Resolve(r) 31 + if err != nil { 32 + l.Error("failed to get repo and knot", "err", err) 33 + return 34 + } 35 + l = l.With("repo_at", f.RepoAt().String()) 36 + 37 + query := searchquery.Parse(params.Get("q")) 38 + 39 + var state *models.PullState 40 + if urlState := params.Get("state"); urlState != "" { 41 + switch urlState { 42 + case "open": 43 + state = ptrPullState(models.PullOpen) 44 + case "closed": 45 + state = ptrPullState(models.PullClosed) 46 + case "merged": 47 + state = ptrPullState(models.PullMerged) 48 + } 49 + query.Set("state", urlState) 50 + } else if queryState := query.Get("state"); queryState != nil { 51 + switch *queryState { 52 + case "open": 53 + state = ptrPullState(models.PullOpen) 54 + case "closed": 55 + state = ptrPullState(models.PullClosed) 56 + case "merged": 57 + state = ptrPullState(models.PullMerged) 58 + } 59 + } else if _, hasQ := params["q"]; !hasQ { 60 + state = ptrPullState(models.PullOpen) 61 + query.Set("state", "open") 62 + } 63 + 64 + resolve := func(ctx context.Context, ident string) (string, error) { 65 + id, err := s.idResolver.ResolveIdent(ctx, ident) 66 + if err != nil { 67 + return "", err 68 + } 69 + return id.DID.String(), nil 70 + } 71 + 72 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 73 + 74 + labels := query.GetAll("label") 75 + negatedLabels := query.GetAllNegated("label") 76 + labelValues := query.GetDynamicTags() 77 + negatedLabelValues := query.GetNegatedDynamicTags() 78 + 79 + // resolve DID-format label values: if a dynamic tag's label 80 + // definition has format "did", resolve the handle to a DID 81 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 82 + labelDefs, err := db.GetLabelDefinitions( 83 + s.db, 84 + orm.FilterIn("at_uri", f.Labels), 85 + orm.FilterContains("scope", tangled.RepoPullNSID), 86 + ) 87 + if err == nil { 88 + didLabels := make(map[string]bool) 89 + for _, def := range labelDefs { 90 + if def.ValueType.Format == models.ValueTypeFormatDid { 91 + didLabels[def.Name] = true 92 + } 93 + } 94 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 95 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 96 + } else { 97 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 98 + } 99 + } 100 + 101 + tf := searchquery.ExtractTextFilters(query) 102 + 103 + searchOpts := models.PullSearchOptions{ 104 + Keywords: tf.Keywords, 105 + Phrases: tf.Phrases, 106 + RepoAt: f.RepoAt().String(), 107 + State: state, 108 + AuthorDid: authorDid, 109 + Labels: labels, 110 + LabelValues: labelValues, 111 + NegatedKeywords: tf.NegatedKeywords, 112 + NegatedPhrases: tf.NegatedPhrases, 113 + NegatedLabels: negatedLabels, 114 + NegatedLabelValues: negatedLabelValues, 115 + NegatedAuthorDids: negatedAuthorDids, 116 + Page: page, 117 + } 118 + 119 + var totalPulls int 120 + if state == nil { 121 + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 122 + } else { 123 + switch *state { 124 + case models.PullOpen: 125 + totalPulls = f.RepoStats.PullCount.Open 126 + case models.PullMerged: 127 + totalPulls = f.RepoStats.PullCount.Merged 128 + case models.PullClosed: 129 + totalPulls = f.RepoStats.PullCount.Closed 130 + } 131 + } 132 + 133 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 134 + 135 + var pulls []*models.Pull 136 + 137 + if searchOpts.HasSearchFilters() { 138 + res, err := s.indexer.Search(r.Context(), searchOpts) 139 + if err != nil { 140 + l.Error("failed to search for pulls", "err", err) 141 + return 142 + } 143 + totalPulls = int(res.Total) 144 + l.Debug("searched pulls with indexer", "count", len(res.Hits)) 145 + 146 + // update tab counts to reflect filtered results 147 + countOpts := searchOpts 148 + countOpts.Page = pagination.Page{Limit: 1} 149 + for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 150 + countOpts.State = &ps 151 + countRes, err := s.indexer.Search(r.Context(), countOpts) 152 + if err != nil { 153 + continue 154 + } 155 + switch ps { 156 + case models.PullOpen: 157 + repoInfo.Stats.PullCount.Open = int(countRes.Total) 158 + case models.PullMerged: 159 + repoInfo.Stats.PullCount.Merged = int(countRes.Total) 160 + case models.PullClosed: 161 + repoInfo.Stats.PullCount.Closed = int(countRes.Total) 162 + } 163 + } 164 + 165 + if len(res.Hits) > 0 { 166 + pulls, err = db.GetPulls( 167 + s.db, 168 + orm.FilterIn("id", res.Hits), 169 + ) 170 + if err != nil { 171 + l.Error("failed to get pulls", "err", err) 172 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 173 + return 174 + } 175 + } 176 + } else { 177 + filters := []orm.Filter{ 178 + orm.FilterEq("repo_at", f.RepoAt()), 179 + } 180 + if state != nil { 181 + filters = append(filters, orm.FilterEq("state", *state)) 182 + } 183 + pulls, err = db.GetPullsPaginated( 184 + s.db, 185 + page, 186 + filters..., 187 + ) 188 + if err != nil { 189 + l.Error("failed to get pulls", "err", err) 190 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 191 + return 192 + } 193 + } 194 + 195 + for _, p := range pulls { 196 + var pullSourceRepo *models.Repo 197 + if p.PullSource != nil { 198 + if p.PullSource.RepoAt != nil { 199 + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 200 + if err != nil { 201 + l.Error("failed to get repo by at uri", "err", err, "repo_at", p.PullSource.RepoAt.String()) 202 + continue 203 + } else { 204 + p.PullSource.Repo = pullSourceRepo 205 + } 206 + } 207 + } 208 + } 209 + 210 + var stacks []models.Stack 211 + var shas []string 212 + 213 + pullMap := make(map[string]*models.Pull) 214 + for _, p := range pulls { 215 + shas = append(shas, p.LatestSha()) 216 + pullMap[p.AtUri().String()] = p 217 + } 218 + 219 + // track which PRs have been added to stacks 220 + visited := make(map[string]bool) 221 + 222 + // group stacked PRs together using dependent_on relationships 223 + for _, p := range pulls { 224 + if visited[p.AtUri().String()] { 225 + continue 226 + } 227 + 228 + root := p 229 + for root.DependentOn != nil { 230 + if parent, ok := pullMap[root.DependentOn.String()]; ok { 231 + root = parent 232 + } else { 233 + break // parent not in current page 234 + } 235 + } 236 + 237 + var stack models.Stack 238 + current := root 239 + for { 240 + if visited[current.AtUri().String()] { 241 + break 242 + } 243 + stack = append(stack, current) 244 + visited[current.AtUri().String()] = true 245 + 246 + found := false 247 + for _, candidate := range pulls { 248 + if candidate.DependentOn != nil && 249 + candidate.DependentOn.String() == current.AtUri().String() { 250 + current = candidate 251 + found = true 252 + break 253 + } 254 + } 255 + if !found { 256 + break 257 + } 258 + } 259 + 260 + slices.Reverse(stack) 261 + stacks = append(stacks, stack) 262 + } 263 + 264 + ps, err := db.GetPipelineStatuses( 265 + s.db, 266 + len(shas), 267 + orm.FilterEq("p.repo_owner", f.Did), 268 + orm.FilterEq("p.repo_name", f.Name), 269 + orm.FilterEq("p.knot", f.Knot), 270 + orm.FilterIn("p.sha", shas), 271 + ) 272 + if err != nil { 273 + l.Warn("failed to fetch pipeline statuses", "err", err) 274 + // non-fatal 275 + } 276 + m := make(map[string]models.Pipeline) 277 + for _, p := range ps { 278 + m[p.Sha] = p 279 + } 280 + 281 + labelDefs, err := db.GetLabelDefinitions( 282 + s.db, 283 + orm.FilterIn("at_uri", f.Labels), 284 + orm.FilterContains("scope", tangled.RepoPullNSID), 285 + ) 286 + if err != nil { 287 + l.Error("failed to fetch labels", "err", err) 288 + s.pages.Error503(w) 289 + return 290 + } 291 + 292 + defs := make(map[string]*models.LabelDefinition) 293 + for _, l := range labelDefs { 294 + defs[l.AtUri().String()] = &l 295 + } 296 + 297 + filterState := "" 298 + if state != nil { 299 + filterState = state.String() 300 + } 301 + 302 + vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 303 + if user != nil { 304 + dids := make([]syntax.DID, len(pulls)) 305 + for i, p := range pulls { 306 + dids[i] = syntax.DID(p.OwnerDid) 307 + } 308 + vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids) 309 + if err != nil { 310 + l.Error("failed to fetch vouch relationships", "err", err) 311 + } 312 + } 313 + 314 + err = s.pages.RepoPulls(w, pages.RepoPullsParams{ 315 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 316 + RepoInfo: repoInfo, 317 + Pulls: pulls, 318 + LabelDefs: defs, 319 + FilterState: filterState, 320 + FilterQuery: query.String(), 321 + Stacks: stacks, 322 + Pipelines: m, 323 + Page: page, 324 + PullCount: totalPulls, 325 + VouchRelationships: vouchRelationships, 326 + }) 327 + if err != nil { 328 + l.Error("failed to render page", "err", err) 329 + } 330 + }
+143
appview/pulls/merge.go
··· 1 + package pulls 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "time" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/reporesolver" 13 + "tangled.org/core/appview/xrpcclient" 14 + "tangled.org/core/orm" 15 + 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + ) 18 + 19 + func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 20 + l := s.logger.With("handler", "MergePull") 21 + 22 + user := s.oauth.GetMultiAccountUser(r) 23 + if user != nil { 24 + l = l.With("user", user.Did) 25 + } 26 + 27 + f, err := s.repoResolver.Resolve(r) 28 + if err != nil { 29 + l.Error("failed to resolve repo", "err", err) 30 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 31 + return 32 + } 33 + l = l.With("repo_at", f.RepoAt().String()) 34 + 35 + pull, ok := r.Context().Value("pull").(*models.Pull) 36 + if !ok { 37 + l.Error("failed to get pull") 38 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 39 + return 40 + } 41 + l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 42 + 43 + stack, ok := r.Context().Value("stack").(models.Stack) 44 + if !ok { 45 + l.Error("failed to get stack") 46 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 47 + return 48 + } 49 + 50 + // combine patches of substack 51 + subStack := stack.Below(pull) 52 + // collect the portion of the stack that is mergeable 53 + pullsToMerge := subStack.Mergeable() 54 + l = l.With("pulls_to_merge", len(pullsToMerge)) 55 + 56 + patch := pullsToMerge.CombinedPatch() 57 + 58 + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 59 + if err != nil { 60 + l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 61 + w.WriteHeader(http.StatusNotFound) 62 + return 63 + } 64 + 65 + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 66 + if err != nil { 67 + l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 68 + } 69 + 70 + authorName := ident.Handle.String() 71 + mergeInput := &tangled.RepoMerge_Input{ 72 + Did: f.Did, 73 + Name: f.Name, 74 + Branch: pull.TargetBranch, 75 + Patch: patch, 76 + CommitMessage: &pull.Title, 77 + AuthorName: &authorName, 78 + } 79 + 80 + if pull.Body != "" { 81 + mergeInput.CommitBody = &pull.Body 82 + } 83 + 84 + if email.Address != "" { 85 + mergeInput.AuthorEmail = &email.Address 86 + } 87 + 88 + client, err := s.oauth.ServiceClient( 89 + r, 90 + oauth.WithService(f.Knot), 91 + oauth.WithLxm(tangled.RepoMergeNSID), 92 + oauth.WithDev(s.config.Core.Dev), 93 + oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 94 + ) 95 + if err != nil { 96 + l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 97 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 98 + return 99 + } 100 + 101 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 102 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 103 + s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 104 + s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 105 + return 106 + } 107 + 108 + tx, err := s.db.Begin() 109 + if err != nil { 110 + l.Error("failed to start transaction", "err", err) 111 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 112 + return 113 + } 114 + defer tx.Rollback() 115 + 116 + var atUris []syntax.ATURI 117 + for _, p := range pullsToMerge { 118 + atUris = append(atUris, p.AtUri()) 119 + p.State = models.PullMerged 120 + } 121 + err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 122 + if err != nil { 123 + l.Error("failed to update pull request status in database", "err", err) 124 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 125 + return 126 + } 127 + 128 + err = tx.Commit() 129 + if err != nil { 130 + // TODO: this is unsound, we should also revert the merge from the knotserver here 131 + l.Error("failed to commit merge transaction", "err", err) 132 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 133 + return 134 + } 135 + 136 + // notify about the pull merge 137 + for _, p := range pullsToMerge { 138 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 139 + } 140 + 141 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 142 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 143 + }
-3031
appview/pulls/pulls.go
··· 3 3 import ( 4 4 "bytes" 5 5 "compress/gzip" 6 - "context" 7 - "database/sql" 8 - "encoding/json" 9 - "errors" 10 6 "fmt" 11 7 "io" 12 - "iter" 13 8 "log/slog" 14 - "net/http" 15 - "net/url" 16 - "slices" 17 - "sort" 18 - "strconv" 19 - "strings" 20 - "time" 21 9 22 - "tangled.org/core/api/tangled" 23 10 "tangled.org/core/appview/config" 24 11 "tangled.org/core/appview/db" 25 12 pulls_indexer "tangled.org/core/appview/indexer/pulls" ··· 28 15 "tangled.org/core/appview/notify" 29 16 "tangled.org/core/appview/oauth" 30 17 "tangled.org/core/appview/pages" 31 - "tangled.org/core/appview/pages/markup" 32 - "tangled.org/core/appview/pages/repoinfo" 33 - "tangled.org/core/appview/pagination" 34 18 "tangled.org/core/appview/reporesolver" 35 - "tangled.org/core/appview/searchquery" 36 19 "tangled.org/core/appview/validator" 37 - "tangled.org/core/appview/xrpcclient" 38 20 "tangled.org/core/idresolver" 39 21 "tangled.org/core/ogre" 40 - "tangled.org/core/orm" 41 - "tangled.org/core/patchutil" 42 22 "tangled.org/core/rbac" 43 - "tangled.org/core/tid" 44 - "tangled.org/core/types" 45 - "tangled.org/core/xrpc" 46 23 47 - comatproto "github.com/bluesky-social/indigo/api/atproto" 48 - "github.com/bluesky-social/indigo/atproto/atclient" 49 - "github.com/bluesky-social/indigo/atproto/syntax" 50 - lexutil "github.com/bluesky-social/indigo/lex/util" 51 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 52 - "github.com/go-chi/chi/v5" 53 25 ) 54 26 55 27 const ApplicationGzip = "application/gzip" ··· 107 79 scheme = "http" 108 80 } 109 81 return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 110 - } 111 - 112 - // htmx fragment 113 - func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 114 - l := s.logger.With("handler", "PullActions") 115 - 116 - switch r.Method { 117 - case http.MethodGet: 118 - user := s.oauth.GetMultiAccountUser(r) 119 - if user != nil { 120 - l = l.With("user", user.Did) 121 - } 122 - 123 - f, err := s.repoResolver.Resolve(r) 124 - if err != nil { 125 - l.Error("failed to get repo and knot", "err", err) 126 - return 127 - } 128 - 129 - pull, ok := r.Context().Value("pull").(*models.Pull) 130 - if !ok { 131 - l.Error("failed to get pull") 132 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 133 - return 134 - } 135 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 136 - 137 - // can be nil if this pull is not stacked 138 - stack, _ := r.Context().Value("stack").(models.Stack) 139 - 140 - roundNumberStr := chi.URLParam(r, "round") 141 - roundNumber, err := strconv.Atoi(roundNumberStr) 142 - if err != nil { 143 - roundNumber = pull.LastRoundNumber() 144 - } 145 - if roundNumber >= len(pull.Submissions) { 146 - http.Error(w, "bad round id", http.StatusBadRequest) 147 - l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 148 - return 149 - } 150 - 151 - mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 152 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 153 - resubmitResult := pages.Unknown 154 - if user.Did == pull.OwnerDid { 155 - resubmitResult = s.resubmitCheck(r, f, pull, stack) 156 - } 157 - 158 - s.pages.PullActionsFragment(w, pages.PullActionsParams{ 159 - LoggedInUser: user, 160 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 161 - Pull: pull, 162 - RoundNumber: roundNumber, 163 - MergeCheck: mergeCheckResponse, 164 - ResubmitCheck: resubmitResult, 165 - BranchDeleteStatus: branchDeleteStatus, 166 - Stack: stack, 167 - }) 168 - return 169 - } 170 - } 171 - 172 - func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 173 - l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 174 - 175 - user := s.oauth.GetMultiAccountUser(r) 176 - if user != nil { 177 - l = l.With("user", user.Did) 178 - } 179 - 180 - f, err := s.repoResolver.Resolve(r) 181 - if err != nil { 182 - l.Error("failed to get repo and knot", "err", err) 183 - return 184 - } 185 - 186 - pull, ok := r.Context().Value("pull").(*models.Pull) 187 - if !ok { 188 - l.Error("failed to get pull") 189 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 190 - return 191 - } 192 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 193 - 194 - backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 195 - if err != nil { 196 - l.Error("failed to get pull backlinks", "err", err) 197 - s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 198 - return 199 - } 200 - 201 - roundId := chi.URLParam(r, "round") 202 - roundIdInt := pull.LastRoundNumber() 203 - if r, err := strconv.Atoi(roundId); err == nil { 204 - roundIdInt = r 205 - } 206 - if roundIdInt >= len(pull.Submissions) { 207 - http.Error(w, "bad round id", http.StatusBadRequest) 208 - l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 209 - return 210 - } 211 - 212 - var diffOpts types.DiffOpts 213 - if d := r.URL.Query().Get("diff"); d == "split" { 214 - diffOpts.Split = true 215 - } 216 - 217 - // can be nil if this pull is not stacked 218 - stack, _ := r.Context().Value("stack").(models.Stack) 219 - 220 - mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 221 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 222 - resubmitResult := pages.Unknown 223 - if user != nil && user.Did == pull.OwnerDid { 224 - resubmitResult = s.resubmitCheck(r, f, pull, stack) 225 - } 226 - 227 - m := make(map[string]models.Pipeline) 228 - 229 - var shas []string 230 - for _, s := range pull.Submissions { 231 - shas = append(shas, s.SourceRev) 232 - } 233 - for _, p := range stack { 234 - shas = append(shas, p.LatestSha()) 235 - } 236 - 237 - ps, err := db.GetPipelineStatuses( 238 - s.db, 239 - len(shas), 240 - orm.FilterEq("p.repo_owner", f.Did), 241 - orm.FilterEq("p.repo_name", f.Name), 242 - orm.FilterEq("p.knot", f.Knot), 243 - orm.FilterIn("p.sha", shas), 244 - ) 245 - if err != nil { 246 - l.Error("failed to fetch pipeline statuses", "err", err) 247 - // non-fatal 248 - } 249 - 250 - for _, p := range ps { 251 - m[p.Sha] = p 252 - } 253 - 254 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 255 - if err != nil { 256 - l.Error("failed to get pull reactions", "err", err) 257 - } 258 - 259 - userReactions := map[models.ReactionKind]bool{} 260 - if user != nil { 261 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 262 - } 263 - 264 - labelDefs, err := db.GetLabelDefinitions( 265 - s.db, 266 - orm.FilterIn("at_uri", f.Labels), 267 - orm.FilterContains("scope", tangled.RepoPullNSID), 268 - ) 269 - if err != nil { 270 - l.Error("failed to fetch labels", "err", err) 271 - s.pages.Error503(w) 272 - return 273 - } 274 - 275 - defs := make(map[string]*models.LabelDefinition) 276 - for _, l := range labelDefs { 277 - defs[l.AtUri().String()] = &l 278 - } 279 - 280 - vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 281 - if user != nil { 282 - participants := pull.Participants() 283 - vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 284 - if err != nil { 285 - l.Error("failed to fetch vouch relationships", "err", err) 286 - } 287 - } 288 - 289 - patch := pull.Submissions[roundIdInt].CombinedPatch() 290 - var diff types.DiffRenderer 291 - diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 292 - 293 - if interdiff { 294 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 295 - if err != nil { 296 - l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 297 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 298 - return 299 - } 300 - 301 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 302 - if err != nil { 303 - l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 304 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 305 - return 306 - } 307 - 308 - diff = patchutil.Interdiff(previousPatch, currentPatch) 309 - } 310 - 311 - err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 312 - LoggedInUser: user, 313 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 314 - Pull: pull, 315 - Stack: stack, 316 - Backlinks: backlinks, 317 - BranchDeleteStatus: branchDeleteStatus, 318 - MergeCheck: mergeCheckResponse, 319 - ResubmitCheck: resubmitResult, 320 - Pipelines: m, 321 - Diff: diff, 322 - DiffOpts: diffOpts, 323 - ActiveRound: roundIdInt, 324 - IsInterdiff: interdiff, 325 - 326 - Reactions: reactionMap, 327 - UserReacted: userReactions, 328 - 329 - LabelDefs: defs, 330 - VouchRelationships: vouchRelationships, 331 - }) 332 - if err != nil { 333 - l.Error("failed to render page", "err", err) 334 - } 335 - } 336 - 337 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 338 - l := s.logger.With("handler", "RepoSinglePull") 339 - 340 - pull, ok := r.Context().Value("pull").(*models.Pull) 341 - if !ok { 342 - l.Error("failed to get pull") 343 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 344 - return 345 - } 346 - 347 - http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 348 - } 349 - 350 - func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 351 - if pull.State == models.PullMerged { 352 - return types.MergeCheckResponse{} 353 - } 354 - 355 - xrpcc := s.knotClient(f.Knot) 356 - 357 - // combine patches of substack 358 - subStack := stack.Below(pull) 359 - // collect the portion of the stack that is mergeable 360 - mergeable := subStack.Mergeable() 361 - // combine each patch 362 - patch := mergeable.CombinedPatch() 363 - 364 - resp, err := tangled.RepoMergeCheck( 365 - r.Context(), 366 - xrpcc, 367 - &tangled.RepoMergeCheck_Input{ 368 - Did: f.Did, 369 - Name: f.Name, 370 - Branch: pull.TargetBranch, 371 - Patch: patch, 372 - }, 373 - ) 374 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 375 - s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 376 - return types.MergeCheckResponse{ 377 - Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 378 - } 379 - } 380 - 381 - return mergeCheckResponseFrom(resp) 382 - } 383 - 384 - func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 385 - conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 386 - for i, c := range resp.Conflicts { 387 - conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 388 - } 389 - out := types.MergeCheckResponse{ 390 - IsConflicted: resp.Is_conflicted, 391 - Conflicts: conflicts, 392 - } 393 - if resp.Message != nil { 394 - out.Message = *resp.Message 395 - } 396 - if resp.Error != nil { 397 - out.Error = *resp.Error 398 - } 399 - return out 400 - } 401 - 402 - func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 403 - if pull.State != models.PullMerged { 404 - return nil 405 - } 406 - 407 - user := s.oauth.GetMultiAccountUser(r) 408 - if user == nil { 409 - return nil 410 - } 411 - 412 - var branch string 413 - // check if the branch exists 414 - // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 415 - if pull.IsBranchBased() { 416 - branch = pull.PullSource.Branch 417 - } else if pull.IsForkBased() { 418 - branch = pull.PullSource.Branch 419 - repo = pull.PullSource.Repo 420 - } else { 421 - return nil 422 - } 423 - 424 - // deleted fork 425 - if repo == nil { 426 - return nil 427 - } 428 - 429 - // user can only delete branch if they are a collaborator in the repo that the branch belongs to 430 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 431 - if !slices.Contains(perms, "repo:push") { 432 - return nil 433 - } 434 - 435 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 436 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 437 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 438 - s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 439 - return nil 440 - } 441 - 442 - return &models.BranchDeleteStatus{ 443 - Repo: repo, 444 - Branch: resp.Name, 445 - } 446 - } 447 - 448 - func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 449 - if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 450 - return pages.Unknown 451 - } 452 - 453 - var sourceRepo syntax.ATURI 454 - if pull.PullSource.RepoAt != nil { 455 - sourceRepo = *pull.PullSource.RepoAt 456 - } else { 457 - sourceRepo = repo.RepoAt() 458 - } 459 - 460 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 461 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 462 - if err != nil { 463 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 464 - s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 465 - return pages.Unknown 466 - } 467 - s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 468 - return pages.Unknown 469 - } 470 - 471 - targetBranch := branchResp 472 - 473 - top := stack[0] 474 - latestSourceRev := top.LatestSha() 475 - 476 - if latestSourceRev != targetBranch.Hash { 477 - return pages.ShouldResubmit 478 - } 479 - 480 - return pages.ShouldNotResubmit 481 - } 482 - 483 - func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 484 - s.repoPullHelper(w, r, false) 485 - } 486 - 487 - func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 488 - s.repoPullHelper(w, r, true) 489 - } 490 - 491 - func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 492 - l := s.logger.With("handler", "RepoPullPatchRaw") 493 - 494 - pull, ok := r.Context().Value("pull").(*models.Pull) 495 - if !ok { 496 - l.Error("failed to get pull") 497 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 498 - return 499 - } 500 - l = l.With("pull_id", pull.PullId) 501 - 502 - roundId := chi.URLParam(r, "round") 503 - roundIdInt, err := strconv.Atoi(roundId) 504 - if err != nil || roundIdInt >= len(pull.Submissions) { 505 - http.Error(w, "bad round id", http.StatusBadRequest) 506 - l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 507 - return 508 - } 509 - 510 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 511 - w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 512 - } 513 - 514 - func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 515 - l := s.logger.With("handler", "RepoPulls") 516 - 517 - user := s.oauth.GetMultiAccountUser(r) 518 - if user != nil { 519 - l = l.With("user", user.Did) 520 - } 521 - 522 - params := r.URL.Query() 523 - page := pagination.FromContext(r.Context()) 524 - 525 - f, err := s.repoResolver.Resolve(r) 526 - if err != nil { 527 - l.Error("failed to get repo and knot", "err", err) 528 - return 529 - } 530 - l = l.With("repo_at", f.RepoAt().String()) 531 - 532 - query := searchquery.Parse(params.Get("q")) 533 - 534 - var state *models.PullState 535 - if urlState := params.Get("state"); urlState != "" { 536 - switch urlState { 537 - case "open": 538 - state = ptrPullState(models.PullOpen) 539 - case "closed": 540 - state = ptrPullState(models.PullClosed) 541 - case "merged": 542 - state = ptrPullState(models.PullMerged) 543 - } 544 - query.Set("state", urlState) 545 - } else if queryState := query.Get("state"); queryState != nil { 546 - switch *queryState { 547 - case "open": 548 - state = ptrPullState(models.PullOpen) 549 - case "closed": 550 - state = ptrPullState(models.PullClosed) 551 - case "merged": 552 - state = ptrPullState(models.PullMerged) 553 - } 554 - } else if _, hasQ := params["q"]; !hasQ { 555 - state = ptrPullState(models.PullOpen) 556 - query.Set("state", "open") 557 - } 558 - 559 - resolve := func(ctx context.Context, ident string) (string, error) { 560 - id, err := s.idResolver.ResolveIdent(ctx, ident) 561 - if err != nil { 562 - return "", err 563 - } 564 - return id.DID.String(), nil 565 - } 566 - 567 - authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 568 - 569 - labels := query.GetAll("label") 570 - negatedLabels := query.GetAllNegated("label") 571 - labelValues := query.GetDynamicTags() 572 - negatedLabelValues := query.GetNegatedDynamicTags() 573 - 574 - // resolve DID-format label values: if a dynamic tag's label 575 - // definition has format "did", resolve the handle to a DID 576 - if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 577 - labelDefs, err := db.GetLabelDefinitions( 578 - s.db, 579 - orm.FilterIn("at_uri", f.Labels), 580 - orm.FilterContains("scope", tangled.RepoPullNSID), 581 - ) 582 - if err == nil { 583 - didLabels := make(map[string]bool) 584 - for _, def := range labelDefs { 585 - if def.ValueType.Format == models.ValueTypeFormatDid { 586 - didLabels[def.Name] = true 587 - } 588 - } 589 - labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 590 - negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 591 - } else { 592 - l.Debug("failed to fetch label definitions for DID resolution", "err", err) 593 - } 594 - } 595 - 596 - tf := searchquery.ExtractTextFilters(query) 597 - 598 - searchOpts := models.PullSearchOptions{ 599 - Keywords: tf.Keywords, 600 - Phrases: tf.Phrases, 601 - RepoAt: f.RepoAt().String(), 602 - State: state, 603 - AuthorDid: authorDid, 604 - Labels: labels, 605 - LabelValues: labelValues, 606 - NegatedKeywords: tf.NegatedKeywords, 607 - NegatedPhrases: tf.NegatedPhrases, 608 - NegatedLabels: negatedLabels, 609 - NegatedLabelValues: negatedLabelValues, 610 - NegatedAuthorDids: negatedAuthorDids, 611 - Page: page, 612 - } 613 - 614 - var totalPulls int 615 - if state == nil { 616 - totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 617 - } else { 618 - switch *state { 619 - case models.PullOpen: 620 - totalPulls = f.RepoStats.PullCount.Open 621 - case models.PullMerged: 622 - totalPulls = f.RepoStats.PullCount.Merged 623 - case models.PullClosed: 624 - totalPulls = f.RepoStats.PullCount.Closed 625 - } 626 - } 627 - 628 - repoInfo := s.repoResolver.GetRepoInfo(r, user) 629 - 630 - var pulls []*models.Pull 631 - 632 - if searchOpts.HasSearchFilters() { 633 - res, err := s.indexer.Search(r.Context(), searchOpts) 634 - if err != nil { 635 - l.Error("failed to search for pulls", "err", err) 636 - return 637 - } 638 - totalPulls = int(res.Total) 639 - l.Debug("searched pulls with indexer", "count", len(res.Hits)) 640 - 641 - // update tab counts to reflect filtered results 642 - countOpts := searchOpts 643 - countOpts.Page = pagination.Page{Limit: 1} 644 - for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 645 - countOpts.State = &ps 646 - countRes, err := s.indexer.Search(r.Context(), countOpts) 647 - if err != nil { 648 - continue 649 - } 650 - switch ps { 651 - case models.PullOpen: 652 - repoInfo.Stats.PullCount.Open = int(countRes.Total) 653 - case models.PullMerged: 654 - repoInfo.Stats.PullCount.Merged = int(countRes.Total) 655 - case models.PullClosed: 656 - repoInfo.Stats.PullCount.Closed = int(countRes.Total) 657 - } 658 - } 659 - 660 - if len(res.Hits) > 0 { 661 - pulls, err = db.GetPulls( 662 - s.db, 663 - orm.FilterIn("id", res.Hits), 664 - ) 665 - if err != nil { 666 - l.Error("failed to get pulls", "err", err) 667 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 668 - return 669 - } 670 - } 671 - } else { 672 - filters := []orm.Filter{ 673 - orm.FilterEq("repo_at", f.RepoAt()), 674 - } 675 - if state != nil { 676 - filters = append(filters, orm.FilterEq("state", *state)) 677 - } 678 - pulls, err = db.GetPullsPaginated( 679 - s.db, 680 - page, 681 - filters..., 682 - ) 683 - if err != nil { 684 - l.Error("failed to get pulls", "err", err) 685 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 686 - return 687 - } 688 - } 689 - 690 - for _, p := range pulls { 691 - var pullSourceRepo *models.Repo 692 - if p.PullSource != nil { 693 - if p.PullSource.RepoAt != nil { 694 - pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 695 - if err != nil { 696 - l.Error("failed to get repo by at uri", "err", err, "repo_at", p.PullSource.RepoAt.String()) 697 - continue 698 - } else { 699 - p.PullSource.Repo = pullSourceRepo 700 - } 701 - } 702 - } 703 - } 704 - 705 - var stacks []models.Stack 706 - var shas []string 707 - 708 - pullMap := make(map[string]*models.Pull) 709 - for _, p := range pulls { 710 - shas = append(shas, p.LatestSha()) 711 - pullMap[p.AtUri().String()] = p 712 - } 713 - 714 - // track which PRs have been added to stacks 715 - visited := make(map[string]bool) 716 - 717 - // group stacked PRs together using dependent_on relationships 718 - for _, p := range pulls { 719 - if visited[p.AtUri().String()] { 720 - continue 721 - } 722 - 723 - root := p 724 - for root.DependentOn != nil { 725 - if parent, ok := pullMap[root.DependentOn.String()]; ok { 726 - root = parent 727 - } else { 728 - break // parent not in current page 729 - } 730 - } 731 - 732 - var stack models.Stack 733 - current := root 734 - for { 735 - if visited[current.AtUri().String()] { 736 - break 737 - } 738 - stack = append(stack, current) 739 - visited[current.AtUri().String()] = true 740 - 741 - found := false 742 - for _, candidate := range pulls { 743 - if candidate.DependentOn != nil && 744 - candidate.DependentOn.String() == current.AtUri().String() { 745 - current = candidate 746 - found = true 747 - break 748 - } 749 - } 750 - if !found { 751 - break 752 - } 753 - } 754 - 755 - slices.Reverse(stack) 756 - stacks = append(stacks, stack) 757 - } 758 - 759 - ps, err := db.GetPipelineStatuses( 760 - s.db, 761 - len(shas), 762 - orm.FilterEq("p.repo_owner", f.Did), 763 - orm.FilterEq("p.repo_name", f.Name), 764 - orm.FilterEq("p.knot", f.Knot), 765 - orm.FilterIn("p.sha", shas), 766 - ) 767 - if err != nil { 768 - l.Warn("failed to fetch pipeline statuses", "err", err) 769 - // non-fatal 770 - } 771 - m := make(map[string]models.Pipeline) 772 - for _, p := range ps { 773 - m[p.Sha] = p 774 - } 775 - 776 - labelDefs, err := db.GetLabelDefinitions( 777 - s.db, 778 - orm.FilterIn("at_uri", f.Labels), 779 - orm.FilterContains("scope", tangled.RepoPullNSID), 780 - ) 781 - if err != nil { 782 - l.Error("failed to fetch labels", "err", err) 783 - s.pages.Error503(w) 784 - return 785 - } 786 - 787 - defs := make(map[string]*models.LabelDefinition) 788 - for _, l := range labelDefs { 789 - defs[l.AtUri().String()] = &l 790 - } 791 - 792 - filterState := "" 793 - if state != nil { 794 - filterState = state.String() 795 - } 796 - 797 - vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 798 - if user != nil { 799 - dids := make([]syntax.DID, len(pulls)) 800 - for i, p := range pulls { 801 - dids[i] = syntax.DID(p.OwnerDid) 802 - } 803 - vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids) 804 - if err != nil { 805 - l.Error("failed to fetch vouch relationships", "err", err) 806 - } 807 - } 808 - 809 - err = s.pages.RepoPulls(w, pages.RepoPullsParams{ 810 - LoggedInUser: s.oauth.GetMultiAccountUser(r), 811 - RepoInfo: repoInfo, 812 - Pulls: pulls, 813 - LabelDefs: defs, 814 - FilterState: filterState, 815 - FilterQuery: query.String(), 816 - Stacks: stacks, 817 - Pipelines: m, 818 - Page: page, 819 - PullCount: totalPulls, 820 - VouchRelationships: vouchRelationships, 821 - }) 822 - if err != nil { 823 - l.Error("failed to render page", "err", err) 824 - } 825 - } 826 - 827 - func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 828 - l := s.logger.With("handler", "PullComment") 829 - 830 - user := s.oauth.GetMultiAccountUser(r) 831 - if user != nil { 832 - l = l.With("user", user.Did) 833 - } 834 - 835 - f, err := s.repoResolver.Resolve(r) 836 - if err != nil { 837 - l.Error("failed to get repo and knot", "err", err) 838 - return 839 - } 840 - 841 - pull, ok := r.Context().Value("pull").(*models.Pull) 842 - if !ok { 843 - l.Error("failed to get pull") 844 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 845 - return 846 - } 847 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 848 - 849 - roundNumberStr := chi.URLParam(r, "round") 850 - roundNumber, err := strconv.Atoi(roundNumberStr) 851 - if err != nil || roundNumber >= len(pull.Submissions) { 852 - http.Error(w, "bad round id", http.StatusBadRequest) 853 - l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr) 854 - return 855 - } 856 - 857 - switch r.Method { 858 - case http.MethodGet: 859 - s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 860 - LoggedInUser: user, 861 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 862 - Pull: pull, 863 - RoundNumber: roundNumber, 864 - }) 865 - return 866 - case http.MethodPost: 867 - body := r.FormValue("body") 868 - if body == "" { 869 - s.pages.Notice(w, "pull", "Comment body is required") 870 - return 871 - } 872 - 873 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 874 - 875 - // Start a transaction 876 - tx, err := s.db.BeginTx(r.Context(), nil) 877 - if err != nil { 878 - l.Error("failed to start transaction", "err", err) 879 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 880 - return 881 - } 882 - defer tx.Rollback() 883 - 884 - createdAt := time.Now().Format(time.RFC3339) 885 - 886 - client, err := s.oauth.AuthorizedClient(r) 887 - if err != nil { 888 - l.Error("failed to get authorized client", "err", err) 889 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 890 - return 891 - } 892 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 893 - Collection: tangled.RepoPullCommentNSID, 894 - Repo: user.Did, 895 - Rkey: tid.TID(), 896 - Record: &lexutil.LexiconTypeDecoder{ 897 - Val: &tangled.RepoPullComment{ 898 - Pull: pull.AtUri().String(), 899 - Body: body, 900 - CreatedAt: createdAt, 901 - }, 902 - }, 903 - }) 904 - if err != nil { 905 - l.Error("failed to create pull comment", "err", err) 906 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 907 - return 908 - } 909 - 910 - comment := &models.PullComment{ 911 - OwnerDid: user.Did, 912 - RepoAt: f.RepoAt().String(), 913 - PullId: pull.PullId, 914 - Body: body, 915 - CommentAt: atResp.Uri, 916 - SubmissionId: pull.Submissions[roundNumber].ID, 917 - Mentions: mentions, 918 - References: references, 919 - } 920 - 921 - // Create the pull comment in the database with the commentAt field 922 - commentId, err := db.NewPullComment(tx, comment) 923 - if err != nil { 924 - l.Error("failed to create pull comment in database", "err", err) 925 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 926 - return 927 - } 928 - 929 - // Commit the transaction 930 - if err = tx.Commit(); err != nil { 931 - l.Error("failed to commit transaction", "err", err) 932 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 933 - return 934 - } 935 - 936 - s.notifier.NewPullComment(r.Context(), comment, mentions) 937 - 938 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 939 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 940 - return 941 - } 942 - } 943 - 944 - func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 945 - l := s.logger.With("handler", "NewPull") 946 - 947 - user := s.oauth.GetMultiAccountUser(r) 948 - if user != nil { 949 - l = l.With("user", user.Did) 950 - } 951 - 952 - f, err := s.repoResolver.Resolve(r) 953 - if err != nil { 954 - l.Error("failed to get repo and knot", "err", err) 955 - return 956 - } 957 - l = l.With("repo_at", f.RepoAt().String()) 958 - 959 - switch r.Method { 960 - case http.MethodGet: 961 - params, err := s.composeParams(r, f) 962 - if err != nil { 963 - l.Error("failed to build compose params", "err", err) 964 - s.pages.Error503(w) 965 - return 966 - } 967 - s.pages.RepoNewPull(w, params) 968 - 969 - case http.MethodPost: 970 - title := r.FormValue("title") 971 - body := r.FormValue("body") 972 - targetBranch := r.FormValue("targetBranch") 973 - fromFork := r.FormValue("fork") 974 - sourceBranch := r.FormValue("sourceBranch") 975 - patch := r.FormValue("patch") 976 - userDid := syntax.DID(user.Did) 977 - 978 - if targetBranch == "" { 979 - s.pages.Notice(w, "pull", "Target branch is required.") 980 - return 981 - } 982 - 983 - // Determine PR type based on input parameters 984 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 985 - isPushAllowed := roles.IsPushAllowed() 986 - isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 987 - isForkBased := fromFork != "" && sourceBranch != "" 988 - isPatchBased := patch != "" && !isBranchBased && !isForkBased 989 - isStacked := r.FormValue("mode") == "stack" && !isPatchBased 990 - 991 - if isPatchBased && !patchutil.IsFormatPatch(patch) { 992 - if title == "" { 993 - s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 994 - return 995 - } 996 - sanitizer := markup.NewSanitizer() 997 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 998 - s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 999 - return 1000 - } 1001 - } 1002 - 1003 - // Validate we have at least one valid PR creation method 1004 - if !isBranchBased && !isPatchBased && !isForkBased { 1005 - s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 1006 - return 1007 - } 1008 - 1009 - // Can't mix branch-based and patch-based approaches 1010 - if isBranchBased && patch != "" { 1011 - s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 1012 - return 1013 - } 1014 - 1015 - if isBranchBased && sourceBranch == targetBranch { 1016 - s.pages.Notice(w, "pull", "Source and target branch must be different.") 1017 - return 1018 - } 1019 - 1020 - // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1021 - // if err != nil { 1022 - // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 1023 - // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1024 - // return 1025 - // } 1026 - 1027 - // TODO: make capabilities an xrpc call 1028 - caps := struct { 1029 - PullRequests struct { 1030 - FormatPatch bool 1031 - BranchSubmissions bool 1032 - ForkSubmissions bool 1033 - PatchSubmissions bool 1034 - } 1035 - }{ 1036 - PullRequests: struct { 1037 - FormatPatch bool 1038 - BranchSubmissions bool 1039 - ForkSubmissions bool 1040 - PatchSubmissions bool 1041 - }{ 1042 - FormatPatch: true, 1043 - BranchSubmissions: true, 1044 - ForkSubmissions: true, 1045 - PatchSubmissions: true, 1046 - }, 1047 - } 1048 - 1049 - // caps, err := us.Capabilities() 1050 - // if err != nil { 1051 - // log.Println("error fetching knot caps", f.Knot, err) 1052 - // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1053 - // return 1054 - // } 1055 - 1056 - if !caps.PullRequests.FormatPatch { 1057 - s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1058 - return 1059 - } 1060 - 1061 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 1062 - stackBodies := parseBracketedForm(r.Form, "stackBody") 1063 - 1064 - // Handle the PR creation based on the type 1065 - if isBranchBased { 1066 - if !caps.PullRequests.BranchSubmissions { 1067 - s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1068 - return 1069 - } 1070 - s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1071 - } else if isForkBased { 1072 - if !caps.PullRequests.ForkSubmissions { 1073 - s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1074 - return 1075 - } 1076 - s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1077 - } else if isPatchBased { 1078 - if !caps.PullRequests.PatchSubmissions { 1079 - s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1080 - return 1081 - } 1082 - s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 1083 - } 1084 - return 1085 - } 1086 - } 1087 - 1088 - func (s *Pulls) handleBranchBasedPull( 1089 - w http.ResponseWriter, 1090 - r *http.Request, 1091 - repo *models.Repo, 1092 - userDid syntax.DID, 1093 - title, 1094 - body, 1095 - targetBranch, 1096 - sourceBranch string, 1097 - isStacked bool, 1098 - stackTitles, stackBodies map[string]string, 1099 - ) { 1100 - l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1101 - 1102 - xrpcc := s.knotClient(repo.Knot) 1103 - 1104 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1105 - if err != nil { 1106 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1107 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 1108 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1109 - return 1110 - } 1111 - l.Error("failed to compare", "err", err) 1112 - s.pages.Notice(w, "pull", err.Error()) 1113 - return 1114 - } 1115 - 1116 - var comparison types.RepoFormatPatchResponse 1117 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1118 - l.Error("failed to decode XRPC compare response", "err", err) 1119 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1120 - return 1121 - } 1122 - 1123 - if len(comparison.FormatPatch) == 0 { 1124 - s.pages.Notice(w, "pull", "No commits between target and source.") 1125 - return 1126 - } 1127 - 1128 - sourceRev := comparison.Rev2 1129 - patch := comparison.FormatPatchRaw 1130 - combined := comparison.CombinedPatchRaw 1131 - 1132 - if err := s.validator.ValidatePatch(&patch); err != nil { 1133 - s.logger.Error("failed to validate patch", "err", err) 1134 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1135 - return 1136 - } 1137 - 1138 - pullSource := &models.PullSource{ 1139 - Branch: sourceBranch, 1140 - } 1141 - 1142 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1143 - } 1144 - 1145 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1146 - if err := s.validator.ValidatePatch(&patch); err != nil { 1147 - s.logger.Error("patch validation failed", "err", err) 1148 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1149 - return 1150 - } 1151 - 1152 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 1153 - } 1154 - 1155 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1156 - l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1157 - 1158 - repoString := strings.SplitN(forkRepo, "/", 2) 1159 - forkOwnerDid := repoString[0] 1160 - repoName := repoString[1] 1161 - fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1162 - if errors.Is(err, sql.ErrNoRows) { 1163 - s.pages.Notice(w, "pull", "No such fork.") 1164 - return 1165 - } else if err != nil { 1166 - l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName) 1167 - s.pages.Notice(w, "pull", "Failed to fetch fork.") 1168 - return 1169 - } 1170 - 1171 - client, err := s.oauth.ServiceClient( 1172 - r, 1173 - oauth.WithService(fork.Knot), 1174 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 1175 - oauth.WithDev(s.config.Core.Dev), 1176 - ) 1177 - 1178 - resp, err := tangled.RepoHiddenRef( 1179 - r.Context(), 1180 - client, 1181 - &tangled.RepoHiddenRef_Input{ 1182 - ForkRef: sourceBranch, 1183 - RemoteRef: targetBranch, 1184 - Repo: fork.RepoAt().String(), 1185 - }, 1186 - ) 1187 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1188 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 1189 - s.pages.Notice(w, "pull", xrpcerr.Error()) 1190 - return 1191 - } 1192 - 1193 - if !resp.Success { 1194 - errorMsg := "Failed to create pull request" 1195 - if resp.Error != nil { 1196 - errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1197 - } 1198 - s.pages.Notice(w, "pull", errorMsg) 1199 - return 1200 - } 1201 - 1202 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1203 - // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1204 - // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1205 - // hiddenRef: hidden/feature-1/main (on repo-fork) 1206 - // targetBranch: main (on repo-1) 1207 - // sourceBranch: feature-1 (on repo-fork) 1208 - forkXrpcc := s.knotClient(fork.Knot) 1209 - 1210 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1211 - if err != nil { 1212 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1213 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 1214 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1215 - return 1216 - } 1217 - l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 1218 - s.pages.Notice(w, "pull", err.Error()) 1219 - return 1220 - } 1221 - 1222 - var comparison types.RepoFormatPatchResponse 1223 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1224 - l.Error("failed to decode XRPC compare response for fork", "err", err) 1225 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1226 - return 1227 - } 1228 - 1229 - if len(comparison.FormatPatch) == 0 { 1230 - s.pages.Notice(w, "pull", "No commits between target and source.") 1231 - return 1232 - } 1233 - 1234 - sourceRev := comparison.Rev2 1235 - patch := comparison.FormatPatchRaw 1236 - combined := comparison.CombinedPatchRaw 1237 - 1238 - if err := s.validator.ValidatePatch(&patch); err != nil { 1239 - s.logger.Error("failed to validate patch", "err", err) 1240 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1241 - return 1242 - } 1243 - 1244 - forkAtUri := fork.RepoAt() 1245 - var forkDid *syntax.DID 1246 - if fork.RepoDid != "" { 1247 - forkDid = new(syntax.DID) 1248 - *forkDid = syntax.DID(fork.RepoDid) 1249 - } 1250 - 1251 - pullSource := &models.PullSource{ 1252 - Branch: sourceBranch, 1253 - RepoAt: &forkAtUri, 1254 - RepoDid: forkDid, 1255 - } 1256 - 1257 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1258 - } 1259 - 1260 - func (s *Pulls) createPullRequest( 1261 - w http.ResponseWriter, 1262 - r *http.Request, 1263 - repo *models.Repo, 1264 - userDid syntax.DID, 1265 - title, body, targetBranch string, 1266 - patch string, 1267 - combined string, 1268 - sourceRev string, 1269 - pullSource *models.PullSource, 1270 - isStacked bool, 1271 - stackTitles, stackBodies map[string]string, 1272 - ) { 1273 - l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 1274 - 1275 - if isStacked { 1276 - // creates a series of PRs, each linking to the previous, identified by jj's change-id 1277 - s.createStackedPullRequest( 1278 - w, 1279 - r, 1280 - repo, 1281 - userDid, 1282 - targetBranch, 1283 - patch, 1284 - sourceRev, 1285 - pullSource, 1286 - stackTitles, 1287 - stackBodies, 1288 - ) 1289 - return 1290 - } 1291 - 1292 - client, err := s.oauth.AuthorizedClient(r) 1293 - if err != nil { 1294 - l.Error("failed to get authorized client", "err", err) 1295 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1296 - return 1297 - } 1298 - 1299 - tx, err := s.db.BeginTx(r.Context(), nil) 1300 - if err != nil { 1301 - l.Error("failed to start tx", "err", err) 1302 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1303 - return 1304 - } 1305 - defer tx.Rollback() 1306 - 1307 - // We've already checked earlier if it's diff-based and title is empty, 1308 - // so if it's still empty now, it's intentionally skipped owing to format-patch. 1309 - if title == "" || body == "" { 1310 - formatPatches, err := patchutil.ExtractPatches(patch) 1311 - if err != nil { 1312 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1313 - return 1314 - } 1315 - if len(formatPatches) == 0 { 1316 - s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1317 - return 1318 - } 1319 - 1320 - if title == "" { 1321 - title = formatPatches[0].Title 1322 - } 1323 - if body == "" { 1324 - body = formatPatches[0].Body 1325 - } 1326 - } 1327 - 1328 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1329 - 1330 - rkey := tid.TID() 1331 - 1332 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1333 - if err != nil { 1334 - l.Error("failed to upload patch", "err", err) 1335 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1336 - return 1337 - } 1338 - 1339 - now := time.Now() 1340 - 1341 - pull := &models.Pull{ 1342 - Title: title, 1343 - Body: body, 1344 - TargetBranch: targetBranch, 1345 - OwnerDid: userDid.String(), 1346 - RepoAt: repo.RepoAt(), 1347 - Rkey: rkey, 1348 - Mentions: mentions, 1349 - References: references, 1350 - Submissions: []*models.PullSubmission{ 1351 - { 1352 - Patch: patch, 1353 - Combined: combined, 1354 - SourceRev: sourceRev, 1355 - Blob: *blob.Blob, 1356 - Created: now, 1357 - }, 1358 - }, 1359 - PullSource: pullSource, 1360 - State: models.PullOpen, 1361 - Created: now, 1362 - } 1363 - 1364 - record := pull.AsRecord() 1365 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1366 - Collection: tangled.RepoPullNSID, 1367 - Repo: userDid.String(), 1368 - Rkey: rkey, 1369 - Record: &lexutil.LexiconTypeDecoder{ 1370 - Val: &record, 1371 - }, 1372 - }) 1373 - if err != nil { 1374 - l.Error("failed to create pull request", "err", err) 1375 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1376 - return 1377 - } 1378 - 1379 - err = db.PutPull(tx, pull) 1380 - if err != nil { 1381 - l.Error("failed to create pull request in database", "err", err) 1382 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1383 - return 1384 - } 1385 - pullId, err := db.NextPullId(tx, repo.RepoAt()) 1386 - if err != nil { 1387 - s.logger.Error("failed to get pull id", "err", err) 1388 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1389 - return 1390 - } 1391 - 1392 - if err = tx.Commit(); err != nil { 1393 - l.Error("failed to commit transaction for pull request", "err", err) 1394 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1395 - return 1396 - } 1397 - 1398 - s.notifier.NewPull(r.Context(), pull) 1399 - 1400 - s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 1401 - 1402 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1403 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1404 - } 1405 - 1406 - func (s *Pulls) createStackedPullRequest( 1407 - w http.ResponseWriter, 1408 - r *http.Request, 1409 - repo *models.Repo, 1410 - userDid syntax.DID, 1411 - targetBranch string, 1412 - patch string, 1413 - sourceRev string, 1414 - pullSource *models.PullSource, 1415 - stackTitles, stackBodies map[string]string, 1416 - ) { 1417 - l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 1418 - 1419 - // run some necessary checks for stacked-prs first 1420 - 1421 - formatPatches, err := patchutil.ExtractPatches(patch) 1422 - if err != nil { 1423 - l.Error("failed to extract patches", "err", err) 1424 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1425 - return 1426 - } 1427 - 1428 - // must have atleast 1 patch to begin with 1429 - if len(formatPatches) == 0 { 1430 - l.Error("empty patches") 1431 - s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1432 - return 1433 - } 1434 - 1435 - client, err := s.oauth.AuthorizedClient(r) 1436 - if err != nil { 1437 - l.Error("failed to get authorized client", "err", err) 1438 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1439 - return 1440 - } 1441 - 1442 - // first upload all blobs 1443 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 1444 - for i, p := range formatPatches { 1445 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 1446 - if err != nil { 1447 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 1448 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1449 - return 1450 - } 1451 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 1452 - blobs[i] = blob.Blob 1453 - } 1454 - 1455 - // build a stack out of this patch 1456 - stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 1457 - if err != nil { 1458 - l.Error("failed to create stack", "err", err) 1459 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1460 - return 1461 - } 1462 - 1463 - // apply all record creations at once 1464 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1465 - for _, p := range stack { 1466 - record := p.AsRecord() 1467 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1468 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1469 - Collection: tangled.RepoPullNSID, 1470 - Rkey: &p.Rkey, 1471 - Value: &lexutil.LexiconTypeDecoder{ 1472 - Val: &record, 1473 - }, 1474 - }, 1475 - }) 1476 - } 1477 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1478 - Repo: userDid.String(), 1479 - Writes: writes, 1480 - }) 1481 - if err != nil { 1482 - l.Error("failed to create stacked pull request", "err", err) 1483 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1484 - return 1485 - } 1486 - 1487 - // create all pulls at once 1488 - tx, err := s.db.BeginTx(r.Context(), nil) 1489 - if err != nil { 1490 - l.Error("failed to start tx", "err", err) 1491 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1492 - return 1493 - } 1494 - defer tx.Rollback() 1495 - 1496 - for _, p := range stack { 1497 - err = db.PutPull(tx, p) 1498 - if err != nil { 1499 - l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 1500 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1501 - return 1502 - } 1503 - 1504 - } 1505 - 1506 - if err = tx.Commit(); err != nil { 1507 - l.Error("failed to commit transaction for pull requests", "err", err) 1508 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1509 - return 1510 - } 1511 - 1512 - // notify about each pull 1513 - // 1514 - // this is performed after tx.Commit, because it could result in a locked DB otherwise 1515 - for _, p := range stack { 1516 - s.notifier.NewPull(r.Context(), p) 1517 - } 1518 - 1519 - s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 1520 - 1521 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1522 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1523 - } 1524 - 1525 - func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 1526 - body := r.FormValue("body") 1527 - s.pages.MarkdownPreviewFragment(w, body) 1528 - } 1529 - 1530 - func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 1531 - l := s.logger.With("handler", "RefreshCompose") 1532 - 1533 - f, err := s.repoResolver.Resolve(r) 1534 - if err != nil { 1535 - l.Error("failed to resolve repo", "err", err) 1536 - s.pages.Error503(w) 1537 - return 1538 - } 1539 - 1540 - params, err := s.composeParams(r, f) 1541 - if err != nil { 1542 - l.Error("failed to build compose params", "err", err) 1543 - s.pages.Error503(w) 1544 - return 1545 - } 1546 - w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 1547 - s.pages.PullComposeHostFragment(w, params) 1548 - } 1549 - 1550 - func composeCanonicalURL(params pages.RepoNewPullParams) string { 1551 - base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 1552 - q := url.Values{} 1553 - if params.IsStacked { 1554 - q.Set("mode", "stack") 1555 - } 1556 - if params.Source != "" && params.Source != pages.SourceBranch { 1557 - q.Set("source", string(params.Source)) 1558 - } 1559 - if params.SourceBranch != "" { 1560 - q.Set("sourceBranch", params.SourceBranch) 1561 - } 1562 - if params.TargetBranch != "" { 1563 - q.Set("targetBranch", params.TargetBranch) 1564 - } 1565 - if params.Source == pages.SourceFork && params.Fork != "" { 1566 - q.Set("fork", params.Fork) 1567 - } 1568 - if len(q) == 0 { 1569 - return base 1570 - } 1571 - return base + "?" + q.Encode() 1572 - } 1573 - 1574 - func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 1575 - l := s.logger.With("handler", "composeParams") 1576 - user := s.oauth.GetMultiAccountUser(r) 1577 - 1578 - branches, err := s.listBranches(r.Context(), repo) 1579 - if err != nil { 1580 - return pages.RepoNewPullParams{}, err 1581 - } 1582 - 1583 - var forks []models.Repo 1584 - if user != nil { 1585 - forks, err = db.GetForksByDid(s.db, user.Did) 1586 - if err != nil { 1587 - l.Warn("failed to list user forks", "err", err, "user", user.Did) 1588 - } 1589 - } 1590 - 1591 - repoInfo := s.repoResolver.GetRepoInfo(r, user) 1592 - source, ok := pages.ParseSource(r.FormValue("source")) 1593 - if !ok { 1594 - source = pages.SourceBranch 1595 - if !repoInfo.Roles.IsPushAllowed() { 1596 - source = pages.SourceFork 1597 - } 1598 - } 1599 - 1600 - sourceBranch := r.FormValue("sourceBranch") 1601 - targetBranch := r.FormValue("targetBranch") 1602 - fork := r.FormValue("fork") 1603 - patch := r.FormValue("patch") 1604 - 1605 - if source == pages.SourceFork && fork == "" && len(forks) == 1 { 1606 - fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 1607 - } 1608 - 1609 - var forkBranches []types.Branch 1610 - var forkBranchesErr error 1611 - if source == pages.SourceFork && fork != "" { 1612 - forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 1613 - if forkBranchesErr != nil { 1614 - l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 1615 - } 1616 - } 1617 - 1618 - sourceBranchList := sourceBranchChoices(branches) 1619 - targetBranch = defaultTargetBranch(branches, targetBranch) 1620 - sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 1621 - 1622 - comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 1623 - var prefillErr string 1624 - if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 1625 - prefillErr = joined.Error() 1626 - } 1627 - 1628 - mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 1629 - 1630 - refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 1631 - var diffOpts types.DiffOpts 1632 - if r.FormValue("diff") == "split" { 1633 - diffOpts.Split = true 1634 - } 1635 - diffOpts.RefreshUrl = refreshUrl 1636 - diffOpts.Target = "#diff-area" 1637 - 1638 - labelDefs, err := s.pullLabelDefs(repo) 1639 - if err != nil { 1640 - l.Warn("failed to load label definitions", "err", err) 1641 - } 1642 - labelState := labelStateFromForm(r.Form, labelDefs) 1643 - perCidLabelForms := parseStackLabelForms(r.Form) 1644 - stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 1645 - for cid, perForm := range perCidLabelForms { 1646 - stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 1647 - } 1648 - 1649 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 1650 - stackBodies := parseBracketedForm(r.Form, "stackBody") 1651 - stackSplits := parseBracketedForm(r.Form, "stackSplit") 1652 - 1653 - title := r.FormValue("title") 1654 - body := r.FormValue("body") 1655 - if comparison != nil && len(comparison.FormatPatch) > 0 { 1656 - first := comparison.FormatPatch[0] 1657 - if title == "" && first.PatchHeader != nil { 1658 - title = first.Title 1659 - } 1660 - if body == "" && first.PatchHeader != nil { 1661 - body = first.Body 1662 - } 1663 - } 1664 - 1665 - isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 1666 - var stackedDiffs []pages.StackedDiff 1667 - if isStacked { 1668 - stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 1669 - } 1670 - 1671 - return pages.RepoNewPullParams{ 1672 - LoggedInUser: user, 1673 - RepoInfo: repoInfo, 1674 - Branches: branches, 1675 - SourceBranches: sourceBranchList, 1676 - ForkBranches: forkBranches, 1677 - Forks: forks, 1678 - Source: source, 1679 - SourceBranch: sourceBranch, 1680 - TargetBranch: targetBranch, 1681 - Fork: fork, 1682 - Patch: patch, 1683 - Title: title, 1684 - Body: body, 1685 - IsStacked: isStacked, 1686 - Comparison: comparison, 1687 - Diff: diff, 1688 - DiffOpts: diffOpts, 1689 - StackedDiffs: stackedDiffs, 1690 - MergeCheck: mergeCheck, 1691 - StackTitles: stackTitles, 1692 - StackBodies: stackBodies, 1693 - PrefillError: prefillErr, 1694 - LabelDefs: labelDefs, 1695 - LabelState: labelState, 1696 - StackLabelStates: stackLabelStates, 1697 - }, nil 1698 - } 1699 - 1700 - func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 1701 - defs, err := db.GetLabelDefinitions( 1702 - s.db, 1703 - orm.FilterIn("at_uri", repo.Labels), 1704 - orm.FilterContains("scope", tangled.RepoPullNSID), 1705 - ) 1706 - if err != nil { 1707 - return nil, err 1708 - } 1709 - 1710 - out := make(map[string]*models.LabelDefinition, len(defs)) 1711 - for i := range defs { 1712 - d := defs[i] 1713 - if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 1714 - continue 1715 - } 1716 - out[d.AtUri().String()] = &d 1717 - } 1718 - return out, nil 1719 - } 1720 - 1721 - func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 1722 - return func(yield func(string, string) bool) { 1723 - for key := range defs { 1724 - for _, v := range form[key] { 1725 - if v == "" { 1726 - continue 1727 - } 1728 - if !yield(key, v) { 1729 - return 1730 - } 1731 - } 1732 - } 1733 - } 1734 - } 1735 - 1736 - func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 1737 - state := models.NewLabelState() 1738 - actx := &models.LabelApplicationCtx{Defs: defs} 1739 - for key, val := range formLabelEntries(form, defs) { 1740 - _ = actx.ApplyLabelOp(state, models.LabelOp{ 1741 - Operation: models.LabelOperationAdd, 1742 - OperandKey: key, 1743 - OperandValue: val, 1744 - }) 1745 - } 1746 - return state 1747 - } 1748 - 1749 - func buildCreationLabelOps( 1750 - userDid syntax.DID, 1751 - subject syntax.ATURI, 1752 - rkey string, 1753 - form url.Values, 1754 - defs map[string]*models.LabelDefinition, 1755 - performedAt time.Time, 1756 - ) []models.LabelOp { 1757 - var ops []models.LabelOp 1758 - for key, val := range formLabelEntries(form, defs) { 1759 - ops = append(ops, models.LabelOp{ 1760 - Did: userDid.String(), 1761 - Rkey: rkey, 1762 - Subject: subject, 1763 - Operation: models.LabelOperationAdd, 1764 - OperandKey: key, 1765 - OperandValue: val, 1766 - PerformedAt: performedAt, 1767 - }) 1768 - } 1769 - return ops 1770 - } 1771 - 1772 - func (s *Pulls) applyCreationLabels( 1773 - ctx context.Context, 1774 - client *atclient.APIClient, 1775 - userDid syntax.DID, 1776 - pulls []*models.Pull, 1777 - form url.Values, 1778 - repo *models.Repo, 1779 - ) { 1780 - l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 1781 - 1782 - defs, err := s.pullLabelDefs(repo) 1783 - if err != nil { 1784 - l.Warn("failed to fetch label defs", "err", err) 1785 - return 1786 - } 1787 - if len(defs) == 0 { 1788 - return 1789 - } 1790 - 1791 - perCidForms := parseStackLabelForms(form) 1792 - 1793 - applyAll := form.Get("applyLabelsToAll") == "on" 1794 - var firstStackForm url.Values 1795 - if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 1796 - if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 1797 - if f, ok := perCidForms[firstCid]; ok { 1798 - firstStackForm = f 1799 - } 1800 - } 1801 - } 1802 - 1803 - performedAt := time.Now() 1804 - for _, pull := range pulls { 1805 - labelForm := form 1806 - if firstStackForm != nil { 1807 - labelForm = firstStackForm 1808 - } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 1809 - if cid := pull.Submissions[0].ChangeId(); cid != "" { 1810 - if perForm, ok := perCidForms[cid]; ok { 1811 - labelForm = perForm 1812 - } 1813 - } 1814 - } 1815 - rkey := tid.TID() 1816 - raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 1817 - 1818 - valid := make([]models.LabelOp, 0, len(raw)) 1819 - for _, op := range raw { 1820 - def := defs[op.OperandKey] 1821 - if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 1822 - l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 1823 - continue 1824 - } 1825 - valid = append(valid, op) 1826 - } 1827 - if len(valid) == 0 { 1828 - continue 1829 - } 1830 - 1831 - record := models.LabelOpsAsRecord(valid) 1832 - if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1833 - Collection: tangled.LabelOpNSID, 1834 - Repo: userDid.String(), 1835 - Rkey: rkey, 1836 - Record: &lexutil.LexiconTypeDecoder{Val: &record}, 1837 - }); err != nil { 1838 - l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 1839 - continue 1840 - } 1841 - 1842 - if err := s.indexLabelOps(ctx, valid); err != nil { 1843 - l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 1844 - if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 1845 - Collection: tangled.LabelOpNSID, 1846 - Repo: userDid.String(), 1847 - Rkey: rkey, 1848 - }); err != nil { 1849 - l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 1850 - } 1851 - continue 1852 - } 1853 - 1854 - s.notifier.NewPullLabelOp(ctx, pull) 1855 - } 1856 - } 1857 - 1858 - func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 1859 - tx, err := s.db.BeginTx(ctx, nil) 1860 - if err != nil { 1861 - return err 1862 - } 1863 - defer tx.Rollback() 1864 - for _, op := range ops { 1865 - if _, err := db.AddLabelOp(tx, &op); err != nil { 1866 - return err 1867 - } 1868 - } 1869 - return tx.Commit() 1870 - } 1871 - 1872 - func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 1873 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1874 - xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 1875 - if err != nil { 1876 - return nil, err 1877 - } 1878 - var result types.RepoBranchesResponse 1879 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1880 - return nil, err 1881 - } 1882 - return result.Branches, nil 1883 - } 1884 - 1885 - func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 1886 - parts := strings.SplitN(forkIdent, "/", 2) 1887 - if len(parts) != 2 { 1888 - return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 1889 - } 1890 - forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 1891 - if err != nil { 1892 - return nil, err 1893 - } 1894 - branches, err := s.listBranches(ctx, forkRepo) 1895 - if err != nil { 1896 - return nil, err 1897 - } 1898 - return sortBranchesByRecency(branches), nil 1899 - } 1900 - 1901 - func sourceBranchChoices(branches []types.Branch) []types.Branch { 1902 - withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 1903 - return b.IsDefault 1904 - }) 1905 - return sortBranchesByRecency(withoutDefault) 1906 - } 1907 - 1908 - func defaultTargetBranch(branches []types.Branch, current string) string { 1909 - if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 1910 - return current 1911 - } 1912 - if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 1913 - return branches[idx].Reference.Name 1914 - } 1915 - return "" 1916 - } 1917 - 1918 - func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 1919 - var candidates []types.Branch 1920 - switch source { 1921 - case pages.SourceFork: 1922 - candidates = forkBranches 1923 - case pages.SourceBranch: 1924 - candidates = branchChoices 1925 - default: 1926 - return current 1927 - } 1928 - if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 1929 - return current 1930 - } 1931 - if len(candidates) == 0 { 1932 - return "" 1933 - } 1934 - return candidates[0].Reference.Name 1935 - } 1936 - 1937 - func sortBranchesByRecency(branches []types.Branch) []types.Branch { 1938 - out := slices.Clone(branches) 1939 - sort.SliceStable(out, func(i, j int) bool { 1940 - if out[i].Commit == nil || out[j].Commit == nil { 1941 - return out[i].Commit != nil 1942 - } 1943 - return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 1944 - }) 1945 - return out 1946 - } 1947 - 1948 - func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 1949 - var ( 1950 - comparison *types.RepoFormatPatchResponse 1951 - err error 1952 - ) 1953 - switch source { 1954 - case pages.SourcePatch: 1955 - if strings.TrimSpace(patch) == "" { 1956 - return nil, nil, nil 1957 - } 1958 - if verr := s.validator.ValidatePatch(&patch); verr != nil { 1959 - return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 1960 - } 1961 - comparison = parsePastedPatch(patch) 1962 - case pages.SourceBranch: 1963 - if targetBranch == "" || sourceBranch == "" { 1964 - return nil, nil, nil 1965 - } 1966 - comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 1967 - case pages.SourceFork: 1968 - if fork == "" || targetBranch == "" || sourceBranch == "" { 1969 - return nil, nil, nil 1970 - } 1971 - comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 1972 - default: 1973 - return nil, nil, nil 1974 - } 1975 - if err != nil { 1976 - s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 1977 - return nil, nil, err 1978 - } 1979 - 1980 - return comparison, deriveDiff(comparison, targetBranch), nil 1981 - } 1982 - 1983 - func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 1984 - if comparison == nil || targetBranch == "" { 1985 - return nil 1986 - } 1987 - patch := comparison.CombinedPatchRaw 1988 - if patch == "" { 1989 - patch = comparison.FormatPatchRaw 1990 - } 1991 - if patch == "" { 1992 - return nil 1993 - } 1994 - 1995 - xrpcc := s.knotClient(repo.Knot) 1996 - 1997 - resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 1998 - Did: repo.Did, 1999 - Name: repo.Name, 2000 - Branch: targetBranch, 2001 - Patch: patch, 2002 - }) 2003 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2004 - s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 2005 - return &types.MergeCheckResponse{Error: xrpcerr.Error()} 2006 - } 2007 - 2008 - out := mergeCheckResponseFrom(resp) 2009 - return &out 2010 - } 2011 - 2012 - func bracketComponents(key, prefix string) ([]string, bool) { 2013 - if !strings.HasPrefix(key, prefix) { 2014 - return nil, false 2015 - } 2016 - rest := key[len(prefix):] 2017 - var parts []string 2018 - for len(rest) > 0 { 2019 - if !strings.HasPrefix(rest, "[") { 2020 - return nil, false 2021 - } 2022 - end := strings.Index(rest, "]") 2023 - if end <= 0 { 2024 - return nil, false 2025 - } 2026 - parts = append(parts, rest[1:end]) 2027 - rest = rest[end+1:] 2028 - } 2029 - if len(parts) == 0 { 2030 - return nil, false 2031 - } 2032 - return parts, true 2033 - } 2034 - 2035 - func parseBracketedForm(form url.Values, prefix string) map[string]string { 2036 - out := make(map[string]string) 2037 - for key, vals := range form { 2038 - parts, ok := bracketComponents(key, prefix) 2039 - if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 2040 - continue 2041 - } 2042 - out[parts[0]] = vals[0] 2043 - } 2044 - return out 2045 - } 2046 - 2047 - func parseStackLabelForms(form url.Values) map[string]url.Values { 2048 - out := make(map[string]url.Values) 2049 - for key, vals := range form { 2050 - parts, ok := bracketComponents(key, "stackLabel") 2051 - if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 2052 - continue 2053 - } 2054 - cid, atUri := parts[0], parts[1] 2055 - if _, ok := out[cid]; !ok { 2056 - out[cid] = make(url.Values) 2057 - } 2058 - out[cid][atUri] = append(out[cid][atUri], vals...) 2059 - } 2060 - return out 2061 - } 2062 - 2063 - func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 2064 - if patch == "" { 2065 - return nil 2066 - } 2067 - response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 2068 - if patchutil.IsFormatPatch(patch) { 2069 - if patches, err := patchutil.ExtractPatches(patch); err == nil { 2070 - response.FormatPatch = patches 2071 - } 2072 - } 2073 - return response 2074 - } 2075 - 2076 - func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2077 - xrpcc := s.knotClient(repo.Knot) 2078 - 2079 - xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 2080 - if err != nil { 2081 - return nil, err 2082 - } 2083 - 2084 - var comparison types.RepoFormatPatchResponse 2085 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2086 - return nil, err 2087 - } 2088 - return &comparison, nil 2089 - } 2090 - 2091 - func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2092 - parts := strings.SplitN(forkIdent, "/", 2) 2093 - if len(parts) != 2 { 2094 - return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 2095 - } 2096 - fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 2097 - if err != nil { 2098 - return nil, err 2099 - } 2100 - 2101 - client, err := s.oauth.ServiceClient( 2102 - r, 2103 - oauth.WithService(fork.Knot), 2104 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 2105 - oauth.WithDev(s.config.Core.Dev), 2106 - ) 2107 - if err != nil { 2108 - return nil, err 2109 - } 2110 - 2111 - resp, err := tangled.RepoHiddenRef( 2112 - r.Context(), 2113 - client, 2114 - &tangled.RepoHiddenRef_Input{ 2115 - ForkRef: sourceBranch, 2116 - RemoteRef: targetBranch, 2117 - Repo: fork.RepoAt().String(), 2118 - }, 2119 - ) 2120 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2121 - return nil, xrpcerr 2122 - } 2123 - if !resp.Success { 2124 - if resp.Error != nil { 2125 - return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 2126 - } 2127 - return nil, fmt.Errorf("hidden ref failed") 2128 - } 2129 - 2130 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 2131 - forkXrpcc := s.knotClient(fork.Knot) 2132 - 2133 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 2134 - if err != nil { 2135 - return nil, err 2136 - } 2137 - 2138 - var comparison types.RepoFormatPatchResponse 2139 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 2140 - return nil, err 2141 - } 2142 - return &comparison, nil 2143 - } 2144 - 2145 - func stackPerCommitDiffs( 2146 - comparison *types.RepoFormatPatchResponse, 2147 - targetBranch, refreshUrl string, 2148 - stackSplits map[string]string, 2149 - ) []pages.StackedDiff { 2150 - if comparison == nil { 2151 - return nil 2152 - } 2153 - out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 2154 - for i, p := range comparison.FormatPatch { 2155 - nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 2156 - out[i].Diff = &nd 2157 - cid := p.ChangeIdOrEmpty() 2158 - if cid == "" { 2159 - continue 2160 - } 2161 - out[i].Opts = types.DiffOpts{ 2162 - Split: stackSplits[cid] == "split", 2163 - RefreshUrl: refreshUrl, 2164 - Target: fmt.Sprintf("#stack-diff-%s", cid), 2165 - Field: fmt.Sprintf("stackSplit[%s]", cid), 2166 - } 2167 - } 2168 - return out 2169 - } 2170 - 2171 - func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 2172 - if comparison == nil { 2173 - return nil 2174 - } 2175 - raw := comparison.CombinedPatchRaw 2176 - if raw == "" { 2177 - raw = comparison.FormatPatchRaw 2178 - } 2179 - d := patchutil.AsNiceDiff(raw, targetBranch) 2180 - return &d 2181 - } 2182 - 2183 - func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 2184 - l := s.logger.With("handler", "ResubmitPull") 2185 - 2186 - user := s.oauth.GetMultiAccountUser(r) 2187 - if user != nil { 2188 - l = l.With("user", user.Did) 2189 - } 2190 - 2191 - pull, ok := r.Context().Value("pull").(*models.Pull) 2192 - if !ok { 2193 - l.Error("failed to get pull") 2194 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2195 - return 2196 - } 2197 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2198 - 2199 - switch r.Method { 2200 - case http.MethodGet: 2201 - s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 2202 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 2203 - Pull: pull, 2204 - }) 2205 - return 2206 - case http.MethodPost: 2207 - if pull.IsPatchBased() { 2208 - s.resubmitPatch(w, r) 2209 - return 2210 - } else if pull.IsBranchBased() { 2211 - s.resubmitBranch(w, r) 2212 - return 2213 - } else if pull.IsForkBased() { 2214 - s.resubmitFork(w, r) 2215 - return 2216 - } 2217 - } 2218 - } 2219 - 2220 - func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 2221 - l := s.logger.With("handler", "resubmitPatch") 2222 - 2223 - user := s.oauth.GetMultiAccountUser(r) 2224 - if user != nil { 2225 - l = l.With("user", user.Did) 2226 - } 2227 - 2228 - pull, ok := r.Context().Value("pull").(*models.Pull) 2229 - if !ok { 2230 - l.Error("failed to get pull") 2231 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2232 - return 2233 - } 2234 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2235 - 2236 - if user == nil || user.Did != pull.OwnerDid { 2237 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2238 - w.WriteHeader(http.StatusUnauthorized) 2239 - return 2240 - } 2241 - 2242 - f, err := s.repoResolver.Resolve(r) 2243 - if err != nil { 2244 - l.Error("failed to get repo and knot", "err", err) 2245 - return 2246 - } 2247 - 2248 - patch := r.FormValue("patch") 2249 - 2250 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 2251 - } 2252 - 2253 - func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 2254 - l := s.logger.With("handler", "resubmitBranch") 2255 - 2256 - user := s.oauth.GetMultiAccountUser(r) 2257 - if user != nil { 2258 - l = l.With("user", user.Did) 2259 - } 2260 - 2261 - pull, ok := r.Context().Value("pull").(*models.Pull) 2262 - if !ok { 2263 - l.Error("failed to get pull") 2264 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2265 - return 2266 - } 2267 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2268 - 2269 - if user == nil || user.Did != pull.OwnerDid { 2270 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2271 - w.WriteHeader(http.StatusUnauthorized) 2272 - return 2273 - } 2274 - 2275 - f, err := s.repoResolver.Resolve(r) 2276 - if err != nil { 2277 - l.Error("failed to get repo and knot", "err", err) 2278 - return 2279 - } 2280 - 2281 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2282 - if !roles.IsPushAllowed() { 2283 - l.Warn("unauthorized user - no push permission") 2284 - w.WriteHeader(http.StatusUnauthorized) 2285 - return 2286 - } 2287 - 2288 - xrpcc := s.knotClient(f.Knot) 2289 - 2290 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 2291 - if err != nil { 2292 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2293 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 2294 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2295 - return 2296 - } 2297 - l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 2298 - s.pages.Notice(w, "resubmit-error", err.Error()) 2299 - return 2300 - } 2301 - 2302 - var comparison types.RepoFormatPatchResponse 2303 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2304 - l.Error("failed to decode XRPC compare response", "err", err) 2305 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2306 - return 2307 - } 2308 - 2309 - sourceRev := comparison.Rev2 2310 - patch := comparison.FormatPatchRaw 2311 - combined := comparison.CombinedPatchRaw 2312 - 2313 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2314 - } 2315 - 2316 - func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 2317 - l := s.logger.With("handler", "resubmitFork") 2318 - 2319 - user := s.oauth.GetMultiAccountUser(r) 2320 - if user != nil { 2321 - l = l.With("user", user.Did) 2322 - } 2323 - 2324 - pull, ok := r.Context().Value("pull").(*models.Pull) 2325 - if !ok { 2326 - l.Error("failed to get pull") 2327 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2328 - return 2329 - } 2330 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2331 - 2332 - if user == nil || user.Did != pull.OwnerDid { 2333 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2334 - w.WriteHeader(http.StatusUnauthorized) 2335 - return 2336 - } 2337 - 2338 - f, err := s.repoResolver.Resolve(r) 2339 - if err != nil { 2340 - l.Error("failed to get repo and knot", "err", err) 2341 - return 2342 - } 2343 - 2344 - forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 2345 - if err != nil { 2346 - l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String()) 2347 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2348 - return 2349 - } 2350 - 2351 - // update the hidden tracking branch to latest 2352 - client, err := s.oauth.ServiceClient( 2353 - r, 2354 - oauth.WithService(forkRepo.Knot), 2355 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 2356 - oauth.WithDev(s.config.Core.Dev), 2357 - ) 2358 - if err != nil { 2359 - l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 2360 - return 2361 - } 2362 - 2363 - resp, err := tangled.RepoHiddenRef( 2364 - r.Context(), 2365 - client, 2366 - &tangled.RepoHiddenRef_Input{ 2367 - ForkRef: pull.PullSource.Branch, 2368 - RemoteRef: pull.TargetBranch, 2369 - Repo: forkRepo.RepoAt().String(), 2370 - }, 2371 - ) 2372 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2373 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 2374 - s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 2375 - return 2376 - } 2377 - if !resp.Success { 2378 - l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 2379 - s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 2380 - return 2381 - } 2382 - 2383 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 2384 - // extract patch by performing compare 2385 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 2386 - if err != nil { 2387 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2388 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2389 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2390 - return 2391 - } 2392 - l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2393 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2394 - return 2395 - } 2396 - 2397 - var forkComparison types.RepoFormatPatchResponse 2398 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 2399 - l.Error("failed to decode XRPC compare response for fork", "err", err) 2400 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2401 - return 2402 - } 2403 - 2404 - // Use the fork comparison we already made 2405 - comparison := forkComparison 2406 - 2407 - sourceRev := comparison.Rev2 2408 - patch := comparison.FormatPatchRaw 2409 - combined := comparison.CombinedPatchRaw 2410 - 2411 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2412 - } 2413 - 2414 - func (s *Pulls) resubmitPullHelper( 2415 - w http.ResponseWriter, 2416 - r *http.Request, 2417 - repo *models.Repo, 2418 - userDid syntax.DID, 2419 - pull *models.Pull, 2420 - patch string, 2421 - combined string, 2422 - sourceRev string, 2423 - ) { 2424 - l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2425 - 2426 - stack := r.Context().Value("stack").(models.Stack) 2427 - if stack != nil && len(stack) != 1 { 2428 - l.Info("resubmitting stacked PR", "stack_size", len(stack)) 2429 - s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 2430 - return 2431 - } 2432 - 2433 - if err := s.validator.ValidatePatch(&patch); err != nil { 2434 - s.pages.Notice(w, "resubmit-error", err.Error()) 2435 - return 2436 - } 2437 - 2438 - if patch == pull.LatestPatch() { 2439 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 2440 - return 2441 - } 2442 - 2443 - // validate sourceRev if branch/fork based 2444 - if pull.IsBranchBased() || pull.IsForkBased() { 2445 - if sourceRev == pull.LatestSha() { 2446 - s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 2447 - return 2448 - } 2449 - } 2450 - 2451 - pullAt := pull.AtUri() 2452 - newRoundNumber := len(pull.Submissions) 2453 - newPatch := patch 2454 - newSourceRev := sourceRev 2455 - combinedPatch := combined 2456 - 2457 - client, err := s.oauth.AuthorizedClient(r) 2458 - if err != nil { 2459 - l.Error("failed to authorize client", "err", err) 2460 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2461 - return 2462 - } 2463 - 2464 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 2465 - if err != nil { 2466 - // failed to get record 2467 - l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 2468 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 2469 - return 2470 - } 2471 - 2472 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2473 - if err != nil { 2474 - l.Error("failed to upload patch blob", "err", err) 2475 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2476 - return 2477 - } 2478 - record := pull.AsRecord() 2479 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2480 - CreatedAt: time.Now().Format(time.RFC3339), 2481 - PatchBlob: blob.Blob, 2482 - }) 2483 - 2484 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 2485 - Collection: tangled.RepoPullNSID, 2486 - Repo: userDid.String(), 2487 - Rkey: pull.Rkey, 2488 - SwapRecord: ex.Cid, 2489 - Record: &lexutil.LexiconTypeDecoder{ 2490 - Val: &record, 2491 - }, 2492 - }) 2493 - if err != nil { 2494 - l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 2495 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2496 - return 2497 - } 2498 - 2499 - err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2500 - if err != nil { 2501 - l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 2502 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2503 - return 2504 - } 2505 - 2506 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2507 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2508 - } 2509 - 2510 - func (s *Pulls) resubmitStackedPullHelper( 2511 - w http.ResponseWriter, 2512 - r *http.Request, 2513 - repo *models.Repo, 2514 - userDid syntax.DID, 2515 - pull *models.Pull, 2516 - patch string, 2517 - ) { 2518 - l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2519 - 2520 - targetBranch := pull.TargetBranch 2521 - 2522 - origStack, _ := r.Context().Value("stack").(models.Stack) 2523 - 2524 - formatPatches, err := patchutil.ExtractPatches(patch) 2525 - if err != nil { 2526 - l.Error("failed to extract patches", "err", err) 2527 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 2528 - return 2529 - } 2530 - 2531 - // must have atleast 1 patch to begin with 2532 - if len(formatPatches) == 0 { 2533 - l.Error("no patches found in the generated format-patch") 2534 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 2535 - return 2536 - } 2537 - 2538 - client, err := s.oauth.AuthorizedClient(r) 2539 - if err != nil { 2540 - l.Error("failed to get authorized client", "err", err) 2541 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2542 - return 2543 - } 2544 - 2545 - // first upload all blobs 2546 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 2547 - for i, p := range formatPatches { 2548 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 2549 - if err != nil { 2550 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 2551 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2552 - return 2553 - } 2554 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 2555 - blobs[i] = blob.Blob 2556 - } 2557 - 2558 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 2559 - if err != nil { 2560 - l.Error("failed to create resubmitted stack", "err", err) 2561 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2562 - return 2563 - } 2564 - 2565 - // find the diff between the stacks, first, map them by changeId 2566 - origById := make(map[string]*models.Pull) 2567 - newById := make(map[string]*models.Pull) 2568 - for _, p := range origStack { 2569 - origById[p.LatestSubmission().ChangeId()] = p 2570 - } 2571 - for _, p := range newStack { 2572 - newById[p.LatestSubmission().ChangeId()] = p 2573 - } 2574 - 2575 - // commits that got deleted: corresponding pull is closed 2576 - // commits that got added: new pull is created 2577 - // commits that got updated: corresponding pull is resubmitted & new round begins 2578 - additions := make(map[string]*models.Pull) 2579 - deletions := make(map[string]*models.Pull) 2580 - updated := make(map[string]struct{}) 2581 - 2582 - // pulls in original stack but not in new one 2583 - for _, op := range origStack { 2584 - if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 2585 - deletions[op.LatestSubmission().ChangeId()] = op 2586 - } 2587 - } 2588 - 2589 - // pulls in new stack but not in original one 2590 - for _, np := range newStack { 2591 - if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 2592 - additions[np.LatestSubmission().ChangeId()] = np 2593 - } 2594 - } 2595 - 2596 - // NOTE: this loop can be written in any of above blocks, 2597 - // but is written separately in the interest of simpler code 2598 - for _, np := range newStack { 2599 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2600 - // pull exists in both stacks 2601 - updated[op.LatestSubmission().ChangeId()] = struct{}{} 2602 - } 2603 - } 2604 - 2605 - // NOTE: we can go through the newStack and update dependent relations and 2606 - // rkeys now that we know which ones have been updated 2607 - // update dependentOn relations for the entire stack 2608 - var parentAt *syntax.ATURI 2609 - for _, np := range newStack { 2610 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2611 - // pull exists in both stacks 2612 - np.Rkey = op.Rkey 2613 - } 2614 - np.DependentOn = parentAt 2615 - x := np.AtUri() 2616 - parentAt = &x 2617 - } 2618 - 2619 - l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 2620 - 2621 - tx, err := s.db.Begin() 2622 - if err != nil { 2623 - l.Error("failed to start transaction", "err", err) 2624 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2625 - return 2626 - } 2627 - defer tx.Rollback() 2628 - 2629 - // pds updates to make 2630 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2631 - 2632 - // deleted pulls are marked as deleted in the DB 2633 - for _, p := range deletions { 2634 - // do not do delete already merged PRs 2635 - if p.State == models.PullMerged { 2636 - continue 2637 - } 2638 - 2639 - err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 2640 - if err != nil { 2641 - l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 2642 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2643 - return 2644 - } 2645 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2646 - RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2647 - Collection: tangled.RepoPullNSID, 2648 - Rkey: p.Rkey, 2649 - }, 2650 - }) 2651 - } 2652 - 2653 - // new pulls are created 2654 - for _, p := range additions { 2655 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 2656 - if err != nil { 2657 - l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 2658 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2659 - return 2660 - } 2661 - p.Submissions[0].Blob = *blob.Blob 2662 - 2663 - if err = db.PutPull(tx, p); err != nil { 2664 - l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 2665 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2666 - return 2667 - } 2668 - 2669 - record := p.AsRecord() 2670 - record.Rounds = []*tangled.RepoPull_Round{ 2671 - { 2672 - CreatedAt: time.Now().Format(time.RFC3339), 2673 - PatchBlob: blob.Blob, 2674 - }, 2675 - } 2676 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2677 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2678 - Collection: tangled.RepoPullNSID, 2679 - Rkey: &p.Rkey, 2680 - Value: &lexutil.LexiconTypeDecoder{ 2681 - Val: &record, 2682 - }, 2683 - }, 2684 - }) 2685 - } 2686 - 2687 - // updated pulls are, well, updated; to start a new round 2688 - for id := range updated { 2689 - op, _ := origById[id] 2690 - np, _ := newById[id] 2691 - 2692 - // do not update already merged PRs 2693 - if op.State == models.PullMerged { 2694 - continue 2695 - } 2696 - 2697 - // resubmit the new pull 2698 - np.Rkey = op.Rkey 2699 - pullAt := op.AtUri() 2700 - newRoundNumber := len(op.Submissions) 2701 - newPatch := np.LatestPatch() 2702 - combinedPatch := np.LatestSubmission().Combined 2703 - newSourceRev := np.LatestSha() 2704 - 2705 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 2706 - if err != nil { 2707 - l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 2708 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2709 - return 2710 - } 2711 - 2712 - // create new round 2713 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2714 - if err != nil { 2715 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2716 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2717 - return 2718 - } 2719 - 2720 - // update dependent-on relation 2721 - if np.DependentOn != nil { 2722 - err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 2723 - if err != nil { 2724 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2725 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2726 - return 2727 - } 2728 - } 2729 - 2730 - record := np.AsRecord() 2731 - record.Rounds = op.AsRecord().Rounds 2732 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2733 - CreatedAt: time.Now().Format(time.RFC3339), 2734 - PatchBlob: blob.Blob, 2735 - }) 2736 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2737 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2738 - Collection: tangled.RepoPullNSID, 2739 - Rkey: op.Rkey, 2740 - Value: &lexutil.LexiconTypeDecoder{ 2741 - Val: &record, 2742 - }, 2743 - }, 2744 - }) 2745 - } 2746 - 2747 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2748 - Repo: userDid.String(), 2749 - Writes: writes, 2750 - }) 2751 - if err != nil { 2752 - l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 2753 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2754 - return 2755 - } 2756 - 2757 - err = tx.Commit() 2758 - if err != nil { 2759 - l.Error("failed to commit resubmit transaction", "err", err) 2760 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2761 - return 2762 - } 2763 - 2764 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2765 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2766 - } 2767 - 2768 - func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2769 - l := s.logger.With("handler", "MergePull") 2770 - 2771 - user := s.oauth.GetMultiAccountUser(r) 2772 - if user != nil { 2773 - l = l.With("user", user.Did) 2774 - } 2775 - 2776 - f, err := s.repoResolver.Resolve(r) 2777 - if err != nil { 2778 - l.Error("failed to resolve repo", "err", err) 2779 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2780 - return 2781 - } 2782 - l = l.With("repo_at", f.RepoAt().String()) 2783 - 2784 - pull, ok := r.Context().Value("pull").(*models.Pull) 2785 - if !ok { 2786 - l.Error("failed to get pull") 2787 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2788 - return 2789 - } 2790 - l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2791 - 2792 - stack, ok := r.Context().Value("stack").(models.Stack) 2793 - if !ok { 2794 - l.Error("failed to get stack") 2795 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2796 - return 2797 - } 2798 - 2799 - // combine patches of substack 2800 - subStack := stack.Below(pull) 2801 - // collect the portion of the stack that is mergeable 2802 - pullsToMerge := subStack.Mergeable() 2803 - l = l.With("pulls_to_merge", len(pullsToMerge)) 2804 - 2805 - patch := pullsToMerge.CombinedPatch() 2806 - 2807 - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2808 - if err != nil { 2809 - l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 2810 - w.WriteHeader(http.StatusNotFound) 2811 - return 2812 - } 2813 - 2814 - email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2815 - if err != nil { 2816 - l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 2817 - } 2818 - 2819 - authorName := ident.Handle.String() 2820 - mergeInput := &tangled.RepoMerge_Input{ 2821 - Did: f.Did, 2822 - Name: f.Name, 2823 - Branch: pull.TargetBranch, 2824 - Patch: patch, 2825 - CommitMessage: &pull.Title, 2826 - AuthorName: &authorName, 2827 - } 2828 - 2829 - if pull.Body != "" { 2830 - mergeInput.CommitBody = &pull.Body 2831 - } 2832 - 2833 - if email.Address != "" { 2834 - mergeInput.AuthorEmail = &email.Address 2835 - } 2836 - 2837 - client, err := s.oauth.ServiceClient( 2838 - r, 2839 - oauth.WithService(f.Knot), 2840 - oauth.WithLxm(tangled.RepoMergeNSID), 2841 - oauth.WithDev(s.config.Core.Dev), 2842 - oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 2843 - ) 2844 - if err != nil { 2845 - l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 2846 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2847 - return 2848 - } 2849 - 2850 - err = tangled.RepoMerge(r.Context(), client, mergeInput) 2851 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2852 - s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 2853 - s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 2854 - return 2855 - } 2856 - 2857 - tx, err := s.db.Begin() 2858 - if err != nil { 2859 - l.Error("failed to start transaction", "err", err) 2860 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2861 - return 2862 - } 2863 - defer tx.Rollback() 2864 - 2865 - var atUris []syntax.ATURI 2866 - for _, p := range pullsToMerge { 2867 - atUris = append(atUris, p.AtUri()) 2868 - p.State = models.PullMerged 2869 - } 2870 - err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 2871 - if err != nil { 2872 - l.Error("failed to update pull request status in database", "err", err) 2873 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2874 - return 2875 - } 2876 - 2877 - err = tx.Commit() 2878 - if err != nil { 2879 - // TODO: this is unsound, we should also revert the merge from the knotserver here 2880 - l.Error("failed to commit merge transaction", "err", err) 2881 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2882 - return 2883 - } 2884 - 2885 - // notify about the pull merge 2886 - for _, p := range pullsToMerge { 2887 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2888 - } 2889 - 2890 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2891 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2892 - } 2893 - 2894 - func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2895 - l := s.logger.With("handler", "ClosePull") 2896 - 2897 - user := s.oauth.GetMultiAccountUser(r) 2898 - if user != nil { 2899 - l = l.With("user", user.Did) 2900 - } 2901 - 2902 - f, err := s.repoResolver.Resolve(r) 2903 - if err != nil { 2904 - l.Error("failed to resolve repo", "err", err) 2905 - return 2906 - } 2907 - 2908 - pull, ok := r.Context().Value("pull").(*models.Pull) 2909 - if !ok { 2910 - l.Error("failed to get pull") 2911 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2912 - return 2913 - } 2914 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2915 - 2916 - // auth filter: only owner or collaborators can close 2917 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2918 - isOwner := roles.IsOwner() 2919 - isCollaborator := roles.IsCollaborator() 2920 - isPullAuthor := user.Did == pull.OwnerDid 2921 - isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2922 - if !isCloseAllowed { 2923 - l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2924 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2925 - return 2926 - } 2927 - 2928 - // Start a transaction 2929 - tx, err := s.db.BeginTx(r.Context(), nil) 2930 - if err != nil { 2931 - l.Error("failed to start transaction", "err", err) 2932 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 2933 - return 2934 - } 2935 - defer tx.Rollback() 2936 - 2937 - // if this PR is stacked, then we want to close all PRs above this one on the stack 2938 - stack := r.Context().Value("stack").(models.Stack) 2939 - pullsToClose := stack.Above(pull) 2940 - var atUris []syntax.ATURI 2941 - for _, p := range pullsToClose { 2942 - atUris = append(atUris, p.AtUri()) 2943 - p.State = models.PullClosed 2944 - } 2945 - err = db.ClosePulls( 2946 - tx, 2947 - orm.FilterEq("repo_at", f.RepoAt()), 2948 - orm.FilterIn("at_uri", atUris), 2949 - ) 2950 - if err != nil { 2951 - l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 2952 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 2953 - } 2954 - 2955 - // Commit the transaction 2956 - if err = tx.Commit(); err != nil { 2957 - l.Error("failed to commit transaction", "err", err) 2958 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 2959 - return 2960 - } 2961 - 2962 - for _, p := range pullsToClose { 2963 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2964 - } 2965 - 2966 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2967 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2968 - } 2969 - 2970 - func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2971 - l := s.logger.With("handler", "ReopenPull") 2972 - 2973 - user := s.oauth.GetMultiAccountUser(r) 2974 - if user != nil { 2975 - l = l.With("user", user.Did) 2976 - } 2977 - 2978 - f, err := s.repoResolver.Resolve(r) 2979 - if err != nil { 2980 - l.Error("failed to resolve repo", "err", err) 2981 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2982 - return 2983 - } 2984 - 2985 - pull, ok := r.Context().Value("pull").(*models.Pull) 2986 - if !ok { 2987 - l.Error("failed to get pull") 2988 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2989 - return 2990 - } 2991 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 2992 - 2993 - // auth filter: only owner or collaborators can close 2994 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2995 - isOwner := roles.IsOwner() 2996 - isCollaborator := roles.IsCollaborator() 2997 - isPullAuthor := user.Did == pull.OwnerDid 2998 - isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2999 - if !isCloseAllowed { 3000 - l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 3001 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 3002 - return 3003 - } 3004 - 3005 - // Start a transaction 3006 - tx, err := s.db.BeginTx(r.Context(), nil) 3007 - if err != nil { 3008 - l.Error("failed to start transaction", "err", err) 3009 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3010 - return 3011 - } 3012 - defer tx.Rollback() 3013 - 3014 - // if this PR is stacked, then we want to reopen all PRs above this one on the stack 3015 - stack := r.Context().Value("stack").(models.Stack) 3016 - pullsToReopen := stack.Below(pull) 3017 - var atUris []syntax.ATURI 3018 - for _, p := range pullsToReopen { 3019 - atUris = append(atUris, p.AtUri()) 3020 - p.State = models.PullOpen 3021 - } 3022 - err = db.ReopenPulls( 3023 - tx, 3024 - orm.FilterEq("repo_at", f.RepoAt()), 3025 - orm.FilterIn("at_uri", atUris), 3026 - ) 3027 - if err != nil { 3028 - l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 3029 - s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 3030 - } 3031 - 3032 - // Commit the transaction 3033 - if err = tx.Commit(); err != nil { 3034 - l.Error("failed to commit transaction", "err", err) 3035 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3036 - return 3037 - } 3038 - 3039 - for _, p := range pullsToReopen { 3040 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 3041 - } 3042 - 3043 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 3044 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 3045 - } 3046 - 3047 - func (s *Pulls) newStack( 3048 - ctx context.Context, 3049 - repo *models.Repo, 3050 - userDid syntax.DID, 3051 - targetBranch string, 3052 - pullSource *models.PullSource, 3053 - formatPatches []types.FormatPatch, 3054 - blobs []*lexutil.LexBlob, 3055 - stackTitles, stackBodies map[string]string, 3056 - ) (models.Stack, error) { 3057 - var stack models.Stack 3058 - var parentAtUri *syntax.ATURI 3059 - for i, fp := range formatPatches { 3060 - // all patches must have a jj change-id 3061 - cid, err := fp.ChangeId() 3062 - if err != nil { 3063 - return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 3064 - } 3065 - 3066 - title := fp.Title 3067 - body := fp.Body 3068 - if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 3069 - title = override 3070 - } 3071 - if override, ok := stackBodies[cid]; ok { 3072 - body = override 3073 - } 3074 - rkey := tid.TID() 3075 - 3076 - mentions, references := s.mentionsResolver.Resolve(ctx, body) 3077 - 3078 - now := time.Now() 3079 - 3080 - pull := models.Pull{ 3081 - Title: title, 3082 - Body: body, 3083 - TargetBranch: targetBranch, 3084 - OwnerDid: userDid.String(), 3085 - RepoAt: repo.RepoAt(), 3086 - Rkey: rkey, 3087 - Mentions: mentions, 3088 - References: references, 3089 - Submissions: []*models.PullSubmission{ 3090 - { 3091 - Patch: fp.Raw, 3092 - SourceRev: fp.SHA, 3093 - Combined: fp.Raw, 3094 - Blob: *blobs[i], 3095 - Created: now, 3096 - }, 3097 - }, 3098 - PullSource: pullSource, 3099 - Created: now, 3100 - State: models.PullOpen, 3101 - 3102 - DependentOn: parentAtUri, 3103 - Repo: repo, 3104 - } 3105 - 3106 - stack = append(stack, &pull) 3107 - 3108 - parent := pull.AtUri() 3109 - parentAtUri = &parent 3110 - } 3111 - 3112 - return stack, nil 3113 82 } 3114 83 3115 84 func gz(s string) io.Reader {
+610
appview/pulls/resubmit.go
··· 1 + package pulls 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/oauth" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pages/repoinfo" 15 + "tangled.org/core/appview/reporesolver" 16 + "tangled.org/core/appview/xrpcclient" 17 + "tangled.org/core/orm" 18 + "tangled.org/core/patchutil" 19 + "tangled.org/core/types" 20 + "tangled.org/core/xrpc" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + "github.com/bluesky-social/indigo/atproto/syntax" 24 + lexutil "github.com/bluesky-social/indigo/lex/util" 25 + ) 26 + 27 + func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 28 + l := s.logger.With("handler", "ResubmitPull") 29 + 30 + user := s.oauth.GetMultiAccountUser(r) 31 + if user != nil { 32 + l = l.With("user", user.Did) 33 + } 34 + 35 + pull, ok := r.Context().Value("pull").(*models.Pull) 36 + if !ok { 37 + l.Error("failed to get pull") 38 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 39 + return 40 + } 41 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 42 + 43 + switch r.Method { 44 + case http.MethodGet: 45 + s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 46 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 47 + Pull: pull, 48 + }) 49 + return 50 + case http.MethodPost: 51 + if pull.IsPatchBased() { 52 + s.resubmitPatch(w, r) 53 + return 54 + } else if pull.IsBranchBased() { 55 + s.resubmitBranch(w, r) 56 + return 57 + } else if pull.IsForkBased() { 58 + s.resubmitFork(w, r) 59 + return 60 + } 61 + } 62 + } 63 + 64 + func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 65 + l := s.logger.With("handler", "resubmitPatch") 66 + 67 + user := s.oauth.GetMultiAccountUser(r) 68 + if user != nil { 69 + l = l.With("user", user.Did) 70 + } 71 + 72 + pull, ok := r.Context().Value("pull").(*models.Pull) 73 + if !ok { 74 + l.Error("failed to get pull") 75 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 76 + return 77 + } 78 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 79 + 80 + if user == nil || user.Did != pull.OwnerDid { 81 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 82 + w.WriteHeader(http.StatusUnauthorized) 83 + return 84 + } 85 + 86 + f, err := s.repoResolver.Resolve(r) 87 + if err != nil { 88 + l.Error("failed to get repo and knot", "err", err) 89 + return 90 + } 91 + 92 + patch := r.FormValue("patch") 93 + 94 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 95 + } 96 + 97 + func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 98 + l := s.logger.With("handler", "resubmitBranch") 99 + 100 + user := s.oauth.GetMultiAccountUser(r) 101 + if user != nil { 102 + l = l.With("user", user.Did) 103 + } 104 + 105 + pull, ok := r.Context().Value("pull").(*models.Pull) 106 + if !ok { 107 + l.Error("failed to get pull") 108 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 109 + return 110 + } 111 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 112 + 113 + if user == nil || user.Did != pull.OwnerDid { 114 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 115 + w.WriteHeader(http.StatusUnauthorized) 116 + return 117 + } 118 + 119 + f, err := s.repoResolver.Resolve(r) 120 + if err != nil { 121 + l.Error("failed to get repo and knot", "err", err) 122 + return 123 + } 124 + 125 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 126 + if !roles.IsPushAllowed() { 127 + l.Warn("unauthorized user - no push permission") 128 + w.WriteHeader(http.StatusUnauthorized) 129 + return 130 + } 131 + 132 + xrpcc := s.knotClient(f.Knot) 133 + 134 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 135 + if err != nil { 136 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 137 + l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 138 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 139 + return 140 + } 141 + l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 142 + s.pages.Notice(w, "resubmit-error", err.Error()) 143 + return 144 + } 145 + 146 + var comparison types.RepoFormatPatchResponse 147 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 148 + l.Error("failed to decode XRPC compare response", "err", err) 149 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 150 + return 151 + } 152 + 153 + sourceRev := comparison.Rev2 154 + patch := comparison.FormatPatchRaw 155 + combined := comparison.CombinedPatchRaw 156 + 157 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 158 + } 159 + 160 + func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 161 + l := s.logger.With("handler", "resubmitFork") 162 + 163 + user := s.oauth.GetMultiAccountUser(r) 164 + if user != nil { 165 + l = l.With("user", user.Did) 166 + } 167 + 168 + pull, ok := r.Context().Value("pull").(*models.Pull) 169 + if !ok { 170 + l.Error("failed to get pull") 171 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 172 + return 173 + } 174 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 175 + 176 + if user == nil || user.Did != pull.OwnerDid { 177 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 178 + w.WriteHeader(http.StatusUnauthorized) 179 + return 180 + } 181 + 182 + f, err := s.repoResolver.Resolve(r) 183 + if err != nil { 184 + l.Error("failed to get repo and knot", "err", err) 185 + return 186 + } 187 + 188 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 189 + if err != nil { 190 + l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String()) 191 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 192 + return 193 + } 194 + 195 + // update the hidden tracking branch to latest 196 + client, err := s.oauth.ServiceClient( 197 + r, 198 + oauth.WithService(forkRepo.Knot), 199 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 200 + oauth.WithDev(s.config.Core.Dev), 201 + ) 202 + if err != nil { 203 + l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 204 + return 205 + } 206 + 207 + resp, err := tangled.RepoHiddenRef( 208 + r.Context(), 209 + client, 210 + &tangled.RepoHiddenRef_Input{ 211 + ForkRef: pull.PullSource.Branch, 212 + RemoteRef: pull.TargetBranch, 213 + Repo: forkRepo.RepoAt().String(), 214 + }, 215 + ) 216 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 217 + s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 218 + s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 219 + return 220 + } 221 + if !resp.Success { 222 + l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 223 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 224 + return 225 + } 226 + 227 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 228 + // extract patch by performing compare 229 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 230 + if err != nil { 231 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 232 + l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 233 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 234 + return 235 + } 236 + l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 237 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 238 + return 239 + } 240 + 241 + var forkComparison types.RepoFormatPatchResponse 242 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 243 + l.Error("failed to decode XRPC compare response for fork", "err", err) 244 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 245 + return 246 + } 247 + 248 + // Use the fork comparison we already made 249 + comparison := forkComparison 250 + 251 + sourceRev := comparison.Rev2 252 + patch := comparison.FormatPatchRaw 253 + combined := comparison.CombinedPatchRaw 254 + 255 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 256 + } 257 + 258 + func (s *Pulls) resubmitPullHelper( 259 + w http.ResponseWriter, 260 + r *http.Request, 261 + repo *models.Repo, 262 + userDid syntax.DID, 263 + pull *models.Pull, 264 + patch string, 265 + combined string, 266 + sourceRev string, 267 + ) { 268 + l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 269 + 270 + stack := r.Context().Value("stack").(models.Stack) 271 + if stack != nil && len(stack) != 1 { 272 + l.Info("resubmitting stacked PR", "stack_size", len(stack)) 273 + s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 274 + return 275 + } 276 + 277 + if err := s.validator.ValidatePatch(&patch); err != nil { 278 + s.pages.Notice(w, "resubmit-error", err.Error()) 279 + return 280 + } 281 + 282 + if patch == pull.LatestPatch() { 283 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 284 + return 285 + } 286 + 287 + // validate sourceRev if branch/fork based 288 + if pull.IsBranchBased() || pull.IsForkBased() { 289 + if sourceRev == pull.LatestSha() { 290 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 291 + return 292 + } 293 + } 294 + 295 + pullAt := pull.AtUri() 296 + newRoundNumber := len(pull.Submissions) 297 + newPatch := patch 298 + newSourceRev := sourceRev 299 + combinedPatch := combined 300 + 301 + client, err := s.oauth.AuthorizedClient(r) 302 + if err != nil { 303 + l.Error("failed to authorize client", "err", err) 304 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 305 + return 306 + } 307 + 308 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 309 + if err != nil { 310 + // failed to get record 311 + l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 312 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 313 + return 314 + } 315 + 316 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 317 + if err != nil { 318 + l.Error("failed to upload patch blob", "err", err) 319 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 320 + return 321 + } 322 + record := pull.AsRecord() 323 + record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 324 + CreatedAt: time.Now().Format(time.RFC3339), 325 + PatchBlob: blob.Blob, 326 + }) 327 + 328 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 329 + Collection: tangled.RepoPullNSID, 330 + Repo: userDid.String(), 331 + Rkey: pull.Rkey, 332 + SwapRecord: ex.Cid, 333 + Record: &lexutil.LexiconTypeDecoder{ 334 + Val: &record, 335 + }, 336 + }) 337 + if err != nil { 338 + l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 339 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 340 + return 341 + } 342 + 343 + err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 344 + if err != nil { 345 + l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 346 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 347 + return 348 + } 349 + 350 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 351 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 352 + } 353 + 354 + func (s *Pulls) resubmitStackedPullHelper( 355 + w http.ResponseWriter, 356 + r *http.Request, 357 + repo *models.Repo, 358 + userDid syntax.DID, 359 + pull *models.Pull, 360 + patch string, 361 + ) { 362 + l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 363 + 364 + targetBranch := pull.TargetBranch 365 + 366 + origStack, _ := r.Context().Value("stack").(models.Stack) 367 + 368 + formatPatches, err := patchutil.ExtractPatches(patch) 369 + if err != nil { 370 + l.Error("failed to extract patches", "err", err) 371 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 372 + return 373 + } 374 + 375 + // must have atleast 1 patch to begin with 376 + if len(formatPatches) == 0 { 377 + l.Error("no patches found in the generated format-patch") 378 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 379 + return 380 + } 381 + 382 + client, err := s.oauth.AuthorizedClient(r) 383 + if err != nil { 384 + l.Error("failed to get authorized client", "err", err) 385 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 386 + return 387 + } 388 + 389 + // first upload all blobs 390 + blobs := make([]*lexutil.LexBlob, len(formatPatches)) 391 + for i, p := range formatPatches { 392 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 393 + if err != nil { 394 + l.Error("failed to upload patch blob", "err", err, "patch_index", i) 395 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 396 + return 397 + } 398 + l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 399 + blobs[i] = blob.Blob 400 + } 401 + 402 + newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 403 + if err != nil { 404 + l.Error("failed to create resubmitted stack", "err", err) 405 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 406 + return 407 + } 408 + 409 + // find the diff between the stacks, first, map them by changeId 410 + origById := make(map[string]*models.Pull) 411 + newById := make(map[string]*models.Pull) 412 + for _, p := range origStack { 413 + origById[p.LatestSubmission().ChangeId()] = p 414 + } 415 + for _, p := range newStack { 416 + newById[p.LatestSubmission().ChangeId()] = p 417 + } 418 + 419 + // commits that got deleted: corresponding pull is closed 420 + // commits that got added: new pull is created 421 + // commits that got updated: corresponding pull is resubmitted & new round begins 422 + additions := make(map[string]*models.Pull) 423 + deletions := make(map[string]*models.Pull) 424 + updated := make(map[string]struct{}) 425 + 426 + // pulls in original stack but not in new one 427 + for _, op := range origStack { 428 + if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 429 + deletions[op.LatestSubmission().ChangeId()] = op 430 + } 431 + } 432 + 433 + // pulls in new stack but not in original one 434 + for _, np := range newStack { 435 + if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 436 + additions[np.LatestSubmission().ChangeId()] = np 437 + } 438 + } 439 + 440 + // NOTE: this loop can be written in any of above blocks, 441 + // but is written separately in the interest of simpler code 442 + for _, np := range newStack { 443 + if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 444 + // pull exists in both stacks 445 + updated[op.LatestSubmission().ChangeId()] = struct{}{} 446 + } 447 + } 448 + 449 + // NOTE: we can go through the newStack and update dependent relations and 450 + // rkeys now that we know which ones have been updated 451 + // update dependentOn relations for the entire stack 452 + var parentAt *syntax.ATURI 453 + for _, np := range newStack { 454 + if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 455 + // pull exists in both stacks 456 + np.Rkey = op.Rkey 457 + } 458 + np.DependentOn = parentAt 459 + x := np.AtUri() 460 + parentAt = &x 461 + } 462 + 463 + l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 464 + 465 + tx, err := s.db.Begin() 466 + if err != nil { 467 + l.Error("failed to start transaction", "err", err) 468 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 469 + return 470 + } 471 + defer tx.Rollback() 472 + 473 + // pds updates to make 474 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 475 + 476 + // deleted pulls are marked as deleted in the DB 477 + for _, p := range deletions { 478 + // do not do delete already merged PRs 479 + if p.State == models.PullMerged { 480 + continue 481 + } 482 + 483 + err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 484 + if err != nil { 485 + l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 486 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 487 + return 488 + } 489 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 490 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 491 + Collection: tangled.RepoPullNSID, 492 + Rkey: p.Rkey, 493 + }, 494 + }) 495 + } 496 + 497 + // new pulls are created 498 + for _, p := range additions { 499 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 500 + if err != nil { 501 + l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 502 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 503 + return 504 + } 505 + p.Submissions[0].Blob = *blob.Blob 506 + 507 + if err = db.PutPull(tx, p); err != nil { 508 + l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 509 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 510 + return 511 + } 512 + 513 + record := p.AsRecord() 514 + record.Rounds = []*tangled.RepoPull_Round{ 515 + { 516 + CreatedAt: time.Now().Format(time.RFC3339), 517 + PatchBlob: blob.Blob, 518 + }, 519 + } 520 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 521 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 522 + Collection: tangled.RepoPullNSID, 523 + Rkey: &p.Rkey, 524 + Value: &lexutil.LexiconTypeDecoder{ 525 + Val: &record, 526 + }, 527 + }, 528 + }) 529 + } 530 + 531 + // updated pulls are, well, updated; to start a new round 532 + for id := range updated { 533 + op, _ := origById[id] 534 + np, _ := newById[id] 535 + 536 + // do not update already merged PRs 537 + if op.State == models.PullMerged { 538 + continue 539 + } 540 + 541 + // resubmit the new pull 542 + np.Rkey = op.Rkey 543 + pullAt := op.AtUri() 544 + newRoundNumber := len(op.Submissions) 545 + newPatch := np.LatestPatch() 546 + combinedPatch := np.LatestSubmission().Combined 547 + newSourceRev := np.LatestSha() 548 + 549 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 550 + if err != nil { 551 + l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 552 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 553 + return 554 + } 555 + 556 + // create new round 557 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 558 + if err != nil { 559 + l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 560 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 561 + return 562 + } 563 + 564 + // update dependent-on relation 565 + if np.DependentOn != nil { 566 + err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 567 + if err != nil { 568 + l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 569 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 570 + return 571 + } 572 + } 573 + 574 + record := np.AsRecord() 575 + record.Rounds = op.AsRecord().Rounds 576 + record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 577 + CreatedAt: time.Now().Format(time.RFC3339), 578 + PatchBlob: blob.Blob, 579 + }) 580 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 581 + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 582 + Collection: tangled.RepoPullNSID, 583 + Rkey: op.Rkey, 584 + Value: &lexutil.LexiconTypeDecoder{ 585 + Val: &record, 586 + }, 587 + }, 588 + }) 589 + } 590 + 591 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 592 + Repo: userDid.String(), 593 + Writes: writes, 594 + }) 595 + if err != nil { 596 + l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 597 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 598 + return 599 + } 600 + 601 + err = tx.Commit() 602 + if err != nil { 603 + l.Error("failed to commit resubmit transaction", "err", err) 604 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 605 + return 606 + } 607 + 608 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 609 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 610 + }
+423
appview/pulls/single.go
··· 1 + package pulls 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "slices" 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 24 + func (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 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 63 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 64 + resubmitResult := pages.Unknown 65 + if user.Did == pull.OwnerDid { 66 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 67 + } 68 + 69 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 70 + LoggedInUser: user, 71 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 72 + Pull: pull, 73 + RoundNumber: roundNumber, 74 + MergeCheck: mergeCheckResponse, 75 + ResubmitCheck: resubmitResult, 76 + BranchDeleteStatus: branchDeleteStatus, 77 + Stack: stack, 78 + }) 79 + return 80 + } 81 + } 82 + 83 + func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 84 + l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 85 + 86 + user := s.oauth.GetMultiAccountUser(r) 87 + if user != nil { 88 + l = l.With("user", user.Did) 89 + } 90 + 91 + f, err := s.repoResolver.Resolve(r) 92 + if err != nil { 93 + l.Error("failed to get repo and knot", "err", err) 94 + return 95 + } 96 + 97 + pull, ok := r.Context().Value("pull").(*models.Pull) 98 + if !ok { 99 + l.Error("failed to get pull") 100 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 101 + return 102 + } 103 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 104 + 105 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 106 + if err != nil { 107 + l.Error("failed to get pull backlinks", "err", err) 108 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 109 + return 110 + } 111 + 112 + roundId := chi.URLParam(r, "round") 113 + roundIdInt := pull.LastRoundNumber() 114 + if r, err := strconv.Atoi(roundId); err == nil { 115 + roundIdInt = r 116 + } 117 + if roundIdInt >= len(pull.Submissions) { 118 + http.Error(w, "bad round id", http.StatusBadRequest) 119 + l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 120 + return 121 + } 122 + 123 + var diffOpts types.DiffOpts 124 + if d := r.URL.Query().Get("diff"); d == "split" { 125 + diffOpts.Split = true 126 + } 127 + 128 + // can be nil if this pull is not stacked 129 + stack, _ := r.Context().Value("stack").(models.Stack) 130 + 131 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 132 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 133 + resubmitResult := pages.Unknown 134 + if user != nil && user.Did == pull.OwnerDid { 135 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 136 + } 137 + 138 + m := make(map[string]models.Pipeline) 139 + 140 + var shas []string 141 + for _, s := range pull.Submissions { 142 + shas = append(shas, s.SourceRev) 143 + } 144 + for _, p := range stack { 145 + shas = append(shas, p.LatestSha()) 146 + } 147 + 148 + ps, err := db.GetPipelineStatuses( 149 + s.db, 150 + len(shas), 151 + orm.FilterEq("p.repo_owner", f.Did), 152 + orm.FilterEq("p.repo_name", f.Name), 153 + orm.FilterEq("p.knot", f.Knot), 154 + orm.FilterIn("p.sha", shas), 155 + ) 156 + if err != nil { 157 + l.Error("failed to fetch pipeline statuses", "err", err) 158 + // non-fatal 159 + } 160 + 161 + for _, p := range ps { 162 + m[p.Sha] = p 163 + } 164 + 165 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 166 + if err != nil { 167 + l.Error("failed to get pull reactions", "err", err) 168 + } 169 + 170 + userReactions := map[models.ReactionKind]bool{} 171 + if user != nil { 172 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 173 + } 174 + 175 + labelDefs, err := db.GetLabelDefinitions( 176 + s.db, 177 + orm.FilterIn("at_uri", f.Labels), 178 + orm.FilterContains("scope", tangled.RepoPullNSID), 179 + ) 180 + if err != nil { 181 + l.Error("failed to fetch labels", "err", err) 182 + s.pages.Error503(w) 183 + return 184 + } 185 + 186 + defs := make(map[string]*models.LabelDefinition) 187 + for _, l := range labelDefs { 188 + defs[l.AtUri().String()] = &l 189 + } 190 + 191 + vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 192 + if user != nil { 193 + participants := pull.Participants() 194 + vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 195 + if err != nil { 196 + l.Error("failed to fetch vouch relationships", "err", err) 197 + } 198 + } 199 + 200 + patch := pull.Submissions[roundIdInt].CombinedPatch() 201 + var diff types.DiffRenderer 202 + diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 203 + 204 + if interdiff { 205 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 206 + if err != nil { 207 + l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 208 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 209 + return 210 + } 211 + 212 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 213 + if err != nil { 214 + l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 215 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 216 + return 217 + } 218 + 219 + diff = patchutil.Interdiff(previousPatch, currentPatch) 220 + } 221 + 222 + err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 223 + LoggedInUser: user, 224 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 225 + Pull: pull, 226 + Stack: stack, 227 + Backlinks: backlinks, 228 + BranchDeleteStatus: branchDeleteStatus, 229 + MergeCheck: mergeCheckResponse, 230 + ResubmitCheck: resubmitResult, 231 + Pipelines: m, 232 + Diff: diff, 233 + DiffOpts: diffOpts, 234 + ActiveRound: roundIdInt, 235 + IsInterdiff: interdiff, 236 + 237 + Reactions: reactionMap, 238 + UserReacted: userReactions, 239 + 240 + LabelDefs: defs, 241 + VouchRelationships: vouchRelationships, 242 + }) 243 + if err != nil { 244 + l.Error("failed to render page", "err", err) 245 + } 246 + } 247 + 248 + func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 249 + l := s.logger.With("handler", "RepoSinglePull") 250 + 251 + pull, ok := r.Context().Value("pull").(*models.Pull) 252 + if !ok { 253 + l.Error("failed to get pull") 254 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 255 + return 256 + } 257 + 258 + http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 259 + } 260 + 261 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 262 + if pull.State == models.PullMerged { 263 + return types.MergeCheckResponse{} 264 + } 265 + 266 + xrpcc := s.knotClient(f.Knot) 267 + 268 + // combine patches of substack 269 + subStack := stack.Below(pull) 270 + // collect the portion of the stack that is mergeable 271 + mergeable := subStack.Mergeable() 272 + // combine each patch 273 + patch := mergeable.CombinedPatch() 274 + 275 + resp, err := tangled.RepoMergeCheck( 276 + r.Context(), 277 + xrpcc, 278 + &tangled.RepoMergeCheck_Input{ 279 + Did: f.Did, 280 + Name: f.Name, 281 + Branch: pull.TargetBranch, 282 + Patch: patch, 283 + }, 284 + ) 285 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 286 + s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 287 + return types.MergeCheckResponse{ 288 + Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 289 + } 290 + } 291 + 292 + return mergeCheckResponseFrom(resp) 293 + } 294 + 295 + func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 296 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 297 + for i, c := range resp.Conflicts { 298 + conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 299 + } 300 + out := types.MergeCheckResponse{ 301 + IsConflicted: resp.Is_conflicted, 302 + Conflicts: conflicts, 303 + } 304 + if resp.Message != nil { 305 + out.Message = *resp.Message 306 + } 307 + if resp.Error != nil { 308 + out.Error = *resp.Error 309 + } 310 + return out 311 + } 312 + 313 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 314 + if pull.State != models.PullMerged { 315 + return nil 316 + } 317 + 318 + user := s.oauth.GetMultiAccountUser(r) 319 + if user == nil { 320 + return nil 321 + } 322 + 323 + var branch string 324 + // check if the branch exists 325 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 326 + if pull.IsBranchBased() { 327 + branch = pull.PullSource.Branch 328 + } else if pull.IsForkBased() { 329 + branch = pull.PullSource.Branch 330 + repo = pull.PullSource.Repo 331 + } else { 332 + return nil 333 + } 334 + 335 + // deleted fork 336 + if repo == nil { 337 + return nil 338 + } 339 + 340 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 341 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 342 + if !slices.Contains(perms, "repo:push") { 343 + return nil 344 + } 345 + 346 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 347 + resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 348 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 349 + s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 350 + return nil 351 + } 352 + 353 + return &models.BranchDeleteStatus{ 354 + Repo: repo, 355 + Branch: resp.Name, 356 + } 357 + } 358 + 359 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 360 + if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 361 + return pages.Unknown 362 + } 363 + 364 + var sourceRepo syntax.ATURI 365 + if pull.PullSource.RepoAt != nil { 366 + sourceRepo = *pull.PullSource.RepoAt 367 + } else { 368 + sourceRepo = repo.RepoAt() 369 + } 370 + 371 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 372 + branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 373 + if err != nil { 374 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 375 + s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 376 + return pages.Unknown 377 + } 378 + s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 379 + return pages.Unknown 380 + } 381 + 382 + targetBranch := branchResp 383 + 384 + top := stack[0] 385 + latestSourceRev := top.LatestSha() 386 + 387 + if latestSourceRev != targetBranch.Hash { 388 + return pages.ShouldResubmit 389 + } 390 + 391 + return pages.ShouldNotResubmit 392 + } 393 + 394 + func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 395 + s.repoPullHelper(w, r, false) 396 + } 397 + 398 + func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 399 + s.repoPullHelper(w, r, true) 400 + } 401 + 402 + func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 403 + l := s.logger.With("handler", "RepoPullPatchRaw") 404 + 405 + pull, ok := r.Context().Value("pull").(*models.Pull) 406 + if !ok { 407 + l.Error("failed to get pull") 408 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 409 + return 410 + } 411 + l = l.With("pull_id", pull.PullId) 412 + 413 + roundId := chi.URLParam(r, "round") 414 + roundIdInt, err := strconv.Atoi(roundId) 415 + if err != nil || roundIdInt >= len(pull.Submissions) { 416 + http.Error(w, "bad round id", http.StatusBadRequest) 417 + l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 418 + return 419 + } 420 + 421 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 422 + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 423 + }