blob: f760e9e89a0b80264eba824743198f5edb27a3f4 [file] [log] [blame]
#!/usr/bin/python3
#
# Copyright (C) 2020 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import os, sys
from enum import Enum
import subprocess
from ReleaseNoteMarkdown import *
GIT_LOG_CMD_PREFIX = "git log --name-only"
def print_e(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def removePrefix(text, prefix):
if text.startswith(prefix): return text[len(prefix):]
return text
class GitClient:
def __init__(self, workingDir):
self.workingDir = workingDir
self.gitRoot = self.findGitDirInParentFilepath(workingDir)
if self.gitRoot == None:
self.gitRoot = workingDir
def findGitDirInParentFilepath(self, filepath):
curDirectory = filepath
while curDirectory != "/" and curDirectory != "" and curDirectory != None:
if os.path.exists(curDirectory + "/.git"):
return curDirectory
curDirectory = os.path.dirname(curDirectory)
return None
def executeCommand(self, command):
try:
command_output = subprocess.check_output(command, shell=True)
except subprocess.CalledProcessError as cmdErr:
print_e('FAILED: The following command: \n%s\n raised error:\n%s' % (command, cmdErr.returncode))
return None
# Make the output into a string, because the subprocess returns a byte object by default
# This is necessary because when we mock the command output in tests, we use strings. Also
# defaulting to returning a string is just easier to reason about.
if not isinstance(command_output, str):
return command_output.decode()
else:
return command_output
def getGitLog(self, fromExclusiveSha, untilInclusiveSha, keepMerges, subProjectDir, n=None):
""" Converts a diff log command into a [List<Commit>]
@param fromExclusiveSha the older Sha to include in the git log (exclusive)
@param untilInclusiveSha the newest Sha to include in the git log (inclusive)
@param keepMerges boolean for whether or not to add merges to the return [List<Commit>].
@param subProjectDir a string that represents the project directory relative to the gitRoot.
@param n the maximum number of commits to output in the git log. n==None is treated
equivalently to n=infinity
"""
commitStartDelimiter = "_CommitStart"
commitSHADelimiter = "_CommitSHA:"
subjectDelimiter = "_Subject:"
authorEmailDelimiter = "_Author:"
dateDelimiter = "_Date:"
bodyDelimiter = "_Body:"
if subProjectDir[0] == '/':
raise RuntimeError("Fatal error: the subproject directory (subProjectDir) passed to " +
"GitClient.getGitLog was an absolute filepath. The subproject directory should " +
"be a relative filepath to the GitClient.gitRoot")
fullProjectDir = os.path.join(self.gitRoot, subProjectDir)
gitLogOptions = "--pretty=format:" + \
commitStartDelimiter + "\%n" + \
commitSHADelimiter + "\%H\%n" + \
authorEmailDelimiter + "\%ae\%n" + \
dateDelimiter + "\%ad\%n" + \
subjectDelimiter + "\%s\%n" + \
bodyDelimiter + "\%b"
if not keepMerges:
gitLogOptions += " --no-merges"
gitLogCmd = GIT_LOG_CMD_PREFIX + " " + gitLogOptions + " "
if n is not None:
gitLogCmd += " -n " + str(n) + " "
if fromExclusiveSha != "":
gitLogCmd += " " + fromExclusiveSha + ".."
gitLogCmd += untilInclusiveSha
gitLogCmd += " -- " + fullProjectDir
gitLogOutputString = self.executeCommand(gitLogCmd)
return self.parseCommitLogString(gitLogOutputString,commitStartDelimiter,commitSHADelimiter,subjectDelimiter,authorEmailDelimiter,subProjectDir)
def parseCommitLogString(self, commitLogString, commitStartDelimiter, commitSHADelimiter, subjectDelimiter, authorEmailDelimiter, localProjectDir):
if commitLogString == "" or commitLogString == None: return []
# Split commits string out into individual commits (note: this removes the deliminter)
gitLogStringList = commitLogString.split(commitStartDelimiter)
commitLog = []
for gitCommit in gitLogStringList:
if gitCommit.strip() == "": continue
commitLog.append(
Commit(
gitCommit,
localProjectDir,
commitSHADelimiter,
subjectDelimiter,
authorEmailDelimiter
)
)
return commitLog
class CommitType(Enum):
NEW_FEATURE = 1
API_CHANGE = 2
BUG_FIX = 3
EXTERNAL_CONTRIBUTION = 4
def getTitleFromCommitType(commitType):
if commitType == CommitType.NEW_FEATURE: return "New Features"
if commitType == CommitType.API_CHANGE: return "API Changes"
if commitType == CommitType.BUG_FIX: return "Bug Fixes"
if commitType == CommitType.EXTERNAL_CONTRIBUTION: return "External Contribution"
class Commit:
def __init__(self, gitCommit, projectDir, commitSHADelimiter="_CommitSHA:", subjectDelimiter="_Subject:", authorEmailDelimiter="_Author:"):
self.gitCommit = gitCommit
self.projectDir = projectDir
self.commitSHADelimiter = commitSHADelimiter
self.subjectDelimiter = subjectDelimiter
self.authorEmailDelimiter = authorEmailDelimiter
self.changeIdDelimiter = "Change-Id:"
self.bugs = []
self.files = []
self.sha = ""
self.authorEmail = ""
self.changeId = ""
self.summary = ""
self.changeType = CommitType.BUG_FIX
self.releaseNote = ""
self.releaseNoteDelimiter = "relnote:"
self.formatGitCommitRelnoteTag()
listedCommit = self.gitCommit.split('\n')
for line in listedCommit:
if line.strip() == "": continue
if self.commitSHADelimiter in line:
self.getSHAFromGitLine(line)
if self.subjectDelimiter in line:
self.getSummary(line)
if self.changeIdDelimiter in line:
self.getChangeIdFromGitLine(line)
if self.authorEmailDelimiter in line:
self.getAuthorEmailFromGitLine(line)
if ("Bug:" in line) or ("b/" in line) or ("bug:" in line) or ("Fixes:" in line) or ("fixes b/" in line):
self.getBugsFromGitLine(line)
if self.releaseNoteDelimiter in line:
self.getReleaseNotesFromGitLine(line, self.gitCommit)
if self.projectDir.strip('/') in line:
self.getFileFromGitLine(line)
def formatGitCommitRelnoteTag(self):
""" This method accounts for the fact that the releaseNoteDelimiter is case insensitive
To do this, we just replace it with the tag we expect and can easily parse
"""
relnoteIndex = self.gitCommit.lower().find(self.releaseNoteDelimiter)
if relnoteIndex > -1:
self.gitCommit = self.gitCommit[:relnoteIndex] + \
self.releaseNoteDelimiter + \
self.gitCommit[relnoteIndex + len(self.releaseNoteDelimiter):]
# Provide support for other types of quotes around the Relnote message
self.gitCommit = self.gitCommit.replace('“','"')
self.gitCommit = self.gitCommit.replace('”','"')
def isExternalAuthorEmail(self, authorEmail):
return "@google.com" not in self.authorEmail
def getSHAFromGitLine(self, line):
""" Parses SHAs from git commit line, with the format:
[Commit.commitSHADelimiter] <commitSHA>
"""
self.sha = line.split(self.commitSHADelimiter, 1)[1].strip()
def getSummary(self, line):
""" Parses subject from git commit line, with the format:
[Commit.subjectDelimiter]<commit subject>
"""
self.summary = line.split(self.subjectDelimiter, 1)[1].strip()
def getChangeIdFromGitLine(self, line):
""" Parses commit Change-Id lines, with the format:
`commit.changeIdDelimiter` <changeId>
"""
self.changeId = line.split(self.changeIdDelimiter, 1)[1].strip()
def getAuthorEmailFromGitLine(self, line):
""" Parses commit author lines, with the format:
[Commit.authorEmailDelimiter]email@google.com
"""
self.authorEmail = line.split(self.authorEmailDelimiter, 1)[1].strip()
if self.isExternalAuthorEmail(self.authorEmail):
self.changeType = CommitType.EXTERNAL_CONTRIBUTION
def getFileFromGitLine(self, filepath):
""" Parses filepath to get changed files from commit, with the format:
{project_directory}/{filepath}
"""
self.files.append(filepath.strip())
if "current.txt" in filepath and self.changeType != CommitType.EXTERNAL_CONTRIBUTION:
self.changeType = CommitType.API_CHANGE
def getBugsFromGitLine(self, line):
""" Parses bugs from a git commit message line
"""
punctuationChars = ["b/", ":", ",", ".", "(", ")", "!", "\\"]
formattedLine = line
for punctChar in punctuationChars:
formattedLine = formattedLine.replace(punctChar, " ")
words = formattedLine.split(' ')
possibleBug = 0
for word in words:
try:
possibleBug = int(word)
except ValueError:
# Do nothing, it's not a bug number
pass
if possibleBug > 1000 and possibleBug not in self.bugs:
self.bugs.append(possibleBug)
def getReleaseNotesFromGitLine(self, line, gitCommit):
""" Reads in the release notes field from the git commit message line
They can have a couple valid formats:
`Relnote: This is a one-line release note`
`Relnote: "This is a multi-line release note. This accounts for the use case where
the commit cannot be explained in one line"
`Relnote: "This is a one-line release note. The quotes can be used this way too"`
"""
releaseNote = ""
# Account for the use of quotes in a release note line
# No quotes in the Release Note line means it's a one-line release note
# If there are quotes, assume it's a multi-line release note
# Notes that we only count non-escaped quotes
quoteCountInRelNoteLine = 0
for i in range(0, len(line)):
if line[i] == '"' and i > 0 and line[i-1] != '\\':
quoteCountInRelNoteLine += 1
# Case 1: Single line relnote
if quoteCountInRelNoteLine == 0:
releaseNote = self.getOneLineReleaseNotesFromGitLine(line)
# Case 2: Triple quote relnote
elif line.find('"""') >= 0:
releaseNote = self.getQuotedReleaseNotesFromGitLine(line, gitCommit, '"""')
# Case 3: Single quote relnote
else:
releaseNote = self.getQuotedReleaseNotesFromGitLine(line, gitCommit, '"')
# Replace all instances of '\"' with just '"'
releaseNote = releaseNote.replace('\\"', '"')
# Finally, set the release note to be an empty string if the Relnote says the commit
# is not applicable for release notes.
if self.isIgnoredChange(releaseNote):
self.releaseNote = ""
else:
self.releaseNote = releaseNote
def getOneLineReleaseNotesFromGitLine(self, line):
releaseNote = ""
if self.releaseNoteDelimiter in line:
releaseNoteStartIndex = line.index(self.releaseNoteDelimiter) + len(self.releaseNoteDelimiter)
# Strip space and quotes from left side, and extra space from right side
releaseNote = line[releaseNoteStartIndex:].strip(' ')
# Strip quotes and spaces
if releaseNote.find('"""') >= 0:
releaseNote = releaseNote.strip('"""')
elif releaseNote.startswith('"'):
releaseNote = releaseNote.lstrip('"')
if releaseNote.endswith('\\""'):
releaseNote = releaseNote.replace('\\""', '\\"')
else:
releaseNote = releaseNote.rstrip('"')
return releaseNote
def getQuotedReleaseNotesFromGitLine(self, line, gitCommit, quoteDelim):
releaseNote = ""
quoteDelimLen = len(quoteDelim)
if self.releaseNoteDelimiter in line:
# Find the starting quoteDelim of the release notes quote block
releaseNoteStartIndexInit = gitCommit.rfind(self.releaseNoteDelimiter) + len(self.releaseNoteDelimiter)
releaseNoteStartIndex = gitCommit.find(quoteDelim, releaseNoteStartIndexInit)
# Move to the starting index to after the first quote
if releaseNoteStartIndex < 0:
releaseNoteStartIndex = releaseNoteStartIndexInit
else:
releaseNoteStartIndex += quoteDelimLen
# Find the ending quote of the release notes quote block
releaseNoteEndIndex = releaseNoteStartIndex + 1
while True:
releaseNoteEndIndex = gitCommit.find(quoteDelim, releaseNoteEndIndex)
if releaseNoteEndIndex < 0:
# No closing quoteDelim found, so just use the first line and fix it
return self.getOneLineReleaseNotesFromGitLine(line + quoteDelim)
else:
releaseNote = gitCommit[releaseNoteStartIndex:releaseNoteEndIndex].strip()
# If we're searching for a singluar quote, continue searching until we find a
# quote without a backslash
if quoteDelim != '"':
break
if gitCommit[releaseNoteEndIndex-1] != '\\':
break
else:
releaseNoteEndIndex += 1
return releaseNote
def isIgnoredChange(self, releaseNote):
notApplicableStringOptions = ['na', 'n/a', 'n a']
return releaseNote.lower().strip() in notApplicableStringOptions
def __str__(self):
commitString = self.summary
commitString += " (" + str(getChangeIdAOSPLink(self.changeId))
for bug in self.bugs:
commitString += ", " + str(getBuganizerLink(bug))
commitString += ")"
return commitString
def getChangeIdAOSPLink(changeId):
""" @param changeId The Gerrit Change-Id to link to
@return A [MarkdownLink] to AOSP Gerrit
"""
baseAOSPUrl = "https://android-review.googlesource.com/#/q/"
return MarkdownLink(changeId[:6], "%s%s" % (baseAOSPUrl, changeId))
def getBuganizerLink(bugId):
""" @param bugId the Id of the buganizer issue
@return A [MarkdownLink] to the public buganizer issue tracker
Note: This method does not check if the bug is public
"""
baseBuganizerUrl = "https://issuetracker.google.com/issues/"
return MarkdownLink("b/%d" % bugId, "%s%d" % (baseBuganizerUrl, bugId))