Repository: git-town/git-town

Issue:
set-parent (changing parent branch in general) overwrites the title incorrectly in bitbucket PR for corresponding proposal
I have branch `bugfix/explicit-view-access-checks` which is based on `feature/upgrade-to-dotnet-9` which in turn is based on master.

I open a proposal which creates a PR in bitbucket from `bugfix/explicit-view-access-checks` -> `feature/upgrade-to-dotnet-9`.

Someone merges `feature/upgrade-to-dotnet-9` to master, but forgets to click the checkbox that retargets my PR to master. Now I go to my repo, call `set-parent master` on my bugfix branch and get the following output:

```
git town set-parent

Looking for proposal online ... #704
Selected parent branch for "bugfix/explicit-view-access-checks": master

Updating target branch of proposal #704 to master ... ok
```

However I go to bitbucket and my PR's title has been set to an empty text, the barring below meaning the title has been erased.

> Malcolm Watt edited the title: ~~Make view permission checks explicit~~ 9 minutes ago

I think the title should be left alone by these types of operations if possible, hopefully it's not a limitation of the bibucket API.. It seems to me like git town is probably using a PUT endpoint instead of a PATCH (which is _hopefully_ availlable).

This impacts the UX particularly badly in bitbucket because the clickable link in the pull requests list is _on the title text_ so people can't click directly on the PR untilI have to go manually change the title back. To get there I need to navigate to the PR using the pull request number.. Obviously this UX particularity is nothing to do with git town, but it can still serve as a motivation to fix this issue 

Discussion:
@mw00120: I wonder if this should call get -> merge -> update instead of update directly? https://github.com/git-town/git-town/blob/6b322663e4896d3dffe46b1e6f54a70f448fbb62/internal/forge/bitbucketcloud/connector.go#L273

I don't actually know how to do that b/c I don't know go.. but hopefully that's useful analysis of the problem@kevgo: Thanks for reaching out about this. Sounds like a bug in our Bitbucket driver. Which is pretty new, so there might be bugs.@kevgo: Does this happen with a Bitbucket Cloud or Bitbucket DataCenter instance?@mw00120: Bitbucket cloud@kevgo: Thanks! I can reproduce the issue on my end.@kevgo: The issue seems indeed to be what @mw00120 indicated: that the Bitbucket driver that Git Town uses performs a PUT request. This seems to be a limitation of the BitBucket API, which only offers a PUT endpoint: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-put. I don't see PATCH endpoints there.@kevgo: Other people seem to run into this issue as well: https://community.atlassian.com/forums/Bitbucket-questions/How-to-update-only-the-description-of-a-pull-request-using-the/qaq-p/2844011@kevgo: When updating a proposal, the BitBucket driver requires the following fields. When not provided, some of them will reset to their zero value on Bitbucket's side.

- source branch
- source repo
- destination branch
- destination commit
- reviewers (list of UUIDs)
- title
- description
- message
- close-source-branch (bool)
- draft status
@kevgo: Unfortunately, there doesn't seem to be another Go driver for the Bitbucket API v2. So we will need to address this on our side.

