Skip to content

Commit e15c0c0

Browse files
Antonello Tartamodmach
authored andcommitted
Add 'git-obs staging' command for staging multiple package pull requests into a single project pull request
1 parent d74457d commit e15c0c0

File tree

7 files changed

+355
-6
lines changed

7 files changed

+355
-6
lines changed

osc/commands_git/staging.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import osc.commandline_git
2+
3+
4+
class StagingCommand(osc.commandline_git.GitObsCommand):
5+
"""
6+
Manage staging projects
7+
"""
8+
9+
name = "staging"
10+
11+
def init_arguments(self):
12+
pass
13+
14+
def run(self, args):
15+
self.parser.print_help()

osc/commands_git/staging_group.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import os
2+
3+
import osc.commandline_git
4+
5+
6+
class StagingGroupCommand(osc.commandline_git.GitObsCommand):
7+
"""
8+
Group multiple staging project pull requests into a target staging project pull request
9+
"""
10+
11+
name = "group"
12+
parent = "StagingCommand"
13+
14+
def init_arguments(self):
15+
self.add_argument_owner_repo_pull(
16+
dest="--target",
17+
help="Target project pull request to modify. If not specified, a new pull request will be created.",
18+
).completer = osc.commandline_git.complete_pr
19+
20+
self.add_argument(
21+
"--title",
22+
help="Title of the new pull request. Defaults to 'Update packages: <pkg> [pkg] ...'. Conflicts with --target.",
23+
)
24+
25+
self.add_argument(
26+
"--fork-owner",
27+
help="Owner of the fork used to create a new pull request. Defaults to the currently logged user. Conflicts with --target.",
28+
)
29+
30+
self.add_argument(
31+
"--fork-branch",
32+
help="Branch in the fork used to create a new pull request. Defaults to 'for/<target_branch>/group-YYYY-MM-DD_HH-MM-SS'. Conflicts with --target."
33+
)
34+
35+
self.add_argument(
36+
"--force",
37+
action="store_true",
38+
help="Allow force-push to the branch associated with the pull request",
39+
)
40+
41+
self.add_argument_owner_repo_pull(
42+
dest="pr_list",
43+
nargs="+",
44+
help="List of project pull request to be merged into the target project pull request",
45+
).completer = osc.commandline_git.complete_pr
46+
47+
self.add_argument(
48+
"--keep-temp-dir",
49+
action="store_true",
50+
help="Don't delete the temporary directory with git checkouts",
51+
)
52+
53+
def run(self, args):
54+
import datetime
55+
from osc import gitea_api
56+
from osc.gitea_api.common import TemporaryDirectory
57+
58+
if args.target in args.pr_list:
59+
self.parser.error("Target pull request was found among pull requests for merging")
60+
61+
if args.title and args.target:
62+
self.parser.error("--title conflicts with --target")
63+
64+
if args.fork_owner and args.target:
65+
self.parser.error("--fork-owner conflicts with --target")
66+
67+
if args.fork_branch and args.target:
68+
self.parser.error("--fork-branch conflicts with --target")
69+
70+
self.print_gitea_settings()
71+
72+
with TemporaryDirectory(prefix="git-obs-staging_", dir=".", delete=not args.keep_temp_dir) as temp_dir:
73+
user_obj = gitea_api.User.get(self.gitea_conn)
74+
75+
if args.target:
76+
target_owner, target_repo, target_number = args.target
77+
pr_obj = gitea_api.PullRequest.get(self.gitea_conn, target_owner, target_repo, target_number)
78+
# # to update a pull request, we either need to be its creator or an admin in the repo
79+
# if not (pr_obj._data["base"]["repo"]["permissions"]["admin"] or pr_obj.user == user_obj.login):
80+
# raise gitea_api.GitObsRuntimeError(f"You don't have sufficient permissions to modify pull request {target_owner}/{target_repo}#{target_number}")
81+
82+
# get pull request data from gitea
83+
pr_map = {}
84+
for owner, repo, number in args.pr_list:
85+
pr = gitea_api.StagingPullRequestWrapper(self.gitea_conn, owner, repo, number, topdir=temp_dir)
86+
pr_map[(owner, repo, number)] = pr
87+
88+
89+
# run checks
90+
target_owner = None
91+
target_repo = None
92+
target_branch = None
93+
for owner, repo, number in args.pr_list:
94+
pr = pr_map[(owner, repo, number)]
95+
96+
if pr.pr_obj.state != "open":
97+
# we don't care about the state of the package pull requests - they can be merged already
98+
raise gitea_api.GitObsRuntimeError(f"Pull request {owner}/{repo}#{number} is not open (the state is '{pr.pr_obj.state}')")
99+
100+
# if not (pr.pr_obj._data["base"]["repo"]["permissions"]["admin"] or pr.pr_obj.user == user_obj.login):
101+
# raise gitea_api.GitObsRuntimeError(f"You don't have sufficient permissions to modify pull request {owner}/{repo}#{number}")
102+
103+
if gitea_api.StagingPullRequestWrapper.BACKLOG_LABEL not in pr.pr_obj.labels:
104+
raise gitea_api.GitObsRuntimeError(f"Pull request {owner}/{repo}#{number} is missing the '{gitea_api.StagingPullRequestWrapper.BACKLOG_LABEL}' label.")
105+
106+
# test that all PRs go to the same branch
107+
if target_owner is None:
108+
target_owner = pr.pr_obj.base_owner
109+
else:
110+
assert target_owner == pr.pr_obj.base_owner, f"{target_owner} != {pr.pr_obj.base_owner}"
111+
112+
if target_repo is None:
113+
target_repo = pr.pr_obj.base_repo
114+
else:
115+
assert target_repo == pr.pr_obj.base_repo, f"{target_repo} != {pr.pr_obj.base_repo}"
116+
117+
if target_branch is None:
118+
target_branch = pr.pr_obj.base_branch
119+
else:
120+
assert target_branch == pr.pr_obj.base_branch, f"{target_branch} != {pr.pr_obj.base_branch}"
121+
122+
# clone the git repos, cache submodule data
123+
for owner, repo, number in args.pr_list:
124+
pr = pr_map[(owner, repo, number)]
125+
pr.clone()
126+
127+
# run checks #2
128+
for owner, repo, number in args.pr_list:
129+
pr = pr_map[(owner, repo, number)]
130+
if not pr.package_pr_map:
131+
# TODO: we don't know if the submodules are packages or not, we should cross-reference those with _manifest
132+
raise gitea_api.GitObsRuntimeError(f"Pull request {owner}/{repo}#{number} doesn't have any submodules changed.")
133+
134+
if not args.target:
135+
fork_owner = args.fork_owner if args.fork_owner else user_obj.login
136+
# dates in ISO 8601 format cannot be part of a valid branch name, we need a custom format
137+
fork_branch = args.fork_branch if args.fork_branch else f"for/{target_branch}/group-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}"
138+
139+
fork_repo = None
140+
forks = gitea_api.Fork.list(self.gitea_conn, target_owner, target_repo)
141+
for repo in forks:
142+
if repo.owner.lower() == fork_owner.lower():
143+
fork_repo = repo.repo
144+
if not fork_repo:
145+
raise gitea_api.GitObsRuntimeError(f"Cannot find a matching fork of {target_owner}/{target_repo}")
146+
147+
clone_dir = gitea_api.Repo.clone(
148+
self.gitea_conn,
149+
fork_owner,
150+
fork_repo,
151+
directory=os.path.join(temp_dir, f"{fork_owner}_{fork_repo}"),
152+
add_remotes=True,
153+
ssh_private_key_path=self.gitea_conn.login.ssh_key,
154+
)
155+
clone_git = gitea_api.Git(clone_dir)
156+
clone_git.fetch()
157+
clone_git._run_git(["fetch", "origin", f"{target_branch}:{fork_branch}", "--force", "--update-head-ok"])
158+
clone_git.switch(fork_branch)
159+
clone_git.push(remote="origin", branch=fork_branch, set_upstream=True, force=args.force)
160+
161+
# target project pull request wasn't specified, let's create it
162+
desc = ""
163+
updated_packages = []
164+
for owner, repo, number in args.pr_list:
165+
pr = pr_map[(owner, repo, number)]
166+
for (pkg_owner, pkg_repo, pkg_number), pr_obj in pr.package_pr_map.items():
167+
desc += f"PR: {pkg_owner}/{pkg_repo}!{pkg_number}\n"
168+
updated_packages.append(os.path.basename(pr.submodules_by_owner_repo[pkg_owner, pkg_repo]["path"]))
169+
170+
# TODO: it would be nice to mention the target OBS project
171+
# we keep only the first ``max_packages``, because the title might get too long quite easily
172+
max_packages = 5
173+
updated_packages_str = ", ".join(sorted(updated_packages)[:max_packages])
174+
if len(updated_packages) > max_packages:
175+
updated_packages_str += f" + {len(updated_packages) - max_packages} more"
176+
title = args.title if args.title else f"Update packages: {updated_packages_str}"
177+
178+
pr_obj = gitea_api.PullRequest.create(
179+
self.gitea_conn,
180+
target_owner=target_owner,
181+
target_repo=target_repo,
182+
target_branch=target_branch,
183+
source_owner=fork_owner,
184+
# source_repo is not required because the information lives in Gitea database
185+
source_branch=fork_branch,
186+
title=title,
187+
description=desc,
188+
labels=[gitea_api.StagingPullRequestWrapper.INPROGRESS_LABEL],
189+
)
190+
target_number = pr_obj.number
191+
192+
# clone the git repos, cache submodule data
193+
target = gitea_api.StagingPullRequestWrapper(self.gitea_conn, target_owner, target_repo, target_number, topdir=temp_dir)
194+
target.clone()
195+
for owner, repo, number in args.pr_list:
196+
pr = pr_map[(owner, repo, number)]
197+
pr.clone()
198+
199+
# locally merge package pull requests to the target project pull request (don't change anything on server yet)
200+
for owner, repo, number in args.pr_list:
201+
pr = pr_map[(owner, repo, number)]
202+
target.merge(pr)
203+
204+
# push to git repo associated with the target pull request
205+
target.git.push(remote="fork", branch=f"pull/{target.pr_obj.number}:{target.pr_obj.head_branch}")
206+
# update target pull request
207+
if args.target:
208+
# if args.target is not set, we've created a new pull request with all 'PR:' references included
209+
# if args.target is set (which is the case here), we need to update the description with the newly added 'PR:' references
210+
target.pr_obj.set(self.gitea_conn, target_owner, target_repo, target_number, description=target.pr_obj.body)
211+
212+
for owner, repo, number in args.pr_list:
213+
pr = pr_map[(owner, repo, number)]
214+
try:
215+
# apply the removed 'PR:' reference to the package pull request
216+
pr.pr_obj.set(self.gitea_conn, owner, repo, number, description=pr.pr_obj.body)
217+
except Exception as e:
218+
print(f"Unable to remove 'PR:' references from pull request {owner}/{repo}#{number}: {e}")
219+
220+
# close the pull request that was merged into the target
221+
try:
222+
gitea_api.PullRequest.close(self.gitea_conn, owner, repo, number)
223+
except Exception as e:
224+
print(f"Unable to close pull request {owner}/{repo}#{number}: {e}")
225+
226+
print()
227+
print(target.pr_obj.to_human_readable_string())
228+
229+
print()
230+
print("Staging project pull requests have been successfully merged")
231+
232+
if args.keep_temp_dir:
233+
print()
234+
print(f"Temporary files are available here: {temp_dir}")

