3 from pathlib import Path
4 from urllib.parse import urlparse
5 from contextlib import contextmanager
11 except ModuleNotFoundError:
12 print("========================================================================", file=sys.stderr)
13 print("ERROR: Install python-gitlab with `python3 -m pip install python-gitlab`", file=sys.stderr)
14 print("========================================================================", file=sys.stderr)
21 ROOT_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
23 URL = "https://gitlab.freedesktop.org/"
24 SIGN_IN_URL = URL + 'sign_in'
25 LOGIN_URL = URL + 'users/sign_in'
26 LOGIN_URL_LDAP = URL + '/users/auth/ldapmain/callback'
28 MONOREPO_REMOTE_NAME = 'origin'
29 NAMESPACE = "gstreamer"
30 MONOREPO_NAME = 'gstreamer'
31 MONOREPO_REMOTE = URL + f'{NAMESPACE}/{MONOREPO_NAME}'
32 MONOREPO_BRANCH = 'main'
34 MOVING_NAMESPACE = NAMESPACE
36 PARSER = argparse.ArgumentParser(
37 description="Move merge request from old GStreamer module to the new"
38 "GStreamer 'monorepo'.\n"
39 " All your pending merge requests from all GStreamer modules will"
40 " be moved the the mono repository."
42 PARSER.add_argument("--skip-branch", action="store", nargs="*",
43 help="Ignore MRs for branches which match those names.", dest="skipped_branches")
44 PARSER.add_argument("--skip-on-failure", action="store_true", default=False)
45 PARSER.add_argument("--dry-run", "-n", action="store_true", default=False)
46 PARSER.add_argument("--use-branch-if-exists", action="store_true", default=False)
60 'gst-editing-services',
62 'gst-integration-testsuites',
69 # We do not want to deal with LFS
70 os.environ["GIT_LFS_SKIP_SMUDGE"] = "1"
73 log_depth = [] # type: T.List[str]
78 log_depth.append(name)
85 return f"\033[1m{text}\033[0m"
88 return f"\033[1;32m{text}\033[0m"
91 return f"\033[1;31m{text}\033[0m"
93 def yellow(text: str):
94 return f"\033[1;33m{text}\033[0m"
96 def fprint(msg, nested=True):
98 prepend = log_depth[-1] + ' | ' if nested else ''
102 print(prepend + msg, end="")
109 self.gl = self.connect()
111 self.all_projects = []
112 self.skipped_branches = []
113 self.git_rename_limit = None
114 self.skip_on_failure = None
118 fprint("Logging into gitlab...")
119 gitlab_api_token = os.environ.get('GITLAB_API_TOKEN')
122 gl = gitlab.Gitlab(URL, private_token=gitlab_api_token)
123 fprint(f"{green(' OK')}\n", nested=False)
126 session = requests.Session()
127 sign_in_page = session.get(SIGN_IN_URL).content.decode()
128 for l in sign_in_page.split('\n'):
129 m = re.search('name="authenticity_token" value="([^"]+)"', l)
138 fprint(f"{red('Unable to find the authenticity token')}\n")
143 ({'user[login]': 'login_or_email',
144 'user[password]': 'SECRET',
145 'authenticity_token': token}, LOGIN_URL),
146 ({'username': 'login_or_email',
147 'password': 'SECRET',
148 'authenticity_token': token}, LOGIN_URL_LDAP)]:
150 r = session.post(url, data=data)
151 if r.status_code != 200:
155 gl = gitlab.Gitlab(URL, api_version=4, session=session)
157 except gitlab.exceptions.GitlabAuthenticationError as e:
161 sys.exit(bold(f"{red('FAILED')}.\n\nPlease go to:\n\n"
162 ' https://gitlab.freedesktop.org/-/profile/personal_access_tokens\n\n'
163 f'and generate a token {bold("with read/write access to all but the registry")},'
164 ' then set it in the "GITLAB_API_TOKEN" environment variable:"'
165 f'\n\n $ GITLAB_API_TOKEN=<your token> {" ".join(sys.argv)}\n'))
167 def git(self, *args, can_fail=False, interaction_message=None, call=False):
175 return subprocess.check_output(["git"] + list(args), cwd=cwd,
176 stdin=subprocess.DEVNULL,
177 stderr=subprocess.STDOUT).decode()
180 fprint(f"\n\n{bold(red('ERROR'))}: `git {' '.join(args)}` failed" + "\n", nested=False)
183 subprocess.call(["git"] + list(args), cwd=cwd)
185 except Exception as e:
186 if interaction_message:
187 if self.skip_on_failure:
189 output = getattr(e, "output", b"")
190 if output is not None:
191 out = output.decode()
196 f"Entering a shell in {cwd} to fix:\n\n"
197 f" {bold(interaction_message)}\n\n"
198 f"You should then exit with the following codes:\n\n"
199 f" - {bold('`exit 0`')}: once you have fixed the problem and we can keep moving the merge request\n"
200 f" - {bold('`exit 1`')}: {bold('retry')}: once you have let the repo in a state where the operation should be to retried\n"
201 f" - {bold('`exit 2`')}: to skip that merge request\n"
202 f" - {bold('`exit 3`')}: stop the script and abandon moving your MRs\n"
203 "\n```\n", nested=False)
206 shell = os.environ.get(
207 "COMSPEC", r"C:\WINDOWS\system32\cmd.exe")
209 shell = os.environ.get(
210 "SHELL", os.path.realpath("/bin/sh"))
211 subprocess.check_call(shell, cwd=cwd)
212 except subprocess.CalledProcessError as e:
213 if e.returncode == 1:
216 elif e.returncode == 2:
218 elif e.returncode == 3:
221 # Result of subshell does not really matter
224 return "User fixed it"
227 return "Failed but we do not care"
235 from_projects, to_project = self.fetch_projects()
238 self.move_mrs(from_projects, to_project)
240 if self.git_rename_limit is not None:
241 self.git("config", "merge.renameLimit", str(self.git_rename_limit))
243 def fetch_projects(self):
244 fprint("Fetching projects... ")
245 self.all_projects = [proj for proj in self.gl.projects.list(
246 membership=1, all=True) if proj.name in GST_PROJECTS]
247 self.user_project, = [p for p in self.all_projects if p.namespace['path'] == self.gl.user.username and p.name == MONOREPO_NAME]
248 fprint(f"{green(' OK')}\n", nested=False)
250 from_projects = [proj for proj in self.all_projects if proj.namespace['path']
251 == NAMESPACE and proj.name != "gstreamer"]
252 fprint(f"\nMoving MRs from:\n")
253 fprint(f"----------------\n")
254 for p in from_projects:
255 fprint(f" - {bold(p.path_with_namespace)}\n")
257 to_project, = [p for p in self.all_projects if p.path_with_namespace ==
258 MOVING_NAMESPACE + "/gstreamer"]
260 fprint(f"To: {bold(to_project.path_with_namespace)}\n\n")
262 return from_projects, to_project
264 def recreate_mr(self, project, to_project, mr):
265 branch = f"{project.name}-{mr.source_branch}"
266 if not self.create_branch_for_mr(branch, project, mr):
269 description = f"**Copied from {URL}/{project.path_with_namespace}/-/merge_requests/{mr.iid}**\n\n{mr.description}"
272 'source_branch': branch,
273 'allow_collaboration': True,
274 'remove_source_branch': True,
275 'target_project_id': to_project.id,
276 'target_branch': MONOREPO_BRANCH,
279 'description': description,
283 fprint(f"-> Recreating MR '{bold(mr.title)}'...")
285 fprint(f"\nDry info:\n{new_mr_dict}\n")
287 new_mr = self.user_project.mergerequests.create(new_mr_dict)
288 fprint(f"{green(' OK')}\n", nested=False)
289 except gitlab.exceptions.GitlabCreateError as e:
290 fprint(f"{yellow('SKIPPED')} (An MR already exists)\n", nested=False)
293 fprint(f"-> Adding discussings from MR '{mr.title}'...")
295 fprint(f"{green(' OK')}\n", nested=False)
298 new_mr_url = f"{URL}/{to_project.path_with_namespace}/-/merge_requests/{new_mr.iid}"
299 for issue in mr.closes_issues():
300 obj = {'body': f'Fixing MR moved to: {new_mr_url}'}
301 issue.discussions.create(obj)
303 mr_url = f"{URL}/{project.path_with_namespace}/-/merge_requests/{mr.iid}"
304 for discussion in mr.discussions.list():
305 # FIXME notes = [n for n in discussion.attributes['notes'] if n['type'] is not None]
306 notes = [n for n in discussion.attributes['notes']]
310 new_discussion = None
312 note_url = f"{mr_url}#note_{note['id']}"
313 body = f"**{note['author']['name']} - {PING_SIGN}{note['author']['username']} wrote [here]({note_url})**:\n\n"
314 body += '\n'.join([l for l in note['body'].split('\n')])
316 obj = {'body': body, 'type': note['type']}
318 new_discussion.notes.create(obj)
320 new_discussion = new_mr.discussions.create(obj)
321 fprint(f"{green(' OK')}\n", nested=False)
323 print(f"New MR available at: {bold(new_mr_url)}\n")
327 def push_branch(self, branch):
328 fprint(f"-> Pushing branch {branch} to remote {self.gl.user.username}...")
329 if self.git("push", "--no-verify", self.gl.user.username, branch,
330 interaction_message=f"pushing {branch} to {self.gl.user.username} with:\n "
331 f" `$git push {self.gl.user.username} {branch}`") == "SKIP":
332 fprint(yellow("'SKIPPED' (couldn't push)"), nested=False)
336 fprint(f"{green(' OK')}\n", nested=False)
340 def create_branch_for_mr(self, branch, project, mr):
341 remote_name = project.name + '-' + self.gl.user.username
342 remote_branch = f"{MONOREPO_REMOTE_NAME}/{MONOREPO_BRANCH}"
343 if self.use_branch_if_exists:
345 self.git("checkout", branch)
346 self.git("show", remote_branch + "..", call=True)
348 fprint("Dry run... not creating MR")
350 cont = input('\n Create MR [y/n]? ')
351 if cont.strip().lower() != 'y':
354 return self.push_branch(branch)
355 except subprocess.CalledProcessError as e:
358 self.git("remote", "add", remote_name,
359 f"{URL}{self.gl.user.username}/{project.name}.git", can_fail=True)
360 self.git("fetch", remote_name)
362 if self.git("checkout", remote_branch, "-b", branch,
363 interaction_message=f"checking out branch with `git checkout {remote_branch} -b {branch}`") == "SKIP":
364 fprint(bold(f"{red('SKIPPED')} (couldn't checkout)\n"), nested=False)
367 for commit in reversed([c for c in mr.commits()]):
368 if self.git("cherry-pick", commit.id,
369 interaction_message=f"cherry-picking {commit.id} onto {branch} with:\n "
370 f" `$ git cherry-pick {commit.id}`") == "SKIP":
371 fprint(f"{yellow('SKIPPED')} (couldn't cherry-pick).", nested=False)
372 self.git("cherry-pick", "--abort", can_fail=True)
375 self.git("show", remote_branch + "..", call=True)
377 fprint("Dry run... not creating MR\n")
379 cont = input('\n Create MR [y/n]? ')
380 if cont.strip().lower() != 'y':
381 fprint(f"{red('Cancelled')}\n", nested=False)
384 return self.push_branch(branch)
386 def move_mrs(self, from_projects, to_project):
388 for from_project in from_projects:
389 with nested(f'{bold(from_project.path_with_namespace)}'):
390 fprint(f'Fetching mrs')
391 mrs = [mr for mr in from_project.mergerequests.list(
392 all=True, author_id=self.gl.user.id) if mr.author['username'] == self.gl.user.username and mr.state == "opened"]
394 fprint(f"{yellow(' None')}\n", nested=False)
397 fprint(f"{green(' DONE')}\n", nested=False)
400 fprint(f'Moving {mr.source_branch} "{mr.title}": {URL}{from_project.path_with_namespace}/merge_requests/{mr.iid}... ')
401 if mr.source_branch in self.skipped_branches:
402 print(f"{yellow('SKIPPED')} (blacklisted branch)")
404 f"{URL}{from_project.path_with_namespace}/merge_requests/{mr.iid}")
407 with nested(f'{bold(from_project.path_with_namespace)}: {mr.iid}'):
408 new_mr = self.recreate_mr(from_project, to_project, mr)
412 f"{URL}{from_project.path_with_namespace}/merge_requests/{mr.iid}")
414 fprint(f"{green(' OK')}\n", nested=False)
416 self.close_mr(from_project, to_project, mr, new_mr)
418 fprint(f"\n{yellow('DONE')} with {from_project.path_with_namespace}\n\n", nested=False)
420 for mr in failed_mrs:
421 print(f"Didn't move MR: {mr}")
423 def close_mr(self, project, to_project, mr, new_mr):
425 new_mr_url = f"{URL}/{to_project.path_with_namespace}/-/merge_requests/{new_mr.iid}"
428 mr_url = f"{URL}/{project.path_with_namespace}/-/merge_requests/{mr.iid}"
429 cont = input(f'\n Close old MR {mr_url} "{bold(mr.title)}" ? [y/n]')
430 if cont.strip().lower() != 'y':
431 fprint(f"{yellow('Not closing old MR')}\n")
435 obj = {'body': f"Moved to: {new_mr_url}"}
437 ret = input(f"Write a comment to add while closing MR {mr.iid} '{bold(mr.title)}':\n\n").strip()
442 fprint(f"{bold('Dry run, not closing')}\n", nested=False)
445 mr.discussions.create(obj)
446 mr.state_event = 'close'
448 fprint(f'Old MR {mr_url} "{bold(mr.title)}" {yellow("CLOSED")}\n')
450 def setup_repo(self):
451 fprint(f"Setting up '{bold(ROOT_DIR)}'...")
454 out = self.git("status", "--porcelain")
456 fprint("\n" + red('Git repository is not clean:') + "\n```\n" + out + "\n```\n")
459 except Exception as e:
461 f"Git repository{ROOT_DIR} is not clean. Clean it up before running {sys.argv[0]}\n ({e})")
463 self.git('remote', 'add', MONOREPO_REMOTE_NAME,
464 MONOREPO_REMOTE, can_fail=True)
465 self.git('fetch', MONOREPO_REMOTE_NAME)
467 self.git('remote', 'add', self.gl.user.username,
468 f"git@gitlab.freedesktop.org:{self.gl.user.username}/gstreamer.git", can_fail=True)
469 self.git('fetch', self.gl.user.username,
470 interaction_message=f"Setup your fork of {URL}gstreamer/gstreamer as remote called {self.gl.user.username}")
471 fprint(f"{green(' OK')}\n", nested=False)
474 git_rename_limit = int(self.git("config", "merge.renameLimit"))
475 except subprocess.CalledProcessError:
477 if int(git_rename_limit) < 999999:
478 self.git_rename_limit = git_rename_limit
479 fprint("-> Setting git rename limit to 999999 so we can properly cherry-pick between repos")
480 self.git("config", "merge.renameLimit", "999999")
485 PARSER.parse_args(namespace=mover)
489 if __name__ == '__main__':