ghstack 0.13.0__tar.gz → 0.15.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 (36) hide show
  1. {ghstack-0.13.0 → ghstack-0.15.0}/PKG-INFO +1 -1
  2. {ghstack-0.13.0 → ghstack-0.15.0}/pyproject.toml +1 -1
  3. ghstack-0.15.0/src/ghstack/checkout.py +66 -0
  4. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cherry_pick.py +3 -1
  5. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cli.py +136 -13
  6. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/config.py +101 -1
  7. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_fake.py +39 -0
  8. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_utils.py +17 -3
  9. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/land.py +29 -1
  10. ghstack-0.15.0/src/ghstack/log.py +116 -0
  11. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/submit.py +171 -30
  12. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/test_prelude.py +67 -9
  13. ghstack-0.13.0/src/ghstack/checkout.py +0 -31
  14. {ghstack-0.13.0 → ghstack-0.15.0}/LICENSE +0 -0
  15. {ghstack-0.13.0 → ghstack-0.15.0}/README.md +0 -0
  16. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/__init__.py +0 -0
  17. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/__main__.py +0 -0
  18. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/action.py +0 -0
  19. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cache.py +0 -0
  20. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/circleci.py +0 -0
  21. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/circleci_real.py +0 -0
  22. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/diff.py +0 -0
  23. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/forensics.py +0 -0
  24. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/git.py +0 -0
  25. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github.py +0 -0
  26. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_real.py +0 -0
  27. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_schema.graphql +0 -0
  28. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/gpg_sign.py +0 -0
  29. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/logs.py +0 -0
  30. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/py.typed +0 -0
  31. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/rage.py +0 -0
  32. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/shell.py +0 -0
  33. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/status.py +0 -0
  34. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/trailers.py +0 -0
  35. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/types.py +0 -0
  36. {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/unlink.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghstack
3
- Version: 0.13.0
3
+ Version: 0.15.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.13.0"
19
+ version = "0.15.0"
20
20
  description = "Stack diff support for GitHub"
21
21
  readme = "README.md"
22
22
  classifiers = [
@@ -0,0 +1,66 @@
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
+ assert default_branch_ref is not None
56
+ assert current_base is not None
57
+ target_ref = remote_name + "/" + orig_ref
58
+ new_base = sh.git("merge-base", default_branch_ref, target_ref)
59
+
60
+ if current_base != new_base:
61
+ raise RuntimeError(
62
+ f"Checkout would change merge-base from {current_base[:8]} to {new_base[:8]}, "
63
+ f"aborting due to --same-base flag"
64
+ )
65
+
66
+ sh.git("checkout", remote_name + "/" + orig_ref)
@@ -14,6 +14,7 @@ def main(
14
14
  sh: ghstack.shell.Shell,
15
15
  remote_name: str,
16
16
  stack: bool = False,
17
+ no_fetch: bool = False,
17
18
  ) -> None:
18
19
 
19
20
  params = ghstack.github_utils.parse_pull_request(
@@ -26,7 +27,8 @@ def main(
26
27
  "The ref {} doesn't look like a ghstack reference".format(head_ref)
27
28
  )
28
29
 
29
- sh.git("fetch", "--prune", remote_name)
30
+ if not no_fetch:
31
+ sh.git("fetch", "--prune", remote_name)
30
32
 
31
33
  if stack:
32
34
  # Cherry-pick the entire stack from merge-base to the commit
@@ -13,6 +13,7 @@ import ghstack.circleci_real
13
13
  import ghstack.config
14
14
  import ghstack.github_real
15
15
  import ghstack.land
16
+ import ghstack.log
16
17
  import ghstack.logs
17
18
  import ghstack.rage
18
19
  import ghstack.status
@@ -48,22 +49,74 @@ def cli_context(
48
49
  yield shell, config, github
49
50
 
50
51
 
51
- @click.group(invoke_without_command=True)
52
+ @click.group(
53
+ invoke_without_command=True,
54
+ epilog="Running ghstack with no subcommand is equivalent to ghstack submit.",
55
+ )
52
56
  @click.pass_context
53
57
  @click.version_option(ghstack.__version__, "--version", "-V")
54
58
  @click.option("--debug", is_flag=True, help="Log debug information to stderr")
55
- # hidden arguments that we'll pass along to submit if no other command given
56
- @click.option("--message", "-m", default="Update", hidden=True)
57
- @click.option("--update-fields", "-u", is_flag=True, hidden=True)
58
- @click.option("--short", is_flag=True, hidden=True)
59
- @click.option("--force", is_flag=True, hidden=True)
60
- @click.option("--no-skip", is_flag=True, hidden=True)
61
- @click.option("--draft", is_flag=True, hidden=True)
59
+ # These options are forwarded to the submit command when no subcommand is given.
60
+ @click.option(
61
+ "--message",
62
+ "-m",
63
+ default="Update",
64
+ help="Description of change you made",
65
+ )
66
+ @click.option(
67
+ "--update-fields",
68
+ "-u",
69
+ is_flag=True,
70
+ help="Update GitHub pull request summary from the local commit",
71
+ )
72
+ @click.option(
73
+ "--short", is_flag=True, help="Print only the URL of the latest opened PR to stdout"
74
+ )
75
+ @click.option(
76
+ "--force",
77
+ is_flag=True,
78
+ help="force push the branch even if your local branch is stale",
79
+ )
80
+ @click.option(
81
+ "--no-skip",
82
+ is_flag=True,
83
+ help="Never skip pushing commits, even if the contents didn't change",
84
+ )
85
+ @click.option(
86
+ "--draft",
87
+ is_flag=True,
88
+ help="Create the pull request in draft mode (only if it has not already been created)",
89
+ )
90
+ @click.option(
91
+ "--direct/--no-direct",
92
+ "direct_opt",
93
+ is_flag=True,
94
+ default=None,
95
+ help="Create stack that directly merges into master",
96
+ )
97
+ @click.option(
98
+ "--base",
99
+ "-B",
100
+ default=None,
101
+ help="Branch to base the stack off of",
102
+ )
62
103
  @click.option(
63
- "--direct/--no-direct", "direct_opt", is_flag=True, hidden=True, default=None
104
+ "--stack/--no-stack",
105
+ "-s/-S",
106
+ is_flag=True,
107
+ default=True,
108
+ help="Submit the entire stack of commits reachable from HEAD",
109
+ )
110
+ @click.option(
111
+ "--reviewer",
112
+ default=None,
113
+ help="Comma-separated list of GitHub usernames to add as reviewers",
114
+ )
115
+ @click.option(
116
+ "--label",
117
+ default=None,
118
+ help="Comma-separated list of labels to add to new PRs",
64
119
  )
65
- @click.option("--base", "-B", default=None, hidden=True)
66
- @click.option("--stack/--no-stack", "-s/-S", is_flag=True, default=True, hidden=True)
67
120
  def main(
68
121
  ctx: click.Context,
69
122
  debug: bool,
@@ -76,6 +129,8 @@ def main(
76
129
  draft: bool,
77
130
  base: Optional[str],
78
131
  stack: bool,
132
+ reviewer: Optional[str],
133
+ label: Optional[str],
79
134
  ) -> None:
80
135
  """
81
136
  Submit stacks of diffs to Github
@@ -100,9 +155,20 @@ def main(
100
155
  base=base,
101
156
  stack=stack,
102
157
  direct_opt=direct_opt,
158
+ reviewer=reviewer,
159
+ label=label,
103
160
  )
104
161
 
105
162
 
163
+ @main.command("auth")
164
+ def auth() -> None:
165
+ """
166
+ Set up GitHub authentication if not already configured.
167
+ """
168
+ with EXIT_STACK:
169
+ ghstack.config.read_config()
170
+
171
+
106
172
  @main.command("action")
107
173
  @click.option("--close", is_flag=True, help="Close the specified pull request")
108
174
  @click.argument("pull_request", metavar="PR")
@@ -120,8 +186,13 @@ def action(close: bool, pull_request: str) -> None:
120
186
 
121
187
 
122
188
  @main.command("checkout")
189
+ @click.option(
190
+ "--same-base",
191
+ is_flag=True,
192
+ help="Only checkout if merge-base with main branch would remain the same",
193
+ )
123
194
  @click.argument("pull_request", metavar="PR")
124
- def checkout(pull_request: str) -> None:
195
+ def checkout(same_base: bool, pull_request: str) -> None:
125
196
  """
126
197
  Checkout a PR
127
198
  """
@@ -131,6 +202,7 @@ def checkout(pull_request: str) -> None:
131
202
  github=github,
132
203
  sh=shell,
133
204
  remote_name=config.remote_name,
205
+ same_base=same_base,
134
206
  )
135
207
 
136
208
 
@@ -141,8 +213,13 @@ def checkout(pull_request: str) -> None:
141
213
  is_flag=True,
142
214
  help="Cherry-pick all commits from the commit to the merge-base with main branch",
143
215
  )
216
+ @click.option(
217
+ "--no-fetch",
218
+ is_flag=True,
219
+ help="Skip fetching from the remote before cherry-picking",
220
+ )
144
221
  @click.argument("pull_request", metavar="PR")
145
- def cherry_pick(stack: bool, pull_request: str) -> None:
222
+ def cherry_pick(stack: bool, no_fetch: bool, pull_request: str) -> None:
146
223
  """
147
224
  Cherry-pick a PR
148
225
  """
@@ -153,6 +230,7 @@ def cherry_pick(stack: bool, pull_request: str) -> None:
153
230
  sh=shell,
154
231
  remote_name=config.remote_name,
155
232
  stack=stack,
233
+ no_fetch=no_fetch,
156
234
  )
157
235
 
158
236
 
@@ -174,6 +252,35 @@ def land(force: bool, pull_request: str) -> None:
174
252
  )
175
253
 
176
254
 
255
+ @main.command(
256
+ "log",
257
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
258
+ )
259
+ @click.option(
260
+ "--pr",
261
+ "pull_request",
262
+ default=None,
263
+ help="Explicit PR (URL or number) to log. If omitted, the PR is inferred "
264
+ "from HEAD's Pull-Request trailer, and local pending changes are shown "
265
+ "on top as a synthesized commit.",
266
+ )
267
+ @click.argument("git_log_args", nargs=-1, type=click.UNPROCESSED)
268
+ def log(pull_request: Optional[str], git_log_args: Tuple[str, ...]) -> None:
269
+ """
270
+ Show git log for a PR, restricted to that PR's commits.
271
+ Extra arguments are forwarded to git log (e.g. -p).
272
+ """
273
+ with cli_context(request_github_token=False) as (shell, config, github):
274
+ ghstack.log.main(
275
+ github=github,
276
+ sh=shell,
277
+ remote_name=config.remote_name,
278
+ github_url=config.github_url,
279
+ args=list(git_log_args),
280
+ pull_request=pull_request,
281
+ )
282
+
283
+
177
284
  @main.command("rage")
178
285
  @click.option(
179
286
  "--latest",
@@ -257,6 +364,18 @@ def status(pull_request: str) -> None:
257
364
  "With --no-stack, we support only non-range identifiers, and will submit each commit "
258
365
  "listed in the command line.",
259
366
  )
367
+ @click.option(
368
+ "--reviewer",
369
+ default=None,
370
+ help="Comma-separated list of GitHub usernames to add as reviewers to new PRs "
371
+ "(overrides .ghstackrc setting)",
372
+ )
373
+ @click.option(
374
+ "--label",
375
+ default=None,
376
+ help="Comma-separated list of labels to add to new PRs "
377
+ "(overrides .ghstackrc setting)",
378
+ )
260
379
  @click.option(
261
380
  "--direct/--no-direct",
262
381
  "direct_opt",
@@ -280,6 +399,8 @@ def submit(
280
399
  base: Optional[str],
281
400
  revs: Tuple[str, ...],
282
401
  stack: bool,
402
+ reviewer: Optional[str],
403
+ label: Optional[str],
283
404
  ) -> None:
284
405
  """
285
406
  Submit or update a PR stack
@@ -301,6 +422,8 @@ def submit(
301
422
  revs=revs,
302
423
  stack=stack,
303
424
  direct_opt=direct_opt,
425
+ reviewer=reviewer if reviewer is not None else config.reviewer,
426
+ label=label if label is not None else config.label,
304
427
  )
305
428
 
306
429
 
@@ -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]
@@ -460,6 +463,24 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
460
463
  if m:
461
464
  # For now, pretend all branches are not protected
462
465
  raise ghstack.github.NotFoundError()
466
+ if m := re.match(r"^repos/([^/]+)/([^/]+)/pulls/([^/]+)$", path):
467
+ state = self.state
468
+ repo = state.repository(m.group(1), m.group(2))
469
+ pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
470
+ return {
471
+ "number": pr.number,
472
+ "state": "closed" if pr.closed else "open",
473
+ "title": pr.title,
474
+ "body": pr.body,
475
+ }
476
+ if m := re.match(r"^repos/([^/]+)/([^/]+)/issues/comments/([^/]+)$", path):
477
+ state = self.state
478
+ repo = state.repository(m.group(1), m.group(2))
479
+ comment = state.issue_comment(repo, int(m.group(3)))
480
+ return {
481
+ "id": comment.fullDatabaseId,
482
+ "body": comment.body,
483
+ }
463
484
 
464
485
  elif method == "post":
465
486
  if m := re.match(r"^repos/([^/]+)/([^/]+)/pulls$", path):
@@ -473,6 +494,24 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
473
494
  GitHubNumber(int(m.group(3))),
474
495
  cast(CreateIssueCommentInput, kwargs),
475
496
  )
497
+ if m := re.match(
498
+ r"^repos/([^/]+)/([^/]+)/pulls/([^/]+)/requested_reviewers", path
499
+ ):
500
+ # Handle adding reviewers
501
+ state = self.state
502
+ repo = state.repository(m.group(1), m.group(2))
503
+ pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
504
+ reviewers = kwargs.get("reviewers", [])
505
+ pr.reviewers.extend(reviewers)
506
+ return {}
507
+ if m := re.match(r"^repos/([^/]+)/([^/]+)/issues/([^/]+)/labels", path):
508
+ # Handle adding labels
509
+ state = self.state
510
+ repo = state.repository(m.group(1), m.group(2))
511
+ pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
512
+ labels = kwargs.get("labels", [])
513
+ pr.labels.extend(labels)
514
+ return {}
476
515
  elif method == "patch":
477
516
  if m := re.match(r"^repos/([^/]+)/([^/]+)(?:/pulls/([^/]+))?$", path):
478
517
  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
@@ -145,13 +145,41 @@ to complain to the ghstack authors."""
145
145
  stack_orig_refs.append((ref, pr_resolved))
146
146
 
147
147
  # OK, actually do the land now
148
- for orig_ref, _ in stack_orig_refs:
148
+ for orig_ref, pr_resolved in stack_orig_refs:
149
149
  try:
150
150
  sh.git("cherry-pick", f"{remote_name}/{orig_ref}")
151
151
  except BaseException:
152
152
  sh.git("cherry-pick", "--abort")
153
153
  raise
154
154
 
155
+ # Add PR number to commit message like GitHub does
156
+ commit_msg = sh.git("log", "-1", "--pretty=%B")
157
+ # Get the original author and committer dates to preserve the commit hash
158
+ author_date = sh.git("log", "-1", "--pretty=%aD")
159
+ committer_date = sh.git("log", "-1", "--pretty=%cD")
160
+ lines = commit_msg.split("\n")
161
+ if lines:
162
+ # Add PR number to the subject line (first line)
163
+ subject = lines[0].rstrip()
164
+ # Only add if not already present
165
+ pr_tag = f"(#{pr_resolved.number})"
166
+ if pr_tag not in subject:
167
+ subject = f"{subject} {pr_tag}"
168
+ lines[0] = subject
169
+ new_msg = "\n".join(lines)
170
+ # Preserve dates to keep the commit hash consistent
171
+ sh.git(
172
+ "commit",
173
+ "--amend",
174
+ "-F",
175
+ "-",
176
+ input=new_msg,
177
+ env={
178
+ "GIT_AUTHOR_DATE": author_date,
179
+ "GIT_COMMITTER_DATE": committer_date,
180
+ },
181
+ )
182
+
155
183
  # All good! Push!
156
184
  maybe_force_arg = []
157
185
  if needs_force:
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import subprocess
4
+ import sys
5
+ from typing import List, Optional, Tuple
6
+
7
+ import ghstack.diff
8
+ import ghstack.github
9
+ import ghstack.github_utils
10
+ import ghstack.shell
11
+
12
+
13
+ def _resolve_refs(
14
+ *,
15
+ github: ghstack.github.GitHubEndpoint,
16
+ params: ghstack.github_utils.GitHubPullRequestParams,
17
+ ) -> Tuple[str, str]:
18
+ pr_result = github.graphql(
19
+ """
20
+ query ($owner: String!, $name: String!, $number: Int!) {
21
+ repository(name: $name, owner: $owner) {
22
+ pullRequest(number: $number) {
23
+ headRefName
24
+ baseRefName
25
+ }
26
+ }
27
+ }
28
+ """,
29
+ **params,
30
+ )
31
+ pr = pr_result["data"]["repository"]["pullRequest"]
32
+ return pr["headRefName"], pr["baseRefName"]
33
+
34
+
35
+ def main(
36
+ github: ghstack.github.GitHubEndpoint,
37
+ sh: ghstack.shell.Shell,
38
+ remote_name: str,
39
+ github_url: str,
40
+ args: List[str],
41
+ pull_request: Optional[str] = None,
42
+ ) -> None:
43
+ if pull_request is not None:
44
+ # Explicit PR: fetch from remote and show what's there. HEAD isn't
45
+ # involved; no synthesized pending-changes commit.
46
+ params = ghstack.github_utils.parse_pull_request(
47
+ pull_request, sh=sh, remote_name=remote_name
48
+ )
49
+ sh.git("fetch", "--prune", remote_name)
50
+ show_pending = False
51
+ else:
52
+ # Infer PR from HEAD's Pull-Request trailer; show the log relative
53
+ # to the local understanding of the remote state (no fetch).
54
+ commit_msg = sh.git("log", "-1", "--format=%B", "HEAD")
55
+ pr = ghstack.diff.PullRequestResolved.search(commit_msg, github_url)
56
+ if pr is None:
57
+ raise RuntimeError(
58
+ "HEAD commit is not associated with a ghstack pull request "
59
+ "(no Pull-Request trailer found). Check out the commit for the "
60
+ "PR you want to log, or pass the PR explicitly."
61
+ )
62
+ params = {
63
+ "github_url": pr.github_url,
64
+ "owner": pr.owner,
65
+ "name": pr.repo,
66
+ "number": pr.number,
67
+ }
68
+ show_pending = True
69
+
70
+ head_ref, base_ref = _resolve_refs(github=github, params=params)
71
+
72
+ remote_head = f"{remote_name}/{head_ref}"
73
+ remote_base = f"{remote_name}/{base_ref}"
74
+
75
+ tip = remote_head
76
+ if show_pending:
77
+ # If the local HEAD tree differs from the remote head tree, synthesize
78
+ # a disposable commit on top of the remote head so pending changes
79
+ # show up as the newest commit in `git log`.
80
+ local_tree = sh.git("rev-parse", "HEAD^{tree}")
81
+ remote_tree = sh.git("rev-parse", f"{remote_head}^{{tree}}")
82
+ if local_tree != remote_tree:
83
+ tip = sh.git(
84
+ "commit-tree",
85
+ local_tree,
86
+ "-p",
87
+ remote_head,
88
+ input="Local pending changes (not yet submitted)\n",
89
+ )
90
+
91
+ # ^remote_base restricts the walk to the head chain (each head commit is a
92
+ # merge of the previous head and a base-update commit; excluding everything
93
+ # reachable from the base ref drops the base-update side).
94
+ #
95
+ # --diff-merges=remerge replays the merge and diffs the stored tree
96
+ # against the auto-merge result, which for ghstack isolates just the
97
+ # user's code edits from the base-update changes that got folded in.
98
+ # This only affects merge commits (non-merges still need -p). Requires
99
+ # git 2.35+. Users who prefer a portable alternative can pass
100
+ # --diff-merges=cc to override.
101
+ log_args = ["--diff-merges=remerge", tip]
102
+ if sh.git("rev-parse", "--verify", "--quiet", remote_base, exitcode=True):
103
+ log_args.append(f"^{remote_base}")
104
+ log_args.extend(args)
105
+
106
+ if sys.stdout.isatty():
107
+ # Let git manage its own pager.
108
+ subprocess.run(["git", "log", *log_args], cwd=sh.cwd, check=False)
109
+ else:
110
+ # In test/piped contexts, capture and write to sys.stdout so the
111
+ # caller can intercept it.
112
+ out = sh.git("log", *log_args)
113
+ if out:
114
+ sys.stdout.write(out)
115
+ if not out.endswith("\n"):
116
+ sys.stdout.write("\n")
@@ -90,6 +90,7 @@ class PreBranchState:
90
90
 
91
91
  # Ya, sometimes we get carriage returns. Crazy right?
92
92
  RE_STACK = re.compile(r"Stack.*:\r?\n(\* [^\r\n]+\r?\n)+")
93
+ RE_STACK_PR_NUMBER = re.compile(r"#(\d+)")
93
94
 
94
95
 
95
96
  # NB: This regex is fuzzy because the D1234567 identifier is typically
@@ -343,6 +344,12 @@ class Submitter:
343
344
  # merged. If None, infer whether or not the PR should be direct or not.
344
345
  direct_opt: Optional[bool] = None
345
346
 
347
+ # Default reviewers to add to new pull requests (comma-separated usernames)
348
+ reviewer: Optional[str] = None
349
+
350
+ # Default labels to add to new pull requests (comma-separated labels)
351
+ label: Optional[str] = None
352
+
346
353
  # ~~~~~~~~~~~~~~~~~~~~~~~~
347
354
  # Computed in post init
348
355
 
@@ -481,18 +488,20 @@ class Submitter:
481
488
  d = ghstack.git.convert_header(h, self.github_url)
482
489
  if d.pull_request_resolved is not None:
483
490
  ed = self.elaborate_diff(d)
484
- pre_branch_state_index[h.commit_id] = PreBranchState(
485
- head_commit_id=GitCommitHash(
486
- self.sh.git(
487
- "rev-parse", f"{self.remote_name}/{ed.head_ref}"
488
- )
489
- ),
490
- base_commit_id=GitCommitHash(
491
- self.sh.git(
492
- "rev-parse", f"{self.remote_name}/{ed.base_ref}"
493
- )
494
- ),
495
- )
491
+ # Skip closed PRs (e.g., after landing) where branches have been deleted
492
+ if not ed.closed:
493
+ pre_branch_state_index[h.commit_id] = PreBranchState(
494
+ head_commit_id=GitCommitHash(
495
+ self.sh.git(
496
+ "rev-parse", f"{self.remote_name}/{ed.head_ref}"
497
+ )
498
+ ),
499
+ base_commit_id=GitCommitHash(
500
+ self.sh.git(
501
+ "rev-parse", f"{self.remote_name}/{ed.base_ref}"
502
+ )
503
+ ),
504
+ )
496
505
 
497
506
  # NB: deduplicates
498
507
  commit_index = {
@@ -510,7 +519,12 @@ class Submitter:
510
519
  for h in commits_to_submit
511
520
  if h.commit_id in diff_meta_index
512
521
  ]
513
- self.push_updates(diffs_to_submit)
522
+ all_diffs_in_topo_order = [
523
+ diff_meta_index[h.commit_id]
524
+ for h in commits_to_rebase
525
+ if h.commit_id in diff_meta_index
526
+ ]
527
+ self.push_updates(diffs_to_submit, all_diffs=all_diffs_in_topo_order)
514
528
  if new_head := rebase_index.get(
515
529
  old_head := GitCommitHash(self.sh.git("rev-parse", "HEAD"))
516
530
  ):
@@ -864,21 +878,24 @@ to disassociate the commit with the pull request, and then try again.
864
878
  "--header",
865
879
  self.remote_name + "/" + branch_orig(username, gh_number),
866
880
  )
867
- except RuntimeError as e:
881
+ except RuntimeError:
868
882
  if r["closed"]:
869
- raise RuntimeError(
870
- f"Cannot ghstack a stack with closed PR #{number} whose branch was deleted. "
871
- "If you were just trying to update a later PR in the stack, `git rebase` and try again. "
872
- "Otherwise, you may have been trying to update a PR that was already closed. "
873
- "To disassociate your update from the old PR and open a new PR, "
874
- "run `ghstack unlink`, `git rebase` and then try again."
875
- ) from e
876
- raise
877
- remote_summary = ghstack.git.split_header(rev_list)[0]
878
- m_remote_source_id = RE_GHSTACK_SOURCE_ID.search(remote_summary.commit_msg)
879
- remote_source_id = m_remote_source_id.group(1) if m_remote_source_id else None
880
- m_comment_id = RE_GHSTACK_COMMENT_ID.search(remote_summary.commit_msg)
881
- comment_id = int(m_comment_id.group(1)) if m_comment_id else None
883
+ # If the PR is closed and the branch is deleted (e.g., after landing),
884
+ # we can't get the remote source ID. Return None for it, which will
885
+ # signal to process_commit that this commit has been landed and should
886
+ # be skipped (not updated).
887
+ remote_source_id = None
888
+ comment_id = None
889
+ else:
890
+ raise
891
+ else:
892
+ remote_summary = ghstack.git.split_header(rev_list)[0]
893
+ m_remote_source_id = RE_GHSTACK_SOURCE_ID.search(remote_summary.commit_msg)
894
+ remote_source_id = (
895
+ m_remote_source_id.group(1) if m_remote_source_id else None
896
+ )
897
+ m_comment_id = RE_GHSTACK_COMMENT_ID.search(remote_summary.commit_msg)
898
+ comment_id = int(m_comment_id.group(1)) if m_comment_id else None
882
899
 
883
900
  return DiffWithGitHubMetadata(
884
901
  diff=diff,
@@ -911,6 +928,48 @@ to disassociate the commit with the pull request, and then try again.
911
928
  if elab_diff is not None and elab_diff.closed:
912
929
  if self.direct:
913
930
  self._raise_needs_rebase()
931
+ # If we're trying to submit a closed commit, check if it has been modified
932
+ if elab_diff.remote_source_id is None:
933
+ # The branch was deleted (e.g., after landing). Check if the commit has been
934
+ # modified by comparing source_ids. If the commit is reachable from master with
935
+ # the same source_id (tree hash), it means it was landed and we should skip it.
936
+ # Otherwise, it's been modified and we should raise an error.
937
+ try:
938
+ # Check if there's a commit on master with the same tree (source_id)
939
+ master_commits = self.sh.git(
940
+ "log",
941
+ "--format=%H %T",
942
+ f"{self.remote_name}/{self.base}",
943
+ "-n",
944
+ "100", # Check last 100 commits
945
+ )
946
+ for line in master_commits.split("\n"):
947
+ if not line.strip():
948
+ continue
949
+ commit_hash, tree_hash = line.split()
950
+ if tree_hash == diff.source_id:
951
+ # Found a commit on master with the same tree, so this commit
952
+ # was landed (just with a different commit message/hash)
953
+ return None
954
+ except Exception:
955
+ pass
956
+ # Didn't find a matching commit on master, so this is a modified closed commit
957
+ raise RuntimeError(
958
+ f"Cannot ghstack a stack with closed PR #{elab_diff.number} whose branch was deleted. "
959
+ "If you were just trying to update a later PR in the stack, `git rebase` and try again. "
960
+ "Otherwise, you may have been trying to update a PR that was already closed. "
961
+ "To disassociate your update from the old PR and open a new PR, "
962
+ "run `ghstack unlink`, `git rebase` and then try again."
963
+ )
964
+ elif diff.source_id != elab_diff.remote_source_id:
965
+ # The commit has been modified locally
966
+ raise RuntimeError(
967
+ f"Cannot ghstack a stack with closed PR #{elab_diff.number} whose branch was deleted. "
968
+ "If you were just trying to update a later PR in the stack, `git rebase` and try again. "
969
+ "Otherwise, you may have been trying to update a PR that was already closed. "
970
+ "To disassociate your update from the old PR and open a new PR, "
971
+ "run `ghstack unlink`, `git rebase` and then try again."
972
+ )
914
973
  return None
915
974
 
916
975
  # Edge case: check if the commit is empty; if so skip submitting
@@ -1434,6 +1493,32 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1434
1493
  )
1435
1494
  comment_id = rc["id"]
1436
1495
 
1496
+ # Add reviewers if specified
1497
+ if self.reviewer:
1498
+ reviewers = [r.strip() for r in self.reviewer.split(",") if r.strip()]
1499
+ if reviewers:
1500
+ try:
1501
+ self.github.post(
1502
+ f"repos/{self.repo_owner}/{self.repo_name}/pulls/{number}/requested_reviewers",
1503
+ reviewers=reviewers,
1504
+ )
1505
+ logging.info(f"Added reviewers: {', '.join(reviewers)}")
1506
+ except Exception as e:
1507
+ logging.warning(f"Failed to add reviewers: {e}")
1508
+
1509
+ # Add labels if specified
1510
+ if self.label:
1511
+ labels = [label.strip() for label in self.label.split(",") if label.strip()]
1512
+ if labels:
1513
+ try:
1514
+ self.github.post(
1515
+ f"repos/{self.repo_owner}/{self.repo_name}/issues/{number}/labels",
1516
+ labels=labels,
1517
+ )
1518
+ logging.info(f"Added labels: {', '.join(labels)}")
1519
+ except Exception as e:
1520
+ logging.warning(f"Failed to add labels: {e}")
1521
+
1437
1522
  logging.info("Opened PR #{}".format(number))
1438
1523
 
1439
1524
  pull_request_resolved = ghstack.diff.PullRequestResolved(
@@ -1459,7 +1544,11 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1459
1544
  )
1460
1545
 
1461
1546
  def push_updates(
1462
- self, diffs_to_submit: List[DiffMeta], *, import_help: bool = True
1547
+ self,
1548
+ diffs_to_submit: List[DiffMeta],
1549
+ *,
1550
+ all_diffs: Optional[List[DiffMeta]] = None,
1551
+ import_help: bool = True,
1463
1552
  ) -> None:
1464
1553
  # update pull request information, update bases as necessary
1465
1554
  # preferably do this in one network call
@@ -1492,6 +1581,46 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1492
1581
  if force_push_branches:
1493
1582
  self._git_push(force_push_branches, force=True)
1494
1583
 
1584
+ # Discover orphan PR numbers from the old stack listing.
1585
+ # We search the full local stack for old stack text, then
1586
+ # collect open PRs that aren't being submitted — both above
1587
+ # and below the submitted ones. Closed PRs are filtered out.
1588
+ submitted_numbers = {s.number for s in diffs_to_submit}
1589
+ orphan_above: List[GitHubNumber] = []
1590
+ orphan_below: List[GitHubNumber] = []
1591
+ for s in all_diffs or diffs_to_submit:
1592
+ old_stack_text: Optional[str] = None
1593
+ if self.direct and s.elab_diff.comment_id is not None:
1594
+ r = self.github.get(
1595
+ f"repos/{self.repo_owner}/{self.repo_name}/issues/comments/{s.elab_diff.comment_id}",
1596
+ )
1597
+ old_stack_text = r.get("body")
1598
+ else:
1599
+ m = RE_STACK.search(s.body)
1600
+ if m:
1601
+ old_stack_text = m.group(0)
1602
+ if old_stack_text:
1603
+ old_pr_numbers = [
1604
+ GitHubNumber(int(n))
1605
+ for n in RE_STACK_PR_NUMBER.findall(old_stack_text)
1606
+ ]
1607
+ if not old_pr_numbers:
1608
+ continue
1609
+ seen_submitted = False
1610
+ for num in old_pr_numbers:
1611
+ if num in submitted_numbers:
1612
+ seen_submitted = True
1613
+ continue
1614
+ pr_info = self.github.get(
1615
+ f"repos/{self.repo_owner}/{self.repo_name}/pulls/{num}",
1616
+ )
1617
+ if pr_info.get("state") == "open":
1618
+ if seen_submitted:
1619
+ orphan_below.append(num)
1620
+ else:
1621
+ orphan_above.append(num)
1622
+ break
1623
+
1495
1624
  for s in reversed(diffs_to_submit):
1496
1625
  # NB: GraphQL API does not support modifying PRs
1497
1626
  assert not s.closed
@@ -1509,7 +1638,9 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1509
1638
  base_kwargs["base"] = s.base
1510
1639
  else:
1511
1640
  assert s.base == s.elab_diff.base_ref
1512
- stack_desc = self._format_stack(diffs_to_submit, s.number)
1641
+ stack_desc = self._format_stack(
1642
+ diffs_to_submit, s.number, orphan_above, orphan_below
1643
+ )
1513
1644
  self.github.patch(
1514
1645
  "repos/{owner}/{repo}/pulls/{number}".format(
1515
1646
  owner=self.repo_owner, repo=self.repo_name, number=s.number
@@ -1729,14 +1860,24 @@ is closed (likely due to being merged). Please rebase to upstream and try again
1729
1860
  # - want "as complete" a tree as possible; this may involve
1730
1861
  # poking around the xrefs to find out all the other PRs
1731
1862
  # involved in the stack
1732
- def _format_stack(self, diffs_to_submit: List[DiffMeta], number: int) -> str:
1863
+ def _format_stack(
1864
+ self,
1865
+ diffs_to_submit: List[DiffMeta],
1866
+ number: int,
1867
+ orphan_above: Sequence[GitHubNumber] = (),
1868
+ orphan_below: Sequence[GitHubNumber] = (),
1869
+ ) -> str:
1733
1870
  rows = []
1871
+ for n in orphan_above:
1872
+ rows.append(f"* #{n}")
1734
1873
  # NB: top is top of stack, opposite of update order
1735
1874
  for s in diffs_to_submit:
1736
1875
  if s.number == number:
1737
1876
  rows.append(f"* __->__ #{s.number}")
1738
1877
  else:
1739
1878
  rows.append(f"* #{s.number}")
1879
+ for n in orphan_below:
1880
+ rows.append(f"* #{n}")
1740
1881
  return self.stack_header + ":\n" + "\n".join(rows) + "\n"
1741
1882
 
1742
1883
  def _default_title_and_body(
@@ -8,16 +8,18 @@ import shutil
8
8
  import stat
9
9
  import sys
10
10
  import tempfile
11
- from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple, Union
11
+ from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple, Type, Union
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
18
19
  import ghstack.github_fake
19
20
  import ghstack.github_utils
20
21
  import ghstack.land
22
+ import ghstack.log
21
23
  import ghstack.shell
22
24
  import ghstack.submit
23
25
  import ghstack.unlink
@@ -32,6 +34,8 @@ __all__ = [
32
34
  "gh_land",
33
35
  "gh_unlink",
34
36
  "gh_cherry_pick",
37
+ "gh_checkout",
38
+ "gh_log",
35
39
  "GitCommitHash",
36
40
  "checkout",
37
41
  "amend",
@@ -49,6 +53,8 @@ __all__ = [
49
53
  "get_sh",
50
54
  "get_upstream_sh",
51
55
  "get_github",
56
+ "get_pr_reviewers",
57
+ "get_pr_labels",
52
58
  "tick",
53
59
  "captured_output",
54
60
  ]
@@ -192,6 +198,8 @@ def gh_submit(
192
198
  base: Optional[str] = None,
193
199
  revs: Sequence[str] = (),
194
200
  stack: bool = True,
201
+ reviewer: Optional[str] = None,
202
+ label: Optional[str] = None,
195
203
  ) -> List[ghstack.submit.DiffMeta]:
196
204
  self = CTX
197
205
  r = ghstack.submit.main(
@@ -212,6 +220,8 @@ def gh_submit(
212
220
  revs=revs,
213
221
  stack=stack,
214
222
  check_invariants=True,
223
+ reviewer=reviewer,
224
+ label=label,
215
225
  )
216
226
  self.check_global_github_invariants(self.direct)
217
227
  return r
@@ -251,6 +261,29 @@ def gh_cherry_pick(pull_request: str, stack: bool = False) -> None:
251
261
  )
252
262
 
253
263
 
264
+ def gh_checkout(pull_request: str, same_base: bool = False) -> None:
265
+ self = CTX
266
+ return ghstack.checkout.main(
267
+ pull_request=pull_request,
268
+ github=self.github,
269
+ sh=self.sh,
270
+ remote_name="origin",
271
+ same_base=same_base,
272
+ )
273
+
274
+
275
+ def gh_log(pull_request: Optional[str] = None, args: Sequence[str] = ()) -> None:
276
+ self = CTX
277
+ return ghstack.log.main(
278
+ github=self.github,
279
+ sh=self.sh,
280
+ remote_name="origin",
281
+ github_url="github.com",
282
+ args=list(args),
283
+ pull_request=pull_request,
284
+ )
285
+
286
+
254
287
  def write_file_and_add(filename: str, contents: str) -> None:
255
288
  self = CTX
256
289
  with self.sh.open(filename, "w") as f:
@@ -373,13 +406,38 @@ def is_direct() -> bool:
373
406
  return CTX.direct
374
407
 
375
408
 
409
+ def get_github() -> "ghstack.github_fake.FakeGitHubEndpoint":
410
+ github = CTX.github
411
+ assert isinstance(github, ghstack.github_fake.FakeGitHubEndpoint)
412
+ return github
413
+
414
+
415
+ def get_pr_reviewers(pr_number: int) -> List[str]:
416
+ """Get the reviewers for a PR number."""
417
+ github = get_github()
418
+ repo = github.state.repository("pytorch", "pytorch")
419
+ pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
420
+ return pr.reviewers
421
+
422
+
423
+ def get_pr_labels(pr_number: int) -> List[str]:
424
+ """Get the labels for a PR number."""
425
+ github = get_github()
426
+ repo = github.state.repository("pytorch", "pytorch")
427
+ pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
428
+ return pr.labels
429
+
430
+
376
431
  def assert_eq(a: Any, b: Any) -> None:
377
432
  assert a == b, f"{a} != {b}"
378
433
 
379
434
 
380
435
  def assert_raises(
381
- exc_type: any, callable: Callable[..., any], *args: any, **kwargs: any
382
- ):
436
+ exc_type: Type[BaseException],
437
+ callable: Callable[..., Any],
438
+ *args: Any,
439
+ **kwargs: Any,
440
+ ) -> None:
383
441
  try:
384
442
  callable(*args, **kwargs)
385
443
  except exc_type:
@@ -388,8 +446,12 @@ def assert_raises(
388
446
 
389
447
 
390
448
  def assert_expected_raises_inline(
391
- exc_type: any, callable: Callable[..., any], expect: str, *args: any, **kwargs: any
392
- ):
449
+ exc_type: Type[BaseException],
450
+ callable: Callable[..., Any],
451
+ expect: str,
452
+ *args: Any,
453
+ **kwargs: Any,
454
+ ) -> None:
393
455
  try:
394
456
  callable(*args, **kwargs)
395
457
  except exc_type as e:
@@ -406,9 +468,5 @@ def get_upstream_sh() -> ghstack.shell.Shell:
406
468
  return CTX.upstream_sh
407
469
 
408
470
 
409
- def get_github() -> ghstack.github.GitHubEndpoint:
410
- return CTX.github
411
-
412
-
413
471
  def tick() -> None:
414
472
  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