mirror of
https://github.com/ditkrg/todo-to-issue-action.git
synced 2026-01-22 22:06:43 +00:00
Merge pull request #222 from rgalonso/refactor-and-add-tests
refactor and add tests
This commit is contained in:
commit
d8db8a9571
22
.devcontainer/devcontainer.json
Normal file
22
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||||
|
{
|
||||||
|
"name": "Python 3",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements-dev.txt"
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
// "customizations": {},
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -13,6 +13,6 @@ jobs:
|
|||||||
- name: "Install test dependencies"
|
- name: "Install test dependencies"
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
- name: "Run tests"
|
- name: "Run tests"
|
||||||
run: python -m pytest
|
run: python -m pytest
|
||||||
12
Client.py
Normal file
12
Client.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
class Client(object):
|
||||||
|
def get_last_diff(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_issue(self, issue):
|
||||||
|
return [201, None]
|
||||||
|
|
||||||
|
def close_issue(self, issue):
|
||||||
|
return 200
|
||||||
|
|
||||||
|
def get_issue_url(self, new_issue_number):
|
||||||
|
return "N/A"
|
||||||
@ -6,6 +6,13 @@ RUN pip install --target=/app requests
|
|||||||
RUN pip install --target=/app -U pip setuptools wheel
|
RUN pip install --target=/app -U pip setuptools wheel
|
||||||
RUN pip install --target=/app ruamel.yaml
|
RUN pip install --target=/app ruamel.yaml
|
||||||
|
|
||||||
|
FROM ubuntu AS ubuntu-runtime
|
||||||
|
RUN apt update -y && apt install -y python3 git
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONPATH /app
|
||||||
|
CMD ["python3", "/app/main.py"]
|
||||||
|
|
||||||
FROM gcr.io/distroless/python3-debian12
|
FROM gcr.io/distroless/python3-debian12
|
||||||
COPY --from=builder /app /app
|
COPY --from=builder /app /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
|
from Client import Client
|
||||||
|
|
||||||
class GitHubClient(object):
|
class GitHubClient(Client):
|
||||||
"""Basic client for getting the last diff and managing issues."""
|
"""Basic client for getting the last diff and managing issues."""
|
||||||
existing_issues = []
|
existing_issues = []
|
||||||
milestones = []
|
milestones = []
|
||||||
@ -17,7 +18,7 @@ class GitHubClient(object):
|
|||||||
self.before = os.getenv('INPUT_BEFORE')
|
self.before = os.getenv('INPUT_BEFORE')
|
||||||
self.sha = os.getenv('INPUT_SHA')
|
self.sha = os.getenv('INPUT_SHA')
|
||||||
self.commits = json.loads(os.getenv('INPUT_COMMITS')) or []
|
self.commits = json.loads(os.getenv('INPUT_COMMITS')) or []
|
||||||
self.diff_url = os.getenv('INPUT_DIFF_URL')
|
self.__init_diff_url__()
|
||||||
self.token = os.getenv('INPUT_TOKEN')
|
self.token = os.getenv('INPUT_TOKEN')
|
||||||
self.issues_url = f'{self.repos_url}{self.repo}/issues'
|
self.issues_url = f'{self.repos_url}{self.repo}/issues'
|
||||||
self.milestones_url = f'{self.repos_url}{self.repo}/milestones'
|
self.milestones_url = f'{self.repos_url}{self.repo}/milestones'
|
||||||
@ -34,7 +35,6 @@ class GitHubClient(object):
|
|||||||
self.line_break = '\n\n' if auto_p else '\n'
|
self.line_break = '\n\n' if auto_p else '\n'
|
||||||
self.auto_assign = os.getenv('INPUT_AUTO_ASSIGN', 'false') == 'true'
|
self.auto_assign = os.getenv('INPUT_AUTO_ASSIGN', 'false') == 'true'
|
||||||
self.actor = os.getenv('INPUT_ACTOR')
|
self.actor = os.getenv('INPUT_ACTOR')
|
||||||
self.insert_issue_urls = os.getenv('INPUT_INSERT_ISSUE_URLS', 'false') == 'true'
|
|
||||||
if self.base_url == 'https://api.github.com/':
|
if self.base_url == 'https://api.github.com/':
|
||||||
self.line_base_url = 'https://github.com/'
|
self.line_base_url = 'https://github.com/'
|
||||||
else:
|
else:
|
||||||
@ -45,6 +45,20 @@ class GitHubClient(object):
|
|||||||
# Populate milestones so we can perform a lookup if one is specified.
|
# Populate milestones so we can perform a lookup if one is specified.
|
||||||
self._get_milestones()
|
self._get_milestones()
|
||||||
|
|
||||||
|
def __init_diff_url__(self):
|
||||||
|
manual_commit_ref = os.getenv('MANUAL_COMMIT_REF')
|
||||||
|
manual_base_ref = os.getenv('MANUAL_BASE_REF')
|
||||||
|
if manual_commit_ref:
|
||||||
|
self.sha = manual_commit_ref
|
||||||
|
if manual_commit_ref and manual_base_ref:
|
||||||
|
print(f'Manually comparing {manual_base_ref}...{manual_commit_ref}')
|
||||||
|
self.diff_url = f'{self.repos_url}{self.repo}/compare/{manual_base_ref}...{manual_commit_ref}'
|
||||||
|
elif manual_commit_ref:
|
||||||
|
print(f'Manual checking {manual_commit_ref}')
|
||||||
|
self.diff_url = f'{self.repos_url}{self.repo}/commits/{manual_commit_ref}'
|
||||||
|
else:
|
||||||
|
self.diff_url = os.getenv('INPUT_DIFF_URL')
|
||||||
|
|
||||||
def get_last_diff(self):
|
def get_last_diff(self):
|
||||||
"""Get the last diff."""
|
"""Get the last diff."""
|
||||||
if self.diff_url:
|
if self.diff_url:
|
||||||
@ -56,10 +70,12 @@ class GitHubClient(object):
|
|||||||
elif len(self.commits) == 1:
|
elif len(self.commits) == 1:
|
||||||
# There is only one commit.
|
# There is only one commit.
|
||||||
diff_url = f'{self.repos_url}{self.repo}/commits/{self.sha}'
|
diff_url = f'{self.repos_url}{self.repo}/commits/{self.sha}'
|
||||||
else:
|
elif len(self.commits) > 1:
|
||||||
# There are several commits: compare with the oldest one.
|
# There are several commits: compare with the oldest one.
|
||||||
oldest = sorted(self.commits, key=self._get_timestamp)[0]['id']
|
oldest = sorted(self.commits, key=self._get_timestamp)[0]['id']
|
||||||
diff_url = f'{self.repos_url}{self.repo}/compare/{oldest}...{self.sha}'
|
diff_url = f'{self.repos_url}{self.repo}/compare/{oldest}...{self.sha}'
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
diff_headers = {
|
diff_headers = {
|
||||||
'Accept': 'application/vnd.github.v3.diff',
|
'Accept': 'application/vnd.github.v3.diff',
|
||||||
@ -352,4 +368,4 @@ class GitHubClient(object):
|
|||||||
return pr_request.status_code
|
return pr_request.status_code
|
||||||
|
|
||||||
def get_issue_url(self, new_issue_number):
|
def get_issue_url(self, new_issue_number):
|
||||||
return f'Issue URL: {self.line_base_url}{self.repo}/issues/{new_issue_number}'
|
return f'{self.line_base_url}{self.repo}/issues/{new_issue_number}'
|
||||||
|
|||||||
@ -1,16 +1,34 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
import os
|
||||||
|
from Client import Client
|
||||||
|
|
||||||
class LocalClient(object):
|
class LocalClient(Client):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.diff_url = None
|
self.diff_url = None
|
||||||
self.commits = ['placeholder'] # content doesn't matter, just length
|
self.commits = ['placeholder'] # content doesn't matter, just length
|
||||||
self.insert_issue_urls = False
|
self.insert_issue_urls = False
|
||||||
|
self.__set_diff_refs__()
|
||||||
|
|
||||||
|
def __set_diff_refs__(self):
|
||||||
|
# set the target of the comparison to user-specified value, if
|
||||||
|
# provided, falling back to HEAD
|
||||||
|
manual_commit_ref = os.getenv('MANUAL_COMMIT_REF')
|
||||||
|
if manual_commit_ref:
|
||||||
|
self.sha = manual_commit_ref
|
||||||
|
else:
|
||||||
|
self.sha = subprocess.run(['git', 'rev-parse', 'HEAD'], stdout=subprocess.PIPE).stdout.decode('utf-8').strip()
|
||||||
|
# set the soruce of the comparison to user-specified value, if
|
||||||
|
# provided, falling back to commit immediately before the target
|
||||||
|
manual_base_ref = os.getenv('MANUAL_BASE_REF')
|
||||||
|
if manual_base_ref:
|
||||||
|
self.base_ref = manual_base_ref
|
||||||
|
else:
|
||||||
|
self.base_ref = subprocess.run(['git', 'rev-parse', f'{self.sha}^'], stdout=subprocess.PIPE).stdout.decode('utf-8').strip()
|
||||||
|
# print feedback to the user
|
||||||
|
if manual_commit_ref and manual_base_ref:
|
||||||
|
print(f'Manually comparing {manual_base_ref}...{manual_commit_ref}')
|
||||||
|
elif manual_commit_ref:
|
||||||
|
print(f'Manual checking {manual_commit_ref}')
|
||||||
|
|
||||||
def get_last_diff(self):
|
def get_last_diff(self):
|
||||||
return subprocess.run(['git', 'diff', 'HEAD^..HEAD'], stdout=subprocess.PIPE).stdout.decode('utf-8')
|
return subprocess.run(['git', 'diff', f'{self.base_ref}..{self.sha}'], stdout=subprocess.PIPE).stdout.decode('latin-1')
|
||||||
|
|
||||||
def create_issue(self, issue):
|
|
||||||
return [201, None]
|
|
||||||
|
|
||||||
def close_issue(self, issue):
|
|
||||||
return 200
|
|
||||||
|
|||||||
41
main.py
41
main.py
@ -10,46 +10,37 @@ import operator
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from TodoParser import TodoParser
|
from TodoParser import TodoParser
|
||||||
from LineStatus import LineStatus
|
from LineStatus import LineStatus
|
||||||
|
from Client import Client
|
||||||
from LocalClient import LocalClient
|
from LocalClient import LocalClient
|
||||||
from GitHubClient import GitHubClient
|
from GitHubClient import GitHubClient
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
client: Client | None = None
|
||||||
# Try to create a basic client for communicating with the remote version control server, automatically initialised with environment variables.
|
# Try to create a basic client for communicating with the remote version control server, automatically initialised with environment variables.
|
||||||
try:
|
try:
|
||||||
# try to build a GitHub client
|
# try to build a GitHub client
|
||||||
client = GitHubClient()
|
client = GitHubClient()
|
||||||
except EnvironmentError:
|
except EnvironmentError:
|
||||||
# don't immediately give up
|
# don't immediately give up
|
||||||
client = None
|
pass
|
||||||
# if needed, fall back to using a local client for testing
|
# if needed, fall back to using a local client for testing
|
||||||
client = client or LocalClient()
|
client = client or LocalClient()
|
||||||
|
|
||||||
# Check to see if the workflow has been run manually.
|
|
||||||
# If so, adjust the client SHA and diff URL to use the manually supplied inputs.
|
|
||||||
manual_commit_ref = os.getenv('MANUAL_COMMIT_REF')
|
|
||||||
manual_base_ref = os.getenv('MANUAL_BASE_REF')
|
|
||||||
if manual_commit_ref:
|
|
||||||
client.sha = manual_commit_ref
|
|
||||||
if manual_commit_ref and manual_base_ref:
|
|
||||||
print(f'Manually comparing {manual_base_ref}...{manual_commit_ref}')
|
|
||||||
client.diff_url = f'{client.repos_url}{client.repo}/compare/{manual_base_ref}...{manual_commit_ref}'
|
|
||||||
elif manual_commit_ref:
|
|
||||||
print(f'Manual checking {manual_commit_ref}')
|
|
||||||
client.diff_url = f'{client.repos_url}{client.repo}/commits/{manual_commit_ref}'
|
|
||||||
if client.diff_url or len(client.commits) != 0:
|
|
||||||
# Get the diff from the last pushed commit.
|
# Get the diff from the last pushed commit.
|
||||||
last_diff = StringIO(client.get_last_diff())
|
last_diff = client.get_last_diff()
|
||||||
|
|
||||||
|
if last_diff:
|
||||||
# Parse the diff for TODOs and create an Issue object for each.
|
# Parse the diff for TODOs and create an Issue object for each.
|
||||||
raw_issues = TodoParser().parse(last_diff)
|
raw_issues = TodoParser().parse(StringIO(last_diff))
|
||||||
# This is a simple, non-perfect check to filter out any TODOs that have just been moved.
|
# This is a simple, non-perfect check to filter out any TODOs that have just been moved.
|
||||||
# It looks for items that appear in the diff as both an addition and deletion.
|
# It looks for items that appear in the diff as both an addition and deletion.
|
||||||
# It is based on the assumption that TODOs will not have identical titles in identical files.
|
# It is based on the assumption that TODOs will not have identical titles in identical files.
|
||||||
# That is about as good as we can do for TODOs without issue URLs.
|
# That is about as good as we can do for TODOs without issue URLs.
|
||||||
issues_to_process = []
|
issues_to_process = []
|
||||||
for values, similar_issues in itertools.groupby(raw_issues, key=operator.attrgetter('title', 'file_name',
|
for values, similar_issues_iter in itertools.groupby(raw_issues, key=operator.attrgetter('title', 'file_name',
|
||||||
'markdown_language')):
|
'markdown_language')):
|
||||||
similar_issues = list(similar_issues)
|
similar_issues = list(similar_issues_iter)
|
||||||
if (len(similar_issues) == 2 and all(issue.issue_url is None for issue in similar_issues)
|
if (len(similar_issues) == 2 and all(issue.issue_url is None for issue in similar_issues)
|
||||||
and ((similar_issues[0].status == LineStatus.ADDED
|
and ((similar_issues[0].status == LineStatus.ADDED
|
||||||
and similar_issues[1].status == LineStatus.DELETED)
|
and similar_issues[1].status == LineStatus.DELETED)
|
||||||
@ -84,16 +75,18 @@ if __name__ == "__main__":
|
|||||||
issues_to_process = [issue for issue in issues_to_process if
|
issues_to_process = [issue for issue in issues_to_process if
|
||||||
not (issue.issue_url in update_and_close_issues and issue.status == LineStatus.DELETED)]
|
not (issue.issue_url in update_and_close_issues and issue.status == LineStatus.DELETED)]
|
||||||
|
|
||||||
|
# Check to see if we should insert the issue URL back into the linked TODO.
|
||||||
|
insert_issue_urls = os.getenv('INPUT_INSERT_ISSUE_URLS', 'false') == 'true'
|
||||||
|
|
||||||
# Cycle through the Issue objects and create or close a corresponding GitHub issue for each.
|
# Cycle through the Issue objects and create or close a corresponding GitHub issue for each.
|
||||||
for j, raw_issue in enumerate(issues_to_process):
|
for j, raw_issue in enumerate(issues_to_process):
|
||||||
print(f'Processing issue {j + 1} of {len(issues_to_process)}')
|
print(f"Processing issue {j + 1} of {len(issues_to_process)}: '{raw_issue.title}' @ {raw_issue.file_name}:{raw_issue.start_line}")
|
||||||
if raw_issue.status == LineStatus.ADDED:
|
if raw_issue.status == LineStatus.ADDED:
|
||||||
status_code, new_issue_number = client.create_issue(raw_issue)
|
status_code, new_issue_number = client.create_issue(raw_issue)
|
||||||
if status_code == 201:
|
if status_code == 201:
|
||||||
print('Issue created')
|
print(f'Issue created: : #{new_issue_number} @ {client.get_issue_url(new_issue_number)}')
|
||||||
# Check to see if we should insert the issue URL back into the linked TODO.
|
|
||||||
# Don't insert URLs for comments. Comments do not get updated.
|
# Don't insert URLs for comments. Comments do not get updated.
|
||||||
if client.insert_issue_urls and not (raw_issue.ref and raw_issue.ref.startswith('#')):
|
if insert_issue_urls and not (raw_issue.ref and raw_issue.ref.startswith('#')):
|
||||||
line_number = raw_issue.start_line - 1
|
line_number = raw_issue.start_line - 1
|
||||||
with open(raw_issue.file_name, 'r') as issue_file:
|
with open(raw_issue.file_name, 'r') as issue_file:
|
||||||
file_lines = issue_file.readlines()
|
file_lines = issue_file.readlines()
|
||||||
@ -101,7 +94,7 @@ if __name__ == "__main__":
|
|||||||
# Duplicate the line to retain the comment syntax.
|
# Duplicate the line to retain the comment syntax.
|
||||||
new_line = file_lines[line_number]
|
new_line = file_lines[line_number]
|
||||||
remove = fr'{raw_issue.identifier}.*{raw_issue.title}'
|
remove = fr'{raw_issue.identifier}.*{raw_issue.title}'
|
||||||
insert = client.get_issue_url(new_issue_number)
|
insert = f'Issue URL: {client.get_issue_url(new_issue_number)}'
|
||||||
new_line = re.sub(remove, insert, new_line)
|
new_line = re.sub(remove, insert, new_line)
|
||||||
# Check if the URL line already exists, if so abort.
|
# Check if the URL line already exists, if so abort.
|
||||||
if line_number == len(file_lines) - 1 or file_lines[line_number + 1] != new_line:
|
if line_number == len(file_lines) - 1 or file_lines[line_number + 1] != new_line:
|
||||||
@ -109,7 +102,7 @@ if __name__ == "__main__":
|
|||||||
with open(raw_issue.file_name, 'w') as issue_file:
|
with open(raw_issue.file_name, 'w') as issue_file:
|
||||||
issue_file.writelines(file_lines)
|
issue_file.writelines(file_lines)
|
||||||
elif status_code == 200:
|
elif status_code == 200:
|
||||||
print('Issue updated')
|
print(f'Issue updated: : #{new_issue_number} @ {client.get_issue_url(new_issue_number)}')
|
||||||
else:
|
else:
|
||||||
print('Issue could not be created')
|
print('Issue could not be created')
|
||||||
elif raw_issue.status == LineStatus.DELETED and os.getenv('INPUT_CLOSE_ISSUES', 'true') == 'true':
|
elif raw_issue.status == LineStatus.DELETED and os.getenv('INPUT_CLOSE_ISSUES', 'true') == 'true':
|
||||||
|
|||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pytest==8.3.3
|
||||||
|
mypy==1.13.0
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
types-requests==2.32.0.20241016
|
||||||
@ -1,3 +1,2 @@
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
ruamel.yaml==0.18.6
|
ruamel.yaml==0.18.6
|
||||||
pytest==8.3.3
|
|
||||||
39
tests/test_syntax.py
Normal file
39
tests/test_syntax.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# based on https://gist.github.com/bbarker/4ddf4a1c58ae8465f3d37b6f2234a421
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class MyPyTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def __call_mypy__(self, args, files):
|
||||||
|
result: int = subprocess.call(self.base_mypy_call + args + files, env=os.environ, cwd=self.pypath)
|
||||||
|
self.assertEqual(result, 0, '')
|
||||||
|
|
||||||
|
def test_run_mypy_app(self):
|
||||||
|
mypy_args: List[str] = [
|
||||||
|
"--disable-error-code", "var-annotated"
|
||||||
|
]
|
||||||
|
self.__call_mypy__(mypy_args, ["main.py"])
|
||||||
|
|
||||||
|
# Run test again, but without disabling any error codes.
|
||||||
|
# This is expected to fail, but we intentionally keep this test around to
|
||||||
|
# 1) try not to add any more errors to what's already in the baseline
|
||||||
|
# 2) as a reminder to try to move the codebase towards having type checking eventually
|
||||||
|
@unittest.expectedFailure
|
||||||
|
def test_run_strict_mypy_app(self):
|
||||||
|
mypy_args: List[str] = []
|
||||||
|
self.__call_mypy__(mypy_args, ["main.py"])
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super(MyPyTest, self).__init__(*args, **kwargs)
|
||||||
|
my_env = os.environ.copy()
|
||||||
|
self.pypath: str = my_env.get("PYTHONPATH", os.getcwd())
|
||||||
|
self.base_mypy_call: List[str] = [sys.executable, "-m", "mypy", "--ignore-missing-imports"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user