releasedocmaker.py 18 KB

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