- `forgedomain.Proposal` becomes an interface
- driver implementations return their own struct instances that implement the `forgedomain.Proposal` interface
- driver functions that update proposals receive an instance of their driver-specific proposal struct, rather than just the proposal number
  - they can then populate all the necessary fields to not lose data@kevgo: I have just released [v20.2](https://github.com/git-town/git-town/releases/tag/v20.2.0) to get this bug fix in your hand asap. Git Town now preserves various attributes of Bitbucket Cloud pull requests, including the description, draft status, and whether to close the source branch.

Please reach out if you run into any other issues. Thanks!@mw00120: Awesome, trying this right away@mw00120: This works, thanks @kevgo !

Solution patch:
diff --git a/internal/cli/dialog/lineage.go b/internal/cli/dialog/lineage.go
index f20b08664..c1bdff421 100644
--- a/internal/cli/dialog/lineage.go
+++ b/internal/cli/dialog/lineage.go
@@ -41,7 +41,7 @@ func Lineage(args LineageArgs) (additionalLineage configdomain.Lineage, addition
 			if searchProposals, canSearchProposals := connector.SearchProposalFn().Get(); canSearchProposals {
 				proposalOpt, _ := searchProposals(branchToVerify)
 				if proposal, hasProposal := proposalOpt.Get(); hasProposal {
-					parent := proposal.Target
+					parent := proposal.Data.GetTarget()
 					additionalLineage = additionalLineage.Set(branchToVerify, parent)
 					branchesToVerify = append(branchesToVerify, parent)
 					continue
diff --git a/internal/cmd/merge.go b/internal/cmd/merge.go
index d75b892df..fcb0b4df3 100644
--- a/internal/cmd/merge.go
+++ b/internal/cmd/merge.go
@@ -305,15 +305,15 @@ func mergeProgram(data mergeData, dryRun configdomain.DryRun) program.Program {
 		_, connectorCanUpdateTargetBranch := connector.UpdateProposalTargetFn().Get()
 		if hasInitialBranchProposal && connectorCanUpdateTargetBranch {
 			prog.Value.Add(&opcodes.ProposalUpdateTarget{
-				NewBranch:      data.grandParentBranch,
-				OldBranch:      data.parentBranch,
-				ProposalNumber: initialBranchProposal.Number,
+				NewBranch: data.grandParentBranch,
+				OldBranch: data.parentBranch,
+				Proposal:  initialBranchProposal,
 			})
 		} else if hasParentBranchProposal && connectorCanUpdateSourceBranch {
 			prog.Value.Add(&opcodes.ProposalUpdateSource{
-				NewBranch:      data.initialBranch,
-				OldBranch:      data.parentBranch,
-				ProposalNumber: parentBranchProposal.Number,
+				NewBranch: data.initialBranch,
+				OldBranch: data.parentBranch,
+				Proposal:  parentBranchProposal,
 			})
 		}
 	}
diff --git a/internal/cmd/prepend.go b/internal/cmd/prepend.go
index b01311780..1a9ca4227 100644
--- a/internal/cmd/prepend.go
+++ b/internal/cmd/prepend.go
@@ -421,9 +421,9 @@ func prependProgram(data prependData, finalMessages stringslice.Collector) progr
 	connectorCanUpdateProposalTargets := hasConnector && connector.UpdateProposalTargetFn().IsSome()
 	if hasProposal && hasConnector && connectorCanUpdateProposalTargets {
 		prog.Value.Add(&opcodes.ProposalUpdateTarget{
-			NewBranch:      data.targetBranch,
-			OldBranch:      data.existingParent,
-			ProposalNumber: proposal.Number,
+			NewBranch: data.targetBranch,
+			OldBranch: data.existingParent,
+			Proposal:  proposal,
 		})
 	}
 	moveCommitsToPrependedBranch(prog, data)
diff --git a/internal/cmd/propose.go b/internal/cmd/propose.go
index 0d458da31..038c8bab5 100644
--- a/internal/cmd/propose.go
+++ b/internal/cmd/propose.go
@@ -281,7 +281,7 @@ func determineProposeData(repo execute.OpenRepoResult, dryRun configdomain.DryRu
 					print.Error(err)
 				}
 				if existingProposal, has := existingProposalOpt.Get(); has {
-					existingProposalURL = Some(existingProposal.URL)
+					existingProposalURL = Some(existingProposal.Data.GetURL())
 				}
 			}
 		}
diff --git a/internal/cmd/rename.go b/internal/cmd/rename.go
index 3287d450f..f23de029f 100644
--- a/internal/cmd/rename.go
+++ b/internal/cmd/rename.go
@@ -302,9 +302,9 @@ func renameProgram(data renameData, finalMessages stringslice.Collector) program
 			connectorCanUpdateProposalSource := hasConnector && connector.UpdateProposalSourceFn().IsSome()
 			if hasProposal && hasConnector && connectorCanUpdateProposalSource {
 				prog.Value.Add(&opcodes.ProposalUpdateSource{
-					NewBranch:      data.newBranch,
-					OldBranch:      data.oldBranch.LocalBranchName(),
-					ProposalNumber: proposal.Number,
+					NewBranch: data.newBranch,
+					OldBranch: data.oldBranch.LocalBranchName(),
+					Proposal:  proposal,
 				})
 			}
 			prog.Value.Add(&opcodes.BranchTrackingDelete{Branch: oldTrackingBranch})
@@ -324,9 +324,9 @@ func renameProgram(data renameData, finalMessages stringslice.Collector) program
 func updateChildBranchProposalsToBranch(prog *program.Program, proposals []forgedomain.Proposal, target gitdomain.LocalBranchName) {
 	for _, childProposal := range proposals {
 		prog.Add(&opcodes.ProposalUpdateTarget{
-			NewBranch:      target,
-			OldBranch:      childProposal.Target,
-			ProposalNumber: childProposal.Number,
+			NewBranch: target,
+			OldBranch: childProposal.Data.GetTarget(),
+			Proposal:  childProposal,
 		})
 	}
 }
diff --git a/internal/cmd/set_parent.go b/internal/cmd/set_parent.go
index ec6f4e497..2aa6a4e16 100644
--- a/internal/cmd/set_parent.go
+++ b/internal/cmd/set_parent.go
@@ -274,9 +274,9 @@ func setParentProgram(dialogOutcome dialog.ParentOutcome, selectedBranch gitdoma
 		connectorCanUpdateProposalTarget := hasConnector && connector.UpdateProposalTargetFn().IsSome()
 		if hasProposal && hasConnector && connectorCanUpdateProposalTarget {
 			prog.Add(&opcodes.ProposalUpdateTarget{
-				NewBranch:      selectedBranch,
-				OldBranch:      proposal.Target,
-				ProposalNumber: proposal.Number,
+				NewBranch: selectedBranch,
+				OldBranch: proposal.Data.GetTarget(),
+				Proposal:  proposal,
 			})
 		}
 		// update commits
diff --git a/internal/cmd/ship/api.go b/internal/cmd/ship/api.go
index 766b0bd0a..65acb335f 100644
--- a/internal/cmd/ship/api.go
+++ b/internal/cmd/ship/api.go
@@ -18,7 +18,6 @@ type shipDataAPI struct {
 	branchToShipRemoteName gitdomain.RemoteBranchName
 	connector              forgedomain.Connector
 	proposal               forgedomain.Proposal
-	proposalMessage        string
 }
 
 func determineAPIData(sharedData sharedShipData) (result shipDataAPI, err error) {
@@ -42,12 +41,10 @@ func determineAPIData(sharedData sharedShipData) (result shipDataAPI, err error)
 	if !hasProposal {
 		return result, fmt.Errorf(messages.ShipAPINoProposal, sharedData.branchNameToShip)
 	}
-	proposalMessage := connector.DefaultProposalMessage(proposal)
 	return shipDataAPI{
 		branchToShipRemoteName: branchToShipRemoteName,
 		connector:              connector,
 		proposal:               proposal,
-		proposalMessage:        proposalMessage,
 	}, nil
 }
 
@@ -63,10 +60,9 @@ func shipAPIProgram(prog Mutable[program.Program], sharedData sharedShipData, ap
 		return errors.New(messages.ShipAPIConnectorUnsupported)
 	}
 	prog.Value.Add(&opcodes.ConnectorProposalMerge{
-		Branch:          branchToShipLocal,
-		ProposalNumber:  apiData.proposal.Number,
-		CommitMessage:   commitMessage,
-		ProposalMessage: apiData.proposalMessage,
+		Branch:        branchToShipLocal,
+		Proposal:      apiData.proposal,
+		CommitMessage: commitMessage,
 	})
 	if sharedData.config.NormalConfig.ShipDeleteTrackingBranch {
 		prog.Value.Add(&opcodes.BranchTrackingDelete{Branch: apiData.branchToShipRemoteName})
diff --git a/internal/cmd/ship/cmd.go b/internal/cmd/ship/cmd.go
index 40bf36534..910b8f1bf 100644
--- a/internal/cmd/ship/cmd.go
+++ b/internal/cmd/ship/cmd.go
@@ -174,9 +174,9 @@ func executeShip(args []string, message Option[gitdomain.CommitMessage], dryRun
 func UpdateChildBranchProposalsToGrandParent(prog *program.Program, proposals []forgedomain.Proposal) {
 	for _, childProposal := range proposals {
 		prog.Add(&opcodes.ProposalUpdateTargetToGrandParent{
-			Branch:         childProposal.Source,
-			OldTarget:      childProposal.Target,
-			ProposalNumber: childProposal.Number,
+			Branch:    childProposal.Data.GetSource(),
+			OldTarget: childProposal.Data.GetTarget(),
+			Proposal:  childProposal,
 		})
 	}
 }
diff --git a/internal/forge/bitbucketcloud/connector.go b/internal/forge/bitbucketcloud/connector.go
index 49fde7f1c..9da638bfb 100644
--- a/internal/forge/bitbucketcloud/connector.go
+++ b/internal/forge/bitbucketcloud/connector.go
@@ -49,7 +49,7 @@ type NewConnectorArgs struct {
 }
 
 func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (#%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -82,11 +82,11 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return Some(self.squashMergeProposal)
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, source gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, source gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	return Some(self.updateProposalSource)
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	return Some(self.updateProposalTarget)
 }
 
@@ -156,7 +156,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 		return None[forgedomain.Proposal](), nil
 	}
 	self.log.Success(fmt.Sprintf("#%d", proposal4.Number))
-	return Some(proposal4), nil
+	return Some(forgedomain.Proposal{Data: proposal4, ForgeType: forgedomain.ForgeTypeBitbucket}), nil
 }
 
 func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -166,15 +166,20 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 	if proposalURLOverride == forgedomain.OverrideNoProposal {
 		return None[forgedomain.Proposal](), nil
 	}
-	return Some(forgedomain.Proposal{
-		Body:         None[string](),
-		MergeWithAPI: true,
-		Number:       123,
-		Source:       branch,
-		Target:       target,
-		Title:        "title",
-		URL:          proposalURLOverride,
-	}), nil
+	proposal := forgedomain.BitbucketCloudProposalData{
+		ProposalData: forgedomain.ProposalData{
+			Body:         None[string](),
+			MergeWithAPI: true,
+			Number:       123,
+			Source:       branch,
+			Target:       target,
+			Title:        "title",
+			URL:          proposalURLOverride,
+		},
+		CloseSourceBranch: false,
+		Draft:             false,
+	}
+	return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeBitbucket}), nil
 }
 
 func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -230,7 +235,7 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 		return None[forgedomain.Proposal](), nil
 	}
 	self.log.Success(proposal2.Target.String())
-	return Some(proposal2), nil
+	return Some(forgedomain.Proposal{Data: proposal2, ForgeType: forgedomain.ForgeTypeBitbucket}), nil
 }
 
 func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMessage) error {
@@ -252,13 +257,19 @@ func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMe
 	return nil
 }
 
