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.path import expanduser
|
||||
import shlex
|
||||
from subprocess import check_output
|
||||
from os import system
|
||||
|
||||
from helpers import (
|
||||
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)
|
||||
EDITOR = 'vim'
|
||||
MAILALIASES_FILE = expanduser('~/.mutt/mail_aliases')
|
||||
|
||||
UPCOMING_HEADER = """
|
||||
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
|
||||
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
|
||||
|
||||
"""
|
||||
|
||||
|
||||
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__':
|
||||
offlineimapInfo = OfflineimapInfo()
|
||||
|
||||
Git.chdirToRepositoryTopLevel()
|
||||
oVersion = offlineimapInfo.getCurrentVersion()
|
||||
oVersion = offlineimapInfo.getVersion()
|
||||
ccList = getTesters()
|
||||
authors = Git.getAuthors(oVersion)
|
||||
authors = Git.getAuthorsList(oVersion)
|
||||
for author in authors:
|
||||
email = author.getEmail()
|
||||
if email not in ccList:
|
||||
|
Loading…
Reference in New Issue
Block a user