releasedocmaker.py 17 KB

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