blob: 2baa63b493ed0c38e3023083923b2dd045dcb342 [file] [log] [blame]
#!/usr/bin/env python3
"""
This script parses git diffs and outputs a set of GitHub Action annotations where each section of
each file is an annotation containing the diff chunk as a message. All annotations are error-level.
"""
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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 re
import sys
import typing
from enum import Enum
class Level(Enum):
"""
Level encodes the level of an annotation.
"""
error = "error"
notice = "notice" # notice not implemented (yet?)
warn = "warning" # warning unused - diffs are assumed errors
class Annotation(typing.NamedTuple):
"""
Annotation represents a GitHub Actions Annotation. To preview the GitHub Annotation string,
coerce it to a string value. For a fully sanitized, printable Annotation, use the sanitize
method.
>>> print(Annotation(level=Level.error, file='test', line=1, message='test'))
::error file=test,line=1::test
"""
level: Level
file: str
line: int
message: str
def __str__(self) -> str:
msg = self.message.replace("]", "%5D").replace(";", "%3B")
return f"::{self.level.value} file={self.file},line={self.line}::{msg}"
def __repr__(self) -> str:
return f"Annotation(level={self.level}, file='{self.file}', line={self.line})"
def sanitize(self) -> str:
"""
sanitize returns a sanitized string, suitable for GHA, but not for humans to read because
all newlines have been replaced with their URL percent-encoded code points.
"""
return str(self).replace("\r", "%0D").replace("\n", "%0A")
CHUNK_HEADER_PATTERN = re.compile(r"^@@ -\d+,\d+ \+(\d+),(\d+) @@")
def parse_chunk(chunk: str, file: str) -> Annotation:
"""
parse_chunk parses a single diff chunk and produces a corresponding annotation.
>>> chunk = '''@@ -1,3 +1,3 @@
... {
... + "test": "quest"
... - "foo": "bar"
... }'''
>>> ann = parse_chunk(chunk, "test")
>>> ann
Annotation(level=Level.error, file='test', line=2)
>>> print(ann)
::error file=test,line=2::Format error
```diff
@@ -1,3 +1,3 @@
{
+ "test": "quest"
- "foo": "bar"
}
```
"""
lines = chunk.splitlines()
if len(lines) < 2:
raise ValueError(f"invalid diff chunk: {chunk}")
match = CHUNK_HEADER_PATTERN.match(lines[0])
if not match or len(match.groups()) != 2:
raise ValueError(f"invalid diff chunk header: {lines[0]}")
line = int(match.groups()[0]) + int(match.groups()[1])//2
content = "\n".join(["Format error", "```diff", chunk, "```"])
return Annotation(Level.error, file, line, content)
FILE_HEADER_PATTERN = re.compile(r"^diff --git a/(.+) b/(.+)$")
def parse_file(contents: str) -> typing.List[Annotation]:
"""
parse_file parses the diff for a single file and returns the corresponding annotations.
>>> file = '''diff --git a/test b/test
... index c9072dcb7..2b7686061 100644
... --- a/test
... +++ b/test
... @@ -24,7 +24,7 @@ package tc
... // in: body
... type ASNsResponse struct {
... // in: body
... - Response []ASN `json:"response"`
... +Response []ASN `json:"response"`
... Alerts
... }
...
... @@ -85,7 +85,7 @@ type ASNNullable struct {
... // ID of the ASN
... //
... // required: true
... - ID *int `json:"id" db:"id"`
... +ID *int `json:"id" db:"id"`
...
... // LastUpdated
... //
... '''
>>> anns = parse_file(file)
>>> len(anns)
2
>>> anns[0]
Annotation(level=Level.error, file='test', line=27)
>>> anns[1]
Annotation(level=Level.error, file='test', line=88)
"""
lines = contents.splitlines()
if len(lines) < 5:
raise ValueError(f"'{contents}' does not represent a file diff in git format")
match = FILE_HEADER_PATTERN.match(lines[0])
if not match or len(match.groups()) != 2:
raise ValueError(f"invalid git diff file header: '{lines[0]}'")
fname = match.groups()[1]
lines = lines[4:]
chunk = [lines[0]]
annotations = []
for line in lines[1:]:
if CHUNK_HEADER_PATTERN.match(line):
annotations.append(parse_chunk("\n".join(chunk), fname))
chunk = []
chunk.append(line)
if chunk:
annotations.append(parse_chunk("\n".join(chunk), fname))
return annotations
def parse_diff(diff: str) -> typing.List[Annotation]:
"""
parse_diff parses a git diff output and returns the corresponding annotations.
>>> diff= '''diff --git a/test b/test
... index c9072dcb7..2b7686061 100644
... --- a/test
... +++ b/test
... @@ -24,7 +24,7 @@ package tc
... // in: body
... type ASNsResponse struct {
... // in: body
... - Response []ASN `json:"response"`
... +Response []ASN `json:"response"`
... Alerts
... }
...
... @@ -85,7 +85,7 @@ type ASNNullable struct {
... // ID of the ASN
... //
... // required: true
... - ID *int `json:"id" db:"id"`
... +ID *int `json:"id" db:"id"`
...
... // LastUpdated
... //
... diff --git a/quest b/quest
... index 283901f14..0c1e2b0c1 100644
... --- a/quest
... +++ b/quest
... @@ -1,7 +1,7 @@
... package tc
...
... import (
... - "database/sql"
... +"database/sql"
... )
...
... /*
... '''
>>> anns = parse_diff(diff)
>>> len(anns)
3
>>> anns[0]
Annotation(level=Level.error, file='test', line=27)
>>> anns[1]
Annotation(level=Level.error, file='test', line=88)
>>> anns[2]
Annotation(level=Level.error, file='quest', line=4)
"""
lines = diff.splitlines()
if len(lines) < 5:
raise ValueError(f"'{diff}' does not represent a git diff")
match = FILE_HEADER_PATTERN.match(lines[0])
if not match or len(match.groups()) != 2:
raise ValueError(f"invalid git diff file header: '{lines[0]}''")
file = lines[:4]
lines = lines[4:]
annotations = []
for line in lines:
if FILE_HEADER_PATTERN.match(line):
annotations += parse_file("\n".join(file))
file = []
file.append(line)
if file:
annotations += parse_file("\n".join(file))
return annotations
def main() -> int:
"""
Runs the main program, based on the passed-in arguments.
Returns an exit code based on success or failure of the script - NOT based
on the presence of any error-level annotations.
"""
try:
print(*(x.sanitize() for x in parse_diff(sys.stdin.read())), sep="\n")
return 0
except ValueError as e:
print("error:", e, file=sys.stderr)
return 1
except OSError as e:
print("error reading input:", e, file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())