ghstack 0.12.0__tar.gz → 0.14.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 (35) hide show
  1. ghstack-0.14.0/LICENSE +19 -0
  2. {ghstack-0.12.0 → ghstack-0.14.0}/PKG-INFO +4 -3
  3. {ghstack-0.12.0 → ghstack-0.14.0}/pyproject.toml +5 -2
  4. ghstack-0.14.0/src/ghstack/checkout.py +64 -0
  5. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/cli.py +30 -1
  6. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/config.py +101 -1
  7. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/github_fake.py +21 -0
  8. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/github_utils.py +17 -3
  9. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/submit.py +32 -0
  10. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/test_prelude.py +39 -4
  11. ghstack-0.12.0/src/ghstack/checkout.py +0 -31
  12. {ghstack-0.12.0 → ghstack-0.14.0}/README.md +0 -0
  13. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/__init__.py +0 -0
  14. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/__main__.py +0 -0
  15. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/action.py +0 -0
  16. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/cache.py +0 -0
  17. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/cherry_pick.py +0 -0
  18. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/circleci.py +0 -0
  19. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/circleci_real.py +0 -0
  20. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/diff.py +0 -0
  21. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/forensics.py +0 -0
  22. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/git.py +0 -0
  23. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/github.py +0 -0
  24. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/github_real.py +0 -0
  25. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/github_schema.graphql +0 -0
  26. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/gpg_sign.py +0 -0
  27. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/land.py +0 -0
  28. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/logs.py +0 -0
  29. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/py.typed +0 -0
  30. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/rage.py +0 -0
  31. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/shell.py +0 -0
  32. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/status.py +0 -0
  33. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/trailers.py +0 -0
  34. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/types.py +0 -0
  35. {ghstack-0.12.0 → ghstack-0.14.0}/src/ghstack/unlink.py +0 -0
ghstack-0.14.0/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) Edward Z. Yang <ezyang@mit.edu>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -1,10 +1,11 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: ghstack
3
- Version: 0.12.0
3
+ Version: 0.14.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>
7
- License: MIT
7
+ License-Expression: MIT
8
+ License-File: LICENSE
8
9
  Classifier: Programming Language :: Python :: 3
9
10
  Classifier: License :: OSI Approved :: MIT License
10
11
  Classifier: Operating System :: OS Independent
@@ -2,7 +2,10 @@
2
2
  authors = [
3
3
  {name = "Edward Z. Yang", email = "ezyang@mit.edu"},
4
4
  ]
5
- license = {text = "MIT"}
5
+ license = "MIT"
6
+ license-files = [
7
+ "LICENSE",
8
+ ]
6
9
  requires-python = "<4.0.0,>=3.9.1"