-func (self Connector) updateProposalSource(number int, source gitdomain.LocalBranchName, _ stringslice.Collector) error {
-	self.log.Start(messages.APIUpdateProposalSource, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(source.String()))
+func (self Connector) updateProposalSource(proposal forgedomain.Proposal, source gitdomain.LocalBranchName, _ stringslice.Collector) error {
+	bitbucketData := proposal.Data.(forgedomain.BitbucketCloudProposalData)
+	self.log.Start(messages.APIUpdateProposalSource, colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Data.GetNumber())), colors.BoldCyan().Styled(source.String()))
 	_, err := self.client.Repositories.PullRequests.Update(&bitbucket.PullRequestsOptions{
-		ID:           strconv.Itoa(number),
-		Owner:        self.Organization,
-		RepoSlug:     self.Repository,
-		SourceBranch: source.String(),
+		ID:                strconv.Itoa(proposal.Data.GetNumber()),
+		Owner:             self.Organization,
+		RepoSlug:          self.Repository,
+		SourceBranch:      source.String(),
+		DestinationBranch: proposal.Data.GetTarget().String(),
+		Title:             proposal.Data.GetTitle(),
+		Description:       proposal.Data.GetBody().GetOrDefault(),
+		Draft:             bitbucketData.Draft,
+		CloseSourceBranch: bitbucketData.CloseSourceBranch,
 	})
 	if err != nil {
 		self.log.Failed(err.Error())
@@ -268,14 +279,19 @@ func (self Connector) updateProposalSource(number int, source gitdomain.LocalBra
 	return nil
 }
 
-func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
-	targetName := target.String()
-	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(targetName))
+func (self Connector) updateProposalTarget(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+	bitbucketData := proposal.Data.(forgedomain.BitbucketCloudProposalData)
+	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Data.GetNumber())), colors.BoldCyan().Styled(target.String()))
 	_, err := self.client.Repositories.PullRequests.Update(&bitbucket.PullRequestsOptions{
-		ID:                strconv.Itoa(number),
+		ID:                strconv.Itoa(proposal.Data.GetNumber()),
 		Owner:             self.Organization,
 		RepoSlug:          self.Repository,
+		SourceBranch:      proposal.Data.GetSource().String(),
 		DestinationBranch: target.String(),
+		Title:             proposal.Data.GetTitle(),
+		Description:       proposal.Data.GetBody().GetOrDefault(),
+		Draft:             bitbucketData.Draft,
+		CloseSourceBranch: bitbucketData.CloseSourceBranch,
 	})
 	if err != nil {
 		self.log.Failed(err.Error())
@@ -285,7 +301,7 @@ func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBra
 	return nil
 }
 
-func parsePullRequest(pullRequest map[string]interface{}) (result forgedomain.Proposal, err error) {
+func parsePullRequest(pullRequest map[string]interface{}) (result forgedomain.BitbucketCloudProposalData, err error) {
 	id1, has := pullRequest["id"]
 	if !has {
 		return result, errors.New(messages.APIUnexpectedResultDataStructure)
@@ -383,13 +399,33 @@ func parsePullRequest(pullRequest map[string]interface{}) (result forgedomain.Pr
 	if !ok {
 		return result, errors.New(messages.APIUnexpectedResultDataStructure)
 	}
-	return forgedomain.Proposal{
-		MergeWithAPI: false,
-		Number:       number,
-		Source:       gitdomain.NewLocalBranchName(source6),
-		Target:       gitdomain.NewLocalBranchName(destination6),
-		Title:        title2,
-		Body:         NewOption(body2),
-		URL:          url6,
+	closeSourceBranch1, has := pullRequest["close_source_branch"]
+	if !has {
+		return result, errors.New(messages.APIUnexpectedResultDataStructure)
+	}
+	closeSourceBranch2, ok := closeSourceBranch1.(bool)
+	if !ok {
+		return result, errors.New(messages.APIUnexpectedResultDataStructure)
+	}
+	draft1, has := pullRequest["draft"]
+	if !has {
+		return result, errors.New(messages.APIUnexpectedResultDataStructure)
+	}
+	draft2, ok := draft1.(bool)
+	if !ok {
+		return result, errors.New(messages.APIUnexpectedResultDataStructure)
+	}
+	return forgedomain.BitbucketCloudProposalData{
+		ProposalData: forgedomain.ProposalData{
+			MergeWithAPI: false,
+			Number:       number,
+			Source:       gitdomain.NewLocalBranchName(source6),
+			Target:       gitdomain.NewLocalBranchName(destination6),
+			Title:        title2,
+			Body:         NewOption(body2),
+			URL:          url6,
+		},
+		CloseSourceBranch: closeSourceBranch2,
+		Draft:             draft2,
 	}, nil
 }
diff --git a/internal/forge/bitbucketdatacenter/connector.go b/internal/forge/bitbucketdatacenter/connector.go
index ebc6d077d..6b474dff6 100644
--- a/internal/forge/bitbucketdatacenter/connector.go
+++ b/internal/forge/bitbucketdatacenter/connector.go
@@ -48,7 +48,7 @@ type NewConnectorArgs struct {
 }
 
 func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (#%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -79,12 +79,12 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return None[func(number int, message gitdomain.CommitMessage) error]()
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, source gitdomain.LocalBranchName, _ stringslice.Collector) error] {
-	return None[func(number int, source gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, source gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, source gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
-	return None[func(number int, source gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, source gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 }
 
 func (self Connector) apiBaseURL() string {
@@ -128,7 +128,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 	}
 	proposal := parsePullRequest(*needle, self.RepositoryURL())
 	self.log.Success(fmt.Sprintf("#%d", proposal.Number))
-	return Some(proposal), nil
+	return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeBitbucketDatacenter}), nil
 }
 
 func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -138,7 +138,7 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 	if proposalURLOverride == forgedomain.OverrideNoProposal {
 		return None[forgedomain.Proposal](), nil
 	}
-	return Some(forgedomain.Proposal{
+	data := forgedomain.ProposalData{
 		Body:         None[string](),
 		MergeWithAPI: true,
 		Number:       123,
@@ -146,7 +146,8 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 		Target:       target,
 		Title:        "title",
 		URL:          proposalURLOverride,
-	}), nil
+	}
+	return Some(forgedomain.Proposal{Data: data, ForgeType: forgedomain.ForgeTypeBitbucketDatacenter}), nil
 }
 
 func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -179,11 +180,11 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 	}
 	proposal := parsePullRequest(*needle, self.RepositoryURL())
 	self.log.Success(fmt.Sprintf("#%d", proposal.Number))
-	return Some(proposal), nil
+	return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeBitbucketDatacenter}), nil
 }
 
-func parsePullRequest(pullRequest PullRequest, repoURL string) forgedomain.Proposal {
-	return forgedomain.Proposal{
+func parsePullRequest(pullRequest PullRequest, repoURL string) forgedomain.ProposalData {
+	return forgedomain.ProposalData{
 		MergeWithAPI: false,
 		Number:       pullRequest.ID,
 		Source:       gitdomain.NewLocalBranchName(pullRequest.FromRef.DisplayID),
diff --git a/internal/forge/codeberg/connector.go b/internal/forge/codeberg/connector.go
index e6d946613..dc9a38aa5 100644
--- a/internal/forge/codeberg/connector.go
+++ b/internal/forge/codeberg/connector.go
@@ -28,7 +28,7 @@ type Connector struct {
 }
 
 func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (#%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -64,15 +64,15 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return None[func(number int, message gitdomain.CommitMessage) error]()
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
-	return None[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	if self.APIToken.IsSome() {
 		return Some(self.updateProposalTarget)
 	}
-	return None[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+	return None[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 }
 
 func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -95,7 +95,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 	case 1:
 		proposal := parsePullRequest(pullRequests[0])
 		self.log.Success(proposal.Target.String())
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeCodeberg}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromToFound, len(pullRequests), branch, target)
 	}
@@ -109,13 +109,16 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 		return None[forgedomain.Proposal](), nil
 	}
 	return Some(forgedomain.Proposal{
-		Body:         None[string](),
-		MergeWithAPI: true,
-		Number:       123,
-		Source:       branch,
-		Target:       target,
-		Title:        "title",
-		URL:          proposalURLOverride,
+		Data: forgedomain.ProposalData{
+			Body:         None[string](),
+			MergeWithAPI: true,
+			Number:       123,
+			Source:       branch,
+			Target:       target,
+			Title:        "title",
+			URL:          proposalURLOverride,
+		},
+		ForgeType: forgedomain.ForgeTypeCodeberg,
 	}), nil
 }
 
@@ -140,7 +143,7 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 		pullRequest := pullRequests[0]
 		proposal := parsePullRequest(pullRequest)
 		self.log.Success(proposal.Target.String())
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeCodeberg}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromFound, len(pullRequests), branch)
 	}
@@ -168,10 +171,10 @@ func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMe
 	return err
 }
 
