···
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/oauth"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/knotclient"
+
"tangled.sh/tangled.sh/core/patchutil"
+
"tangled.sh/tangled.sh/core/types"
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
+
"github.com/google/uuid"
+
"github.com/posthog/posthog-go"
+
repoResolver *reporesolver.RepoResolver
+
resolver *appview.Resolver
+
func New(oauth *oauth.OAuth, repoResolver *reporesolver.RepoResolver, pages *pages.Pages, resolver *appview.Resolver, db *db.DB, config *appview.Config) *Pulls {
+
return &Pulls{oauth: oauth, repoResolver: repoResolver, pages: pages, resolver: resolver, db: db, config: config}
+
func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
// can be nil if this pull is not stacked
+
stack, _ := r.Context().Value("stack").(db.Stack)
+
roundNumberStr := chi.URLParam(r, "round")
+
roundNumber, err := strconv.Atoi(roundNumberStr)
+
roundNumber = pull.LastRoundNumber()
+
if roundNumber >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
+
resubmitResult := pages.Unknown
+
if user.Did == pull.OwnerDid {
+
resubmitResult = s.resubmitCheck(f, pull, stack)
+
s.pages.PullActionsFragment(w, pages.PullActionsParams{
+
RepoInfo: f.RepoInfo(user),
+
RoundNumber: roundNumber,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
+
func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
// can be nil if this pull is not stacked
+
stack, _ := r.Context().Value("stack").(db.Stack)
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
+
for _, submission := range pull.Submissions {
+
totalIdents += len(submission.Comments)
+
identsToResolve := make([]string, totalIdents)
+
identsToResolve[0] = pull.OwnerDid
+
for _, submission := range pull.Submissions {
+
for _, comment := range submission.Comments {
+
identsToResolve[idx] = comment.OwnerDid
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
+
resubmitResult := pages.Unknown
+
if user != nil && user.Did == pull.OwnerDid {
+
resubmitResult = s.resubmitCheck(f, pull, stack)
+
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
AbandonedPulls: abandonedPulls,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
+
func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
+
if pull.State == db.PullMerged {
+
return types.MergeCheckResponse{}
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
+
log.Printf("failed to get registration key: %v", err)
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: this knot is unregistered",
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
+
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status",
+
patch := pull.LatestPatch()
+
// combine patches of substack
+
subStack := stack.Below(pull)
+
// collect the portion of the stack that is mergeable
+
mergeable := subStack.Mergeable()
+
patch = mergeable.CombinedPatch()
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
+
log.Println("failed to check for mergeability:", err)
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status",
+
switch resp.StatusCode {
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: this knot does not support PRs",
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: does this knot support PRs?",
+
respBody, err := io.ReadAll(resp.Body)
+
log.Println("failed to read merge check response body")
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: knot is not speaking the right language",
+
defer resp.Body.Close()
+
var mergeCheckResponse types.MergeCheckResponse
+
err = json.Unmarshal(respBody, &mergeCheckResponse)
+
log.Println("failed to unmarshal merge check response", err)
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: knot is not speaking the right language",
+
return mergeCheckResponse
+
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
+
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
+
var knot, ownerDid, repoName string
+
if pull.PullSource.RepoAt != nil {
+
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
+
log.Println("failed to get source repo", err)
+
ownerDid = sourceRepo.Did
+
repoName = sourceRepo.Name
+
// pulls within the same repo
+
ownerDid = f.OwnerDid()
+
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
+
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
+
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
+
log.Println("failed to reach knotserver", err)
+
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
+
if pull.IsStacked() && stack != nil {
+
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
+
log.Println(latestSourceRev, result.Branch.Hash)
+
if latestSourceRev != result.Branch.Hash {
+
return pages.ShouldResubmit
+
return pages.ShouldNotResubmit
+
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
stack, _ := r.Context().Value("stack").(db.Stack)
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
identsToResolve := []string{pull.OwnerDid}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
patch := pull.Submissions[roundIdInt].Patch
+
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
+
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
+
DidHandleMap: didHandleMap,
+
RepoInfo: f.RepoInfo(user),
+
Submission: pull.Submissions[roundIdInt],
+
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to get pull.")
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("cannot interdiff initial submission")
+
identsToResolve := []string{pull.OwnerDid}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
+
log.Println("failed to interdiff; current patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
+
log.Println("failed to interdiff; previous patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
identsToResolve := []string{pull.OwnerDid}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
w.Header().Set("Content-Type", "text/plain")
+
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
+
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
params := r.URL.Query()
+
switch params.Get("state") {
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pulls, err := db.GetPulls(
+
db.FilterEq("repo_at", f.RepoAt),
+
db.FilterEq("state", state),
+
log.Println("failed to get pulls", err)
+
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
+
for _, p := range pulls {
+
var pullSourceRepo *db.Repo
+
if p.PullSource != nil {
+
if p.PullSource.RepoAt != nil {
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
+
log.Printf("failed to get repo by at uri: %v", err)
+
p.PullSource.Repo = pullSourceRepo
+
identsToResolve := make([]string, len(pulls))
+
for i, pull := range pulls {
+
identsToResolve[i] = pull.OwnerDid
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
s.pages.RepoPulls(w, pages.RepoPullsParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
roundNumberStr := chi.URLParam(r, "round")
+
roundNumber, err := strconv.Atoi(roundNumberStr)
+
if err != nil || roundNumber >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
+
RepoInfo: f.RepoInfo(user),
+
RoundNumber: roundNumber,
+
body := r.FormValue("body")
+
s.pages.Notice(w, "pull", "Comment body is required")
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
createdAt := time.Now().Format(time.RFC3339)
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
+
log.Println("failed to get pull at", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
atUri := f.RepoAt.String()
+
client, err := s.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullCommentNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPullComment{
+
log.Println("failed to create pull comment", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
// Create the pull comment in the database with the commentAt field
+
commentId, err := db.NewPullComment(tx, &db.PullComment{
+
RepoAt: f.RepoAt.String(),
+
SubmissionId: pull.Submissions[roundNumber].ID,
+
log.Println("failed to create pull comment", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
// Commit the transaction
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
if !s.config.Core.Dev {
+
err = s.posthog.Enqueue(posthog.Capture{
+
Event: "new_pull_comment",
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
+
log.Println("failed to enqueue posthog event:", err)
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
+
func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create unsigned client for %s", f.Knot)
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
+
log.Println("failed to fetch branches", err)
+
// can be one of "patch", "branch" or "fork"
+
strategy := r.URL.Query().Get("strategy")
+
// ignored if strategy is "patch"
+
sourceBranch := r.URL.Query().Get("sourceBranch")
+
targetBranch := r.URL.Query().Get("targetBranch")
+
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
+
RepoInfo: f.RepoInfo(user),
+
Branches: result.Branches,
+
SourceBranch: sourceBranch,
+
TargetBranch: targetBranch,
+
Title: r.URL.Query().Get("title"),
+
Body: r.URL.Query().Get("body"),
+
title := r.FormValue("title")
+
body := r.FormValue("body")
+
targetBranch := r.FormValue("targetBranch")
+
fromFork := r.FormValue("fork")
+
sourceBranch := r.FormValue("sourceBranch")
+
patch := r.FormValue("patch")
+
if targetBranch == "" {
+
s.pages.Notice(w, "pull", "Target branch is required.")
+
// Determine PR type based on input parameters
+
isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
+
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
+
isForkBased := fromFork != "" && sourceBranch != ""
+
isPatchBased := patch != "" && !isBranchBased && !isForkBased
+
isStacked := r.FormValue("isStacked") == "on"
+
if isPatchBased && !patchutil.IsFormatPatch(patch) {
+
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
+
// Validate we have at least one valid PR creation method
+
if !isBranchBased && !isPatchBased && !isForkBased {
+
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
+
// Can't mix branch-based and patch-based approaches
+
if isBranchBased && patch != "" {
+
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
caps, err := us.Capabilities()
+
log.Println("error fetching knot caps", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
if !caps.PullRequests.FormatPatch {
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
+
// Handle the PR creation based on the type
+
if !caps.PullRequests.BranchSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
+
} else if isForkBased {
+
if !caps.PullRequests.ForkSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
+
} else if isPatchBased {
+
if !caps.PullRequests.PatchSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
+
func (s *Pulls) handleBranchBasedPull(
+
f *reporesolver.ResolvedRepo,
+
pullSource := &db.PullSource{
+
recordPullSource := &tangled.RepoPull_Source{
+
// Generate a patch using /compare
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
+
log.Println("failed to compare", err)
+
s.pages.Notice(w, "pull", err.Error())
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
+
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
+
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
+
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
+
if errors.Is(err, sql.ErrNoRows) {
+
s.pages.Notice(w, "pull", "No such fork.")
+
log.Println("failed to fetch fork:", err)
+
s.pages.Notice(w, "pull", "Failed to fetch fork.")
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
+
log.Println("failed to fetch registration key:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
+
log.Println("failed to create signed client:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
+
log.Println("failed to create unsigned client:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
switch resp.StatusCode {
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
+
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
+
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
+
// hiddenRef: hidden/feature-1/main (on repo-fork)
+
// targetBranch: main (on repo-1)
+
// sourceBranch: feature-1 (on repo-fork)
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
+
log.Println("failed to compare across branches", err)
+
s.pages.Notice(w, "pull", err.Error())
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
+
log.Println("failed to parse fork AT URI", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
+
func (s *Pulls) createPullRequest(
+
f *reporesolver.ResolvedRepo,
+
title, body, targetBranch string,
+
pullSource *db.PullSource,
+
recordPullSource *tangled.RepoPull_Source,
+
// creates a series of PRs, each linking to the previous, identified by jj's change-id
+
s.createStackedPulLRequest(
+
client, err := s.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
// We've already checked earlier if it's diff-based and title is empty,
+
// so if it's still empty now, it's intentionally skipped owing to format-patch.
+
formatPatches, err := patchutil.ExtractPatches(patch)
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
+
if len(formatPatches) == 0 {
+
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
+
title = formatPatches[0].Title
+
body = formatPatches[0].Body
+
initialSubmission := db.PullSubmission{
+
err = db.NewPull(tx, &db.Pull{
+
TargetBranch: targetBranch,
+
Submissions: []*db.PullSubmission{
+
PullSource: pullSource,
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
pullId, err := db.NextPullId(tx, f.RepoAt)
+
log.Println("failed to get pull id", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: targetBranch,
+
Source: recordPullSource,
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
if !s.config.Core.Dev {
+
err = s.posthog.Enqueue(posthog.Capture{
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
+
log.Println("failed to enqueue posthog event:", err)
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
+
func (s *Pulls) createStackedPulLRequest(
+
f *reporesolver.ResolvedRepo,
+
pullSource *db.PullSource,
+
// run some necessary checks for stacked-prs first
+
// must be branch or fork based
+
log.Println("stacked PR from patch-based pull")
+
s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
+
formatPatches, err := patchutil.ExtractPatches(patch)
+
log.Println("failed to extract patches", err)
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
+
// must have atleast 1 patch to begin with
+
if len(formatPatches) == 0 {
+
log.Println("empty patches")
+
s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
+
// build a stack out of this patch
+
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
+
log.Println("failed to create stack", err)
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
+
client, err := s.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
// apply all record creations at once
+
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
+
for _, p := range stack {
+
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
+
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
+
Collection: tangled.RepoPullNSID,
+
Value: &lexutil.LexiconTypeDecoder{
+
writes = append(writes, &write)
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
+
log.Println("failed to create stacked pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
+
// create all pulls at once
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
for _, p := range stack {
+
err = db.NewPull(tx, p)
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
+
func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
+
_, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
patch := r.FormValue("patch")
+
s.pages.Notice(w, "patch-error", "Patch is required.")
+
if patch == "" || !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
+
if patchutil.IsFormatPatch(patch) {
+
s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
+
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
+
func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
+
RepoInfo: f.RepoInfo(user),
+
func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create unsigned client for %s", f.Knot)
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
+
log.Println("failed to reach knotserver", err)
+
branches := result.Branches
+
sort.Slice(branches, func(i int, j int) bool {
+
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
+
withoutDefault := []types.Branch{}
+
for _, b := range branches {
+
withoutDefault = append(withoutDefault, b)
+
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
+
RepoInfo: f.RepoInfo(user),
+
Branches: withoutDefault,
+
func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
forks, err := db.GetForksByDid(s.db, user.Did)
+
log.Println("failed to get forks", err)
+
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
+
RepoInfo: f.RepoInfo(user),
+
Selected: r.URL.Query().Get("fork"),
+
func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
forkVal := r.URL.Query().Get("fork")
+
repo, err := db.GetRepo(s.db, user.Did, forkVal)
+
log.Println("failed to get repo", user.Did, forkVal)
+
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
+
log.Printf("failed to create unsigned client for %s", repo.Knot)
+
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
+
log.Println("failed to reach knotserver for source branches", err)
+
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
+
log.Println("failed to reach knotserver for target branches", err)
+
sourceBranches := sourceResult.Branches
+
sort.Slice(sourceBranches, func(i int, j int) bool {
+
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
+
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
+
RepoInfo: f.RepoInfo(user),
+
SourceBranches: sourceBranches,
+
TargetBranches: targetResult.Branches,
+
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
+
RepoInfo: f.RepoInfo(user),
+
if pull.IsPatchBased() {
+
} else if pull.IsBranchBased() {
+
} else if pull.IsForkBased() {
+
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
patch := r.FormValue("patch")
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
+
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
if !f.RepoInfo(user).Roles.IsPushAllowed() {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
log.Printf("failed to create client for %s: %s", f.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
+
log.Printf("compare request failed: %s", err)
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
+
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
+
log.Println("failed to get source repo", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
// extract patch by performing compare
+
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
+
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
// update the hidden tracking branch to latest
+
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
+
if err != nil || resp.StatusCode != http.StatusNoContent {
+
log.Printf("failed to update tracking branch: %s", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
+
log.Printf("failed to compare branches: %s", err)
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
+
// validate a resubmission against a pull request
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
+
return fmt.Errorf("Patch is empty.")
+
if patch == pull.LatestPatch() {
+
return fmt.Errorf("Patch is identical to previous submission.")
+
if !patchutil.IsPatchValid(patch) {
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
+
func (s *Pulls) resubmitPullHelper(
+
f *reporesolver.ResolvedRepo,
+
log.Println("resubmitting stacked PR")
+
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
+
if err := validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
// validate sourceRev if branch/fork based
+
if pull.IsBranchBased() || pull.IsForkBased() {
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
client, err := s.oauth.AuthorizedClient(r)
+
log.Println("failed to authorize client")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
+
// failed to get record
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
+
var recordPullSource *tangled.RepoPull_Source
+
if pull.IsBranchBased() {
+
recordPullSource = &tangled.RepoPull_Source{
+
Branch: pull.PullSource.Branch,
+
if pull.IsForkBased() {
+
repoAt := pull.PullSource.RepoAt.String()
+
recordPullSource = &tangled.RepoPull_Source{
+
Branch: pull.PullSource.Branch,
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
PullId: int64(pull.PullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: pull.TargetBranch,
+
Patch: patch, // new patch
+
Source: recordPullSource,
+
log.Println("failed to update record", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
func (s *Pulls) resubmitStackedPullHelper(
+
f *reporesolver.ResolvedRepo,
+
targetBranch := pull.TargetBranch
+
origStack, _ := r.Context().Value("stack").(db.Stack)
+
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
+
log.Println("failed to create resubmitted stack", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
// find the diff between the stacks, first, map them by changeId
+
origById := make(map[string]*db.Pull)
+
newById := make(map[string]*db.Pull)
+
for _, p := range origStack {
+
origById[p.ChangeId] = p
+
for _, p := range newStack {
+
newById[p.ChangeId] = p
+
// commits that got deleted: corresponding pull is closed
+
// commits that got added: new pull is created
+
// commits that got updated: corresponding pull is resubmitted & new round begins
+
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
+
additions := make(map[string]*db.Pull)
+
deletions := make(map[string]*db.Pull)
+
unchanged := make(map[string]struct{})
+
updated := make(map[string]struct{})
+
// pulls in orignal stack but not in new one
+
for _, op := range origStack {
+
if _, ok := newById[op.ChangeId]; !ok {
+
deletions[op.ChangeId] = op
+
// pulls in new stack but not in original one
+
for _, np := range newStack {
+
if _, ok := origById[np.ChangeId]; !ok {
+
additions[np.ChangeId] = np
+
// NOTE: this loop can be written in any of above blocks,
+
// but is written separately in the interest of simpler code
+
for _, np := range newStack {
+
if op, ok := origById[np.ChangeId]; ok {
+
// pull exists in both stacks
+
// TODO: can we avoid reparse?
+
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
+
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
+
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
+
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
+
patchutil.SortPatch(newFiles)
+
patchutil.SortPatch(origFiles)
+
// text content of patch may be identical, but a jj rebase might have forwarded it
+
// we still need to update the hash in submission.Patch and submission.SourceRev
+
if patchutil.Equal(newFiles, origFiles) &&
+
origHeader.Title == newHeader.Title &&
+
origHeader.Body == newHeader.Body {
+
unchanged[op.ChangeId] = struct{}{}
+
updated[op.ChangeId] = struct{}{}
+
tx, err := s.db.Begin()
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
+
// deleted pulls are marked as deleted in the DB
+
for _, p := range deletions {
+
err := db.DeletePull(tx, p.RepoAt, p.PullId)
+
log.Println("failed to delete pull", err, p.PullId)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
+
RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
+
Collection: tangled.RepoPullNSID,
+
// new pulls are created
+
for _, p := range additions {
+
err := db.NewPull(tx, p)
+
log.Println("failed to create pull", err, p.PullId)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
+
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
+
Collection: tangled.RepoPullNSID,
+
Value: &lexutil.LexiconTypeDecoder{
+
// updated pulls are, well, updated; to start a new round
+
for id := range updated {
+
submission := np.Submissions[np.LastRoundNumber()]
+
// resubmit the old pull
+
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
+
log.Println("failed to update pull", err, op.PullId)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
record := op.AsRecord()
+
record.Patch = submission.Patch
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
+
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
+
Collection: tangled.RepoPullNSID,
+
Value: &lexutil.LexiconTypeDecoder{
+
// unchanged pulls are edited without starting a new round
+
// update source-revs & patches without advancing rounds
+
for changeId := range unchanged {
+
op, _ := origById[changeId]
+
np, _ := newById[changeId]
+
origSubmission := op.Submissions[op.LastRoundNumber()]
+
newSubmission := np.Submissions[np.LastRoundNumber()]
+
log.Println("moving unchanged change id : ", changeId)
+
newSubmission.SourceRev,
+
db.FilterEq("id", origSubmission.ID),
+
log.Println("failed to update pull", err, op.PullId)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
record := op.AsRecord()
+
record.Patch = newSubmission.Patch
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
+
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
+
Collection: tangled.RepoPullNSID,
+
Value: &lexutil.LexiconTypeDecoder{
+
// update parent-change-id relations for the entire stack
+
for _, p := range newStack {
+
err := db.SetPullParentChangeId(
+
// these should be enough filters to be unique per-stack
+
db.FilterEq("repo_at", p.RepoAt.String()),
+
db.FilterEq("owner_did", p.OwnerDid),
+
db.FilterEq("change_id", p.ChangeId),
+
log.Println("failed to update pull", err, p.PullId)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
log.Println("failed to resubmit pull", err)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+
client, err := s.oauth.AuthorizedClient(r)
+
log.Println("failed to authorize client")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
+
log.Println("failed to create stacked pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to resolve repo:", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
+
var pullsToMerge db.Stack
+
pullsToMerge = append(pullsToMerge, pull)
+
stack, ok := r.Context().Value("stack").(db.Stack)
+
log.Println("failed to get stack")
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
+
// combine patches of substack
+
subStack := stack.StrictlyBelow(pull)
+
// collect the portion of the stack that is mergeable
+
mergeable := subStack.Mergeable()
+
pullsToMerge = append(pullsToMerge, mergeable...)
+
patch := pullsToMerge.CombinedPatch()
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
+
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
+
log.Printf("resolving identity: %s", err)
+
w.WriteHeader(http.StatusNotFound)
+
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
+
log.Printf("failed to get primary email: %s", err)
+
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
// Merge the pull request
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
+
log.Printf("failed to merge pull request: %s", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
if resp.StatusCode != http.StatusOK {
+
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
tx, err := s.db.Begin()
+
log.Println("failed to start transcation", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
for _, p := range pullsToMerge {
+
err := db.MergePull(tx, f.RepoAt, p.PullId)
+
log.Printf("failed to update pull request status in database: %s", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
// TODO: this is unsound, we should also revert the merge from the knotserver here
+
log.Printf("failed to update pull request status in database: %s", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
+
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("malformed middleware")
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
// auth filter: only owner or collaborators can close
+
roles := f.RolesInRepo(user)
+
isCollaborator := roles.IsCollaborator()
+
isPullAuthor := user.Did == pull.OwnerDid
+
isCloseAllowed := isCollaborator || isPullAuthor
+
log.Println("failed to close pull")
+
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
var pullsToClose []*db.Pull
+
pullsToClose = append(pullsToClose, pull)
+
// if this PR is stacked, then we want to close all PRs below this one on the stack
+
stack := r.Context().Value("stack").(db.Stack)
+
subStack := stack.StrictlyBelow(pull)
+
pullsToClose = append(pullsToClose, subStack...)
+
for _, p := range pullsToClose {
+
// Close the pull in the database
+
err = db.ClosePull(tx, f.RepoAt, p.PullId)
+
log.Println("failed to close pull", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
// Commit the transaction
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
f, err := s.repoResolver.Resolve(r)
+
log.Println("failed to resolve repo", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
// auth filter: only owner or collaborators can close
+
roles := f.RolesInRepo(user)
+
isCollaborator := roles.IsCollaborator()
+
isPullAuthor := user.Did == pull.OwnerDid
+
isCloseAllowed := isCollaborator || isPullAuthor
+
log.Println("failed to close pull")
+
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
+
var pullsToReopen []*db.Pull
+
pullsToReopen = append(pullsToReopen, pull)
+
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
+
stack := r.Context().Value("stack").(db.Stack)
+
subStack := stack.StrictlyAbove(pull)
+
pullsToReopen = append(pullsToReopen, subStack...)
+
for _, p := range pullsToReopen {
+
// Close the pull in the database
+
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
+
log.Println("failed to close pull", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
// Commit the transaction
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
+
formatPatches, err := patchutil.ExtractPatches(patch)
+
return nil, fmt.Errorf("Failed to extract patches: %v", err)
+
// must have atleast 1 patch to begin with
+
if len(formatPatches) == 0 {
+
return nil, fmt.Errorf("No patches found in the generated format-patch.")
+
// the stack is identified by a UUID
+
for _, fp := range formatPatches {
+
// all patches must have a jj change-id
+
changeId, err := fp.ChangeId()
+
return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
+
initialSubmission := db.PullSubmission{
+
TargetBranch: targetBranch,
+
Submissions: []*db.PullSubmission{
+
PullSource: pullSource,
+
ParentChangeId: parentChangeId,
+
stack = append(stack, &pull)
+
parentChangeId = changeId