ghstack 0.16.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {ghstack-0.16.0 → ghstack-0.18.0}/PKG-INFO +1 -1
  2. {ghstack-0.16.0 → ghstack-0.18.0}/pyproject.toml +1 -1
  3. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/cli.py +2 -0
  4. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/config.py +17 -0
  5. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/github_fake.py +31 -7
  6. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/github_real.py +27 -24
  7. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/github_utils.py +7 -2
  8. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/submit.py +38 -6
  9. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/test_prelude.py +10 -7
  10. {ghstack-0.16.0 → ghstack-0.18.0}/LICENSE +0 -0
  11. {ghstack-0.16.0 → ghstack-0.18.0}/README.md +0 -0
  12. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/__init__.py +0 -0
  13. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/__main__.py +0 -0
  14. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/action.py +0 -0
  15. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/async_script.py +0 -0
  16. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/cache.py +0 -0
  17. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/checkout.py +0 -0
  18. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/cherry_pick.py +0 -0
  19. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/circleci.py +0 -0
  20. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/circleci_real.py +0 -0
  21. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/diff.py +0 -0
  22. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/forensics.py +0 -0
  23. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/git.py +0 -0
  24. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/github.py +0 -0
  25. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/github_schema.graphql +0 -0
  26. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/gpg_sign.py +0 -0
  27. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/land.py +0 -0
  28. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/log.py +0 -0
  29. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/logs.py +0 -0
  30. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/pull.py +0 -0
  31. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/py.typed +0 -0
  32. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/rage.py +0 -0
  33. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/shell.py +0 -0
  34. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/status.py +0 -0
  35. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/sync.py +0 -0
  36. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/trailers.py +0 -0
  37. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/types.py +0 -0
  38. {ghstack-0.16.0 → ghstack-0.18.0}/src/ghstack/unlink.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghstack
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Stack diff support for GitHub
5
5
  Author: Edward Z. Yang
6
6
  Author-email: Edward Z. Yang <ezyang@mit.edu>
@@ -16,7 +16,7 @@ dependencies = [
16
16
  "flake8<8.0.0,>=7.0.0",
17
17
  ]
18
18
  name = "ghstack"
19
- version = "0.16.0"
19
+ version = "0.18.0"
20
20
  description = "Stack diff support for GitHub"
21
21
  readme = "README.md"
