contrib: more release automation
- rewrite the release script from shell to python3 - refactoring of the upcoming script and introducing the helpers library - introduce the tested-by.py script to manage the feedbacks from the testers Signed-off-by: Nicolas Sebrecht <nicolas.s-dev@laposte.net>
This commit is contained in:
parent
47a7bdc883
commit
fc77de5af6
316
contrib/helpers.py
Normal file
316
contrib/helpers.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Put into Public Domain, by Nicolas Sebrecht.
|
||||||
|
|
||||||
|
Helpers for maintenance scripts.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import chdir, makedirs, system, getcwd
|
||||||
|
from os.path import expanduser
|
||||||
|
import shlex
|
||||||
|
from subprocess import check_output, check_call, CalledProcessError
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
FS_ENCODING = 'UTF-8'
|
||||||
|
ENCODING = 'UTF-8'
|
||||||
|
|
||||||
|
MAILING_LIST = 'offlineimap-project@lists.alioth.debian.org'
|
||||||
|
CACHEDIR = '.git/offlineimap-release'
|
||||||
|
EDITOR = 'vim'
|
||||||
|
MAILALIASES_FILE = expanduser('~/.mutt/mail_aliases')
|
||||||
|
TESTERS_FILE = "{}/testers.yml".format(CACHEDIR)
|
||||||
|
ME = "Nicolas Sebrecht <nicolas.s-dev@laposte.net>"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
return check_output(cmd, timeout=5).rstrip()
|
||||||
|
|
||||||
|
def goTo(path):
|
||||||
|
try:
|
||||||
|
chdir(path)
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Could not find the '{}' directory in '{}'...".format(
|
||||||
|
path, getcwd())
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Author(object):
|
||||||
|
def __init__(self, name, count, email):
|
||||||
|
self.name = name
|
||||||
|
self.count = count
|
||||||
|
self.email = email
|
||||||
|
|
||||||
|
def getName(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def getCount(self):
|
||||||
|
return self.count
|
||||||
|
|
||||||
|
def getEmail(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
|
||||||
|
class Git(object):
|
||||||
|
@staticmethod
|
||||||
|
def getShortlog(ref):
|
||||||
|
shortlog = ""
|
||||||
|
|
||||||
|
cmd = shlex.split("git shortlog --no-merges -n v{}..".format(ref))
|
||||||
|
output = run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
for line in output.split("\n"):
|
||||||
|
if len(line) > 0:
|
||||||
|
if line[0] != " ":
|
||||||
|
line = " {}\n".format(line)
|
||||||
|
else:
|
||||||
|
line = " {}\n".format(line.lstrip())
|
||||||
|
else:
|
||||||
|
line = "\n"
|
||||||
|
|
||||||
|
shortlog += line
|
||||||
|
|
||||||
|
return shortlog
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add(files):
|
||||||
|
cmd = shlex.split("git add -- {}".format(files))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def commit(msg):
|
||||||
|
cmd = shlex.split("git commit -s -m 'v{}'".format(msg))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tag(version):
|
||||||
|
cmd = shlex.split("git tag -a 'v{}' -m 'v{}'".format(version, version))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stash(msg):
|
||||||
|
cmd = shlex.split("git stash create '{}'".format(msg))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mergeFF(ref):
|
||||||
|
cmd = shlex.split("git merge --ff '{}'".format(ref))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getDiffstat(ref):
|
||||||
|
cmd = shlex.split("git diff --stat v{}..".format(ref))
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def isClean():
|
||||||
|
try:
|
||||||
|
check_call(shlex.split("git diff --quiet"))
|
||||||
|
check_call(shlex.split("git diff --cached --quiet"))
|
||||||
|
except CalledProcessError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def buildMessageId():
|
||||||
|
cmd = shlex.split(
|
||||||
|
"git log HEAD~1.. --oneline --pretty='%H.%t.upcoming.%ce'")
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resetKeep(ref):
|
||||||
|
return run(shlex.split("git reset --keep {}".format(ref)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getRef(ref):
|
||||||
|
return run(shlex.split("git rev-parse {}".format(ref))).rstrip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rmTag(tag):
|
||||||
|
return run(shlex.split("git tag -d {}".format(tag)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def checkout(ref, create=False):
|
||||||
|
if create:
|
||||||
|
create = "-b"
|
||||||
|
else:
|
||||||
|
create = ""
|
||||||
|
|
||||||
|
cmd = shlex.split("git checkout {} {}".format(create, ref))
|
||||||
|
run(cmd)
|
||||||
|
head = shlex.split("git rev-parse HEAD")
|
||||||
|
revparseRef = shlex.split("git rev-parse {}".format(ref))
|
||||||
|
if run(head) != run(revparseRef):
|
||||||
|
raise Exception("checkout to '{}' did not work".format(ref))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def makeCacheDir():
|
||||||
|
try:
|
||||||
|
makedirs(CACHEDIR)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getLocalUser():
|
||||||
|
cmd = shlex.split("git config --get user.name")
|
||||||
|
name = run(cmd).decode(ENCODING)
|
||||||
|
cmd = shlex.split("git config --get user.email")
|
||||||
|
email = run(cmd).decode(ENCODING)
|
||||||
|
return name, email
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def buildDate():
|
||||||
|
cmd = shlex.split("git log HEAD~1.. --oneline --pretty='%cD'")
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getAuthorsList(sinceRef):
|
||||||
|
authors = []
|
||||||
|
|
||||||
|
cmd = shlex.split("git shortlog --no-merges -sne v{}..".format(sinceRef))
|
||||||
|
output = run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
for line in output.split("\n"):
|
||||||
|
count, full = line.strip().split("\t")
|
||||||
|
full = full.split(' ')
|
||||||
|
name = ' '.join(full[:-1])
|
||||||
|
email = full[-1]
|
||||||
|
|
||||||
|
authors.append(Author(name, count, email))
|
||||||
|
|
||||||
|
return authors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getCommitsList(sinceRef):
|
||||||
|
cmd = shlex.split(
|
||||||
|
"git log --no-merges --format='- %h %s. [%aN]' v{}..".format(sinceRef)
|
||||||
|
)
|
||||||
|
return run(cmd).decode(ENCODING)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def chdirToRepositoryTopLevel():
|
||||||
|
cmd = shlex.split("git rev-parse --show-toplevel")
|
||||||
|
topLevel = run(cmd)
|
||||||
|
|
||||||
|
chdir(topLevel)
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineimapInfo(object):
|
||||||
|
def getVersion(self):
|
||||||
|
cmd = shlex.split("./offlineimap.py --version")
|
||||||
|
return run(cmd).rstrip().decode(FS_ENCODING)
|
||||||
|
|
||||||
|
def editInit(self):
|
||||||
|
return system("{} ./offlineimap/__init__.py".format(EDITOR))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
"""Interact with the user."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def request(msg, prompt='--> '):
|
||||||
|
print(msg)
|
||||||
|
return input(prompt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pause(msg=False):
|
||||||
|
return User.request(msg, prompt="Press Enter to continue..")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def yesNo(msg, defaultToYes=False, prompt='--> '):
|
||||||
|
endMsg = " [y/N]: No"
|
||||||
|
if defaultToYes:
|
||||||
|
endMsg = " [Y/n]: Yes"
|
||||||
|
msg += endMsg
|
||||||
|
answer = User.request(msg, prompt).lower()
|
||||||
|
if answer in ['y', 'yes']:
|
||||||
|
return True
|
||||||
|
if defaultToYes and answer not in ['n', 'no']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Tester(object):
|
||||||
|
def __init__(self, name, email, feedback):
|
||||||
|
self.name = name
|
||||||
|
self.email = email
|
||||||
|
self.feedback = feedback
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{} {}".format(self.name, self.email)
|
||||||
|
|
||||||
|
def getName(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def getEmail(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
def getFeedback(self):
|
||||||
|
return self.feedback
|
||||||
|
|
||||||
|
def setFeedback(self, feedback):
|
||||||
|
assert feedback in [True, False, None]
|
||||||
|
self.feedback = feedback
|
||||||
|
|
||||||
|
def switchFeedback(self):
|
||||||
|
self.feedback = not self.feedback
|
||||||
|
|
||||||
|
|
||||||
|
class Testers(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.testers = []
|
||||||
|
self._read()
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
with open(TESTERS_FILE, 'r') as fd:
|
||||||
|
testers = yaml.load(fd)
|
||||||
|
for tester in testers:
|
||||||
|
name = tester['name']
|
||||||
|
email = tester['email']
|
||||||
|
feedback = tester['feedback']
|
||||||
|
self.testers.append(Tester(name, email, feedback))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def listTestersInTeam():
|
||||||
|
"""Returns a list of emails extracted from my mailaliases file."""
|
||||||
|
|
||||||
|
cmd = shlex.split("grep offlineimap-testers {}".format(MAILALIASES_FILE))
|
||||||
|
output = run(cmd).decode(ENCODING)
|
||||||
|
emails = output.lstrip("alias offlineimap-testers ").split(', ')
|
||||||
|
return emails
|
||||||
|
|
||||||
|
def add(self, name, email, feedback=None):
|
||||||
|
self.testers.append(Tester(name, email, feedback))
|
||||||
|
|
||||||
|
def remove(self, tester):
|
||||||
|
self.testers.remove(tester)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.testers
|
||||||
|
|
||||||
|
def getList(self):
|
||||||
|
testersList = ""
|
||||||
|
for tester in self.testers:
|
||||||
|
testersList += "- {}\n".format(tester.getName())
|
||||||
|
return testersList
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
for tester in self.testers:
|
||||||
|
tester.setFeedback(None)
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
testers = []
|
||||||
|
for tester in self.testers:
|
||||||
|
testers.append({
|
||||||
|
'name': tester.getName(),
|
||||||
|
'email': tester.getEmail(),
|
||||||
|
'feedback': tester.getFeedback(),
|
||||||
|
})
|
||||||
|
with open(TESTERS_FILE, 'w') as fd:
|
||||||
|
fd.write(yaml.dump(testers))
|
||||||
|
|
447
contrib/release.py
Executable file
447
contrib/release.py
Executable file
@ -0,0 +1,447 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
Put into Public Domain, by Nicolas Sebrecht.
|
||||||
|
|
||||||
|
Make a new release.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
#TODO: announce: cc list on announce includes all testers
|
||||||
|
#TODO: announce: remove empty sections
|
||||||
|
#TODO: websitedoc up
|
||||||
|
#TODO: website branch not including all changes!
|
||||||
|
|
||||||
|
|
||||||
|
from os import system, path, rename
|
||||||
|
from datetime import datetime
|
||||||
|
from subprocess import check_call
|
||||||
|
import shlex
|
||||||
|
import time
|
||||||
|
from email import utils
|
||||||
|
|
||||||
|
from helpers import (
|
||||||
|
MAILING_LIST, CACHEDIR, EDITOR, Git, OfflineimapInfo, Testers, User, run, goTo
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__VERSION__ = "0.1"
|
||||||
|
|
||||||
|
SPHINXBUILD = 'sphinx-build'
|
||||||
|
DOCSDIR = 'docs'
|
||||||
|
CHANGELOG_MAGIC = '{:toc}'
|
||||||
|
WEBSITE_LATEST = "website/_data/latest.yml"
|
||||||
|
|
||||||
|
CHANGELOG_EXCERPT = "{}/changelog.excerpt.md".format(CACHEDIR)
|
||||||
|
CHANGELOG_EXCERPT_OLD = "{}.old".format(CHANGELOG_EXCERPT)
|
||||||
|
CHANGELOG = "Changelog.md"
|
||||||
|
ANNOUNCE_FILE = "{}/announce.txt".format(CACHEDIR)
|
||||||
|
|
||||||
|
WEBSITE_LATEST_SKEL = """# DO NOT EDIT MANUALLY: it is generated by the release script.
|
||||||
|
stable: v{stable}
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHANGELOG_SKEL = """
|
||||||
|
### OfflineIMAP v{version} ({date})
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
|
||||||
|
|
||||||
|
This release was tested by:
|
||||||
|
|
||||||
|
{testersList}
|
||||||
|
|
||||||
|
#### Authors
|
||||||
|
|
||||||
|
{authorsList}
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
|
||||||
|
#### Fixes
|
||||||
|
|
||||||
|
|
||||||
|
#### Changes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{commitsList}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
END_MESSAGE = """
|
||||||
|
Release is ready!
|
||||||
|
Make your checks and push the changes for both offlineimap and the website.
|
||||||
|
Announce template stands in '{announce}'.
|
||||||
|
Command samples to do manually:
|
||||||
|
|
||||||
|
- git push <remote> master next {new_version}
|
||||||
|
- python setup.py sdist && twine upload dist/* && rm -rf dist MANIFEST
|
||||||
|
- cd website
|
||||||
|
- git checkout master
|
||||||
|
- git merge {website_branch}
|
||||||
|
- git push <remote> master
|
||||||
|
- cd ..
|
||||||
|
- git send-email {announce}
|
||||||
|
|
||||||
|
...and write a Twitter message.
|
||||||
|
Have fun! ,-)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class State(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.master = None
|
||||||
|
self.next = None
|
||||||
|
self.website = None
|
||||||
|
self.tag = None
|
||||||
|
|
||||||
|
def setTag(self, tag):
|
||||||
|
self.tag = tag
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.master = Git.getRef('master')
|
||||||
|
self.next = Git.getRef('next')
|
||||||
|
|
||||||
|
def saveWebsite(self):
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
goTo('website')
|
||||||
|
self.website = Git.getRef('master')
|
||||||
|
goTo('..')
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
Git.checkout('-f')
|
||||||
|
Git.checkout('master')
|
||||||
|
Git.resetKeep(self.master)
|
||||||
|
Git.checkout('next')
|
||||||
|
Git.resetKeep(self.next)
|
||||||
|
|
||||||
|
if self.tag is not None:
|
||||||
|
Git.rmTag(self.tag)
|
||||||
|
|
||||||
|
if self.website is not None:
|
||||||
|
if goTo('website'):
|
||||||
|
Git.checkout(self.website)
|
||||||
|
goTo('..')
|
||||||
|
|
||||||
|
|
||||||
|
class Changelog(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.shouldUsePrevious = False
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
return system("{} {}".format(EDITOR, CHANGELOG_EXCERPT))
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
# Insert excerpt to CHANGELOG.
|
||||||
|
system("sed -i -e '/{}/ r {}' '{}'".format(
|
||||||
|
CHANGELOG_MAGIC, CHANGELOG_EXCERPT, CHANGELOG
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Remove trailing whitespaces.
|
||||||
|
system("sed -i -r -e 's, +$,,' '{}'".format(CHANGELOG))
|
||||||
|
|
||||||
|
def savePrevious(self):
|
||||||
|
rename(CHANGELOG_EXCERPT, CHANGELOG_EXCERPT_OLD)
|
||||||
|
|
||||||
|
def isPrevious(self):
|
||||||
|
if path.isfile(CHANGELOG_EXCERPT_OLD):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def showPrevious(self):
|
||||||
|
return run(shlex.split("tail {}".format(CHANGELOG_EXCERPT_OLD)))
|
||||||
|
|
||||||
|
def usePrevious(self):
|
||||||
|
rename(CHANGELOG_EXCERPT_OLD, CHANGELOG_EXCERPT)
|
||||||
|
self.shouldUsePrevious = True
|
||||||
|
|
||||||
|
def usingPrevious(self):
|
||||||
|
return self.shouldUsePrevious
|
||||||
|
|
||||||
|
def writeExcerpt(self, version, date,
|
||||||
|
testersList, authorsList, commitsList):
|
||||||
|
|
||||||
|
with open(CHANGELOG_EXCERPT, 'w+') as fd:
|
||||||
|
fd.write(CHANGELOG_SKEL.format(
|
||||||
|
version=version,
|
||||||
|
date=date,
|
||||||
|
testersList=testersList,
|
||||||
|
authorsList=authorsList,
|
||||||
|
commitsList=commitsList,
|
||||||
|
))
|
||||||
|
|
||||||
|
def getSectionsContent(self):
|
||||||
|
dict_Content = {}
|
||||||
|
|
||||||
|
with open(CHANGELOG_EXCERPT, 'r') as fd:
|
||||||
|
currentSection = None
|
||||||
|
for line in fd:
|
||||||
|
line = line.rstrip()
|
||||||
|
if line == "#### Notes":
|
||||||
|
currentSection = 'Notes'
|
||||||
|
dict_Content['Notes'] = ""
|
||||||
|
continue # Don't keep this title.
|
||||||
|
elif line == "#### Authors":
|
||||||
|
currentSection = 'Authors'
|
||||||
|
dict_Content['Authors'] = ""
|
||||||
|
continue # Don't keep this title.
|
||||||
|
elif line == "#### Features":
|
||||||
|
currentSection = 'Features'
|
||||||
|
dict_Content['Features'] = ""
|
||||||
|
continue # Don't keep this title.
|
||||||
|
elif line == "#### Fixes":
|
||||||
|
currentSection = 'Fixes'
|
||||||
|
dict_Content['Fixes'] = ""
|
||||||
|
continue # Don't keep this title.
|
||||||
|
elif line == "#### Changes":
|
||||||
|
currentSection = 'Changes'
|
||||||
|
dict_Content['Changes'] = ""
|
||||||
|
continue # Don't keep this title.
|
||||||
|
elif line == "-- ":
|
||||||
|
break # Stop extraction.
|
||||||
|
|
||||||
|
if currentSection is not None:
|
||||||
|
dict_Content[currentSection] += "{}\n".format(line)
|
||||||
|
|
||||||
|
#TODO: cleanup empty sections.
|
||||||
|
return dict_Content
|
||||||
|
|
||||||
|
|
||||||
|
class Announce(object):
|
||||||
|
def __init__(self, version):
|
||||||
|
self.fd = open(ANNOUNCE_FILE, 'w')
|
||||||
|
self.version = version
|
||||||
|
|
||||||
|
def setHeaders(self, messageId, date):
|
||||||
|
self.fd.write("Message-Id: <{}>\n".format(messageId))
|
||||||
|
self.fd.write("Date: {}\n".format(date))
|
||||||
|
self.fd.write("From: Nicolas Sebrecht <nicolas.s-dev@laposte.net>\n")
|
||||||
|
self.fd.write("To: {}\n".format(MAILING_LIST))
|
||||||
|
self.fd.write(
|
||||||
|
"Subject: [ANNOUNCE] OfflineIMAP v{} released\n".format(self.version))
|
||||||
|
self.fd.write("\n")
|
||||||
|
|
||||||
|
self.fd.write("""
|
||||||
|
OfflineIMAP v{version} is out.
|
||||||
|
|
||||||
|
Downloads:
|
||||||
|
http://github.com/OfflineIMAP/offlineimap/archive/v{version}.tar.gz
|
||||||
|
http://github.com/OfflineIMAP/offlineimap/archive/v{version}.zip
|
||||||
|
|
||||||
|
Pip:
|
||||||
|
wget "https://raw.githubusercontent.com/OfflineIMAP/offlineimap/v{version}/requirements.txt" -O requirements.txt
|
||||||
|
pip install -r ./requirements.txt --user git+https://github.com/OfflineIMAP/offlineimap.git@v{version}
|
||||||
|
|
||||||
|
""".format(version=self.version)
|
||||||
|
)
|
||||||
|
|
||||||
|
def setContent(self, dict_Content):
|
||||||
|
self.fd.write("\n")
|
||||||
|
for section in ['Notes', 'Authors', 'Features', 'Fixes', 'Changes']:
|
||||||
|
if section in dict_Content:
|
||||||
|
if section != "Notes":
|
||||||
|
self.fd.write("# {}\n".format(section))
|
||||||
|
self.fd.write(dict_Content[section])
|
||||||
|
self.fd.write("\n")
|
||||||
|
# Signature.
|
||||||
|
self.fd.write("-- \n")
|
||||||
|
self.fd.write("Nicolas Sebrecht\n")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.fd.close()
|
||||||
|
|
||||||
|
|
||||||
|
class Website(object):
|
||||||
|
def updateAPI(self):
|
||||||
|
req = "update API of the website? (requires {}) [Y/n]".format(SPHINXBUILD)
|
||||||
|
if not User.yesNo(req, defaultToYes=True):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if check_call(shlex.split("{} --version".format(SPHINXBUILD))) != 0:
|
||||||
|
print("""
|
||||||
|
Oops! you don't have {} installed?"
|
||||||
|
Cannot update the webite documentation..."
|
||||||
|
You should install it and manually run:"
|
||||||
|
$ cd {}"
|
||||||
|
$ make websitedoc"
|
||||||
|
Then, commit and push changes of the website.""".format(SPHINXBUILD, DOCSDIR))
|
||||||
|
User.pause()
|
||||||
|
return False
|
||||||
|
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
if not goTo('website'):
|
||||||
|
User.pause()
|
||||||
|
return False
|
||||||
|
if not Git.isClean:
|
||||||
|
print("There is WIP in the website repository: stashing")
|
||||||
|
Git.stash('WIP during offlineimap API import')
|
||||||
|
|
||||||
|
goTo('..')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def buildLatest(self, version):
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
with open(WEBSITE_LATEST, 'w') as fd:
|
||||||
|
fd.write(WEBSITE_LATEST_SKEL.format(stable=version))
|
||||||
|
|
||||||
|
def exportDocs(self, version):
|
||||||
|
branchName = "import-v{}".format(version)
|
||||||
|
|
||||||
|
if not goTo(DOCSDIR):
|
||||||
|
User.pause()
|
||||||
|
return
|
||||||
|
|
||||||
|
if check_call(shlex.split("make websitedoc")) != 0:
|
||||||
|
print("error while calling 'make websitedoc'")
|
||||||
|
exit(3)
|
||||||
|
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
if not goTo("website"):
|
||||||
|
User.pause()
|
||||||
|
return
|
||||||
|
|
||||||
|
Git.checkout(branchName, create=True)
|
||||||
|
Git.add('_doc/versions')
|
||||||
|
Git.commit("update for offlineimap v{}".format(version))
|
||||||
|
|
||||||
|
User.pause(
|
||||||
|
"website: branch '{}' is ready for a merge in master!".format(
|
||||||
|
branchName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
goTo('..')
|
||||||
|
return branchName
|
||||||
|
|
||||||
|
|
||||||
|
class Release(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.state = State()
|
||||||
|
self.offlineimapInfo = OfflineimapInfo()
|
||||||
|
self.testers = Testers()
|
||||||
|
self.changelog = Changelog()
|
||||||
|
self.websiteBranch = "NO_BRANCH_NAME_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def getVersion(self):
|
||||||
|
return self.offlineimapInfo.getVersion()
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
if not Git.isClean():
|
||||||
|
print("The git repository is not clean; aborting")
|
||||||
|
exit(1)
|
||||||
|
Git.makeCacheDir()
|
||||||
|
Git.checkout('next')
|
||||||
|
|
||||||
|
def requestVersion(self, currentVersion):
|
||||||
|
User.request("going to make a new release after {}".format(currentVersion))
|
||||||
|
|
||||||
|
def updateVersion(self):
|
||||||
|
self.offlineimapInfo.editInit()
|
||||||
|
|
||||||
|
def checkVersions(self, current, new):
|
||||||
|
if new == current:
|
||||||
|
print("version was not changed; stopping.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
def updateChangelog(self):
|
||||||
|
if self.changelog.isPrevious():
|
||||||
|
self.changelog.showPrevious()
|
||||||
|
if User.yesNo("A previous Changelog excerpt was found. Use it?"):
|
||||||
|
self.changelog.usePrevious()
|
||||||
|
|
||||||
|
if not self.changelog.usingPrevious():
|
||||||
|
date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
testersList = self.testers.getList()
|
||||||
|
authorsList = ""
|
||||||
|
authors = Git.getAuthorsList(currentVersion)
|
||||||
|
for author in authors:
|
||||||
|
authorsList += "- {} ({})\n".format(
|
||||||
|
author.getName(), author.getCount()
|
||||||
|
)
|
||||||
|
commitsList = Git.getCommitsList(currentVersion)
|
||||||
|
date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
self.changelog.writeExcerpt(
|
||||||
|
newVersion, date, testersList, authorsList, commitsList
|
||||||
|
)
|
||||||
|
|
||||||
|
self.changelog.edit()
|
||||||
|
self.changelog.update()
|
||||||
|
|
||||||
|
def writeAnnounce(self):
|
||||||
|
announce = Announce(newVersion)
|
||||||
|
|
||||||
|
messageId = utils.make_msgid('release.py', 'laposte.net')
|
||||||
|
nowtuple = datetime.now().timetuple()
|
||||||
|
nowtimestamp = time.mktime(nowtuple)
|
||||||
|
date = utils.formatdate(nowtimestamp)
|
||||||
|
|
||||||
|
announce.setHeaders(messageId, date)
|
||||||
|
announce.setContent(self.changelog.getSectionsContent())
|
||||||
|
announce.close()
|
||||||
|
|
||||||
|
def make(self):
|
||||||
|
Git.add('offlineimap/__init__.py')
|
||||||
|
Git.add('Changelog.md')
|
||||||
|
commitMsg = "{}\n".format(newVersion)
|
||||||
|
for tester in self.testers.get():
|
||||||
|
commitMsg = "{}\nTested-by: {} {}".format(
|
||||||
|
commitMsg, tester.getName(), tester.getEmail()
|
||||||
|
)
|
||||||
|
Git.commit(commitMsg)
|
||||||
|
self.state.setTag(newVersion)
|
||||||
|
Git.tag(newVersion)
|
||||||
|
Git.checkout('master')
|
||||||
|
Git.mergeFF('next')
|
||||||
|
Git.checkout('next')
|
||||||
|
|
||||||
|
def updateWebsite(self, newVersion):
|
||||||
|
self.state.saveWebsite()
|
||||||
|
website = Website()
|
||||||
|
website.buildLatest(newVersion)
|
||||||
|
if website.updateAPI():
|
||||||
|
self.websiteBranch = website.exportDocs(newVersion)
|
||||||
|
|
||||||
|
def getWebsiteBranch(self):
|
||||||
|
return self.websiteBranch
|
||||||
|
|
||||||
|
def after(self):
|
||||||
|
for protectedRun in [self.testers.reset, self.changelog.savePrevious]:
|
||||||
|
try:
|
||||||
|
protectedRun()
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
self.state.restore()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
release = Release()
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
release.prepare()
|
||||||
|
currentVersion = release.getVersion()
|
||||||
|
|
||||||
|
release.requestVersion(currentVersion)
|
||||||
|
release.updateVersion()
|
||||||
|
newVersion = release.getVersion()
|
||||||
|
|
||||||
|
release.checkVersions(currentVersion, newVersion)
|
||||||
|
release.updateChangelog()
|
||||||
|
|
||||||
|
release.writeAnnounce()
|
||||||
|
User.pause()
|
||||||
|
|
||||||
|
release.make()
|
||||||
|
release.updateWebsite(newVersion)
|
||||||
|
release.after()
|
||||||
|
|
||||||
|
websiteBranch = release.getWebsiteBranch()
|
||||||
|
print(END_MESSAGE.format(ANNOUNCE_FILE, newVersion, websiteBranch))
|
||||||
|
except Exception as e:
|
||||||
|
release.restore()
|
||||||
|
raise
|
143
contrib/tested-by.py
Executable file
143
contrib/tested-by.py
Executable file
@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
Put into Public Domain, by Nicolas Sebrecht.
|
||||||
|
|
||||||
|
Manage the feedbacks of the testers for the release notes.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import system
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from helpers import CACHEDIR, EDITOR, Testers, User, Git
|
||||||
|
|
||||||
|
|
||||||
|
class App(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.args = None
|
||||||
|
self.testers = Testers()
|
||||||
|
self.feedbacks = None
|
||||||
|
|
||||||
|
|
||||||
|
def _getTestersByFeedback(self):
|
||||||
|
if self.feedbacks is not None:
|
||||||
|
return self.feedbacks
|
||||||
|
|
||||||
|
feedbackOk = []
|
||||||
|
feedbackNo = []
|
||||||
|
|
||||||
|
for tester in self.testers.get():
|
||||||
|
if tester.getFeedback() is True:
|
||||||
|
feedbackOk.append(tester)
|
||||||
|
else:
|
||||||
|
feedbackNo.append(tester)
|
||||||
|
|
||||||
|
for array in [feedbackOk, feedbackNo]:
|
||||||
|
array.sort(key=lambda t: t.getName())
|
||||||
|
|
||||||
|
self.feedbacks = feedbackOk + feedbackNo
|
||||||
|
|
||||||
|
def parseArgs(self):
|
||||||
|
parser = argparse.ArgumentParser(description='Manage the feedbacks.')
|
||||||
|
|
||||||
|
parser.add_argument('--add', '-a', dest='add_tester',
|
||||||
|
help='Add tester')
|
||||||
|
parser.add_argument('--delete', '-d', dest='delete_tester',
|
||||||
|
type=int,
|
||||||
|
help='Delete tester NUMBER')
|
||||||
|
parser.add_argument('--list', '-l', dest='list_all_testers',
|
||||||
|
action='store_true',
|
||||||
|
help='List the testers')
|
||||||
|
parser.add_argument('--switchFeedback', '-s', dest='switch_feedback',
|
||||||
|
action='store_true',
|
||||||
|
help='Switch the feedback of a tester')
|
||||||
|
|
||||||
|
self.args = parser.parse_args()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.args.list_all_testers is True:
|
||||||
|
self.listTesters()
|
||||||
|
if self.args.switch_feedback is True:
|
||||||
|
self.switchFeedback()
|
||||||
|
elif self.args.add_tester:
|
||||||
|
self.addTester(self.args.add_tester)
|
||||||
|
elif type(self.args.delete_tester) == int:
|
||||||
|
self.deleteTester(self.args.delete_tester)
|
||||||
|
|
||||||
|
def addTester(self, strTester):
|
||||||
|
try:
|
||||||
|
splitted = strTester.split('<')
|
||||||
|
name = splitted[0].strip()
|
||||||
|
email = "<{}".format(splitted[1]).strip()
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print("expected format is: 'Firstname Lastname <email>'")
|
||||||
|
exit(2)
|
||||||
|
self.testers.add(name, email)
|
||||||
|
self.testers.write()
|
||||||
|
|
||||||
|
def deleteTester(self, number):
|
||||||
|
self.listTesters()
|
||||||
|
removed = self.feedbacks.pop(number)
|
||||||
|
self.testers.remove(removed)
|
||||||
|
|
||||||
|
print("New list:")
|
||||||
|
self.feedbacks = None
|
||||||
|
self.listTesters()
|
||||||
|
print("Removed: {}".format(removed))
|
||||||
|
ans = User.request("Save on disk? (s/Q)").lower()
|
||||||
|
if ans in ['s']:
|
||||||
|
self.testers.write()
|
||||||
|
|
||||||
|
|
||||||
|
def listTesters(self):
|
||||||
|
self._getTestersByFeedback()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for tester in self.feedbacks:
|
||||||
|
feedback = "ok"
|
||||||
|
if tester.getFeedback() is not True:
|
||||||
|
feedback = "no"
|
||||||
|
print("{:02d} - {} {}: {}".format(
|
||||||
|
count, tester.getName(), tester.getEmail(), feedback
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
def switchFeedback(self):
|
||||||
|
self._getTestersByFeedback()
|
||||||
|
msg = "Switch tester: [<number>/s/q]"
|
||||||
|
|
||||||
|
self.listTesters()
|
||||||
|
number = User.request(msg)
|
||||||
|
while number.lower() not in ['s', 'save', 'q', 'quit']:
|
||||||
|
if number == '':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = int(number)
|
||||||
|
self.feedbacks[number].switchFeedback()
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
print(e)
|
||||||
|
exit(1)
|
||||||
|
finally:
|
||||||
|
self.listTesters()
|
||||||
|
number = User.request(msg)
|
||||||
|
if number in ['s', 'save']:
|
||||||
|
self.testers.write()
|
||||||
|
self.listTesters()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.testers.reset()
|
||||||
|
self.testers.write()
|
||||||
|
|
||||||
|
#def updateMailaliases(self):
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
Git.chdirToRepositoryTopLevel()
|
||||||
|
ccList = Testers.listTestersInTeam()
|
||||||
|
|
||||||
|
app = App()
|
||||||
|
app.parseArgs()
|
||||||
|
app.run()
|
@ -8,22 +8,15 @@ Produce the "upcoming release" notes.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from os import chdir, system
|
from os import system
|
||||||
from os.path import expanduser
|
|
||||||
import shlex
|
from helpers import (
|
||||||
from subprocess import check_output
|
MAILING_LIST, CACHEDIR, EDITOR, getTesters, Git, OfflineimapInfo, User
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__VERSION__ = "0.1"
|
|
||||||
|
|
||||||
FS_ENCODING = 'UTF-8'
|
|
||||||
ENCODING = 'UTF-8'
|
|
||||||
|
|
||||||
MAILING_LIST = 'offlineimap-project@lists.alioth.debian.org'
|
|
||||||
CACHEDIR = '.git/offlineimap-release'
|
|
||||||
UPCOMING_FILE = "{}/upcoming.txt".format(CACHEDIR)
|
UPCOMING_FILE = "{}/upcoming.txt".format(CACHEDIR)
|
||||||
EDITOR = 'vim'
|
|
||||||
MAILALIASES_FILE = expanduser('~/.mutt/mail_aliases')
|
|
||||||
|
|
||||||
UPCOMING_HEADER = """
|
UPCOMING_HEADER = """
|
||||||
Message-Id: <{messageId}>
|
Message-Id: <{messageId}>
|
||||||
@ -40,140 +33,22 @@ I think it's time for a new release.
|
|||||||
I aim to make the new release in one week, approximately. If you'd like more
|
I aim to make the new release in one week, approximately. If you'd like more
|
||||||
time, please let me know. ,-)
|
time, please let me know. ,-)
|
||||||
|
|
||||||
|
Please, send me a mail to confirm it works for you. This will be written in the
|
||||||
|
release notes and the git logs.
|
||||||
|
|
||||||
|
|
||||||
# Authors
|
# Authors
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
return check_output(cmd, timeout=5).rstrip()
|
|
||||||
|
|
||||||
|
|
||||||
def getTesters():
|
|
||||||
"""Returns a list of emails extracted from my mailaliases file."""
|
|
||||||
|
|
||||||
cmd = shlex.split("grep offlineimap-testers {}".format(MAILALIASES_FILE))
|
|
||||||
output = run(cmd).decode(ENCODING)
|
|
||||||
emails = output.lstrip("alias offlineimap-testers ").split(', ')
|
|
||||||
return emails
|
|
||||||
|
|
||||||
|
|
||||||
class Author(object):
|
|
||||||
def __init__(self, name, count, email):
|
|
||||||
self.name = name
|
|
||||||
self.count = count
|
|
||||||
self.email = email
|
|
||||||
|
|
||||||
def getName(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def getCount(self):
|
|
||||||
return self.count
|
|
||||||
|
|
||||||
def getEmail(self):
|
|
||||||
return self.email
|
|
||||||
|
|
||||||
|
|
||||||
class Git(object):
|
|
||||||
@staticmethod
|
|
||||||
def getShortlog(ref):
|
|
||||||
shortlog = ""
|
|
||||||
|
|
||||||
cmd = shlex.split("git shortlog --no-merges -n v{}..".format(ref))
|
|
||||||
output = run(cmd).decode(ENCODING)
|
|
||||||
|
|
||||||
for line in output.split("\n"):
|
|
||||||
if len(line) > 0:
|
|
||||||
if line[0] != " ":
|
|
||||||
line = " {}\n".format(line)
|
|
||||||
else:
|
|
||||||
line = " {}\n".format(line.lstrip())
|
|
||||||
else:
|
|
||||||
line = "\n"
|
|
||||||
|
|
||||||
shortlog += line
|
|
||||||
|
|
||||||
return shortlog
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def getDiffstat(ref):
|
|
||||||
cmd = shlex.split("git diff --stat v{}..".format(ref))
|
|
||||||
return run(cmd).decode(ENCODING)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def buildMessageId():
|
|
||||||
cmd = shlex.split(
|
|
||||||
"git log HEAD~1.. --oneline --pretty='%H.%t.upcoming.%ce'")
|
|
||||||
return run(cmd).decode(ENCODING)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def getLocalUser():
|
|
||||||
cmd = shlex.split("git config --get user.name")
|
|
||||||
name = run(cmd).decode(ENCODING)
|
|
||||||
cmd = shlex.split("git config --get user.email")
|
|
||||||
email = run(cmd).decode(ENCODING)
|
|
||||||
return name, email
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def buildDate():
|
|
||||||
cmd = shlex.split("git log HEAD~1.. --oneline --pretty='%cD'")
|
|
||||||
return run(cmd).decode(ENCODING)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def getAuthors(ref):
|
|
||||||
authors = []
|
|
||||||
|
|
||||||
cmd = shlex.split("git shortlog --no-merges -sne v{}..".format(ref))
|
|
||||||
output = run(cmd).decode(ENCODING)
|
|
||||||
|
|
||||||
for line in output.split("\n"):
|
|
||||||
count, full = line.strip().split("\t")
|
|
||||||
full = full.split(' ')
|
|
||||||
name = ' '.join(full[:-1])
|
|
||||||
email = full[-1]
|
|
||||||
|
|
||||||
authors.append(Author(name, count, email))
|
|
||||||
|
|
||||||
return authors
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def chdirToRepositoryTopLevel():
|
|
||||||
cmd = shlex.split("git rev-parse --show-toplevel")
|
|
||||||
topLevel = run(cmd)
|
|
||||||
|
|
||||||
chdir(topLevel)
|
|
||||||
|
|
||||||
|
|
||||||
class OfflineimapInfo(object):
|
|
||||||
def __init__(self):
|
|
||||||
self.version = None
|
|
||||||
|
|
||||||
def getCurrentVersion(self):
|
|
||||||
if self.version is None:
|
|
||||||
cmd = shlex.split("./offlineimap.py --version")
|
|
||||||
self.version = run(cmd).rstrip().decode(FS_ENCODING)
|
|
||||||
return self.version
|
|
||||||
|
|
||||||
|
|
||||||
class User(object):
|
|
||||||
"""Interact with the user."""
|
|
||||||
|
|
||||||
prompt = '-> '
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def request(msg):
|
|
||||||
print(msg)
|
|
||||||
return input(User.prompt)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
offlineimapInfo = OfflineimapInfo()
|
offlineimapInfo = OfflineimapInfo()
|
||||||
|
|
||||||
Git.chdirToRepositoryTopLevel()
|
Git.chdirToRepositoryTopLevel()
|
||||||
oVersion = offlineimapInfo.getCurrentVersion()
|
oVersion = offlineimapInfo.getVersion()
|
||||||
ccList = getTesters()
|
ccList = getTesters()
|
||||||
authors = Git.getAuthors(oVersion)
|
authors = Git.getAuthorsList(oVersion)
|
||||||
for author in authors:
|
for author in authors:
|
||||||
email = author.getEmail()
|
email = author.getEmail()
|
||||||
if email not in ccList:
|
if email not in ccList:
|
||||||
|
Loading…
Reference in New Issue
Block a user