Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260805-045615.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submit: Eligible GitHub pull requests are registered as native stacks after submission.'
time: 2026-08-05T04:56:15.840512-07:00
47 changes: 34 additions & 13 deletions internal/handler/submit/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
return err
}

stackUpdates := new(submitStackUpdates)
var branchesToComment []string
for _, branch := range req.Branches {
// Shallow copy the options because submitBranch may modify them.
Expand All @@ -244,7 +245,7 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
ctx,
graph,
branch,
&submitOptions{Options: &opts},
&submitOptions{Options: &opts, stackUpdates: stackUpdates},
)
if err != nil {
return fmt.Errorf("submit branch %s: %w", branch, err)
Expand All @@ -258,7 +259,8 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
return nil // nothing to do
}

return updateNavigationComments(
stackErr := h.updateStacks(ctx, branchesToComment, stackUpdates)
navCommentErr := updateNavigationComments(
ctx,
h.Store, h.Service, h.Log,
opts.NavComment,
Expand All @@ -271,6 +273,7 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
h.upstreamRepository,
h.pushRepositoryID,
)
return errors.Join(stackErr, navCommentErr)
}

// Request is a request to submit a single branch to a remote repository.
Expand Down Expand Up @@ -306,14 +309,16 @@ func (h *Handler) Submit(ctx context.Context, req *Request) error {
return err
}

stackUpdates := new(submitStackUpdates)
status, err := h.submitBranch(
ctx,
graph,
req.Branch,
&submitOptions{
Options: opts,
Title: req.Title,
Body: req.Body,
Options: opts,
Title: req.Title,
Body: req.Body,
stackUpdates: stackUpdates,
},
)
if err != nil {
Expand All @@ -325,7 +330,8 @@ func (h *Handler) Submit(ctx context.Context, req *Request) error {
return nil
}

return updateNavigationComments(
stackErr := h.updateStacks(ctx, []string{req.Branch}, stackUpdates)
navCommentErr := updateNavigationComments(
ctx,
h.Store, h.Service, h.Log,
opts.NavComment,
Expand All @@ -338,6 +344,7 @@ func (h *Handler) Submit(ctx context.Context, req *Request) error {
h.upstreamRepository,
h.pushRepositoryID,
)
return errors.Join(stackErr, navCommentErr)
}

type submitStatus struct {
Expand All @@ -352,7 +359,8 @@ type submitStatus struct {
type submitOptions struct {
*Options

Title, Body string
Title, Body string
stackUpdates *submitStackUpdates
}

func (h *Handler) submitBranch(
Expand Down Expand Up @@ -929,18 +937,31 @@ func (h *Handler) submitBranch(
AddReviewers: reviewers,
AddAssignees: opts.Assignees,
}
// Some forges, including GitHub, treat setting an unchanged base
// as a mutation and may trigger redundant CI runs.
if pull.BaseName != upstreamBase {
editOpts.Base = upstreamBase
}

// remoteRepo is guaranteed to be available at this point.
remoteRepo, err := h.upstreamRepository(ctx)
if err != nil {
return status, fmt.Errorf("edit CR %v: %w", pull.ID, err)
}

// A native stack provider may need to dissolve existing membership
// before changing the pull request base. Defer that mutation to its
// planned transition; unsupported planning applies the same ordinary
// EditChange after submission.
if pull.BaseName != upstreamBase {
if _, ok := remoteRepo.(forge.StackRepository); ok && opts.stackUpdates != nil {
opts.stackUpdates.deferredBases = append(
opts.stackUpdates.deferredBases,
deferredBaseUpdate{
repository: remoteRepo,
change: pull.ID,
base: upstreamBase,
},
)
} else {
editOpts.Base = upstreamBase
}
}

if err := remoteRepo.EditChange(ctx, pull.ID, editOpts); err != nil {
return status, fmt.Errorf("edit CR %v: %w", pull.ID, err)
}
Expand Down
126 changes: 126 additions & 0 deletions internal/handler/submit/stacks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package submit

import (
"context"
"errors"
"fmt"

"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/spice"
)

// updateStacks plans and executes the forge-native representation of the
// tracked stack trees affected by a successful submit. Unsupported planning
// applies the provider base edits deferred by the submit loop.
func (h *Handler) updateStacks(
ctx context.Context,
submitted []string,
stackUpdates *submitStackUpdates,
) error {
repo, err := h.upstreamRepository(ctx)
if err != nil {
return fmt.Errorf("get remote repository: %w", err)
}

stackRepo, ok := repo.(forge.StackRepository)
if !ok {
return nil
}

// Submission can create change metadata after the command builds its first
// branch graph.
// Reload it so newly published changes participate in the update.
graph, err := h.Service.BranchGraph(ctx, nil)
if err != nil {
return fmt.Errorf("build branch graph: %w", err)
}

changes := nativeStackChanges(graph, repo.Forge().ID(), submitted)
if len(changes) == 0 {
return nil
}

plan, err := stackRepo.PlanStackUpdate(ctx, changes)
if errors.Is(err, forge.ErrUnsupported) {
return stackUpdates.applyDeferredBases(ctx)
}
if err != nil {
h.Log.Warn("Could not plan stack update", "error", err)
return nil
}
if err := plan.Execute(ctx); err != nil {
h.Log.Warn("Could not update stacks", "error", err)
}
return nil
}

type submitStackUpdates struct {
deferredBases []deferredBaseUpdate
}

type deferredBaseUpdate struct {
repository forge.Repository
change forge.ChangeID
base string
}

func (u *submitStackUpdates) applyDeferredBases(ctx context.Context) error {
var errs []error
for _, update := range u.deferredBases {
if err := update.repository.EditChange(ctx, update.change, forge.EditChangeOptions{
Base: update.base,
}); err != nil {
errs = append(errs, fmt.Errorf("update %v base: %w", update.change, err))
}
}
return errors.Join(errs...)
}

// nativeStackChanges projects every published change in a tree containing a
// submitted branch into the forge's native-stack representation.
//
// A submission may affect any branch in the same tree: adding or updating one
// change can complete a relationship elsewhere in its divergent upstack. The
// projection therefore starts at each submitted branch's bottom and retains
// all published changes for the target forge. If a branch's base is absent
// from that projection, the forge contract treats the branch as a tree root.
func nativeStackChanges(
graph *spice.BranchGraph,
forgeID string,
submitted []string,
) []forge.StackChange {
affectedBranches := make(map[string]struct{})
for _, branch := range submitted {
for member := range graph.Upstack(graph.Bottom(branch)) {
affectedBranches[member] = struct{}{}
}
}

changeByBranch := make(map[string]forge.ChangeID, len(affectedBranches))
for branch := range graph.All() {
if _, ok := affectedBranches[branch.Name]; !ok || branch.Change == nil {
continue
}
if branch.Change.ForgeID() == forgeID {
changeByBranch[branch.Name] = branch.Change.ChangeID()
}
}

changes := make([]forge.StackChange, 0, len(changeByBranch))
for branch := range graph.All() {
change, ok := changeByBranch[branch.Name]
if !ok {
continue
}
baseBranch := branch.Base
if base, ok := graph.Lookup(branch.Base); ok && base.UpstreamBranch != "" {
baseBranch = base.UpstreamBranch
}
changes = append(changes, forge.StackChange{
Change: change,
BaseChange: changeByBranch[branch.Base],
BaseBranch: baseBranch,
})
}
return changes
}
Loading
Loading