ECCE @ EIC Software
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
make_release.py
Go to the documentation of this file. Or view the newest version in sPHENIX GitHub for file make_release.py
1 #!/usr/bin/env python3
2 
3 import re
4 import click
5 from sh import git
6 from pathlib import Path
7 import gitlab as gitlab_api
8 import sys
9 from datetime import datetime, date
10 from dateutil.relativedelta import relativedelta, FR
11 import dateutil.parser
12 import jinja2
13 import json
14 import humanize
15 import click_config_file
16 import requests
17 import os
18 from pprint import pprint
19 import tempfile
20 
21 from util import Spinner
22 from release_notes import (
23  collect_milestone,
24  make_release_notes,
25  get_label_groups,
26 )
27 
28 version_ex = re.compile(r"^v?(\d+)\.(\d{1,2})\.(\d{1,2})$")
29 
30 
31 def split_version(version):
32  m = version_ex.match(version)
33  assert m is not None, f"Version {version} is not in valid format"
34  return [int(m.group(i)) for i in range(1, 4)]
35 
36 
37 def format_version(version):
38  return "v{:d}.{:>2d}.{:>02d}".format(*version)
39 
40 
41 def gitlab_instance(ctx, param, token):
42  gl = gitlab_api.Gitlab("https://gitlab.cern.ch", private_token=token)
43  gl.auth()
44  return gl
45 
46 
48  f = click.option(
49  "--gitlab-token", "-t", "gitlab", required=True, callback=gitlab_instance
50  )(f)
51  return f
52 
53 
54 def get_milestones(project, **kwargs):
55  milestones = project.milestones.list(**kwargs)
56  ms_dict = {}
57  for ms in milestones:
58  try:
59  ms_dict[tuple(split_version(ms.title))] = ms
60  except:
61  pass
62  return ms_dict
63 
64 
65 def find_milestone(version, milestones):
66  vstr = "{:d}.{:>02d}.{:>02d}".format(*version)
67  ms_titles = (vstr, "v" + vstr)
68 
69  milestone = None
70  for ms in milestones:
71  # print(ms.title, ms_titles)
72  if ms.title in ms_titles:
73  milestone = ms
74  break
75 
76  return milestone
77 
78 
80  branches = (
81  git("for-each-ref", "refs/heads", format="%(refname:short)").strip().split("\n")
82  )
83  return branches
84 
85 
87  branch = git.branch(show_current=True).strip()
88  print(branch)
89  return branch
90 
91 
92 @click.group()
93 def main():
94  pass
95 
96 
97 @main.command()
98 @gitlab_option
99 @click.option("--dry-run", is_flag=True)
100 @click.argument("version")
101 def minor(version, dry_run, gitlab):
102  project = gitlab.projects.get("acts/acts-core")
103  version = split_version(version)
104  milestone = find_milestone(version, project.milestones.list(state="active"))
105  assert (
106  milestone is not None
107  ), f"Didn't find milestone for {version}. Is it closed already?"
108 
109  branches = get_branches()
110 
111  release_branch = "release/v{:d}.{:>02d}.X".format(*version)
112  source_branch = "master" # always master for minor version
113  version_file = Path() / "version_number"
114  tag_name = format_version(version)
115 
116  print(
117  "Will make new release with version %s from milestone %s and branch %s"
118  % (format_version(version), milestone.title, source_branch)
119  )
120 
121  if click.confirm("Do you want to run local preparation?"):
122  if source_branch not in branches:
123  print("Source branch", source_branch, "not found.")
124  sys.exit(1)
125 
126  with Spinner(text=f"Checkout and update source branch {source_branch}"):
127  if not dry_run:
128  git.checkout(source_branch)
129  assert current_branch() == source_branch
130  git.pull()
131 
132  if release_branch in branches and not dry_run:
133  print("Release branch", release_branch, "exists. I'm bailing")
134  sys.exit(1)
135 
136  with Spinner(text=f"Creating {release_branch} from {source_branch}"):
137  if not dry_run:
138  git.checkout("-b", release_branch)
139 
140  with Spinner(text=f"Bumping version to {format_version(version)}"):
141  if not dry_run:
142  assert current_branch() == release_branch
143  with version_file.open("w") as fh:
144  fh.write(".".join(map(str, version)))
145 
146  with Spinner(
147  text=f"Committing bumped version on release branch {release_branch}"
148  ):
149  if not dry_run:
150  git.add(str(version_file))
151  git.commit(message="Bump version to %s" % ".".join(map(str, version)))
152 
153  with Spinner(text=f"Creating local tag {tag_name} on {release_branch}"):
154  if not dry_run:
155  git.tag(tag_name)
156  print(f"You might want to run 'git push REMOTE {tag_name}'")
157 
158  if click.confirm(f"Do you want me to try to push {release_branch}?"):
159  with Spinner(text=f"Pushing {release_branch}"):
160  if not dry_run:
161  git.push()
162 
163  if click.confirm(f"Do you want me to try to push tag {tag_name}?"):
164  with Spinner(text=f"Pushing {tag_name}"):
165  if not dry_run:
166  git.push("REMOTE", tag_name)
167 
168  if click.confirm(f"Do you want me to close %{milestone.title}?"):
169  with Spinner(text=f"Closing milestone %{milestone.title}"):
170  if not dry_run:
171  milestone.state_event = "close"
172  milestone.save()
173 
174  print("Done!")
175  if dry_run:
176  print("THIS WAS A DRY RUN!")
177 
178 @main.command()
179 @gitlab_option
180 @click.option("--dry-run", is_flag=True)
181 @click.argument("version")
182 def patch(version, dry_run, gitlab):
183  project = gitlab.projects.get("acts/acts-core")
184 
185  version = split_version(version)
186  milestone = find_milestone(version, project.milestones.list(state="active"))
187  assert (
188  milestone is not None
189  ), f"Didn't find milestone for {version}. Is it closed already?"
190 
191  branches = get_branches()
192 
193  release_branch = "release/v{:d}.{:>02d}.X".format(*version)
194  version_file = Path() / "version_number"
195  tag_name = format_version(version)
196 
197  if release_branch not in branches:
198  print("Release branch", release_branch, "does not exist. I'm bailing")
199 
200  print(
201  "Will make new patch version tag %s from milestone %s on branch %s"
202  % (format_version(version), milestone.title, release_branch)
203  )
204 
205  if click.confirm("Do you want to run local preparation?"):
206 
207  with Spinner(text=f"Checkout and update release branch {release_branch}"):
208  if not dry_run:
209  git.checkout(release_branch)
210  assert current_branch() == release_branch
211  git.pull()
212 
213  with Spinner(text=f"Bumping version to {format_version(version)}"):
214  if not dry_run:
215  assert current_branch() == release_branch
216  with version_file.open("w") as fh:
217  fh.write(".".join(map(str, version)))
218 
219  with Spinner(
220  text=f"Committing bumped version on release branch {release_branch}"
221  ):
222  if not dry_run:
223  git.add(str(version_file))
224  git.commit(message="Bump version to %s" % ".".join(map(str, version)))
225 
226  with Spinner(text=f"Creating local tag {tag_name} on {release_branch}"):
227  if not dry_run:
228  git.tag(tag_name)
229  print(f"You might want to run 'git push REMOTE {tag_name}'")
230 
231  if click.confirm(f"Do you want me to try to push {release_branch}?"):
232  with Spinner(text=f"Pushing {release_branch}"):
233  if not dry_run:
234  git.push()
235 
236  if click.confirm(f"Do you want me to close %{milestone.title}?"):
237  with Spinner(text=f"Closing milestone %{milestone.title}"):
238  if not dry_run:
239  milestone.state_event = "close"
240  milestone.save()
241 
242 
243 
244 @main.command()
245 @gitlab_option
246 @click.argument("version")
247 def message(version, gitlab):
248  dfmt = "%Y-%m-%d"
249 
250  current_version = split_version(version)
251  next_version = current_version[:]
252  next_version[1] += 1
253 
254  print(current_version, next_version, file=sys.stderr)
255 
256  project = gitlab.projects.get("acts/acts-core")
257 
258  milestones = project.milestones.list()
259 
260  current_milestone = find_milestone(current_version, milestones)
261  assert current_milestone is not None
262 
263  next_milestone = find_milestone(next_version, milestones)
264 
265  if next_milestone is None:
266  print("Milestone for", format_version(next_version), "does not exist")
267  if click.confirm("Want me to create it?"):
268  title = click.prompt("What title?", format_version(next_version))
269  next_milestone = project.milestones.create(title=title)
270  else:
271  sys.exit(1)
272 
273  if current_milestone.due_date != date.today().strftime(dfmt):
274  if sys.stdout.isatty():
275  if click.confirm(
276  f"Do you want me to set due date of %{current_milestone.title} to {date.today()}? (is {current_milestone.due_date})"
277  ):
278  current_milestone.due_date = date.today().strftime(dfmt)
279  current_milestone.save()
280 
281  if next_milestone.due_date is None:
282  dt = date.today()
283  delta = relativedelta(weekday=FR(1))
284  next_due = dt + delta
285  else:
286  next_due = datetime.strptime(next_milestone.due_date, dfmt)
287  if sys.stdout.isatty():
288  next_due = datetime.strptime(
289  click.prompt(
290  f"Due date for milestone %{next_milestone.title}",
291  next_due.strftime(dfmt),
292  ),
293  dfmt,
294  )
295 
296  start_date = datetime.strptime(next_milestone.start_date, dfmt) or date.today()
297  start_date_str = start_date.strftime(dfmt)
298  next_due_str = next_due.strftime(dfmt)
299 
300  if (
301  next_milestone.start_date != start_date_str
302  or next_milestone.due_date != next_due_str
303  ):
304  if click.confirm(f"Update milestone %{next_milestone.title}?"):
305  with Spinner(text=f"Updating milestone %{next_milestone.title}"):
306  next_milestone.start_date = start_date_str
307  next_milestone.due_date = next_due_str
308  next_milestone.save()
309 
310  release_branch = "release/v{:d}.{:>02d}.X".format(*current_version)
311 
312  tpl = jinja2.Template(
313  """
314 I've just tagged [`{{cv}}`](https://gitlab.cern.ch/acts/acts-core/-/tags/{{cv}}) from milestone [`%{{cm.title}}`](https://gitlab.cern.ch/acts/acts-core/-/milestones/{{cm.iid}}).
315 Bugfixes should be targeted at [`{{ release_branch }}`](https://gitlab.cern.ch/acts/acts-core/tree/{{ release_branch }}).
316 
317 We will tag the next release `{{nv}}` on {{humanize.naturaldate(next_due)}} from [`%{{nm.title}}`](https://gitlab.cern.ch/acts/acts-core/-/milestones/{{nm.iid}}).
318 This release can be cancelled if a sufficient number of merges does not happen before that date.
319 """.strip()
320  )
321 
322  tpl.globals["humanize"] = humanize
323 
324  text = tpl.render(
325  next_due=datetime.strptime(next_milestone.due_date, dfmt).date(),
326  release_branch=release_branch,
327  cm=current_milestone,
328  cv=format_version(current_version),
329  nm=next_milestone,
330  nv=format_version(next_version),
331  )
332 
333  print(text)
334 
335 @main.command()
336 @gitlab_option
337 @click.argument("start", callback=lambda c, p, s: split_version(s))
338 @click.argument("end", callback=lambda c, p, s: split_version(s))
339 def relnotes(start, end, gitlab):
340  start = tuple(start)
341  end = tuple(end)
342  print(start, end, file=sys.stderr)
343  project = gitlab.projects.get("acts/acts-core")
344 
345  all_milestones = get_milestones(project)
346  milestones = []
347  for ms in all_milestones.values():
348  try:
349  ver = split_version(ms.title)
350  milestones.append(ms)
351  except:
352  pass
353 
354  sorted_milestones = list(sorted(all_milestones.keys()))
355 
356  start_ms = all_milestones[start]
357  end_ms = all_milestones[end]
358 
359  ms_range = (sorted_milestones.index(start), sorted_milestones.index(end))
360 
361  md = ""
362 
363  for mst in sorted_milestones[ms_range[0]+1:ms_range[1]+1]:
364  ms = all_milestones[mst]
365  print(ms.title, file=sys.stderr)
366 
367 
368  mrs_grouped, issues_grouped = collect_milestone(ms)
369  with Spinner(text="Assembling release notes", stream=sys.stderr):
370  md += f"## {format_version(mst)}\n\n"
371  md += make_release_notes(ms, mrs_grouped, issues_grouped, badges=False, links=False)
372 
373 
374  print(md)
375 
376 class Zenodo:
377  def __init__(self, token, base_url = "https://zenodo.org/api/"):
378  self.token = token
379  self.base_url = base_url
380 
381  def get(self, url, params = {}, **kwargs):
382  _params = {"access_token": self.token}
383  _params.update(params)
384  r = requests.get(os.path.join(self.base_url, url), params=_params, **kwargs)
385  return r.json()
386 
387  def post(self, url, params = {}, headers = {}, **kwargs):
388  _headers = {"Content-Type": "application/json"}
389  _headers.update(headers)
390  _params = {"access_token": self.token}
391  _params.update(params)
392  r = requests.post(os.path.join(self.base_url, url), params=_params,
393  headers=_headers,
394  **kwargs)
395  assert r.status_code == 201, r.json()
396  return r
397 
398  def put(self, url, data, params = {}, headers = {}, **kwargs):
399  _headers = {"Content-Type": "application/json"}
400  _headers.update(headers)
401  # _params = {"access_token": self.token}
402  # _params.update(params)
403  _url = os.path.join(self.base_url, url)+f"?access_token={self.token}"
404  # print(_url)
405  r = requests.put(_url,
406  data=json.dumps(data),
407  headers=_headers,
408  **kwargs)
409  assert r.status_code == 200, f"Status {r.status_code}, {r.json()}"
410  return r
411 
412  def upload(self, deposition, name, fh):
413  _params = {"access_token": self.token}
414  data={"name": name}
415  files = {"file": fh}
416  r = requests.post(os.path.join(self.base_url, f"deposit/depositions/{deposition}/files"),
417  params=_params, data=data, files=files)
418  assert r.status_code == 201, r.status_code
419  return r
420 
421  def delete(self, url, params = {}, **kwargs):
422  _params = {"access_token": self.token}
423  return requests.delete(os.path.join(self.base_url, url), params=_params)
424 
425 @main.command()
426 @gitlab_option
427 @click.argument("version")
428 @click.option("--zenodo-token", "-z", required=True)
429 @click.option("--deposition", "-d", required=True)
430 @click_config_file.configuration_option()
431 def zenodo(version, gitlab, zenodo_token, deposition):
432  version = split_version(version)
433  # print(version, gitlab, zenodo_token)
434  zenodo = Zenodo(zenodo_token)
435 
436  with Spinner(text="Creating new version of existing deposition"):
437  create_res = zenodo.post(f"deposit/depositions/{deposition}/actions/newversion")
438  # print(create_res)
439  create_res = create_res.json()
440 
441  draft_id = create_res["links"]["latest_draft"].split("/")[-1]
442  # pprint(create_res)
443 
444  print("Created new version with id", draft_id)
445 
446  with Spinner(text="Delete all files for draft"):
447  draft = zenodo.get(f"deposit/depositions/{draft_id}")
448  # pprint(draft)
449 
450  for file in draft["files"]:
451  file_id = file["id"]
452  r = zenodo.delete(f"deposit/depositions/{draft_id}/files/{file_id}")
453  assert r.status_code == 204
454 
455  with Spinner(text="Assembling authors"):
456  creator_file = os.path.join(os.path.dirname(__file__), "../AUTHORS.md")
457  with open(creator_file) as fh:
458  md = fh.read().strip().split("\n")
459  md = [l.strip() for l in md if not l.strip().startswith("#") and not l.strip() == ""]
460 
461  creators = []
462  for line in md:
463  assert line.startswith("- ")
464  line = line[2:]
465  split = line.split(",", 1)
466  creator = {"name": split[0].strip()}
467 
468  if len(split) == 2:
469  creator["affiliation"] = split[1].strip()
470 
471  creators.append(creator)
472 
473  with Spinner(text="Collection milestones for description"):
474  project = gitlab.projects.get("acts/acts-core")
475  milestones = project.milestones.list()
476  milestone = find_milestone(version, milestones)
477  mrs_grouped, issues_grouped = collect_milestone(milestone)
478  assert milestone.state == "closed"
479 
480  tag = project.tags.get(format_version(version))
481  # print(tag)
482  tag_date = dateutil.parser.parse(tag.commit["created_at"]).date().strftime("%Y-%m-%d")
483 
484  description = f'Milestone: <a href="{milestone.web_url}">%{milestone.title}</a> <br/> Merge requested accepted for this version: \n <ul>\n'
485 
486  for mr in sum(mrs_grouped.values(), []):
487  description += f'<li><a href="{mr.web_url}">!{mr.iid} - {mr.title}</a></li>\n'
488 
489  description += "</ul>"
490 
491  with Spinner(text="Updating deposition metadata"):
492  data = {"metadata": {
493  "title": f"Acts Project: {format_version(version)}",
494  "upload_type": "software",
495  "description": description,
496  "creators": creators,
497  "version": format_version(version),
498  "publication_date": tag_date,
499  "license": "MPL-2.0",
500  }}
501  zenodo.put(f"deposit/depositions/{draft_id}", data).json()
502 
503 
504  with tempfile.TemporaryFile() as fh:
505  with Spinner(text="Downloading release archive from Gitlab"):
506  r = requests.get(f"https://gitlab.cern.ch/acts/acts-core/-/archive/{format_version(version)}/acts-core-{format_version(version)}.zip", stream=True)
507  r.raw.decode_content = True
508  fh.write(r.raw.read())
509  fh.seek(0)
510  with Spinner(text="Uploading release archive to zenodo"):
511  name = f"acts-core-{format_version(version)}.zip"
512  zenodo.upload(draft_id, name, fh)
513 
514 
515  print(f"Done: https://zenodo.org/deposit/{draft_id}")
516 
517 if "__main__" == __name__:
518  main()
519 
520