7
10
  dependencies = [
8
11
  "aiohttp<4,>=3",
@@ -13,7 +16,7 @@ dependencies = [
13
16
  "flake8<8.0.0,>=7.0.0",
14
17
  ]
15
18
  name = "ghstack"
16
- version = "0.12.0"
19
+ version = "0.14.0"
17
20
  description = "Stack diff support for GitHub"
18
21
  readme = "README.md"
19
22
  classifiers = [
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import logging
4
+ import re
5
+
6
+ import ghstack.github
7
+ import ghstack.github_utils
8
+ import ghstack.shell
9
+
10
+
11
+ def main(
12
+ pull_request: str,
13
+ github: ghstack.github.GitHubEndpoint,
14
+ sh: ghstack.shell.Shell,
15
+ remote_name: str,
16
+ same_base: bool = False,
17
+ ) -> None:
18
+
19
+ params = ghstack.github_utils.parse_pull_request(
20
+ pull_request, sh=sh, remote_name=remote_name
21
+ )
22
+ head_ref = github.get_head_ref(**params)
23
+ orig_ref = re.sub(r"/head$", "/orig", head_ref)
24
+ if orig_ref == head_ref:
25
+ logging.warning(
26
+ "The ref {} doesn't look like a ghstack reference".format(head_ref)
27
+ )
28
+
29
+ # TODO: Handle remotes correctly too (so this subsumes hub)
30
+
31
+ # If --same-base is specified, check if checkout would change the merge-base
32
+ if same_base:
33
+ # Get the default branch name from the repo
34
+ repo_info = ghstack.github_utils.get_github_repo_info(
35
+ github=github,
36
+ sh=sh,
37
+ repo_owner=params["owner"],
38
+ repo_name=params["name"],
39
+ github_url=params["github_url"],
40
+ remote_name=remote_name,
41
+ )
42
+ default_branch = repo_info["default_branch"]
43
+ default_branch_ref = f"{remote_name}/{default_branch}"
44
+
45
+ # Get current merge-base with default branch
46
+ current_base = sh.git("merge-base", default_branch_ref, "HEAD")
47
+ else:
48
+ current_base = None
49
+ default_branch_ref = None
50
+
51
+ sh.git("fetch", "--prune", remote_name)
52
+
53
+ # If --same-base is specified, check what the new merge-base would be
54
+ if same_base:
55
+ target_ref = remote_name + "/" + orig_ref
56
+ new_base = sh.git("merge-base", default_branch_ref, target_ref)
57
+
58
+ if current_base != new_base:
59
+ raise RuntimeError(
60
+ f"Checkout would change merge-base from {current_base[:8]} to {new_base[:8]}, "
61
+ f"aborting due to --same-base flag"
62
+ )
63
+
64
+ sh.git("checkout", remote_name + "/" + orig_ref)
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import sys
3
4
  from typing import Generator, List, Optional, Tuple
4
5
 
5
6
  import click
@@ -79,6 +80,12 @@ def main(
79
80
  """
80
81
  Submit stacks of diffs to Github
81
82
  """
83
+ if sys.version_info >= (3, 14):
84
+ # Create new event loop as asyncio.get_event_loop() throws runtime error in 3.14
85
+ import asyncio as _asyncio
86
+
87
+ _asyncio.set_event_loop(_asyncio.new_event_loop())
88
+
82
89
  EXIT_STACK.enter_context(ghstack.logs.manager(debug=debug))
83
90
 
84
91
  if not ctx.invoked_subcommand:
@@ -113,8 +120,13 @@ def action(close: bool, pull_request: str) -> None:
113
120
 
114
121
 
115
122
  @main.command("checkout")
123
+ @click.option(
124
+ "--same-base",
125
+ is_flag=True,
126
+ help="Only checkout if merge-base with main branch would remain the same",
127
+ )
116
128
  @click.argument("pull_request", metavar="PR")
117
- def checkout(pull_request: str) -> None:
129
+ def checkout(same_base: bool, pull_request: str) -> None:
118
130
  """
119
131
  Checkout a PR
120
132
  """
@@ -124,6 +136,7 @@ def checkout(pull_request: str) -> None:
124
136
  github=github,
125
137
  sh=shell,
126
138
  remote_name=config.remote_name,
139
+ same_base=same_base,
127
140
  )
128
141
 
129
142
 
@@ -250,6 +263,18 @@ def status(pull_request: str) -> None:
250
263
  "With --no-stack, we support only non-range identifiers, and will submit each commit "
251
264
  "listed in the command line.",
252
265
  )
266
+ @click.option(
267
+ "--reviewer",
268
+ default=None,
269
+ help="Comma-separated list of GitHub usernames to add as reviewers to new PRs "
270
+ "(overrides .ghstackrc setting)",
271
+ )
272
+ @click.option(
273
+ "--label",
274
+ default=None,
275
+ help="Comma-separated list of labels to add to new PRs "
276
+ "(overrides .ghstackrc setting)",
277
+ )
253
278
  @click.option(
254
279
  "--direct/--no-direct",
255
280
  "direct_opt",
@@ -273,6 +298,8 @@ def submit(
273
298
  base: Optional[str],
274
299
  revs: Tuple[str, ...],
275
300
  stack: bool,
301
+ reviewer: Optional[str],
302
+ label: Optional[str],
276
303
  ) -> None:
277
304
  """
278
305
  Submit or update a PR stack
@@ -294,6 +321,8 @@ def submit(
294
321
  revs=revs,
295
322
  stack=stack,
296
323
  direct_opt=direct_opt,
324
+ reviewer=reviewer if reviewer is not None else config.reviewer,
325
+ label=label if label is not None else config.label,
297
326
  )
298
327
 
299
328
 
@@ -5,8 +5,10 @@ import getpass
5
5
  import logging
6
6
  import os
7
7
  import re
8
+ import shutil
9
+ import subprocess
8
10
  from pathlib import Path
9
- from typing import NamedTuple, Optional
11
+ from typing import NamedTuple, Optional, Tuple
10
12
 
11
13
  import requests
12
14
 
@@ -15,6 +17,71 @@ import ghstack.logs
15
17
  DEFAULT_GHSTACKRC_PATH = Path.home() / ".ghstackrc"
16
18
  GHSTACKRC_PATH_VAR = "GHSTACKRC_PATH"
17
19
 
20
+
21
+ def is_gh_cli_available() -> bool:
22
+ """Check if the GitHub CLI (gh) is available in PATH."""
23
+ return shutil.which("gh") is not None
24
+
25
+
26
+ def get_gh_cli_credentials(
27
+ github_url: str = "github.com",
28
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
29
+ """
30
+ Extract credentials from the GitHub CLI if available and authenticated.
31
+
32
+ Args:
33
+ github_url: The GitHub host to get credentials for.
34
+
35
+ Returns:
36
+ A tuple of (token, username, url) or (None, None, None) if unavailable.
37
+ """
38
+ if not is_gh_cli_available():
39
+ return None, None, None
40
+
41
+ try:
42
+ # Check if gh is authenticated for this host
43
+ auth_status = subprocess.run(
44
+ ["gh", "auth", "status", "-h", github_url],
45
+ capture_output=True,
46
+ text=True,
47
+ )
48
+ if auth_status.returncode != 0:
49
+ logging.debug(f"gh CLI not authenticated for {github_url}")
50
+ return None, None, None
51
+
52
+ # Get the token
53
+ token_result = subprocess.run(
54
+ ["gh", "auth", "token", "-h", github_url],
55
+ capture_output=True,
56
+ text=True,
57
+ )
58
+ if token_result.returncode != 0:
59
+ logging.debug("Failed to get token from gh CLI")
60
+ return None, None, None
61
+ token = token_result.stdout.strip()
62
+ if not token:
63
+ return None, None, None
64
+
65
+ # Get the username using gh api
66
+ username_result = subprocess.run(
67
+ ["gh", "api", "user", "-q", ".login", "--hostname", github_url],
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ username = None
72
+ if username_result.returncode == 0:
73
+ username = username_result.stdout.strip()
74
+
75
+ logging.debug(
76
+ f"Successfully retrieved credentials from gh CLI for {github_url}"
77
+ )
78
+ return token, username, github_url
79
+
80
+ except Exception as e:
81
+ logging.debug(f"Error getting credentials from gh CLI: {e}")
82
+ return None, None, None
83
+
84
+
18
85
  Config = NamedTuple(
19
86
  "Config",
20
87
  [
@@ -40,6 +107,10 @@ Config = NamedTuple(
40
107
  ("github_url", str),
41
108
  # Name of the upstream remote
42
109
  ("remote_name", str),
110
+ # Default reviewers to add to new pull requests (comma-separated usernames)
111
+ ("reviewer", Optional[str]),
112
+ # Default labels to add to new pull requests (comma-separated labels)
113
+ ("label", Optional[str]),
43
114
  ],
44
115
  )
45
116
 
@@ -97,6 +168,7 @@ def read_config(
97
168
  # Environment variable overrides config file
98
169
  # This envvar is legacy from ghexport days
99
170
  github_oauth = os.getenv("OAUTH_TOKEN")
171
+ gh_cli_username = None # Track username from gh CLI
100
172
  if github_oauth is not None:
101
173
  logging.warning(
102
174
  "Deprecated OAUTH_TOKEN environment variable used to populate github_oauth--"
@@ -105,6 +177,17 @@ def read_config(
105
177
  )
106
178
  if github_oauth is None and config.has_option("ghstack", "github_oauth"):
107
179
  github_oauth = config.get("ghstack", "github_oauth")
180
+
181
+ # Try GitHub CLI if available and no token found yet
182
+ if github_oauth is None and request_github_token:
183
+ gh_token, gh_username, _ = get_gh_cli_credentials(github_url)
184
+ if gh_token is not None:
185
+ print(f"Using GitHub credentials from gh CLI for {github_url}")
186
+ github_oauth = gh_token
187
+ gh_cli_username = gh_username
188
+ # Don't save gh CLI credentials to config - they may change/expire
189
+
190
+ # Fall back to device flow if still no token
108
191
  if github_oauth is None and request_github_token:
109
192
  print("Generating GitHub access token...")
110
193
  CLIENT_ID = "89cc88ca50efbe86907a"
@@ -150,6 +233,11 @@ def read_config(
150
233
  github_username = None
151
234
  if config.has_option("ghstack", "github_username"):
152
235
  github_username = config.get("ghstack", "github_username")
236
+ # Use username from gh CLI if we got it
237
+ if github_username is None and gh_cli_username is not None:
238
+ github_username = gh_cli_username
239
+ # Don't save gh CLI username to config - it comes from gh CLI
240
+ # Fall back to API lookup if we have a token but no username yet
153
241
  if github_username is None and github_oauth is not None:
154
242
  request_url: str
155
243
  if github_url == "github.com":
@@ -203,6 +291,16 @@ def read_config(
203
291
  else:
204
292
  remote_name = "origin"
205
293
 
294
+ if config.has_option("ghstack", "reviewer"):
295
+ reviewer = config.get("ghstack", "reviewer")
296
+ else:
297
+ reviewer = None
298
+
299
+ if config.has_option("ghstack", "label"):
300
+ label = config.get("ghstack", "label")
301
+ else:
302
+ label = None
303
+
206
304
  if write_back:
207
305
  with open(config_path, "w") as f:
208
306
  config.write(f)
@@ -218,6 +316,8 @@ def read_config(
218
316
  default_project_dir=default_project_dir,
219
317
  github_url=github_url,
220
318
  remote_name=remote_name,
319
+ reviewer=reviewer,
320
+ label=label,
221
321
  )
222
322
  logging.debug(f"conf = {conf}")
223
323
  return conf
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ import dataclasses
3
4
  import os.path
4
5
  import re
5
6
  from dataclasses import dataclass
@@ -271,6 +272,8 @@ class PullRequest(Node):
271
272
  # state: PullRequestState
272
273
  title: str
273
274
  url: str
275
+ reviewers: List[str] = dataclasses.field(default_factory=list)
276
+ labels: List[str] = dataclasses.field(default_factory=list)
274
277
 
275
278
  def repository(self, info: GraphQLResolveInfo) -> Repository:
276
279
  return github_state(info).repositories[self._repository]
@@ -473,6 +476,24 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
473
476
  GitHubNumber(int(m.group(3))),
474
477
  cast(CreateIssueCommentInput, kwargs),
475
478
  )
479
+ if m := re.match(
480
+ r"^repos/([^/]+)/([^/]+)/pulls/([^/]+)/requested_reviewers", path
481
+ ):
482
+ # Handle adding reviewers
483
+ state = self.state
484
+ repo = state.repository(m.group(1), m.group(2))
485
+ pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
486
+ reviewers = kwargs.get("reviewers", [])
487
+ pr.reviewers.extend(reviewers)
488
+ return {}
489
+ if m := re.match(r"^repos/([^/]+)/([^/]+)/issues/([^/]+)/labels", path):
490
+ # Handle adding labels
491
+ state = self.state
492
+ repo = state.repository(m.group(1), m.group(2))
493
+ pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
494
+ labels = kwargs.get("labels", [])
495
+ pr.labels.extend(labels)
496
+ return {}
476
497
  elif method == "patch":
477
498
  if m := re.match(r"^repos/([^/]+)/([^/]+)(?:/pulls/([^/]+))?$", path):
478
499
  owner, name, number = m.groups()
@@ -25,7 +25,9 @@ def get_github_repo_name_with_owner(
25
25
  remote_name: str,
26
26
  ) -> GitHubRepoNameWithOwner:
27
27
  # Grovel in remotes to figure it out
28
- remote_url = sh.git("remote", "get-url", remote_name)
28
+ # Use --push to get the push URL, which is what matters for determining
29
+ # where commits will actually be pushed to
30
+ remote_url = sh.git("remote", "get-url", "--push", remote_name)
29
31
  while True:
30
32
  match = r"^git@{github_url}:/?([^/]+)/(.+?)(?:\.git)?$".format(
31
33
  github_url=github_url
@@ -138,20 +140,32 @@ GitHubPullRequestParams = TypedDict(
138
140
  )
139
141
 
140
142
 
143
+ def _normalize_remote_url(remote_url: str) -> str:
144
+ """Convert SSH remote URL to HTTPS format, strip .git suffix."""
145
+ # git@github.com:owner/repo.git -> https://github.com/owner/repo
146
+ m = re.match(r"^git@([^:]+):/?(.+?)(?:\.git)?$", remote_url)
147
+ if m:
148
+ return f"https://{m.group(1)}/{m.group(2)}"
149
+ return re.sub(r"\.git$", "", remote_url)
150
+
151
+
141
152
  def parse_pull_request(
142
153
  pull_request: str,
143
154
  *,
144
155
  sh: Optional[ghstack.shell.Shell] = None,
145
156
  remote_name: Optional[str] = None,
146
157
  ) -> GitHubPullRequestParams:
158
+ pull_request = pull_request.lstrip("#")
147
159
  m = RE_PR_URL.match(pull_request)
148
160
  if not m:
149
161
  # We can reconstruct the URL if just a PR number is passed
150
162
  if sh is not None and remote_name is not None:
151
- remote_url = sh.git("remote", "get-url", remote_name)
163
+ remote_url = sh.git("remote", "get-url", "--push", remote_name)
152
164
  # Do not pass the shell to avoid infinite loop
153
165
  try:
154
- return parse_pull_request(remote_url + "/pull/" + pull_request)
166
+ return parse_pull_request(
167
+ _normalize_remote_url(remote_url) + "/pull/" + pull_request
168
+ )
155
169
  except RuntimeError:
156
170
  # Fall back on original error message
157
171
  pass
@@ -343,6 +343,12 @@ class Submitter:
343
343
  # merged. If None, infer whether or not the PR should be direct or not.
344
344
  direct_opt: Optional[bool] = None
345
345
 
346
+ # Default reviewers to add to new pull requests (comma-separated usernames)
347
+ reviewer: Optional[str] = None
348
+
349
+ # Default labels to add to new pull requests (comma-separated labels)
350
+ label: Optional[str] = None
351
+
346
352
  # ~~~~~~~~~~~~~~~~~~~~~~~~
347
353
  # Computed in post init
348
354
 
@@ -1434,6 +1440,32 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1434
1440
  )
1435
1441
  comment_id = rc["id"]
1436
1442
 
1443
+ # Add reviewers if specified
1444
+ if self.reviewer:
1445
+ reviewers = [r.strip() for r in self.reviewer.split(",") if r.strip()]
1446
+ if reviewers:
1447
+ try:
1448
+ self.github.post(
1449
+ f"repos/{self.repo_owner}/{self.repo_name}/pulls/{number}/requested_reviewers",
1450
+ reviewers=reviewers,
1451
+ )
1452
+ logging.info(f"Added reviewers: {', '.join(reviewers)}")
1453
+ except Exception as e:
1454
+ logging.warning(f"Failed to add reviewers: {e}")
1455
+
1456
+ # Add labels if specified
1457
+ if self.label:
1458
+ labels = [label.strip() for label in self.label.split(",") if label.strip()]
1459
+ if labels:
1460
+ try:
1461
+ self.github.post(
1462
+ f"repos/{self.repo_owner}/{self.repo_name}/issues/{number}/labels",
1463
+ labels=labels,
1464
+ )
1465
+ logging.info(f"Added labels: {', '.join(labels)}")
1466
+ except Exception as e:
1467
+ logging.warning(f"Failed to add labels: {e}")
1468
+
1437
1469
  logging.info("Opened PR #{}".format(number))
1438
1470
 
1439
1471
  pull_request_resolved = ghstack.diff.PullRequestResolved(
@@ -12,6 +12,7 @@ from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple, Uni
12
12
 
13
13
  from expecttest import assert_expected_inline
14
14
 
15
+ import ghstack.checkout
15
16
  import ghstack.cherry_pick
16
17
 
17
18
  import ghstack.github
@@ -32,6 +33,7 @@ __all__ = [
32
33
  "gh_land",
33
34
  "gh_unlink",
34
35
  "gh_cherry_pick",
36
+ "gh_checkout",
35
37
  "GitCommitHash",
36
38
  "checkout",
37
39
  "amend",
@@ -49,6 +51,8 @@ __all__ = [
49
51
  "get_sh",
50
52
  "get_upstream_sh",
51
53
  "get_github",
54
+ "get_pr_reviewers",
55
+ "get_pr_labels",
52
56
  "tick",
53
57
  "captured_output",
54
58
  ]
@@ -192,6 +196,8 @@ def gh_submit(
192
196
  base: Optional[str] = None,
193
197
  revs: Sequence[str] = (),
194
198
  stack: bool = True,
199
+ reviewer: Optional[str] = None,
200
+ label: Optional[str] = None,
195
201
  ) -> List[ghstack.submit.DiffMeta]:
196
202
  self = CTX
197
203
  r = ghstack.submit.main(
@@ -212,6 +218,8 @@ def gh_submit(
212
218
  revs=revs,
213
219
  stack=stack,
214
220
  check_invariants=True,
221
+ reviewer=reviewer,
222
+ label=label,
215
223
  )
216
224
  self.check_global_github_invariants(self.direct)
217
225
  return r
@@ -251,6 +259,17 @@ def gh_cherry_pick(pull_request: str, stack: bool = False) -> None:
251
259
  )
252
260
 
253
261
 
262
+ def gh_checkout(pull_request: str, same_base: bool = False) -> None:
263
+ self = CTX
264
+ return ghstack.checkout.main(
265
+ pull_request=pull_request,
266
+ github=self.github,
267
+ sh=self.sh,
268
+ remote_name="origin",
269
+ same_base=same_base,
270
+ )
271
+
272
+
254
273
  def write_file_and_add(filename: str, contents: str) -> None:
255
274
  self = CTX
256
275
  with self.sh.open(filename, "w") as f:
@@ -373,6 +392,26 @@ def is_direct() -> bool:
373
392
  return CTX.direct
374
393
 
375
394
 
395
+ def get_github() -> ghstack.github_fake.FakeGitHubEndpoint:
396
+ return CTX.github
397
+
398
+
399
+ def get_pr_reviewers(pr_number: int) -> List[str]:
400
+ """Get the reviewers for a PR number."""
401
+ github = get_github()
402
+ repo = github.state.repository("pytorch", "pytorch")
403
+ pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
404
+ return pr.reviewers
405
+
406
+
407
+ def get_pr_labels(pr_number: int) -> List[str]:
408
+ """Get the labels for a PR number."""
409
+ github = get_github()
410
+ repo = github.state.repository("pytorch", "pytorch")
411
+ pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
412
+ return pr.labels
413
+
414
+
376
415
  def assert_eq(a: Any, b: Any) -> None:
377
416
  assert a == b, f"{a} != {b}"
378
417
 
@@ -406,9 +445,5 @@ def get_upstream_sh() -> ghstack.shell.Shell:
406
445
  return CTX.upstream_sh
407
446
 
408
447
 
409
- def get_github() -> ghstack.github.GitHubEndpoint:
410
- return CTX.github
411
-
412
-
413
448
  def tick() -> None:
414
449
  CTX.sh.test_tick()
@@ -1,31 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- import logging
4
- import re
5
-
6
- import ghstack.github
7
- import ghstack.github_utils
8
- import ghstack.shell
9
-
10
-
11
- def main(
12
- pull_request: str,
13
- github: ghstack.github.GitHubEndpoint,
14
- sh: ghstack.shell.Shell,
15
- remote_name: str,
16
- ) -> None:
17
-
18
- params = ghstack.github_utils.parse_pull_request(
19
- pull_request, sh=sh, remote_name=remote_name
20
- )
21
- head_ref = github.get_head_ref(**params)
22
- orig_ref = re.sub(r"/head$", "/orig", head_ref)
23
- if orig_ref == head_ref:
24
- logging.warning(
25
- "The ref {} doesn't look like a ghstack reference".format(head_ref)
26
- )
27
-
28
- # TODO: Handle remotes correctly too (so this subsumes hub)
29
-
30
- sh.git("fetch", "--prune", remote_name)
31
- sh.git("checkout", remote_name + "/" + orig_ref)
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