-func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+func (self Connector) updateProposalTarget(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
 	targetName := target.String()
-	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(targetName))
-	_, _, err := self.client.EditPullRequest(self.Organization, self.Repository, int64(number), forgejo.EditPullRequestOption{
+	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Data.GetNumber())), colors.BoldCyan().Styled(targetName))
+	_, _, err := self.client.EditPullRequest(self.Organization, self.Repository, int64(proposal.Data.GetNumber()), forgejo.EditPullRequestOption{
 		Base: targetName,
 	})
 	if err != nil {
@@ -223,8 +226,8 @@ type NewConnectorArgs struct {
 	RemoteURL giturl.Parts
 }
 
-func parsePullRequest(pullRequest *forgejo.PullRequest) forgedomain.Proposal {
-	return forgedomain.Proposal{
+func parsePullRequest(pullRequest *forgejo.PullRequest) forgedomain.ProposalData {
+	return forgedomain.ProposalData{
 		MergeWithAPI: pullRequest.Mergeable,
 		Number:       int(pullRequest.Index),
 		Source:       gitdomain.NewLocalBranchName(pullRequest.Head.Ref),
diff --git a/internal/forge/forgedomain/connector.go b/internal/forge/forgedomain/connector.go
index f51a528f2..774fdbc77 100644
--- a/internal/forge/forgedomain/connector.go
+++ b/internal/forge/forgedomain/connector.go
@@ -45,11 +45,11 @@ type Connector interface {
 	// calling this function returns a function that you can call
 	// to update the source branch of the proposal with the given number.
 	// A None return value indicates that this connector does not support this feature (yet).
-	UpdateProposalSourceFn() Option[func(number int, newSource gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]
+	UpdateProposalSourceFn() Option[func(proposal Proposal, newSource gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]
 
 	// If this connector instance supports loading proposals via the API,
 	// calling this function returns a function that you can call
 	// to update the target branch of the proposal with the given number.
 	// A None return value indicates that this connector does not support this feature (yet).
-	UpdateProposalTargetFn() Option[func(number int, newTarget gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]
+	UpdateProposalTargetFn() Option[func(proposal Proposal, newTarget gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]
 }
diff --git a/internal/forge/forgedomain/proposal.go b/internal/forge/forgedomain/proposal.go
index 0afd742ad..d21d7db31 100644
--- a/internal/forge/forgedomain/proposal.go
+++ b/internal/forge/forgedomain/proposal.go
@@ -1,41 +1,55 @@
 package forgedomain
 
 import (
-	"github.com/git-town/git-town/v20/internal/git/gitdomain"
-	. "github.com/git-town/git-town/v20/pkg/prelude"
+	"encoding/json"
+	"fmt"
+
+	"github.com/git-town/git-town/v20/internal/messages"
 )
 
-// Proposal contains information about a change request on a forge.
-// Alternative names are "pull request" or "merge request".
+// Proposal is a wrapper type that makes the Proposal interface serializable to and from JSON.
 type Proposal struct {
-	// text of the body of the proposal
-	// if Some, the string is guaranteed to be non-empty
-	Body Option[string]
-
-	// whether this proposal can be merged via the API
-	MergeWithAPI bool
-
-	// the number used to identify the proposal on the forge
-	Number int
-
-	// name of the source branch ("head") of this proposal
-	Source gitdomain.LocalBranchName
-
-	// name of the target branch ("base") of this proposal
-	Target gitdomain.LocalBranchName
-
-	// text of the title of the proposal
-	Title string
+	Data      ProposalInterface
+	ForgeType ForgeType
+}
 
-	// the URL of this proposal
-	URL string
+func (self Proposal) MarshalJSON() ([]byte, error) {
+	return json.Marshal(map[string]interface{}{
+		"data":       self.Data,
+		"forge-type": self.ForgeType.String(),
+	})
 }
 
-func (self Proposal) CommitBody(title string) string {
-	result := title
-	if body, has := self.Body.Get(); has {
-		result += "\n\n"
-		result += body
+// UnmarshalJSON is used when de-serializing a Proposal from JSON.
+func (self *Proposal) UnmarshalJSON(b []byte) error {
+	var mapping map[string]json.RawMessage
+	err := json.Unmarshal(b, &mapping)
+	if err != nil {
+		return err
+	}
+	var forgeTypeName string
+	err = json.Unmarshal(mapping["forge-type"], &forgeTypeName)
+	if err != nil {
+		return err
+	}
+	forgeTypeOpt, err := ParseForgeType(forgeTypeName)
+	if err != nil {
+		return err
+	}
+	forgeType, hasForgeType := forgeTypeOpt.Get()
+	if !hasForgeType {
+		return fmt.Errorf(messages.ForgeTypeUnknown, forgeTypeName)
+	}
+	switch forgeType {
+	case ForgeTypeBitbucket:
+		var data BitbucketCloudProposalData
+		err = json.Unmarshal(mapping["data"], &data)
+		self.Data = data
+	case ForgeTypeBitbucketDatacenter, ForgeTypeCodeberg, ForgeTypeGitHub, ForgeTypeGitLab, ForgeTypeGitea:
+		var data ProposalData
+		err = json.Unmarshal(mapping["data"], &data)
+		self.Data = data
 	}
-	return result
+	self.ForgeType = forgeType
+	return err
 }
diff --git a/internal/forge/forgedomain/proposal_data.go b/internal/forge/forgedomain/proposal_data.go
new file mode 100644
index 000000000..4c76395f5
--- /dev/null
+++ b/internal/forge/forgedomain/proposal_data.go
@@ -0,0 +1,50 @@
+package forgedomain
+
+import (
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+)
+
+type ProposalData struct {
+	Body         Option[string]
+	MergeWithAPI bool
+	Number       int
+	Source       gitdomain.LocalBranchName
+	Target       gitdomain.LocalBranchName
+	Title        string
+	URL          string
+}
+
+func (self ProposalData) GetBody() Option[string] {
+	return self.Body
+}
+
+func (self ProposalData) GetMergeWithAPI() bool {
+	return self.MergeWithAPI
+}
+
+func (self ProposalData) GetNumber() int {
+	return self.Number
+}
+
+func (self ProposalData) GetSource() gitdomain.LocalBranchName {
+	return self.Source
+}
+
+func (self ProposalData) GetTarget() gitdomain.LocalBranchName {
+	return self.Target
+}
+
+func (self ProposalData) GetTitle() string {
+	return self.Title
+}
+
+func (self ProposalData) GetURL() string {
+	return self.URL
+}
+
+type BitbucketCloudProposalData struct {
+	ProposalData
+	CloseSourceBranch bool
+	Draft             bool
+}
diff --git a/internal/forge/forgedomain/proposal_interface.go b/internal/forge/forgedomain/proposal_interface.go
new file mode 100644
index 000000000..feaf59b34
--- /dev/null
+++ b/internal/forge/forgedomain/proposal_interface.go
@@ -0,0 +1,41 @@
+package forgedomain
+
+import (
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+)
+
+// ProposalInterface provides information about a change request on a forge.
+// Alternative names are "pull request" or "merge request".
+type ProposalInterface interface {
+	// text of the body of the proposal
+	// if Some, the string is guaranteed to be non-empty
+	GetBody() Option[string]
+
+	// whether this proposal can be merged via the API
+	GetMergeWithAPI() bool
+
+	// the number used to identify the proposal on the forge
+	GetNumber() int
+
+	// name of the source branch ("head") of this proposal
+	GetSource() gitdomain.LocalBranchName
+
+	// name of the target branch ("base") of this proposal
+	GetTarget() gitdomain.LocalBranchName
+
+	// text of the title of the proposal
+	GetTitle() string
+
+	// the URL of this proposal
+	GetURL() string
+}
+
+func CommitBody(proposal ProposalInterface, title string) string {
+	result := title
+	if body, has := proposal.GetBody().Get(); has {
+		result += "\n\n"
+		result += body
+	}
+	return result
+}
diff --git a/internal/forge/gitea/connector.go b/internal/forge/gitea/connector.go
index 453f551e8..f9dadf0af 100644
--- a/internal/forge/gitea/connector.go
+++ b/internal/forge/gitea/connector.go
@@ -28,7 +28,7 @@ type Connector struct {
 }
 
 func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (#%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -64,15 +64,15 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return None[func(number int, message gitdomain.CommitMessage) error]()
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
-	return None[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	if self.APIToken.IsSome() {
 		return Some(self.updateProposalTarget)
 	}
-	return None[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+	return None[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 }
 
 func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -95,7 +95,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 	case 1:
 		proposal := parsePullRequest(pullRequests[0])
 		self.log.Success(proposal.Target.String())
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitea}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromToFound, len(pullRequests), branch, target)
 	}
@@ -109,13 +109,16 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 		return None[forgedomain.Proposal](), nil
 	}
 	return Some(forgedomain.Proposal{
-		Body:         None[string](),
-		MergeWithAPI: true,
-		Number:       123,
-		Source:       branch,
-		Target:       target,
-		Title:        "title",
-		URL:          proposalURLOverride,
+		Data: forgedomain.ProposalData{
+			Body:         None[string](),
+			MergeWithAPI: true,
+			Number:       123,
+			Source:       branch,
+			Target:       target,
+			Title:        "title",
+			URL:          proposalURLOverride,
+		},
+		ForgeType: forgedomain.ForgeTypeGitea,
 	}), nil
 }
 
@@ -140,7 +143,7 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 		pullRequest := pullRequests[0]
 		proposal := parsePullRequest(pullRequest)
 		self.log.Success(proposal.Target.String())
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitea}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromFound, len(pullRequests), branch)
 	}
@@ -168,10 +171,10 @@ func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMe
 	return err
 }
 
-func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+func (self Connector) updateProposalTarget(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
 	targetName := target.String()
-	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(targetName))
-	_, _, err := self.client.EditPullRequest(self.Organization, self.Repository, int64(number), gitea.EditPullRequestOption{
+	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Data.GetNumber())), colors.BoldCyan().Styled(targetName))
+	_, _, err := self.client.EditPullRequest(self.Organization, self.Repository, int64(proposal.Data.GetNumber()), gitea.EditPullRequestOption{
 		Base: targetName,
 	})
 	if err != nil {
@@ -226,8 +229,8 @@ type NewConnectorArgs struct {
 	RemoteURL giturl.Parts
 }
 
-func parsePullRequest(pullRequest *gitea.PullRequest) forgedomain.Proposal {
-	return forgedomain.Proposal{
+func parsePullRequest(pullRequest *gitea.PullRequest) forgedomain.ProposalData {
+	return forgedomain.ProposalData{
 		MergeWithAPI: pullRequest.Mergeable,
 		Number:       int(pullRequest.Index),
 		Source:       gitdomain.NewLocalBranchName(pullRequest.Head.Ref),
diff --git a/internal/forge/github/connector.go b/internal/forge/github/connector.go
index 0dabf4e51..93c08abf0 100644
--- a/internal/forge/github/connector.go
+++ b/internal/forge/github/connector.go
@@ -30,7 +30,7 @@ type Connector struct {
 }
 
 func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (#%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
@@ -76,13 +76,13 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return Some(self.squashMergeProposal)
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
-	return None[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	if self.APIToken.IsNone() {
-		return None[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+		return None[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 	}
 	return Some(self.updateProposalTarget)
 }
@@ -107,7 +107,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 	}
 	proposal := parsePullRequest(pullRequests[0])
 	self.log.Log(fmt.Sprintf("%s (%s)", colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Number)), proposal.Title))
-	return Some(proposal), nil
+	return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitHub}), nil
 }
 
 func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
@@ -118,13 +118,16 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 		return None[forgedomain.Proposal](), nil
 	}
 	return Some(forgedomain.Proposal{
-		Body:         None[string](),
-		MergeWithAPI: true,
-		Number:       123,
-		Source:       branch,
-		Target:       target,
-		Title:        "title",
-		URL:          proposalURLOverride,
+		Data: forgedomain.ProposalData{
+			Body:         None[string](),
+			MergeWithAPI: true,
+			Number:       123,
+			Source:       branch,
+			Target:       target,
+			Title:        "title",
+			URL:          proposalURLOverride,
+		},
+		ForgeType: forgedomain.ForgeTypeGitHub,
 	}), nil
 }
 
@@ -147,7 +150,7 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 	}
 	proposal := parsePullRequest(pullRequests[0])
 	self.log.Success(proposal.Target.String())
-	return Some(proposal), nil
+	return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitHub}), nil
 }
 
 func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMessage) (err error) {
@@ -166,10 +169,10 @@ func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMe
 	return err
 }
 