22
22
  classifiers = [
@@ -61,6 +61,8 @@ def cli_context(
61
61
  oauth_token=config.github_oauth,
62
62
  proxy=config.proxy,
63
63
  github_url=config.github_url,
64
+ max_retries=config.max_retries,
65
+ initial_backoff_seconds=config.initial_backoff_seconds,
64
66
  )
65
67
  yield shell, config, github
66
68
 
@@ -111,6 +111,11 @@ Config = NamedTuple(
111
111
  ("reviewer", Optional[str]),
112
112
  # Default labels to add to new pull requests (comma-separated labels)
113
113
  ("label", Optional[str]),
114
+ # Retry/backoff tuning
115
+ ("max_retries", int),
116
+ # The initial backoff time to use, in seconds. We will double this
117
+ # time for each retry.
118
+ ("initial_backoff_seconds", int),
114
119
  # Command to generate a per-PR update description from diff contents
115
120
  ("automsg", Optional[str]),
116
121
  ],
@@ -334,6 +339,16 @@ def read_config(
334
339
  else:
335
340
  automsg = None
336
341
 
342
+ if config.has_option("ghstack", "max_retries"):
343
+ max_retries = config.getint("ghstack", "max_retries")
344
+ else:
345
+ max_retries = 5
346
+
347
+ if config.has_option("ghstack", "initial_backoff_seconds"):
348
+ initial_backoff_seconds = config.getint("ghstack", "initial_backoff_seconds")
349
+ else:
350
+ initial_backoff_seconds = 60
351
+
337
352
  if write_back:
338
353
  with open(config_path, "w") as f:
339
354
  config.write(f)
@@ -351,6 +366,8 @@ def read_config(
351
366
  remote_name=remote_name,
352
367
  reviewer=reviewer,
353
368
  label=label,
369
+ max_retries=max_retries,
370
+ initial_backoff_seconds=initial_backoff_seconds,
354
371
  automsg=automsg,
355
372
  )
356
373
  logging.debug(f"conf = {conf}")
@@ -125,13 +125,32 @@ class GitHubState:
125
125
  return r
126
126
 
127
127
  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
135
154
 
136
155
  def notify_merged(self, pr_resolved: ghstack.diff.PullRequestResolved) -> None:
137
156
  repo = self.repository(pr_resolved.owner, pr_resolved.repo)
@@ -140,6 +159,7 @@ class GitHubState:
140
159
  # TODO: model merged too
141
160
 
142
161
  def __init__(self, upstream_sh: Optional[ghstack.shell.Shell]) -> None:
162
+ self._refs_dirty = False
143
163
  self.repositories = {}
144
164
  self.pull_requests = {}
145
165
  self.issue_comments = {}
@@ -275,6 +295,7 @@ class PullRequest(Node):
275
295
  # state: PullRequestState
276
296
  title: str
277
297
  url: str
298
+ merged: bool = False
278
299
  reviewers: List[str] = dataclasses.field(default_factory=list)
279
300
  labels: List[str] = dataclasses.field(default_factory=list)
280
301
 
@@ -342,6 +363,7 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
342
363
  self.state = GitHubState(upstream_sh)
343
364
 
344
365
  async def graphql(self, query: str, **kwargs: Any) -> Any:
366
+ await self.state.detect_merged()
345
367
  r = await graphql.graphql(
346
368
  schema=GITHUB_SCHEMA,
347
369
  source=query,
@@ -419,6 +441,7 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
419
441
  if "base" in input and input["base"] is not None:
420
442
  pr.baseRefName = input["base"]
421
443
  pr.baseRef = await repo._make_ref_async(state, pr.baseRefName)
444
+ state._refs_dirty = True
422
445
  if "body" in input and input["body"] is not None:
423
446
  pr.body = input["body"]
424
447
 
@@ -463,6 +486,7 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
463
486
  )
464
487
 
465
488
  async def arest(self, method: str, path: str, **kwargs: Any) -> Any:
489
+ await self.state.detect_merged()
466
490
  return await self._arest_impl(method, path, **kwargs)
467
491
 
468
492
  async def _arest_impl(self, method: str, path: str, **kwargs: Any) -> Any:
@@ -13,9 +13,6 @@ import aiohttp
13
13
 
14
14
  import ghstack.github
15
15
 
16
- MAX_RETRIES = 5
17
- INITIAL_BACKOFF_SECONDS = 60
18
-
19
16
 
20
17
  class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
21
18
  """
@@ -61,6 +58,9 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
61
58
  cert: Optional[Union[str, Tuple[str, str]]]
62
59
  _session: Optional[aiohttp.ClientSession]
63
60
 
61
+ max_retries: int
62
+ initial_backoff_seconds: int
63
+
64
64
  def __init__(
65
65
  self,
66
66
  oauth_token: Optional[str],
@@ -68,12 +68,16 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
68
68
  proxy: Optional[str] = None,
69
69
  verify: Optional[Union[str, bool]] = None,
70
70
  cert: Optional[Union[str, Tuple[str, str]]] = None,
71
+ max_retries: int = 5,
72
+ initial_backoff_seconds: int = 60,
71
73
  ):
72
74
  self.oauth_token = oauth_token
73
75
  self.proxy = proxy
74
76
  self.github_url = github_url
75
77
  self.verify = verify
76
78
  self.cert = cert
79
+ self.max_retries = max_retries
80
+ self.initial_backoff_seconds = initial_backoff_seconds
77
81
  self._rest_request_ids = itertools.count(1)
78
82
  self._session = None
79
83
 
@@ -212,11 +216,11 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
212
216
  if aiohttp_ssl is not None:
213
217
  request_kwargs["ssl"] = aiohttp_ssl
214
218
 
215
- backoff_seconds = INITIAL_BACKOFF_SECONDS
219
+ backoff_seconds = self.initial_backoff_seconds
216
220
  request_id = next(self._rest_request_ids)
217
221
  log_prefix = f"rest[{request_id}]"
218
222
  session = self._get_session()
219
- for attempt in range(0, MAX_RETRIES):
223
+ for attempt in range(0, self.max_retries):
220
224
  logging.debug("# %s %s %s", log_prefix, method, url)
221
225
  logging.debug(
222
226
  "%s request body:\n%s", log_prefix, json.dumps(kwargs, indent=1)
@@ -224,12 +228,11 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
224
228
 
225
229
  async with getattr(session, method)(url, **request_kwargs) as resp:
226
230
  logging.debug("%s response status: %s", log_prefix, resp.status)
231
+ resp_text = await resp.text()
227
232
  try:
228
- r = await resp.json()
229
- except (aiohttp.ContentTypeError, ValueError):
230
- logging.debug(
231
- "%s response body:\n%s", log_prefix, await resp.text()
232
- )
233
+ r = json.loads(resp_text)
234
+ except ValueError:
235
+ logging.debug("%s response body:\n%s", log_prefix, resp_text)
233
236
  raise
234
237
  else:
235
238
  pretty_json = json.dumps(r, indent=1)
@@ -240,32 +243,32 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
240
243
  if resp.status in (403, 429):
241
244
  remaining_count = resp.headers.get("x-ratelimit-remaining")
242
245
  reset_time = resp.headers.get("x-ratelimit-reset")
246
+ more_attempts = attempt < self.max_retries - 1
243
247
 
244
248
  if remaining_count == "0" and reset_time:
245
- sleep_time = int(reset_time) - int(time.time())
246
- logging.warning(
247
- f"Rate limit exceeded. Sleeping until reset in {sleep_time} seconds."
248
- )
249
- await asyncio.sleep(sleep_time)
250
- continue
249
+ sleep_time = max(0, int(reset_time) - int(time.time()))
250
+ if more_attempts:
251
+ logging.warning(
252
+ f"Rate limit exceeded. Sleeping until reset in {sleep_time} seconds."
253
+ )
254
+ await asyncio.sleep(sleep_time)
255
+ continue
251
256
  # GitHub doesn't document the content of these messages, but this
252
257
  # seems to be an accurate way to find secondary rate limits. Any
253
258
  # other reason for 403 or 429 will fall through to the error below.
254
- elif b"rate limit" in resp.content.lower():
259
+ elif "rate limit" in resp_text.lower():
255
260
  retry_after_seconds = resp.headers.get("retry-after")
256
261
  if retry_after_seconds:
257
262
  sleep_time = int(retry_after_seconds)
258
- logging.warning(
259
- f"Secondary rate limit hit. Sleeping for {sleep_time} seconds."
260
- )
261
263
  else:
262
264
  sleep_time = backoff_seconds
265
+ backoff_seconds *= 2
266
+ if more_attempts:
263
267
  logging.warning(
264
- f"Secondary rate limit hit. Sleeping for {sleep_time} seconds (exponential backoff)."
268
+ f"Secondary rate limit hit. Sleeping for {sleep_time} seconds."
265
269
  )
266
- backoff_seconds *= 2
267
- await asyncio.sleep(sleep_time)
268
- continue
270
+ await asyncio.sleep(max(0, sleep_time))
271
+ continue
269
272
 
270
273
  if resp.status == 404:
271
274
  raise ghstack.github.NotFoundError(
@@ -32,7 +32,10 @@ async def get_github_repo_name_with_owner(
32
32
  # where commits will actually be pushed to
33
33
  remote_url = await sh.agit("remote", "get-url", "--push", remote_name)
34
34
  while True:
35
- match = r"^git@{github_url}:/?([^/]+)/(.+?)(?:\.git)?$".format(
35
+ # The SSH user can be "git" (e.g. git@github.com:owner/repo.git) or an
36
+ # arbitrary identity such as the org-scoped deploy user GitHub now hands
37
+ # out (e.g. org-21003710@github.com:owner/repo.git), so match any user.
38
+ match = r"^[^@]+@{github_url}:/?([^/]+)/(.+?)(?:\.git)?$".format(
36
39
  github_url=github_url
37
40
  )
38
41
  m = re.match(match, remote_url)
@@ -175,7 +178,9 @@ GitHubPullRequestParams = TypedDict(
175
178
  def _normalize_remote_url(remote_url: str) -> str:
176
179
  """Convert SSH remote URL to HTTPS format, strip .git suffix."""
177
180
  # git@github.com:owner/repo.git -> https://github.com/owner/repo
178
- m = re.match(r"^git@([^:]+):/?(.+?)(?:\.git)?$", remote_url)
181
+ # The SSH user may be "git" or an org-scoped identity such as
182
+ # org-21003710@github.com:owner/repo.git, so match any user.
183
+ m = re.match(r"^[^@]+@([^:]+):/?(.+?)(?:\.git)?$", remote_url)
179
184
  if m:
180
185
  return f"https://{m.group(1)}/{m.group(2)}"
181
186
  return re.sub(r"\.git$", "", remote_url)
@@ -516,8 +516,10 @@ class Submitter:
516
516
 
517
517
  object.__setattr__(self, "base", default_branch)
518
518
 
519
- # Check if direct should be used, if the user didn't explicitly
520
- # specify an option
519
+ # ~~~~~~~~~~~~~~~~~~~~~~~~
520
+ # The main algorithm
521
+
522
+ async def _initialize_direct(self, pr_info_cache: Dict[GitHubNumber, Any]) -> None:
521
523
  direct = self.direct_opt
522
524
  if direct is None:
523
525
  direct_r = await self.sh.agit(
@@ -525,12 +527,22 @@ class Submitter:
525
527
  )
526
528
  assert isinstance(direct_r, bool)
527
529
  direct = direct_r
528
-
530
+ if self.direct_opt is None and not direct:
531
+ styles = {
532
+ re.fullmatch(r"gh/[^/]+/[0-9]+/base", base_ref) is None
533
+ for pr_info in pr_info_cache.values()
534
+ if (base_ref := self._pr_ref_name(pr_info, "base")) is not None
535
+ }
536
+ if len(styles) > 1:
537
+ raise RuntimeError(
538
+ "Cannot infer ghstack submission style: the stack contains "
539
+ "both direct and non-direct pull requests. Pass --direct or "
540
+ "--no-direct explicitly."
541
+ )
542
+ if styles:
543
+ direct = styles.pop()
529
544
  object.__setattr__(self, "direct", direct)
530
545
 
531
- # ~~~~~~~~~~~~~~~~~~~~~~~~
532
- # The main algorithm
533
-
534
546
  async def run(self) -> List[DiffMeta]:
535
547
  timer = _Timer() if _TIMING_ENABLED else None
536
548
 
@@ -601,6 +613,7 @@ class Submitter:
601
613
  )
602
614
 
603
615
  pr_info_cache = await self._prefetch_pr_info(commits_to_rebase)
616
+ await self._initialize_direct(pr_info_cache)
604
617
  if not self.no_fetch:
605
618
  await self._fetch_foreign_pr_refs(pr_info_cache.values())
606
619
 
@@ -1992,6 +2005,25 @@ Current PR description:
1992
2005
  # otherwise GitHub can spuriously think that the user pushed a number
1993
2006
  # of patches as part of the PR, when actually they were just from the
1994
2007
  # 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
+
1995
2027
  all_push_specs: List[str] = []
1996
2028
 
1997
2029
  for s in reversed(diffs_to_submit):
@@ -122,9 +122,9 @@ class Context:
122
122
  github: ghstack.github.GitHubEndpoint
123
123
  upstream_sh: ghstack.shell.Shell
124
124
  sh: ghstack.shell.Shell
125
- direct: bool
125
+ direct: Optional[bool]
126
126
 
127
- def __init__(self, direct: bool) -> None:
127
+ def __init__(self, direct: Optional[bool]) -> None:
128
128
  # Set up a "parent" repository with an empty initial commit that we'll operate on
129
129
  upstream_dir = tempfile.mkdtemp()
130
130
  self.upstream_sh = ghstack.shell.Shell(cwd=upstream_dir, testing=True)
@@ -154,7 +154,7 @@ class Context:
154
154
  onerror=handle_remove_read_only,
155
155
  )
156
156
 
157
- async def check_global_github_invariants(self, direct: bool) -> None:
157
+ async def check_global_github_invariants(self, direct: Optional[bool]) -> None:
158
158
  r = await self.github.graphql(
159
159
  """
160
160
  query {
@@ -177,7 +177,7 @@ class Context:
177
177
  continue
178
178
  # In direct mode, only head refs may not be reused;
179
179
  # base refs can be reused in octopus situations
180
- if not direct:
180
+ if direct is False:
181
181
  assert pr["baseRefName"] not in seen_refs
182
182
  seen_refs.add(pr["baseRefName"])
183
183
  assert pr["headRefName"] not in seen_refs
@@ -200,7 +200,7 @@ async def init_test() -> Context:
200
200
 
201
201
 
202
202
  @contextlib.asynccontextmanager
203
- async def scoped_test(direct: bool) -> AsyncIterator[None]:
203
+ async def scoped_test(direct: Optional[bool]) -> AsyncIterator[None]:
204
204
  global CTX
205
205
  assert CTX is None
206
206
  try:
@@ -224,8 +224,10 @@ async def gh_submit(
224
224
  reviewer: Optional[str] = None,
225
225
  label: Optional[str] = None,
226
226
  automsg: Optional[str] = None,
227
+ **submit_kwargs: Any,
227
228
  ) -> List[ghstack.submit.DiffMeta]:
228
229
  self = CTX
230
+ direct_opt = submit_kwargs.pop("direct_opt", self.direct)
229
231
  r = await ghstack.submit.main(
230
232
  msg=msg,
231
233
  username="ezyang",
@@ -236,7 +238,7 @@ async def gh_submit(
236
238
  repo_owner_opt="pytorch",
237
239
  repo_name_opt="pytorch",
238
240
  short=short,
239
- direct_opt=self.direct,
241
+ direct_opt=direct_opt,
240
242
  no_skip=no_skip,
241
243
  github_url="github.com",
242
244
  remote_name="origin",
@@ -247,6 +249,7 @@ async def gh_submit(
247
249
  reviewer=reviewer,
248
250
  label=label,
249
251
  automsg=automsg,
252
+ **submit_kwargs,
250
253
  )
251
254
  await self.check_global_github_invariants(self.direct)
252
255
  return r
@@ -451,7 +454,7 @@ async def assert_github_state(expect: str, *, skip: int = 0) -> None:
451
454
  assert_expected_inline(await dump_github(), expect, skip=skip + 1)
452
455
 
453
456
 
454
- def is_direct() -> bool:
457
+ def is_direct() -> Optional[bool]:
455
458
  return CTX.direct
456
459
 
457
460
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes