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.
- {ghstack-0.13.0 → ghstack-0.15.0}/PKG-INFO +1 -1
- {ghstack-0.13.0 → ghstack-0.15.0}/pyproject.toml +1 -1
- ghstack-0.15.0/src/ghstack/checkout.py +66 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cherry_pick.py +3 -1
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cli.py +136 -13
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/config.py +101 -1
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_fake.py +39 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_utils.py +17 -3
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/land.py +29 -1
- ghstack-0.15.0/src/ghstack/log.py +116 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/submit.py +171 -30
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/test_prelude.py +67 -9
- ghstack-0.13.0/src/ghstack/checkout.py +0 -31
- {ghstack-0.13.0 → ghstack-0.15.0}/LICENSE +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/README.md +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/__init__.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/__main__.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/action.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/cache.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/circleci.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/circleci_real.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/diff.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/forensics.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/git.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_real.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/github_schema.graphql +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/gpg_sign.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/logs.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/py.typed +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/rage.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/shell.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/status.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/trailers.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/types.py +0 -0
- {ghstack-0.13.0 → ghstack-0.15.0}/src/ghstack/unlink.py +0 -0
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
#
|
|
56
|
-
@click.option(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
"--
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
|
881
|
+
except RuntimeError:
|
|
868
882
|
if r["closed"]:
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
|
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
|