forked from tangled.org/core
this repo has no description

appview: pulls: enable merging stacked PRs

Changed files
+302 -55
appview
patchutil
+152 -17
appview/db/pulls.go
···
"database/sql"
"fmt"
"log"
+
"slices"
"sort"
"strings"
"time"
···
}
}
return false
+
}
+
+
func (p *Pull) IsStacked() bool {
+
return p.StackId != ""
}
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
···
return pullId - 1, err
}
-
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
pulls := make(map[int]*Pull)
-
rows, err := e.Query(`
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.arg)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`
select
owner_did,
+
repo_at,
pull_id,
created,
title,
···
body,
rkey,
source_branch,
-
source_repo_at
+
source_repo_at,
+
stack_id,
+
change_id,
+
parent_change_id
from
pulls
-
where
-
repo_at = ? and state = ?`, repoAt, state)
+
%s
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
···
for rows.Next() {
var pull Pull
var createdAt string
-
var sourceBranch, sourceRepoAt sql.NullString
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
err := rows.Scan(
&pull.OwnerDid,
+
&pull.RepoAt,
&pull.PullId,
&createdAt,
&pull.Title,
···
&pull.Rkey,
&sourceBranch,
&sourceRepoAt,
+
&stackId,
+
&changeId,
+
&parentChangeId,
)
if err != nil {
return nil, err
···
}
}
+
if stackId.Valid {
+
pull.StackId = stackId.String
+
}
+
if changeId.Valid {
+
pull.ChangeId = changeId.String
+
}
+
if parentChangeId.Valid {
+
pull.ParentChangeId = parentChangeId.String
+
}
+
pulls[pull.PullId] = &pull
}
···
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
submissionsQuery := fmt.Sprintf(`
select
-
id, pull_id, round_number
+
id, pull_id, round_number, patch
from
pull_submissions
where
-
repo_at = ? and pull_id in (%s)
-
`, inClause)
+
repo_at in (%s) and pull_id in (%s)
+
`, inClause, inClause)
-
args := make([]any, len(pulls)+1)
-
args[0] = repoAt.String()
-
idx := 1
+
args = make([]any, len(pulls)*2)
+
idx := 0
+
for _, p := range pulls {
+
args[idx] = p.RepoAt
+
idx += 1
+
}
for _, p := range pulls {
args[idx] = p.PullId
idx += 1
···
&s.ID,
&s.PullId,
&s.RoundNumber,
+
&s.Patch,
)
if err != nil {
return nil, err
···
return nil, err
}
-
orderedByDate := []*Pull{}
+
orderedByPullId := []*Pull{}
for _, p := range pulls {
-
orderedByDate = append(orderedByDate, p)
+
orderedByPullId = append(orderedByPullId, p)
}
-
sort.Slice(orderedByDate, func(i, j int) bool {
-
return orderedByDate[i].Created.After(orderedByDate[j].Created)
+
sort.Slice(orderedByPullId, func(i, j int) bool {
+
return orderedByPullId[i].PullId > orderedByPullId[j].PullId
})
-
return orderedByDate, nil
+
return orderedByPullId, nil
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
return count, nil
}
+
+
type Stack []*Pull
+
+
// change-id parent-change-id
+
//
+
// 4 w ,-------- z (TOP)
+
// 3 z <----',------- y
+
// 2 y <-----',------ x
+
// 1 x <------' nil (BOT)
+
//
+
// `w` is parent of none, so it is the top of the stack
+
func GetStack(e Execer, stackId string) (Stack, error) {
+
unorderedPulls, err := GetPulls(e, Filter("stack_id", stackId))
+
if err != nil {
+
return nil, err
+
}
+
// map of parent-change-id to pull
+
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
+
parentMap := make(map[string]*Pull, len(unorderedPulls))
+
for _, p := range unorderedPulls {
+
changeIdMap[p.ChangeId] = p
+
if p.ParentChangeId != "" {
+
parentMap[p.ParentChangeId] = p
+
}
+
}
+
+
// the top of the stack is the pull that is not a parent of any pull
+
var topPull *Pull
+
for _, maybeTop := range unorderedPulls {
+
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
+
topPull = maybeTop
+
break
+
}
+
}
+
+
pulls := []*Pull{}
+
for {
+
pulls = append(pulls, topPull)
+
if topPull.ParentChangeId != "" {
+
if next, ok := changeIdMap[topPull.ParentChangeId]; ok {
+
topPull = next
+
} else {
+
return nil, fmt.Errorf("failed to find parent pull request, stack is malformed")
+
}
+
} else {
+
break
+
}
+
}
+
+
return pulls, nil
+
}
+
+
// position of this pull in the stack
+
func (stack Stack) Position(pull *Pull) int {
+
return slices.IndexFunc(stack, func(p *Pull) bool {
+
return p.ChangeId == pull.ChangeId
+
})
+
}
+
+
// all pulls below this pull (including self) in this stack
+
//
+
// nil if this pull does not belong to this stack
+
func (stack Stack) Below(pull *Pull) Stack {
+
position := stack.Position(pull)
+
+
if position < 0 {
+
return nil
+
}
+
+
return stack[position:]
+
}
+
+
// all pulls below this pull (excluding self) in this stack
+
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
+
below := stack.Below(pull)
+
+
if len(below) > 0 {
+
return below[1:]
+
}
+
+
return nil
+
}
+
+
// the combined format-patches of all the newest submissions in this stack
+
func (stack Stack) CombinedPatch() string {
+
// go in reverse order because the bottom of the stack is the last element in the slice
+
var combined strings.Builder
+
for idx := range stack {
+
pull := stack[len(stack)-1-idx]
+
combined.WriteString(pull.LatestPatch())
+
combined.WriteString("\n")
+
}
+
return combined.String()
+
}
+10
appview/state/middleware.go
···
ctx := context.WithValue(r.Context(), "pull", pr)
+
if pr.IsStacked() {
+
stack, err := db.GetStack(s.db, pr.StackId)
+
if err != nil {
+
log.Println("failed to get stack", err)
+
return
+
}
+
+
ctx = context.WithValue(ctx, "stack", stack)
+
}
+
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+137 -26
appview/state/pull.go
···
return
}
+
// 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)
if err != nil {
···
return
}
-
mergeCheckResponse := s.mergeCheck(f, pull)
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
resubmitResult := pages.Unknown
if user.Did == pull.OwnerDid {
resubmitResult = s.resubmitCheck(f, pull)
···
return
}
+
// can be nil if this pull is not stacked
+
stack := r.Context().Value("stack").(db.Stack)
+
totalIdents := 1
for _, submission := range pull.Submissions {
totalIdents += len(submission.Comments)
···
}
}
-
mergeCheckResponse := s.mergeCheck(f, pull)
+
mergeCheckResponse := s.mergeCheck(f, pull, stack)
resubmitResult := pages.Unknown
if user != nil && user.Did == pull.OwnerDid {
resubmitResult = s.resubmitCheck(f, pull)
···
})
}
-
func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
+
func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
if pull.State == db.PullMerged {
return types.MergeCheckResponse{}
}
···
}
}
-
resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
+
patch := pull.LatestPatch()
+
if pull.IsStacked() {
+
// combine patches of substack
+
subStack := stack.Below(pull)
+
+
// collect the portion of the stack that is mergeable
+
var mergeable db.Stack
+
for _, p := range subStack {
+
// stop at the first merged PR
+
if p.State == db.PullMerged {
+
break
+
}
+
+
// skip over closed PRs
+
//
+
// we will close PRs that are "removed" from a stack
+
if p.State != db.PullClosed {
+
mergeable = append(mergeable, p)
+
}
+
}
+
+
patch = mergeable.CombinedPatch()
+
}
+
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
if err != nil {
log.Println("failed to check for mergeability:", err)
return types.MergeCheckResponse{
···
return
}
-
pulls, err := db.GetPulls(s.db, f.RepoAt, state)
+
pulls, err := db.GetPulls(
+
s.db,
+
db.Filter("repo_at", f.RepoAt),
+
db.Filter("state", state),
+
)
if err != nil {
log.Println("failed to get pulls", err)
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
···
// TODO: can we just use a format-patch string here?
initialSubmission := db.PullSubmission{
-
Patch: fp.Patch(),
+
Patch: fp.Raw,
SourceRev: sourceRev,
err = db.NewPull(tx, &db.Pull{
···
Title: title,
TargetRepo: string(f.RepoAt),
TargetBranch: targetBranch,
-
Patch: fp.Patch(),
+
Patch: fp.Raw,
Source: recordPullSource,
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
···
pull, ok := r.Context().Value("pull").(*db.Pull)
if !ok {
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
return
+
var pullsToMerge db.Stack
+
pullsToMerge = append(pullsToMerge, pull)
+
if pull.IsStacked() {
+
stack, ok := r.Context().Value("stack").(db.Stack)
+
if !ok {
+
log.Println("failed to get stack")
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
+
return
+
}
+
+
// combine patches of substack
+
subStack := stack.Below(pull)
+
+
// collect the portion of the stack that is mergeable
+
for _, p := range subStack {
+
// stop at the first merged PR
+
if p.State == db.PullMerged {
+
break
+
}
+
+
// skip over closed PRs
+
//
+
// TODO: we need a "deleted" state for such PRs, but without losing discussions
+
// we will close PRs that are "removed" from a stack
+
if p.State == db.PullClosed {
+
continue
+
}
+
+
pullsToMerge = append(pullsToMerge, p)
+
}
+
}
+
+
patch := pullsToMerge.CombinedPatch()
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
if err != nil {
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
···
// Merge the pull request
-
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
if err != nil {
log.Printf("failed to merge pull request: %s", err)
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
return
-
if resp.StatusCode == http.StatusOK {
-
err := db.MergePull(s.db, f.RepoAt, pull.PullId)
+
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.")
+
return
+
}
+
+
tx, err := s.db.Begin()
+
if err != nil {
+
log.Printf("failed to start transcation", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
return
+
}
+
+
for _, p := range pullsToMerge {
+
err := db.MergePull(tx, f.RepoAt, p.PullId)
if err != nil {
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.")
return
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
-
} else {
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
// 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.")
+
return
+
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
···
return
-
// Close the pull in the database
-
err = db.ClosePull(tx, f.RepoAt, pull.PullId)
-
if err != nil {
-
log.Println("failed to close pull", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
+
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
+
if pull.IsStacked() {
+
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)
+
if err != nil {
+
log.Println("failed to close pull", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
return
+
}
// Commit the transaction
···
return
-
// Reopen the pull in the database
-
err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
-
if err != nil {
-
log.Println("failed to reopen pull", err)
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
-
return
+
var pullsToReopen []*db.Pull
+
pullsToReopen = append(pullsToReopen, pull)
+
+
// if this PR is stacked, then we want to reopen all PRs below this one on the stack
+
if pull.IsStacked() {
+
stack := r.Context().Value("stack").(db.Stack)
+
subStack := stack.StrictlyBelow(pull)
+
pullsToReopen = append(pullsToReopen, subStack...)
+
}
+
+
for _, p := range pullsToReopen {
+
// Close the pull in the database
+
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
+
if err != nil {
+
log.Println("failed to close pull", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
return
+
}
// Commit the transaction
+1 -3
flake.nix
···
inherit (gitignore.lib) gitignoreSource;
in {
overlays.default = final: prev: let
-
goModHash = "sha256-TwlPge7vhVGmtNvYkHFFnZjJs2DWPUwPhCSBTCUYCtc=";
+
goModHash = "sha256-CmBuvv3duQQoc8iTW4244w1rYLGeqMQS+qQ3wwReZZg=";
buildCmdPackage = name:
final.buildGoModule {
pname = name;
···
pkgs.websocat
pkgs.tailwindcss
pkgs.nixos-shell
-
pkgs.nodePackages.localtunnel
-
pkgs.python312Packages.pyngrok
];
shellHook = ''
mkdir -p appview/pages/static/{fonts,icons}
+2 -9
patchutil/patchutil.go
···
type FormatPatch struct {
Files []*gitdiff.File
*gitdiff.PatchHeader
-
}
-
-
// Extracts just the diff from this format-patch
-
func (f FormatPatch) Patch() string {
-
var b strings.Builder
-
for _, p := range f.Files {
-
b.WriteString(p.String())
-
}
-
return b.String()
+
Raw string
}
func (f FormatPatch) ChangeId() (string, error) {
···
result = append(result, FormatPatch{
Files: files,
PatchHeader: header,
+
Raw: patch,
})
}