Skip to content

Commit a7a5bba

Browse files
smeenaiezyang
authored andcommitted
Don't let reordering a stack mark pull requests as merged
In direct mode a pull request's base is another pull request's head branch. Reordering a stack makes the branch a pull request used to be based on merge that pull request's head in, and every branch is pushed before any base is retargeted, so GitHub sees a head reachable from the base it still has on file and closes that pull request as merged. Merged pull requests cannot be reopened. Retargeting before pushing does not fix it: in a swap, the pull request moving the other way is already reachable from the base it is about to be given. Park every pull request whose base is moving on the default branch first instead, which no head branch is reachable from. Costs one extra API call and two base-change timeline entries per moved pull request. github_fake now closes a pull request as merged when its head becomes reachable from its base, which is what lets reorder.py.test catch this.
1 parent f4e9df5 commit a7a5bba

3 files changed

Lines changed: 78 additions & 7 deletions

File tree

src/ghstack/github_fake.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,32 @@ def next_issue_comment_full_database_id(self, repo_id: GraphQLId) -> int:
125125
return r
126126

127127
def push_hook(self, refs: Sequence[str]) -> None:
128-
# updated_refs = set(refs)
129-
# for pr in self.pull_requests:
130-
# # TODO: this assumes only origin repository
131-
# # if pr.headRefName in updated_refs:
132-
# # pr.headRef =
133-
# pass
134-
pass
128+
self._refs_dirty = True
129+
130+
async def detect_merged(self) -> None:
131+
"""
132+
GitHub closes a pull request as merged once its head becomes
133+
reachable from its base, which in direct mode can happen through an
134+
ordinary push to some other pull request's head branch. Only scan
135+
after something moved; a scan is a git call per open pull request.
136+
"""
137+
if not self._refs_dirty or self.upstream_sh is None:
138+
self._refs_dirty = False
139+
return
140+
self._refs_dirty = False
141+
for pr in self.pull_requests.values():
142+
if pr.closed:
143+
continue
144+
reachable = await self.upstream_sh.agit(
145+
"merge-base",
146+
"--is-ancestor",
147+
pr.headRefName,
148+
pr.baseRefName,
149+
exitcode=True,
150+
)
151+
if reachable:
152+
pr.closed = True
153+
pr.merged = True
135154

136155
def notify_merged(self, pr_resolved: ghstack.diff.PullRequestResolved) -> None:
137156
repo = self.repository(pr_resolved.owner, pr_resolved.repo)
@@ -140,6 +159,7 @@ def notify_merged(self, pr_resolved: ghstack.diff.PullRequestResolved) -> None:
140159
# TODO: model merged too
141160

142161
def __init__(self, upstream_sh: Optional[ghstack.shell.Shell]) -> None:
162+
self._refs_dirty = False
143163
self.repositories = {}
144164
self.pull_requests = {}
145165
self.issue_comments = {}
@@ -275,6 +295,7 @@ class PullRequest(Node):
275295
# state: PullRequestState
276296
title: str
277297
url: str
298+
merged: bool = False
278299
reviewers: List[str] = dataclasses.field(default_factory=list)
279300
labels: List[str] = dataclasses.field(default_factory=list)
280301

@@ -342,6 +363,7 @@ def __init__(self, upstream_sh: Optional[ghstack.shell.Shell] = None) -> None:
342363
self.state = GitHubState(upstream_sh)
343364

344365
async def graphql(self, query: str, **kwargs: Any) -> Any:
366+
await self.state.detect_merged()
345367
r = await graphql.graphql(
346368
schema=GITHUB_SCHEMA,
347369
source=query,
@@ -419,6 +441,7 @@ async def _update_pull_async(
419441
if "base" in input and input["base"] is not None:
420442
pr.baseRefName = input["base"]
421443
pr.baseRef = await repo._make_ref_async(state, pr.baseRefName)
444+
state._refs_dirty = True
422445
if "body" in input and input["body"] is not None:
423446
pr.body = input["body"]
424447

@@ -463,6 +486,7 @@ async def _set_default_branch_async(
463486
)
464487

465488
async def arest(self, method: str, path: str, **kwargs: Any) -> Any:
489+
await self.state.detect_merged()
466490
return await self._arest_impl(method, path, **kwargs)
467491

468492
async def _arest_impl(self, method: str, path: str, **kwargs: Any) -> Any:

src/ghstack/submit.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,25 @@ async def push_updates(
20052005
# otherwise GitHub can spuriously think that the user pushed a number
20062006
# of patches as part of the PR, when actually they were just from the
20072007
# new upstream branch.
2008+
# In direct mode a pull request's base is another pull request's head
2009+
# branch, so a reorder can leave a pull request's head reachable from
2010+
# the base GitHub still has on file, and GitHub closes any pull request
2011+
# in that state as merged. Park the ones whose base is moving on the
2012+
# default branch, which no head branch is ever reachable from, until
2013+
# their real base has been pushed.
2014+
if self.direct:
2015+
await _gather_ordered(
2016+
self.github.arest(
2017+
"patch",
2018+
"repos/{}/{}/pulls/{}".format(
2019+
self.repo_owner, self.repo_name, s.number
2020+
),
2021+
base=self.base,
2022+
)
2023+
for s in diffs_to_submit
2024+
if not s.closed and s.base != s.elab_diff.base_ref
2025+
)
2026+
20082027
all_push_specs: List[str] = []
20092028

20102029
for s in reversed(diffs_to_submit):
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from ghstack.test_prelude import *
2+
3+
await init_test()
4+
5+
for n in ["1", "2", "3", "4"]:
6+
await commit(n)
7+
await gh_submit("Initial")
8+
9+
for rnd in range(3):
10+
# swap the 2nd and 3rd commits
11+
c1, c2, c3, c4 = (await git("rev-list", "--reverse", "origin/main..HEAD")).split()
12+
await git("reset", "--hard", c1)
13+
await git("cherry-pick", c3, c2, c4)
14+
await gh_submit(f"Reorder {rnd}")
15+
16+
r = await get_github().graphql(
17+
"""
18+
query {
19+
repository(name: "pytorch", owner: "pytorch") {
20+
pullRequests { nodes { number closed } }
21+
}
22+
}
23+
"""
24+
)
25+
nodes = r["data"]["repository"]["pullRequests"]["nodes"]
26+
assert_eq([p["number"] for p in nodes if p["closed"]], [])
27+
28+
ok()

0 commit comments

Comments
 (0)