-func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+func (self Connector) updateProposalTarget(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
 	targetName := target.String()
-	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(targetName))
-	_, _, err := self.client.PullRequests.Edit(context.Background(), self.Organization, self.Repository, number, &github.PullRequest{
+	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(proposal.Data.GetNumber())), colors.BoldCyan().Styled(targetName))
+	_, _, err := self.client.PullRequests.Edit(context.Background(), self.Organization, self.Repository, proposal.Data.GetNumber(), &github.PullRequest{
 		Base: &github.PullRequestBranch{
 			Ref: &(targetName),
 		},
@@ -215,13 +218,13 @@ type NewConnectorArgs struct {
 }
 
 // parsePullRequest extracts standardized proposal data from the given GitHub pull-request.
-func parsePullRequest(pullRequest *github.PullRequest) forgedomain.Proposal {
-	return forgedomain.Proposal{
+func parsePullRequest(pullRequest *github.PullRequest) forgedomain.ProposalData {
+	return forgedomain.ProposalData{
+		Body:         NewOption(pullRequest.GetBody()),
 		Number:       pullRequest.GetNumber(),
 		Source:       gitdomain.NewLocalBranchName(pullRequest.Head.GetRef()),
 		Target:       gitdomain.NewLocalBranchName(pullRequest.Base.GetRef()),
 		Title:        pullRequest.GetTitle(),
-		Body:         NewOption(pullRequest.GetBody()),
 		MergeWithAPI: pullRequest.GetMergeableState() == "clean",
 		URL:          *pullRequest.HTMLURL,
 	}
diff --git a/internal/forge/gitlab/connector.go b/internal/forge/gitlab/connector.go
index 3b24a0d2f..400191938 100644
--- a/internal/forge/gitlab/connector.go
+++ b/internal/forge/gitlab/connector.go
@@ -49,13 +49,13 @@ func (self Connector) SquashMergeProposalFn() Option[func(number int, message gi
 	return Some(self.squashMergeProposal)
 }
 
-func (self Connector) UpdateProposalSourceFn() Option[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
-	return None[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
+func (self Connector) UpdateProposalSourceFn() Option[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
+	return None[func(proposal forgedomain.Proposal, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
 }
 
-func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+func (self Connector) UpdateProposalTargetFn() Option[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
 	if self.APIToken.IsNone() {
-		return None[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+		return None[func(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
 	}
 	return Some(self.updateProposalTarget)
 }
@@ -79,7 +79,7 @@ func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchNam
 	case 1:
 		proposal := parseMergeRequest(mergeRequests[0])
 		self.log.Success(strconv.Itoa(proposal.Number))
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitLab}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromToFound, len(mergeRequests), branch, target)
 	}
@@ -93,13 +93,16 @@ func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBran
 		return None[forgedomain.Proposal](), nil
 	}
 	return Some(forgedomain.Proposal{
-		Body:         None[string](),
-		MergeWithAPI: true,
-		Number:       123,
-		Source:       branch,
-		Target:       target,
-		Title:        "title",
-		URL:          proposalURLOverride,
+		Data: forgedomain.ProposalData{
+			Body:         None[string](),
+			MergeWithAPI: true,
+			Number:       123,
+			Source:       branch,
+			Target:       target,
+			Title:        "title",
+			URL:          proposalURLOverride,
+		},
+		ForgeType: forgedomain.ForgeTypeGitLab,
 	}), nil
 }
 
@@ -121,7 +124,7 @@ func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[f
 	case 1:
 		proposal := parseMergeRequest(mergeRequests[0])
 		self.log.Success(proposal.Target.String())
-		return Some(proposal), nil
+		return Some(forgedomain.Proposal{Data: proposal, ForgeType: forgedomain.ForgeTypeGitLab}), nil
 	default:
 		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromFound, len(mergeRequests), branch)
 	}
@@ -147,9 +150,9 @@ func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMe
 	return nil
 }
 
-func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
-	self.log.Start(messages.ForgeGitlabUpdateMRViaAPI, number, target)
-	_, _, err := self.client.MergeRequests.UpdateMergeRequest(self.projectPath(), number, &gitlab.UpdateMergeRequestOptions{
+func (self Connector) updateProposalTarget(proposal forgedomain.Proposal, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+	self.log.Start(messages.ForgeGitlabUpdateMRViaAPI, proposal.Data.GetNumber(), target)
+	_, _, err := self.client.MergeRequests.UpdateMergeRequest(self.projectPath(), proposal.Data.GetNumber(), &gitlab.UpdateMergeRequestOptions{
 		TargetBranch: gitlab.Ptr(target.String()),
 	})
 	if err != nil {
@@ -191,8 +194,8 @@ type NewConnectorArgs struct {
 	RemoteURL giturl.Parts
 }
 
-func parseMergeRequest(mergeRequest *gitlab.BasicMergeRequest) forgedomain.Proposal {
-	return forgedomain.Proposal{
+func parseMergeRequest(mergeRequest *gitlab.BasicMergeRequest) forgedomain.ProposalData {
+	return forgedomain.ProposalData{
 		MergeWithAPI: true,
 		Number:       mergeRequest.IID,
 		Source:       gitdomain.NewLocalBranchName(mergeRequest.SourceBranch),
diff --git a/internal/forge/gitlab/data.go b/internal/forge/gitlab/data.go
index 4ca7aace7..01364c809 100644
--- a/internal/forge/gitlab/data.go
+++ b/internal/forge/gitlab/data.go
@@ -16,7 +16,7 @@ type Data struct {
 }
 
 func (self Data) DefaultProposalMessage(proposal forgedomain.Proposal) string {
-	return proposal.CommitBody(fmt.Sprintf("%s (!%d)", proposal.Title, proposal.Number))
+	return forgedomain.CommitBody(proposal.Data, fmt.Sprintf("%s (!%d)", proposal.Data.GetTitle(), proposal.Data.GetNumber()))
 }
 
 func (self Data) NewProposalURL(branch, parentBranch, _ gitdomain.LocalBranchName, _ gitdomain.ProposalTitle, _ gitdomain.ProposalBody) (string, error) {
diff --git a/internal/vm/opcodes/connector_proposal_merge.go b/internal/vm/opcodes/connector_proposal_merge.go
index af10a888f..d22c0f33f 100644
--- a/internal/vm/opcodes/connector_proposal_merge.go
+++ b/internal/vm/opcodes/connector_proposal_merge.go
@@ -15,8 +15,7 @@ import (
 type ConnectorProposalMerge struct {
 	Branch                    gitdomain.LocalBranchName
 	CommitMessage             Option[gitdomain.CommitMessage]
-	ProposalMessage           string
-	ProposalNumber            int
+	Proposal                  forgedomain.Proposal
 	enteredEmptyCommitMessage bool
 	mergeError                error
 	undeclaredOpcodeMethods   `exhaustruct:"optional"`
@@ -46,7 +45,7 @@ func (self *ConnectorProposalMerge) Run(args shared.RunArgs) error {
 		if err != nil {
 			return err
 		}
-		err = args.Git.CommentOutSquashCommitMessage(Some(self.ProposalMessage + "\n\n"))
+		err = args.Git.CommentOutSquashCommitMessage(Some(forgedomain.CommitBody(self.Proposal.Data, self.Proposal.Data.GetTitle()) + "\n\n"))
 		if err != nil {
 			return fmt.Errorf(messages.SquashMessageProblem, err)
 		}
@@ -72,7 +71,7 @@ func (self *ConnectorProposalMerge) Run(args shared.RunArgs) error {
 	if !canSquashMergeProposal {
 		return errors.New(messages.ShipAPIConnectorUnsupported)
 	}
-	self.mergeError = squashMergeProposal(self.ProposalNumber, commitMessage)
+	self.mergeError = squashMergeProposal(self.Proposal.Data.GetNumber(), commitMessage)
 	return self.mergeError
 }
 
diff --git a/internal/vm/opcodes/proposal_update_source.go b/internal/vm/opcodes/proposal_update_source.go
index 11f5d1a86..e56c8667d 100644
--- a/internal/vm/opcodes/proposal_update_source.go
+++ b/internal/vm/opcodes/proposal_update_source.go
@@ -14,12 +14,12 @@ import (
 type ProposalUpdateSource struct {
 	NewBranch               gitdomain.LocalBranchName
 	OldBranch               gitdomain.LocalBranchName
-	ProposalNumber          int
+	Proposal                forgedomain.Proposal
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *ProposalUpdateSource) AutomaticUndoError() error {
-	return fmt.Errorf(messages.ProposalTargetBranchUpdateProblem, self.ProposalNumber)
+	return fmt.Errorf(messages.ProposalTargetBranchUpdateProblem, self.Proposal.Data.GetNumber())
 }
 
 func (self *ProposalUpdateSource) Run(args shared.RunArgs) error {
@@ -31,7 +31,7 @@ func (self *ProposalUpdateSource) Run(args shared.RunArgs) error {
 	if !canUpdateProposalSource {
 		return errors.New(messages.ProposalSourceCannotUpdate)
 	}
-	return updateProposalSource(self.ProposalNumber, self.NewBranch, args.FinalMessages)
+	return updateProposalSource(self.Proposal, self.NewBranch, args.FinalMessages)
 }
 
 func (self *ProposalUpdateSource) ShouldUndoOnError() bool {
@@ -41,9 +41,9 @@ func (self *ProposalUpdateSource) ShouldUndoOnError() bool {
 func (self *ProposalUpdateSource) UndoExternalChangesProgram() []shared.Opcode {
 	return []shared.Opcode{
 		&ProposalUpdateSource{
-			NewBranch:      self.OldBranch,
-			OldBranch:      self.NewBranch,
-			ProposalNumber: self.ProposalNumber,
+			NewBranch: self.OldBranch,
+			OldBranch: self.NewBranch,
+			Proposal:  self.Proposal,
 		},
 	}
 }
diff --git a/internal/vm/opcodes/proposal_update_target.go b/internal/vm/opcodes/proposal_update_target.go
index 71f918630..9bb8d06e2 100644
--- a/internal/vm/opcodes/proposal_update_target.go
+++ b/internal/vm/opcodes/proposal_update_target.go
@@ -13,12 +13,12 @@ import (
 type ProposalUpdateTarget struct {
 	NewBranch               gitdomain.LocalBranchName
 	OldBranch               gitdomain.LocalBranchName
-	ProposalNumber          int
+	Proposal                forgedomain.Proposal
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *ProposalUpdateTarget) AutomaticUndoError() error {
-	return fmt.Errorf(messages.ProposalTargetBranchUpdateProblem, self.ProposalNumber)
+	return fmt.Errorf(messages.ProposalTargetBranchUpdateProblem, self.Proposal.Data.GetNumber())
 }
 
 func (self *ProposalUpdateTarget) Run(args shared.RunArgs) error {
@@ -30,7 +30,7 @@ func (self *ProposalUpdateTarget) Run(args shared.RunArgs) error {
 	if !canUpdateProposalTarget {
 		return forgedomain.UnsupportedServiceError()
 	}
-	return updateProposalTarget(self.ProposalNumber, self.NewBranch, args.FinalMessages)
+	return updateProposalTarget(self.Proposal, self.NewBranch, args.FinalMessages)
 }
 
 func (self *ProposalUpdateTarget) ShouldUndoOnError() bool {
@@ -40,9 +40,9 @@ func (self *ProposalUpdateTarget) ShouldUndoOnError() bool {
 func (self *ProposalUpdateTarget) UndoExternalChangesProgram() []shared.Opcode {
 	return []shared.Opcode{
 		&ProposalUpdateTarget{
-			NewBranch:      self.OldBranch,
-			OldBranch:      self.NewBranch,
-			ProposalNumber: self.ProposalNumber,
+			NewBranch: self.OldBranch,
+			OldBranch: self.NewBranch,
+			Proposal:  self.Proposal,
 		},
 	}
 }
diff --git a/internal/vm/opcodes/proposal_update_target_to_grandparent.go b/internal/vm/opcodes/proposal_update_target_to_grandparent.go
index 7f12b3027..da54fef0b 100644
--- a/internal/vm/opcodes/proposal_update_target_to_grandparent.go
+++ b/internal/vm/opcodes/proposal_update_target_to_grandparent.go
@@ -3,6 +3,7 @@ package opcodes
 import (
 	"fmt"
 
+	"github.com/git-town/git-town/v20/internal/forge/forgedomain"
 	"github.com/git-town/git-town/v20/internal/git/gitdomain"
 	"github.com/git-town/git-town/v20/internal/vm/shared"
 )
@@ -11,7 +12,7 @@ import (
 type ProposalUpdateTargetToGrandParent struct {
 	Branch                  gitdomain.LocalBranchName
 	OldTarget               gitdomain.LocalBranchName
-	ProposalNumber          int
+	Proposal                forgedomain.Proposal
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
@@ -21,9 +22,9 @@ func (self *ProposalUpdateTargetToGrandParent) Run(args shared.RunArgs) error {
 		return fmt.Errorf("branch %q has no parent", self.Branch)
 	}
 	args.PrependOpcodes(&ProposalUpdateTarget{
-		NewBranch:      parent,
-		OldBranch:      self.OldTarget,
-		ProposalNumber: self.ProposalNumber,
+		NewBranch: parent,
+		OldBranch: self.OldTarget,
+		Proposal:  self.Proposal,
 	})
 	return nil
 }



Test patch:
diff --git a/internal/forge/codeberg/connector_test.go b/internal/forge/codeberg/connector_test.go
index 2d3105888..21899ea3d 100644
--- a/internal/forge/codeberg/connector_test.go
+++ b/internal/forge/codeberg/connector_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/git-town/git-town/v20/internal/forge/github"
 	"github.com/git-town/git-town/v20/internal/git/giturl"
 
-	// . "github.com/git-town/git-town/v20/pkg/prelude"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
 	"github.com/shoenig/test/must"
 )
 
@@ -20,14 +20,36 @@ func TestConnector(t *testing.T) {
 
 	t.Run("DefaultProposalMessage", func(t *testing.T) {
 		t.Parallel()
-		give := forgedomain.Proposal{
-			Number: 1,
-			Title:  "my title",
-		}
-		want := "my title (#1)"
-		connector := codeberg.Connector{}
-		have := connector.DefaultProposalMessage(give)
-		must.EqOp(t, want, have)
+		t.Run("with body", func(t *testing.T) {
+			t.Parallel()
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Body:   Some("body"),
+					Number: 123,
+					Title:  "my title",
+				},
+				ForgeType: forgedomain.ForgeTypeCodeberg,
+			}
+			want := "my title (#123)\n\nbody"
+			connector := codeberg.Connector{}
+			have := connector.DefaultProposalMessage(give)
+			must.EqOp(t, want, have)
+		})
+		t.Run("without body", func(t *testing.T) {
+			t.Parallel()
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Body:   None[string](),
+					Number: 123,
+					Title:  "my title",
+				},
+				ForgeType: forgedomain.ForgeTypeCodeberg,
+			}
+			want := "my title (#123)"
+			connector := codeberg.Connector{}
+			have := connector.DefaultProposalMessage(give)
+			must.EqOp(t, want, have)
+		})
 	})
 
 	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
diff --git a/internal/forge/forgedomain/proposal_data_test.go b/internal/forge/forgedomain/proposal_data_test.go
new file mode 100644
index 000000000..4c24a31e0
--- /dev/null
+++ b/internal/forge/forgedomain/proposal_data_test.go
@@ -0,0 +1,54 @@
+package forgedomain_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/git-town/git-town/v20/internal/forge/forgedomain"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+	"github.com/shoenig/test/must"
+)
+
+func TestBitbucketCloudProposalData(t *testing.T) {
+	t.Parallel()
+
+	t.Run("MarshalJSON", func(t *testing.T) {
+		t.Parallel()
+		data := forgedomain.BitbucketCloudProposalData{
+			ProposalData: forgedomain.ProposalData{
+				Body:         Some("body"),
+				MergeWithAPI: true,
+				Number:       123,
+				Source:       "source",
+				Target:       "target",
+				Title:        "title",
+				URL:          "url",
+			},
+			CloseSourceBranch: true,
+			Draft:             true,
+		}
+		serialized, err := json.MarshalIndent(data, "", "  ")
+		must.NoError(t, err)
+		fmt.Println(string(serialized))
+		want := `
+{
+  "Body": "body",
+  "MergeWithAPI": true,
+  "Number": 123,
+  "Source": "source",
+  "Target": "target",
+  "Title": "title",
+  "URL": "url",
+  "CloseSourceBranch": true,
+  "Draft": true
+}`[1:]
+		must.EqOp(t, want, string(serialized))
+
+		var data2 forgedomain.BitbucketCloudProposalData
+		must.NoError(t, json.Unmarshal(serialized, &data2))
+		must.Eq(t, data, data2)
+		must.True(t, data2.CloseSourceBranch)
+		must.True(t, data2.Draft)
+	})
+}
diff --git a/internal/forge/gitea/connector_test.go b/internal/forge/gitea/connector_test.go
index 981e35060..dd3d5e413 100644
--- a/internal/forge/gitea/connector_test.go
+++ b/internal/forge/gitea/connector_test.go
@@ -6,6 +6,7 @@ import (
 	giteasdk "code.gitea.io/sdk/gitea"
 	"github.com/git-town/git-town/v20/internal/forge/forgedomain"
 	"github.com/git-town/git-town/v20/internal/forge/gitea"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
 	"github.com/shoenig/test/must"
 )
 
@@ -57,14 +58,32 @@ func TestFilterGiteaPullRequests(t *testing.T) {
 //nolint:paralleltest  // mocks HTTP
 func TestGitea(t *testing.T) {
 	t.Run("DefaultProposalMessage", func(t *testing.T) {
-		give := forgedomain.Proposal{
-			Number: 1,
-			Title:  "my title",
-		}
-		want := "my title (#1)"
-		connector := gitea.Connector{}
-		have := connector.DefaultProposalMessage(give)
-		must.EqOp(t, want, have)
+		t.Run("without body", func(t *testing.T) {
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Body:   None[string](),
+					Number: 123,
+					Title:  "my title",
+				},
+			}
+			want := "my title (#123)"
+			connector := gitea.Connector{}
+			have := connector.DefaultProposalMessage(give)
+			must.EqOp(t, want, have)
+		})
+		t.Run("with body", func(t *testing.T) {
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Body:   Some("body"),
+					Number: 123,
+					Title:  "my title",
+				},
+			}
+			want := "my title (#123)\n\nbody"
+			connector := gitea.Connector{}
+			have := connector.DefaultProposalMessage(give)
+			must.EqOp(t, want, have)
+		})
 	})
 
 	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
diff --git a/internal/forge/github/connector_test.go b/internal/forge/github/connector_test.go
index 7655bdc48..578044a1b 100644
--- a/internal/forge/github/connector_test.go
+++ b/internal/forge/github/connector_test.go
@@ -18,14 +18,33 @@ func TestConnector(t *testing.T) {
 
 	t.Run("DefaultProposalMessage", func(t *testing.T) {
 		t.Parallel()
-		connector := github.Connector{}
-		give := forgedomain.Proposal{
-			Number: 1,
-			Title:  "my title",
-		}
-		have := connector.DefaultProposalMessage(give)
-		want := "my title (#1)"
-		must.EqOp(t, want, have)
+		t.Run("without body", func(t *testing.T) {
+			t.Parallel()
+			connector := github.Connector{}
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Number: 123,
+					Title:  "my title",
+				},
+			}
+			have := connector.DefaultProposalMessage(give)
+			want := "my title (#123)"
+			must.EqOp(t, want, have)
+		})
+		t.Run("with body", func(t *testing.T) {
+			t.Parallel()
+			connector := github.Connector{}
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Number: 123,
+					Title:  "my title",
+					Body:   Some("body"),
+				},
+			}
+			have := connector.DefaultProposalMessage(give)
+			want := "my title (#123)\n\nbody"
+			must.EqOp(t, want, have)
+		})
 	})
 
 	t.Run("NewProposalURL", func(t *testing.T) {
diff --git a/internal/forge/gitlab/connector_test.go b/internal/forge/gitlab/connector_test.go
index 817980abe..a6f7bdac6 100644
--- a/internal/forge/gitlab/connector_test.go
+++ b/internal/forge/gitlab/connector_test.go
@@ -18,23 +18,33 @@ func TestGitlabConnector(t *testing.T) {
 
 	t.Run("DefaultProposalMessage", func(t *testing.T) {
 		t.Parallel()
-		config := gitlab.Data{
-			Data: forgedomain.Data{
-				Hostname:     "",
-				Organization: "",
-				Repository:   "",
-			},
-			APIToken: None[configdomain.GitLabToken](),
-		}
-		give := forgedomain.Proposal{
-			Number:       1,
-			MergeWithAPI: true,
-			Target:       "",
-			Title:        "my title",
-		}
-		have := config.DefaultProposalMessage(give)
-		want := "my title (!1)"
-		must.EqOp(t, want, have)
+		t.Run("without body", func(t *testing.T) {
+			t.Parallel()
+			connector := gitlab.Connector{}
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Number: 123,
+					Title:  "my title",
+				},
+			}
+			have := connector.DefaultProposalMessage(give)
+			want := "my title (!123)"
+			must.EqOp(t, want, have)
+		})
+		t.Run("with body", func(t *testing.T) {
+			t.Parallel()
+			connector := gitlab.Connector{}
+			give := forgedomain.Proposal{
+				Data: forgedomain.ProposalData{
+					Number: 123,
+					Title:  "my title",
+					Body:   Some("body"),
+				},
+			}
+			have := connector.DefaultProposalMessage(give)
+			want := "my title (!123)\n\nbody"
+			must.EqOp(t, want, have)
+		})
 	})
 
 	t.Run("NewProposalURL", func(t *testing.T) {
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index c6729b507..9e9b8d8dd 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/git-town/git-town/v20/internal/config/configdomain"
+	"github.com/git-town/git-town/v20/internal/forge/forgedomain"
 	"github.com/git-town/git-town/v20/internal/git/gitdomain"
 	"github.com/git-town/git-town/v20/internal/undo/undoconfig"
 	"github.com/git-town/git-town/v20/internal/vm/opcodes"
@@ -85,7 +86,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.ConflictPhantomResolveAll{ParentBranch: gitdomain.NewLocalBranchNameOption("parent"), ParentSHA: Some(gitdomain.NewSHA("123456")), Resolution: gitdomain.ConflictResolutionOurs},
 				&opcodes.ConflictPhantomFinalize{},
 				&opcodes.ConflictPhantomResolve{FilePath: "file", Resolution: gitdomain.ConflictResolutionOurs},
-				&opcodes.ConnectorProposalMerge{Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message")), ProposalMessage: "proposal message", ProposalNumber: 123},
+				&opcodes.ConnectorProposalMerge{Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message")), Proposal: forgedomain.Proposal{Data: forgedomain.BitbucketCloudProposalData{ProposalData: forgedomain.ProposalData{Body: Some("body"), MergeWithAPI: true, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}}, ForgeType: forgedomain.ForgeTypeBitbucket}},
 				&opcodes.FetchUpstream{Branch: "branch"},
 				&opcodes.LineageBranchRemove{Branch: "branch"},
 				&opcodes.LineageParentRemove{Branch: "branch"},
@@ -102,9 +103,9 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.MessageQueue{Message: "message"},
 				&opcodes.ProgramEndOfBranch{},
 				&opcodes.ProposalCreate{Branch: "branch", MainBranch: "main"},
-				&opcodes.ProposalUpdateTarget{ProposalNumber: 123, NewBranch: "new-target", OldBranch: "old-target"},
-				&opcodes.ProposalUpdateTargetToGrandParent{Branch: "branch", ProposalNumber: 123, OldTarget: "old-target"},
-				&opcodes.ProposalUpdateSource{ProposalNumber: 123, NewBranch: "new-target", OldBranch: "old-target"},
+				&opcodes.ProposalUpdateTarget{Proposal: forgedomain.Proposal{Data: forgedomain.ProposalData{Body: Some("body"), MergeWithAPI: true, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}, ForgeType: forgedomain.ForgeTypeGitLab}, NewBranch: "new-target", OldBranch: "old-target"},
+				&opcodes.ProposalUpdateTargetToGrandParent{Branch: "branch", Proposal: forgedomain.Proposal{Data: forgedomain.ProposalData{Body: Some("body"), MergeWithAPI: true, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}, ForgeType: forgedomain.ForgeTypeGitea}, OldTarget: "old-target"},
+				&opcodes.ProposalUpdateSource{Proposal: forgedomain.Proposal{Data: forgedomain.ProposalData{Body: None[string](), MergeWithAPI: false, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}, ForgeType: forgedomain.ForgeTypeCodeberg}, NewBranch: "new-target", OldBranch: "old-target"},
 				&opcodes.PullCurrentBranch{},
 				&opcodes.PushCurrentBranch{},
 				&opcodes.PushCurrentBranchForce{ForceIfIncludes: true},
@@ -417,8 +418,20 @@ func TestLoadSave(t *testing.T) {
       "data": {
         "Branch": "branch",
         "CommitMessage": "commit message",
-        "ProposalMessage": "proposal message",
-        "ProposalNumber": 123
+        "Proposal": {
+          "data": {
+            "Body": "body",
+            "MergeWithAPI": true,
+            "Number": 123,
+            "Source": "source",
+            "Target": "target",
+            "Title": "title",
+            "URL": "url",
+            "CloseSourceBranch": false,
+            "Draft": false
+          },
+          "forge-type": "bitbucket"
+        }
       },
       "type": "ConnectorProposalMerge"
     },
@@ -534,7 +547,18 @@ func TestLoadSave(t *testing.T) {
       "data": {
         "NewBranch": "new-target",
         "OldBranch": "old-target",
-        "ProposalNumber": 123
+        "Proposal": {
+          "data": {
+            "Body": "body",
+            "MergeWithAPI": true,
+            "Number": 123,
+            "Source": "source",
+            "Target": "target",
+            "Title": "title",
+            "URL": "url"
+          },
+          "forge-type": "gitlab"
+        }
       },
       "type": "ProposalUpdateTarget"
     },
@@ -542,7 +566,18 @@ func TestLoadSave(t *testing.T) {
       "data": {
         "Branch": "branch",
         "OldTarget": "old-target",
-        "ProposalNumber": 123
+        "Proposal": {
+          "data": {
+            "Body": "body",
+            "MergeWithAPI": true,
+            "Number": 123,
+            "Source": "source",
+            "Target": "target",
+            "Title": "title",
+            "URL": "url"
+          },
+          "forge-type": "gitea"
+        }
       },
       "type": "ProposalUpdateTargetToGrandParent"
     },
@@ -550,7 +585,18 @@ func TestLoadSave(t *testing.T) {
       "data": {
         "NewBranch": "new-target",
         "OldBranch": "old-target",
-        "ProposalNumber": 123
+        "Proposal": {
+          "data": {
+            "Body": null,
+            "MergeWithAPI": false,
+            "Number": 123,
+            "Source": "source",
+            "Target": "target",
+            "Title": "title",
+            "URL": "url"
+          },
+          "forge-type": "codeberg"
+        }
       },
       "type": "ProposalUpdateSource"
     },