osc/commands_git/staging_remove.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import osc.commandline_git
2+
3+
4+
class StagingRemoveCommand(osc.commandline_git.GitObsCommand):
5+
"""
6+
Remove package pull requests from a project pull request
7+
"""
8+
name = "remove"
9+
parent = "StagingCommand"
10+
11+
def init_arguments(self):
12+
self.add_argument_owner_repo_pull(
13+
dest="target",
14+
help="Project pull request to modify",
15+
).completer = osc.commandline_git.complete_pr
16+
17+
self.add_argument_owner_repo_pull(
18+
dest="pr_list",
19+
nargs="+",
20+
help="List of package pull requests to be removed from the project pull request",
21+
).completer = osc.commandline_git.complete_pr
22+
23+
self.add_argument(
24+
"--keep-temp-dir",
25+
action="store_true",
26+
help="Don't delete the temporary directory with git checkouts",
27+
)
28+
29+
def run(self, args):
30+
from osc import gitea_api
31+
from osc.gitea_api.common import TemporaryDirectory
32+
33+
target_owner, target_repo, target_number = args.target
34+
35+
if args.target in args.pr_list:
36+
self.parser.error("Target pull request was found among pull requests for removal")
37+
38+
self.print_gitea_settings()
39+
40+
with TemporaryDirectory(prefix="git-obs-staging_", dir=".", delete=not args.keep_temp_dir) as temp_dir:
41+
# get pull request data from gitea
42+
target = gitea_api.StagingPullRequestWrapper(self.gitea_conn, target_owner, target_repo, target_number, topdir=temp_dir)
43+
pr_map = {} # {(owner, repo, number):.
44+
for owner, repo, number in args.pr_list:
45+
pr = gitea_api.StagingPullRequestWrapper(self.gitea_conn, owner, repo, number, topdir=temp_dir)
46+
pr_map[(owner, repo, number)] = pr
47+
48+
# clone the git repos, cache submodule data
49+
target.clone()
50+
target.clone_base()
51+
52+
# locally remove package pull requests from the target project pull request (don't change anything on server yet)
53+
for owner, repo, number in args.pr_list:
54+
pr = pr_map[(owner, repo, number)]
55+
target.remove(pr)
56+
57+
# push to git repo associated with the target pull request
58+
target.git.push(remote="fork", branch=f"pull/{target.pr_obj.number}:{target.pr_obj.head_branch}")
59+
# update target pull request
60+
target.pr_obj.set(self.gitea_conn, target_owner, target_repo, target_number, description=target.pr_obj.body)
61+
62+
for owner, repo, number in args.pr_list:
63+
pr = pr_map[(owner, repo, number)]
64+
# close the removed package pull request
65+
try:
66+
gitea_api.PullRequest.close(self.gitea_conn, owner, repo, number)
67+
except Exception as e:
68+
print(f"Unable to close pull request {owner}/{repo}#{number}: {e}")
69+
70+
print()
71+
print(target.pr_obj.to_human_readable_string())
72+
73+
print()
74+
print("Package pull requests have been successfully removed from the staging project pull request")
75+
76+
if args.keep_temp_dir:
77+
print()
78+
print(f"Temporary files are available here: {temp_dir}")

