releasedocmaker.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. #!/usr/bin/env python
  2. #
  3. # Licensed to the Apache Software Foundation (ASF) under one
  4. # or more contributor license agreements. See the NOTICE file
  5. # distributed with this work for additional information
  6. # regarding copyright ownership. The ASF licenses this file
  7. # to you under the Apache License, Version 2.0 (the
  8. # "License"); you may not use this file except in compliance
  9. # with the License. You may obtain a copy of the License at
  10. #
  11. # http://www.apache.org/licenses/LICENSE-2.0
  12. #
  13. # Unless required by applicable law or agreed to in writing, software
  14. # distributed under the License is distributed on an "AS IS" BASIS,
  15. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  16. # See the License for the specific language governing permissions and
  17. # limitations under the License.
  18. from glob import glob
  19. from optparse import OptionParser
  20. from time import gmtime, strftime
  21. import pprint
  22. import os
  23. import re
  24. import sys
  25. import urllib
  26. import urllib2
  27. try:
  28. import json
  29. except ImportError:
  30. import simplejson as json
  31. releaseVersion={}
  32. namePattern = re.compile(r' \([0-9]+\)')
  33. asflicense='''
  34. <!---
  35. # Licensed to the Apache Software Foundation (ASF) under one
  36. # or more contributor license agreements. See the NOTICE file
  37. # distributed with this work for additional information
  38. # regarding copyright ownership. The ASF licenses this file
  39. # to you under the Apache License, Version 2.0 (the
  40. # "License"); you may not use this file except in compliance
  41. # with the License. You may obtain a copy of the License at
  42. #
  43. # http://www.apache.org/licenses/LICENSE-2.0
  44. #
  45. # Unless required by applicable law or agreed to in writing, software
  46. # distributed under the License is distributed on an "AS IS" BASIS,
  47. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  48. # See the License for the specific language governing permissions and
  49. # limitations under the License.
  50. -->
  51. '''
  52. def clean(str):
  53. return tableclean(re.sub(namePattern, "", str))
  54. def formatComponents(str):
  55. str = re.sub(namePattern, '', str).replace("'", "")
  56. if str != "":
  57. ret = str
  58. else:
  59. # some markdown parsers don't like empty tables
  60. ret = "."
  61. return clean(ret)
  62. # convert to utf-8
  63. # protect some known md metachars
  64. # or chars that screw up doxia
  65. def tableclean(str):
  66. str=str.encode('utf-8')
  67. str=str.replace("_","\_")
  68. str=str.replace("\r","")
  69. str=str.rstrip()
  70. return str
  71. # same thing as tableclean,
  72. # except table metachars are also
  73. # escaped as well as more
  74. # things we don't want doxia to
  75. # screw up
  76. def notableclean(str):
  77. str=tableclean(str)
  78. str=str.replace("|","\|")
  79. str=str.replace("<","\<")
  80. str=str.replace(">","\>")
  81. str=str.replace("*","\*")
  82. str=str.rstrip()
  83. return str
  84. # clean output dir
  85. def cleanOutputDir(dir):
  86. files = os.listdir(dir)
  87. for name in files:
  88. os.remove(os.path.join(dir,name))
  89. os.rmdir(dir)
  90. def mstr(obj):
  91. if (obj is None):
  92. return ""
  93. return unicode(obj)
  94. def buildindex(title,license):
  95. versions=reversed(sorted(glob("[0-9]*.[0-9]*.[0-9]*")))
  96. with open("index.md","w") as indexfile:
  97. if license is True:
  98. indexfile.write(asflicense)
  99. for v in versions:
  100. indexfile.write("* %s v%s\n" % (title,v))
  101. for k in ("Changes","Release Notes"):
  102. indexfile.write(" * %s (%s/%s.%s.html)\n" \
  103. % (k,v,k.upper().replace(" ",""),v))
  104. indexfile.close()
  105. class GetVersions:
  106. """ yo """
  107. def __init__(self,versions, projects):
  108. versions = versions
  109. projects = projects
  110. self.newversions = []
  111. pp = pprint.PrettyPrinter(indent=4)
  112. at=0
  113. end=1
  114. count=100
  115. versions.sort()
  116. print "Looking for %s through %s"%(versions[0],versions[-1])
  117. for p in projects:
  118. resp = urllib2.urlopen("https://issues.apache.org/jira/rest/api/2/project/%s/versions"%p)
  119. data = json.loads(resp.read())
  120. for d in data:
  121. if d['name'][0].isdigit and versions[0] <= d['name'] and d['name'] <= versions[-1]:
  122. print "Adding %s to the list" % d['name']
  123. self.newversions.append(d['name'])
  124. newlist=list(set(self.newversions))
  125. self.newversions=newlist
  126. def getlist(self):
  127. pp = pprint.PrettyPrinter(indent=4)
  128. return(self.newversions)
  129. class Version:
  130. """Represents a version number"""
  131. def __init__(self, data):
  132. self.mod = False
  133. self.data = data
  134. found = re.match('^((\d+)(\.\d+)*).*$', data)
  135. if (found):
  136. self.parts = [ int(p) for p in found.group(1).split('.') ]
  137. else:
  138. self.parts = []
  139. # backfill version with zeroes if missing parts
  140. self.parts.extend((0,) * (3 - len(self.parts)))
  141. def __str__(self):
  142. if (self.mod):
  143. return '.'.join([ str(p) for p in self.parts ])
  144. return self.data
  145. def __cmp__(self, other):
  146. return cmp(self.parts, other.parts)
  147. class Jira:
  148. """A single JIRA"""
  149. def __init__(self, data, parent):
  150. self.key = data['key']
  151. self.fields = data['fields']
  152. self.parent = parent
  153. self.notes = None
  154. self.incompat = None
  155. self.reviewed = None
  156. def getId(self):
  157. return mstr(self.key)
  158. def getDescription(self):
  159. return mstr(self.fields['description'])
  160. def getReleaseNote(self):
  161. if (self.notes is None):
  162. field = self.parent.fieldIdMap['Release Note']
  163. if (self.fields.has_key(field)):
  164. self.notes=mstr(self.fields[field])
  165. else:
  166. self.notes=self.getDescription()
  167. return self.notes
  168. def getPriority(self):
  169. ret = ""
  170. pri = self.fields['priority']
  171. if(pri is not None):
  172. ret = pri['name']
  173. return mstr(ret)
  174. def getAssignee(self):
  175. ret = ""
  176. mid = self.fields['assignee']
  177. if(mid is not None):
  178. ret = mid['displayName']
  179. return mstr(ret)
  180. def getComponents(self):
  181. if (len(self.fields['components'])>0):
  182. return ", ".join([ comp['name'] for comp in self.fields['components'] ])
  183. else:
  184. return ""
  185. def getSummary(self):
  186. return self.fields['summary']
  187. def getType(self):
  188. ret = ""
  189. mid = self.fields['issuetype']
  190. if(mid is not None):
  191. ret = mid['name']
  192. return mstr(ret)
  193. def getReporter(self):
  194. ret = ""
  195. mid = self.fields['reporter']
  196. if(mid is not None):
  197. ret = mid['displayName']
  198. return mstr(ret)
  199. def getProject(self):
  200. ret = ""
  201. mid = self.fields['project']
  202. if(mid is not None):
  203. ret = mid['key']
  204. return mstr(ret)
  205. def __cmp__(self,other):
  206. selfsplit=self.getId().split('-')
  207. othersplit=other.getId().split('-')
  208. v1=cmp(selfsplit[0],othersplit[0])
  209. if (v1!=0):
  210. return v1
  211. else:
  212. if selfsplit[1] < othersplit[1]:
  213. return True
  214. elif selfsplit[1] > othersplit[1]:
  215. return False
  216. return False
  217. def getIncompatibleChange(self):
  218. if (self.incompat is None):
  219. field = self.parent.fieldIdMap['Hadoop Flags']
  220. self.reviewed=False
  221. self.incompat=False
  222. if (self.fields.has_key(field)):
  223. if self.fields[field]:
  224. for hf in self.fields[field]:
  225. if hf['value'] == "Incompatible change":
  226. self.incompat=True
  227. if hf['value'] == "Reviewed":
  228. self.reviewed=True
  229. return self.incompat
  230. def checkMissingComponent(self):
  231. if (len(self.fields['components'])>0):
  232. return False
  233. return True
  234. def checkMissingAssignee(self):
  235. if (self.fields['assignee'] is not None):
  236. return False
  237. return True
  238. def checkVersionString(self):
  239. field = self.parent.fieldIdMap['Fix Version/s']
  240. for h in self.fields[field]:
  241. found = re.match('^((\d+)(\.\d+)*).*$|^(\w+\-\d+)$', h['name'])
  242. if not found:
  243. return True
  244. return False
  245. def getReleaseDate(self,version):
  246. for j in range(len(self.fields['fixVersions'])):
  247. if self.fields['fixVersions'][j]==version:
  248. return(self.fields['fixVersions'][j]['releaseDate'])
  249. return None
  250. class JiraIter:
  251. """An Iterator of JIRAs"""
  252. def __init__(self, version, projects):
  253. self.version = version
  254. self.projects = projects
  255. v=str(version).replace("-SNAPSHOT","")
  256. resp = urllib2.urlopen("https://issues.apache.org/jira/rest/api/2/field")
  257. data = json.loads(resp.read())
  258. self.fieldIdMap = {}
  259. for part in data:
  260. self.fieldIdMap[part['name']] = part['id']
  261. self.jiras = []
  262. at=0
  263. end=1
  264. count=100
  265. while (at < end):
  266. params = urllib.urlencode({'jql': "project in ('"+"' , '".join(projects)+"') and fixVersion in ('"+v+"') and resolution = Fixed", 'startAt':at, 'maxResults':count})
  267. resp = urllib2.urlopen("https://issues.apache.org/jira/rest/api/2/search?%s"%params)
  268. data = json.loads(resp.read())
  269. if (data.has_key('errorMessages')):
  270. raise Exception(data['errorMessages'])
  271. at = data['startAt'] + data['maxResults']
  272. end = data['total']
  273. self.jiras.extend(data['issues'])
  274. needaversion=False
  275. if v not in releaseVersion:
  276. needaversion=True
  277. if needaversion is True:
  278. for i in range(len(data['issues'])):
  279. for j in range(len(data['issues'][i]['fields']['fixVersions'])):
  280. if 'releaseDate' in data['issues'][i]['fields']['fixVersions'][j]:
  281. releaseVersion[data['issues'][i]['fields']['fixVersions'][j]['name']]=\
  282. data['issues'][i]['fields']['fixVersions'][j]['releaseDate']
  283. self.iter = self.jiras.__iter__()
  284. def __iter__(self):
  285. return self
  286. def next(self):
  287. data = self.iter.next()
  288. j = Jira(data, self)
  289. return j
  290. class Outputs:
  291. """Several different files to output to at the same time"""
  292. def __init__(self, base_file_name, file_name_pattern, keys, params={}):
  293. self.params = params
  294. self.base = open(base_file_name%params, 'w')
  295. self.others = {}
  296. for key in keys:
  297. both = dict(params)
  298. both['key'] = key
  299. self.others[key] = open(file_name_pattern%both, 'w')
  300. def writeAll(self, pattern):
  301. both = dict(self.params)
  302. both['key'] = ''
  303. self.base.write(pattern%both)
  304. for key in self.others.keys():
  305. both = dict(self.params)
  306. both['key'] = key
  307. self.others[key].write(pattern%both)
  308. def writeKeyRaw(self, key, str):
  309. self.base.write(str)
  310. if (self.others.has_key(key)):
  311. self.others[key].write(str)
  312. def close(self):
  313. self.base.close()
  314. for fd in self.others.values():
  315. fd.close()
  316. def writeList(self, mylist):
  317. for jira in sorted(mylist):
  318. line = '| [%s](https://issues.apache.org/jira/browse/%s) | %s | %s | %s | %s | %s |\n' \
  319. % (notableclean(jira.getId()), notableclean(jira.getId()),
  320. notableclean(jira.getSummary()),
  321. notableclean(jira.getPriority()),
  322. formatComponents(jira.getComponents()),
  323. notableclean(jira.getReporter()),
  324. notableclean(jira.getAssignee()))
  325. self.writeKeyRaw(jira.getProject(), line)
  326. def main():
  327. parser = OptionParser(usage="usage: %prog --project PROJECT [--project PROJECT] --version VERSION [--version VERSION2 ...]",
  328. epilog=
  329. "Markdown-formatted CHANGES and RELEASENOTES files will be stored in a directory"
  330. " named after the highest version provided.")
  331. parser.add_option("-i","--index", dest="index", action="store_true",
  332. default=False, help="build an index file")
  333. parser.add_option("-l","--license", dest="license", action="store_false",
  334. default=True, help="Add an ASF license")
  335. parser.add_option("-n","--lint", dest="lint", action="store_true",
  336. help="use lint flag to exit on failures")
  337. parser.add_option("-p", "--project", dest="projects",
  338. action="append", type="string",
  339. help="projects in JIRA to include in releasenotes", metavar="PROJECT")
  340. parser.add_option("-r", "--range", dest="range", action="store_true",
  341. default=False, help="Given versions are a range")
  342. parser.add_option("-t", "--projecttitle", dest="title",
  343. type="string",
  344. help="Title to use for the project (default is Apache PROJECT)")
  345. parser.add_option("-u","--usetoday", dest="usetoday", action="store_true",
  346. default=False, help="use current date for unreleased versions")
  347. parser.add_option("-v", "--version", dest="versions",
  348. action="append", type="string",
  349. help="versions in JIRA to include in releasenotes", metavar="VERSION")
  350. (options, args) = parser.parse_args()
  351. if (options.versions is None):
  352. options.versions = []
  353. if (len(args) > 2):
  354. options.versions.append(args[2])
  355. if (len(options.versions) <= 0):
  356. parser.error("At least one version needs to be supplied")
  357. proxy = urllib2.ProxyHandler()
  358. opener = urllib2.build_opener(proxy)
  359. urllib2.install_opener(opener)
  360. projects = options.projects
  361. if (options.range is True):
  362. versions = [ Version(v) for v in GetVersions(options.versions, projects).getlist() ]
  363. else:
  364. versions = [ Version(v) for v in options.versions ]
  365. versions.sort();
  366. if (options.title is None):
  367. title=projects[0]
  368. else:
  369. title=options.title
  370. haderrors=False
  371. for v in versions:
  372. vstr=str(v)
  373. jlist = JiraIter(vstr,projects)
  374. if vstr in releaseVersion:
  375. reldate=releaseVersion[vstr]
  376. elif options.usetoday:
  377. reldate=strftime("%Y-%m-%d", gmtime())
  378. else:
  379. reldate="Unreleased"
  380. if not os.path.exists(vstr):
  381. os.mkdir(vstr)
  382. reloutputs = Outputs("%(ver)s/RELEASENOTES.%(ver)s.md",
  383. "%(ver)s/RELEASENOTES.%(key)s.%(ver)s.md",
  384. [], {"ver":v, "date":reldate, "title":title})
  385. choutputs = Outputs("%(ver)s/CHANGES.%(ver)s.md",
  386. "%(ver)s/CHANGES.%(key)s.%(ver)s.md",
  387. [], {"ver":v, "date":reldate, "title":title})
  388. if (options.license is True):
  389. reloutputs.writeAll(asflicense)
  390. choutputs.writeAll(asflicense)
  391. relhead = '# %(title)s %(key)s %(ver)s Release Notes\n\n' \
  392. 'These release notes cover new developer and user-facing incompatibilities, features, and major improvements.\n\n'
  393. chhead = '# %(title)s Changelog\n\n' \
  394. '## Release %(ver)s - %(date)s\n'\
  395. '\n'
  396. reloutputs.writeAll(relhead)
  397. choutputs.writeAll(chhead)
  398. errorCount=0
  399. warningCount=0
  400. lintMessage=""
  401. incompatlist=[]
  402. buglist=[]
  403. improvementlist=[]
  404. newfeaturelist=[]
  405. subtasklist=[]
  406. tasklist=[]
  407. testlist=[]
  408. otherlist=[]
  409. for jira in sorted(jlist):
  410. if jira.getIncompatibleChange():
  411. incompatlist.append(jira)
  412. elif jira.getType() == "Bug":
  413. buglist.append(jira)
  414. elif jira.getType() == "Improvement":
  415. improvementlist.append(jira)
  416. elif jira.getType() == "New Feature":
  417. newfeaturelist.append(jira)
  418. elif jira.getType() == "Sub-task":
  419. subtasklist.append(jira)
  420. elif jira.getType() == "Task":
  421. tasklist.append(jira)
  422. elif jira.getType() == "Test":
  423. testlist.append(jira)
  424. else:
  425. otherlist.append(jira)
  426. line = '* [%s](https://issues.apache.org/jira/browse/%s) | *%s* | **%s**\n' \
  427. % (notableclean(jira.getId()), notableclean(jira.getId()), notableclean(jira.getPriority()),
  428. notableclean(jira.getSummary()))
  429. if (jira.getIncompatibleChange()) and (len(jira.getReleaseNote())==0):
  430. warningCount+=1
  431. reloutputs.writeKeyRaw(jira.getProject(),"\n---\n\n")
  432. reloutputs.writeKeyRaw(jira.getProject(), line)
  433. line ='\n**WARNING: No release note provided for this incompatible change.**\n\n'
  434. lintMessage += "\nWARNING: incompatible change %s lacks release notes." % (notableclean(jira.getId()))
  435. reloutputs.writeKeyRaw(jira.getProject(), line)
  436. if jira.checkVersionString():
  437. warningCount+=1
  438. lintMessage += "\nWARNING: Version string problem for %s " % jira.getId()
  439. if (jira.checkMissingComponent() or jira.checkMissingAssignee()):
  440. errorCount+=1
  441. errorMessage=[]
  442. jira.checkMissingComponent() and errorMessage.append("component")
  443. jira.checkMissingAssignee() and errorMessage.append("assignee")
  444. lintMessage += "\nERROR: missing %s for %s " % (" and ".join(errorMessage) , jira.getId())
  445. if (len(jira.getReleaseNote())>0):
  446. reloutputs.writeKeyRaw(jira.getProject(),"\n---\n\n")
  447. reloutputs.writeKeyRaw(jira.getProject(), line)
  448. line ='\n%s\n\n' % (tableclean(jira.getReleaseNote()))
  449. reloutputs.writeKeyRaw(jira.getProject(), line)
  450. if (options.lint is True):
  451. print lintMessage
  452. print "======================================="
  453. print "%s: Error:%d, Warning:%d \n" % (vstr, errorCount, warningCount)
  454. if (errorCount>0):
  455. haderrors=True
  456. cleanOutputDir(vstr)
  457. continue
  458. reloutputs.writeAll("\n\n")
  459. reloutputs.close()
  460. choutputs.writeAll("### INCOMPATIBLE CHANGES:\n\n")
  461. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  462. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  463. choutputs.writeList(incompatlist)
  464. choutputs.writeAll("\n\n### NEW FEATURES:\n\n")
  465. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  466. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  467. choutputs.writeList(newfeaturelist)
  468. choutputs.writeAll("\n\n### IMPROVEMENTS:\n\n")
  469. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  470. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  471. choutputs.writeList(improvementlist)
  472. choutputs.writeAll("\n\n### BUG FIXES:\n\n")
  473. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  474. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  475. choutputs.writeList(buglist)
  476. choutputs.writeAll("\n\n### TESTS:\n\n")
  477. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  478. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  479. choutputs.writeList(testlist)
  480. choutputs.writeAll("\n\n### SUB-TASKS:\n\n")
  481. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  482. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  483. choutputs.writeList(subtasklist)
  484. choutputs.writeAll("\n\n### OTHER:\n\n")
  485. choutputs.writeAll("| JIRA | Summary | Priority | Component | Reporter | Contributor |\n")
  486. choutputs.writeAll("|:---- |:---- | :--- |:---- |:---- |:---- |\n")
  487. choutputs.writeList(otherlist)
  488. choutputs.writeList(tasklist)
  489. choutputs.writeAll("\n\n")
  490. choutputs.close()
  491. if options.index:
  492. buildindex(title,options.license)
  493. if haderrors is True:
  494. sys.exit(1)
  495. if __name__ == "__main__":
  496. main()