Skip to content

Commit d51d9a8

Browse files
authored
Merge pull request openSUSE#1961 from openSUSE/feature/staging
Add 'git-obs staging' command
2 parents 08ad031 + 2b908e0 commit d51d9a8

15 files changed

+1135
-69
lines changed

osc/commandline_git.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,21 @@ def print_gitea_settings(self):
9797

9898
def add_argument_owner_repo(self, **kwargs):
9999
dest = kwargs.pop("dest", "owner_repo")
100+
help = kwargs.pop("help", "Owner and repo: (format: <owner>/<repo>)")
100101
return self.add_argument(
101102
dest,
102103
action=OwnerRepoAction,
103-
help="Owner and repo: (format: <owner>/<repo>)",
104+
help=help,
104105
**kwargs,
105106
)
106107

107108
def add_argument_owner_repo_pull(self, **kwargs):
108109
dest = kwargs.pop("dest", "owner_repo_pull")
110+
help = kwargs.pop("help", "Owner, repo and pull request number (format: <owner>/<repo>#<pull-request-number>)")
109111
return self.add_argument(
110112
dest,
111113
action=OwnerRepoPullAction,
112-
help="Owner, repo and pull request number (format: <owner>/<repo>#<pull-request-number>)",
114+
help=help,
113115
**kwargs,
114116
)
115117

osc/commands_git/pr_dump.py

Lines changed: 3 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -40,62 +40,6 @@ def init_arguments(self):
4040
help="Pull request ID in <owner>/<repo>#<number> format",
4141
).completer = complete_checkout_pr
4242

43-
def clone_or_update(
44-
self,
45-
owner: str,
46-
repo: str,
47-
*,
48-
pr_number: Optional[int] = None,
49-
branch: Optional[str] = None,
50-
commit: str,
51-
directory: str,
52-
reference: Optional[str] = None,
53-
):
54-
from osc import gitea_api
55-
56-
if not pr_number and not branch:
57-
raise ValueError("Either 'pr_number' or 'branch' must be specified")
58-
59-
if not os.path.exists(os.path.join(directory, ".git")):
60-
gitea_api.Repo.clone(
61-
self.gitea_conn,
62-
owner,
63-
repo,
64-
directory=directory,
65-
add_remotes=True,
66-
reference=reference,
67-
)
68-
69-
git = gitea_api.Git(directory)
70-
git_owner, git_repo = git.get_owner_repo()
71-
assert git_owner.lower() == owner.lower(), f"owner does not match: {git_owner} != {owner}"
72-
assert git_repo.lower() == repo.lower(), f"repo does not match: {git_repo} != {repo}"
73-
74-
if pr_number:
75-
# ``git reset`` is required for fetching the pull request into an existing branch correctly
76-
# without it, ``git submodule status`` is broken and returns old data
77-
git.reset()
78-
# checkout the pull request and check if HEAD matches head/sha from Gitea
79-
pr_branch = git.fetch_pull_request(pr_number, commit=commit, force=True)
80-
git.switch(pr_branch)
81-
head_commit = git.get_branch_head()
82-
assert (
83-
head_commit == commit
84-
), f"HEAD of the current branch '{pr_branch}' is '{head_commit}' but the Gitea pull request points to '{commit}'"
85-
elif branch:
86-
git.switch(branch)
87-
88-
# run 'git fetch' only when the branch head is different to the expected commit
89-
head_commit = git.get_branch_head()
90-
if head_commit != commit:
91-
git.fetch()
92-
93-
if not git.branch_contains_commit(commit=commit, remote="origin"):
94-
raise RuntimeError(f"Branch '{branch}' doesn't contain commit '{commit}'")
95-
git.reset(commit, hard=True)
96-
else:
97-
raise ValueError("Either 'pr_number' or 'branch' must be specified")
98-
9943
def run(self, args):
10044
import json
10145
import shutil
@@ -278,11 +222,11 @@ def run(self, args):
278222

279223
base_dir = os.path.join(path, "base")
280224
# we must use the `merge_base` instead of `head_commit`, because the latter changes after merging the PR and the `base` directory would contain incorrect data
281-
self.clone_or_update(owner, repo, branch=pr_obj.base_branch, commit=pr_obj.merge_base, directory=base_dir)
225+
gitea_api.Repo.clone_or_update(self.gitea_conn, owner, repo, branch=pr_obj.base_branch, commit=pr_obj.merge_base, directory=base_dir)
282226

283227
head_dir = os.path.join(path, "head")
284-
self.clone_or_update(
285-
owner, repo, pr_number=pr_obj.number, commit=pr_obj.head_commit, directory=head_dir, reference=base_dir
228+
gitea_api.Repo.clone_or_update(
229+
self.gitea_conn, owner, repo, pr_number=pr_obj.number, commit=pr_obj.head_commit, directory=head_dir, reference=base_dir
286230
)
287231

288232
with open(os.path.join(metadata_dir, "submodules-base.json"), "w", encoding="utf-8") as f:

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

0 commit comments

Comments
 (0)