osc/gitea_api/git.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ def current_branch(self) -> Optional[str]:
135135
except subprocess.CalledProcessError:
136136
return None
137137

138+
def branch(self, branch: str, set_upstream_to: Optional[str] = None):
139+
cmd = ["branch"]
140+
if set_upstream_to:
141+
cmd += ["--set-upstream-to", set_upstream_to]
142+
cmd += [branch]
143+
return self._run_git(cmd)
144+
138145
def branch_contains_commit(self, commit: str, branch: Optional[str] = None, remote: Optional[str] = None) -> bool:
139146
if not branch:
140147
branch = self.current_branch
@@ -335,6 +342,18 @@ def commit(self, msg, *, allow_empty: bool = False):
335342
cmd += ["--allow-empty"]
336343
self._run_git(cmd)
337344

345+
def push(self, remote: Optional[str] = None, branch: Optional[str] = None, *, set_upstream: Optional[str] = None, force: bool = False):
346+
cmd = ["push"]
347+
if force:
348+
cmd += ["--force"]
349+
if set_upstream:
350+
cmd += ["--set-upstream"]
351+
if remote:
352+
cmd += [remote]
353+
if branch:
354+
cmd += [branch]
355+
self._run_git(cmd)
356+
338357
def ls_files(self, ref: str = "HEAD", suffixes: Optional[List[str]] = None) -> Dict[str, str]:
339358
out = self._run_git(["ls-tree", "-r", "--format=%(objectname) %(path)", ref])
340359
regex = re.compile(r"^(?P<checksum>[0-9a-f]+) (?P<path>.*)$")

osc/gitea_api/pr.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def set(
422422
"""
423423
json_data = {
424424
"title": title,
425-
"description": description,
425+
"body": description,
426426
"allow_maintainer_edit": allow_maintainer_edit,
427427
}
428428
url = conn.makeurl("repos", owner, repo, "pulls", str(number))
@@ -738,7 +738,7 @@ def add_labels(
738738
repo: str,
739739
number: int,
740740
labels: List[str],
741-
) -> "GiteaHTTPResponse":
741+
) -> Optional["GiteaHTTPResponse"]:
742742
"""
743743
Add one or more labels to a pull request.
744744

osc/gitea_api/repo.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ def repo(self) -> str:
3434

3535
@property
3636
def parent_obj(self) -> Optional["Repo"]:
37-
if not self._data["parent"]:
37+
parent_data = self._data.get("parent")
38+
if not parent_data:
3839
return None
39-
return Repo(self._data["parent"])
40+
return Repo(parent_data)
4041

4142
@property
4243
def clone_url(self) -> str:

0 commit comments

Comments
 (0)