mirror of
https://github.com/ditkrg/todo-to-issue-action.git
synced 2026-01-22 22:06:43 +00:00
Rewrite parser to support block/dynamic comments
This commit is contained in:
parent
a6fcdf71b1
commit
cd988d2d92
16
README.md
16
README.md
@ -2,12 +2,18 @@
|
||||
|
||||
This action will convert your `# TODO` comments to GitHub issues when a new commit is pushed.
|
||||
|
||||
The new issue will contain a link to the line in the file containing the TODO, together with a code snippet and any defined labels. The action performs a `GET` request to retrieve GitHub's [`languages.yml` file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml) file to apply highlighting to the snippet.
|
||||
The new issue will contain a link to the line in the file containing the TODO, together with a code snippet and any defined labels. The action performs a `GET` request to retrieve GitHub's [`languages.yml` file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml) to determine the correct comment syntax to look for, and apply the relevant code highlighting.
|
||||
|
||||
It will also close an issue when a `# TODO` is removed in a pushed commit. A comment will be posted
|
||||
with the ref of the commit that it was closed by.
|
||||
|
||||
The `# TODO` comment is commonly used in Python, but this can be customised to whatever you want.
|
||||
## Important information about v3.0
|
||||
|
||||
This version is a complete rewrite of the action. TODO labels are now parsed dynamically based on the file type identified by the action. As such, you no longer need to hard-code the `LABEL` or `COMMENT_MARKER` inputs (unless you have very specific requirements). If you do provide these, the action will revert to the prior v2.4.1 version.
|
||||
|
||||
Syntax data for identifying comments is defined in `syntax.json`. Whilst this file is not yet exhaustive, it is provided as a starting point and can be easily updated (pull requests welcome). It has not been tested beyond the current markers specified in this file, so the core parser may need modifying to handle any new types.
|
||||
|
||||
A few basic tests are included if you would like to see how the new action works.
|
||||
|
||||
## Summary
|
||||
- [Usage](#usage)
|
||||
@ -32,7 +38,7 @@ Create a workflow file in your .github/workflows directory as follows:
|
||||
|
||||
### workflow.yaml
|
||||
|
||||
Latest version is `v2.4.1`.
|
||||
Latest version is `v3.0`.
|
||||
|
||||
name: "Workflow"
|
||||
on: ["push"]
|
||||
@ -57,8 +63,6 @@ Latest version is `v2.4.1`.
|
||||
| `BEFORE` | `"${{ github.event.before }}"` | The SHA of the last pushed commit (automatically set) |
|
||||
| `SHA` | `"${{ github.sha }}"` | The SHA of the latest commit (automatically set) |
|
||||
| `TOKEN` | `"${{ secrets.GITHUB_TOKEN }}"` | The GitHub access token to allow us to retrieve, create and update issues (automatically set) |
|
||||
| `LABEL` | `"# TODO"` | The label that will be used to identify TODO comments |
|
||||
| `COMMENT_MARKER` | `"#"` | The marker used to signify a line comment in your code |
|
||||
| `CLOSE_ISSUES` | `true` | Optional input that specifies whether to attempt to close an issue when a TODO is removed |
|
||||
| `AUTO_P` | `true` | For multiline TODOs, format each line as a new paragraph when creating the issue |
|
||||
|
||||
@ -91,8 +95,6 @@ You can create a multiline todo by continuing below the initial TODO declaration
|
||||
|
||||
The extra line(s) will be posted in the body of the issue.
|
||||
|
||||
The `COMMENT_MARKER` input must be set to the correct syntax (e.g. `#` for Python).
|
||||
|
||||
Each line in the multiline TODO will be formatted as a paragraph in the issue body. To disable this, set `AUTO_P` to `false`.
|
||||
|
||||
### Specifying Identifier
|
||||
|
||||
@ -23,14 +23,6 @@ inputs:
|
||||
TOKEN:
|
||||
description: "The GitHub access token to allow us to retrieve, create and update issues (automatically set)"
|
||||
required: true
|
||||
LABEL:
|
||||
description: "The label that will be used to identify TODO comments"
|
||||
required: true
|
||||
default: "# TODO"
|
||||
COMMENT_MARKER:
|
||||
description: "The marker used to signify a line comment in your code"
|
||||
required: true
|
||||
default: "#"
|
||||
CLOSE_ISSUES:
|
||||
description: "Optional input that specifies whether to attempt to close an issue when a TODO is removed"
|
||||
required: true
|
||||
|
||||
326
fallback_parser.py
Normal file
326
fallback_parser.py
Normal file
@ -0,0 +1,326 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Convert IDE TODOs to GitHub issues."""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import re
|
||||
import json
|
||||
from time import sleep
|
||||
from io import StringIO
|
||||
from ruamel.yaml import YAML
|
||||
import hashlib
|
||||
|
||||
base_url = 'https://api.github.com/repos/'
|
||||
|
||||
|
||||
def main():
|
||||
repo = os.getenv('INPUT_REPO')
|
||||
before = os.getenv('INPUT_BEFORE')
|
||||
sha = os.getenv('INPUT_SHA')
|
||||
comment_marker = os.getenv('INPUT_COMMENT_MARKER')
|
||||
label = os.getenv('INPUT_LABEL')
|
||||
token = os.getenv('INPUT_TOKEN')
|
||||
close_issues = os.getenv('INPUT_CLOSE_ISSUES') == 'true'
|
||||
auto_p = os.getenv('INPUT_AUTO_P') == 'true'
|
||||
line_break = '\n\n' if auto_p else '\n'
|
||||
|
||||
# Load a file so we can see what language each file is written in and apply highlighting later.
|
||||
languages_url = 'https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml'
|
||||
languages_request = requests.get(url=languages_url)
|
||||
languages_dict = None
|
||||
if languages_request.status_code == 200:
|
||||
languages_data = languages_request.text
|
||||
yaml = YAML(typ='safe')
|
||||
languages_dict = yaml.load(languages_data)
|
||||
|
||||
# Get the current issues so we can check we're not duplicating any, and so we can close any that have been removed.
|
||||
def get_current_issues(page=1):
|
||||
params = {
|
||||
'per_page': 100,
|
||||
'page': page,
|
||||
'state': 'open',
|
||||
'labels': 'todo'
|
||||
}
|
||||
list_issues_request = requests.get(issues_url, headers=issue_headers, params=params)
|
||||
if list_issues_request.status_code == 200:
|
||||
current_issues.extend(list_issues_request.json())
|
||||
links = list_issues_request.links
|
||||
if 'next' in links:
|
||||
get_current_issues(page + 1)
|
||||
|
||||
issues_url = f'{base_url}{repo}/issues'
|
||||
issue_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'token {token}'
|
||||
}
|
||||
current_issues = []
|
||||
get_current_issues()
|
||||
|
||||
# Start to compare the latest and previous commit, to find any added or removed TODOs.
|
||||
diff_url = f'{base_url}{repo}/compare/{before}...{sha}'
|
||||
diff_headers = {
|
||||
'Accept': 'application/vnd.github.v3.diff',
|
||||
'Authorization': f'token {token}'
|
||||
}
|
||||
diff_request = requests.get(url=diff_url, headers=diff_headers)
|
||||
if diff_request.status_code == 200:
|
||||
diff = diff_request.text
|
||||
|
||||
header_pattern = re.compile(r'(?<=diff\s--git\s).+')
|
||||
hunk_start_pattern = re.compile(r'((?<=^@@\s).+(?=\s@@))')
|
||||
line_num_pattern = re.compile(r'(?<=\+).+')
|
||||
addition_pattern = re.compile(r'(?<=^\+).*')
|
||||
deletion_pattern = re.compile(r'(?<=^-).*')
|
||||
todo_pattern = re.compile(r'(?<=' + label + r'[(\s:]).+')
|
||||
comment_pattern = re.compile(r'(?<=' + comment_marker + r'\s).+')
|
||||
labels_pattern = re.compile(r'(?<=labels:).+')
|
||||
assignees_pattern = re.compile(r'(?<=assignees:).+')
|
||||
milestone_pattern = re.compile(r'(?<=milestone:).+')
|
||||
|
||||
new_issues = []
|
||||
closed_issues = []
|
||||
lines = []
|
||||
curr_issue = None
|
||||
|
||||
# Read the diff file one line at a time, checking for additions/deletions in each hunk.
|
||||
with StringIO(diff) as diff_file:
|
||||
curr_file = None
|
||||
previous_line_was_todo = False
|
||||
line_counter = None
|
||||
|
||||
# Used to check if the line passed in is a continuation of the previous line, returning True/False.
|
||||
# If True, the current issue is updated with the extra details from this line.
|
||||
def process_line(next_line):
|
||||
if previous_line_was_todo:
|
||||
if next_line.strip() == comment_marker:
|
||||
curr_issue['body'] += line_break
|
||||
return True
|
||||
comment_search = comment_pattern.search(next_line)
|
||||
if comment_search:
|
||||
comment = comment_search.group(0).lstrip()
|
||||
labels_search = labels_pattern.search(comment, re.IGNORECASE)
|
||||
if labels_search:
|
||||
labels = labels_search.group(0).lstrip().replace(', ', ',')
|
||||
labels = list(filter(None, labels.split(',')))
|
||||
curr_issue['labels'].extend(labels)
|
||||
else:
|
||||
assignees_search = assignees_pattern.search(comment, re.IGNORECASE)
|
||||
if assignees_search:
|
||||
assignees = assignees_search.group(0).lstrip().replace(', ', ',')
|
||||
assignees = list(filter(None, assignees.split(',')))
|
||||
curr_issue['assignees'] = assignees
|
||||
else:
|
||||
milestone_search = milestone_pattern.search(comment, re.IGNORECASE)
|
||||
if milestone_search:
|
||||
milestone = milestone_search.group(0).strip()
|
||||
if milestone.isdigit():
|
||||
curr_issue['milestone'] = int(milestone)
|
||||
else:
|
||||
curr_issue['body'] += line_break + comment
|
||||
return True
|
||||
return False
|
||||
|
||||
for n, line in enumerate(diff_file):
|
||||
# First look for a diff header so we can determine the file the changes relate to.
|
||||
encoded_line = line.encode('utf-8')
|
||||
cleaned_line = encoded_line.rstrip(b'\r\n').decode('utf-8')
|
||||
|
||||
header_search = header_pattern.search(cleaned_line)
|
||||
if header_search:
|
||||
files = header_search.group(0).split(' ')
|
||||
curr_file = files[1][2:]
|
||||
line_counter = None
|
||||
else:
|
||||
# Look for hunks so we can get the line numbers for the changes.
|
||||
hunk_search = hunk_start_pattern.search(cleaned_line)
|
||||
if hunk_search:
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
curr_issue = None
|
||||
|
||||
lines = []
|
||||
hunk = hunk_search.group(0)
|
||||
line_nums = line_num_pattern.search(hunk).group(0).split(',')
|
||||
hunk_start = int(line_nums[0])
|
||||
line_counter = hunk_start
|
||||
else:
|
||||
# Look for additions and deletions (specifically TODOs) within each hunk.
|
||||
addition_search = addition_pattern.search(cleaned_line)
|
||||
if addition_search:
|
||||
lines.append(cleaned_line[1:])
|
||||
addition = addition_search.group(0)
|
||||
todo_search = todo_pattern.search(addition, re.IGNORECASE)
|
||||
if todo_search:
|
||||
# A new item was found. Start recording so we can capture multiline TODOs.
|
||||
previous_line_was_todo = True
|
||||
todo = clean_title(todo_search)
|
||||
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
|
||||
curr_issue = {
|
||||
'labels': ['todo'],
|
||||
'todo': todo,
|
||||
'body': todo,
|
||||
'file': curr_file,
|
||||
'line_num': line_counter
|
||||
}
|
||||
line_counter += 1
|
||||
continue
|
||||
else:
|
||||
# This line isn't a new item. Let's check if it continues from the previous line.
|
||||
line_processed = process_line(addition)
|
||||
if line_processed:
|
||||
line_counter += 1
|
||||
continue
|
||||
if line_counter is not None:
|
||||
line_counter += 1
|
||||
else:
|
||||
deletion_search = deletion_pattern.search(cleaned_line)
|
||||
if deletion_search:
|
||||
deletion = deletion_search.group(0)
|
||||
todo_search = todo_pattern.search(deletion, re.IGNORECASE)
|
||||
if todo_search:
|
||||
todo = clean_title(todo_search)
|
||||
closed_issues.append(todo)
|
||||
else:
|
||||
lines.append(cleaned_line[1:])
|
||||
# Let's check if this line continues from a previous deletion.
|
||||
line_processed = process_line(cleaned_line)
|
||||
if line_processed:
|
||||
line_counter += 1
|
||||
continue
|
||||
if line_counter is not None:
|
||||
line_counter += 1
|
||||
if previous_line_was_todo:
|
||||
previous_line_was_todo = False
|
||||
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
|
||||
# Create new issues for any newly added TODOs.
|
||||
print('Start creating issues')
|
||||
for i, issue in enumerate(new_issues):
|
||||
title = issue['todo']
|
||||
# Truncate the title if it's longer than 80 chars.
|
||||
if len(title) > 80:
|
||||
title = title[:80] + '...'
|
||||
file = issue['file']
|
||||
line = issue['line_num']
|
||||
body = issue['body'] + '\n\n' + f'https://github.com/{repo}/blob/{sha}/{file}#L{line}'
|
||||
if 'hunk' in issue:
|
||||
hunk = issue['hunk']
|
||||
hunk.pop(0)
|
||||
|
||||
file_name, extension = os.path.splitext(os.path.basename(file))
|
||||
markdown_language = None
|
||||
if languages_dict:
|
||||
for language in languages_dict:
|
||||
if ('extensions' in languages_dict[language]
|
||||
and extension in languages_dict[language]['extensions']):
|
||||
markdown_language = languages_dict[language]['ace_mode']
|
||||
if markdown_language:
|
||||
body += '\n\n' + '```' + markdown_language + '\n' + '\n'.join(hunk) + '\n' + '```'
|
||||
else:
|
||||
body += '\n\n' + '```' + '\n'.join(hunk) + '\n' + '```'
|
||||
|
||||
# Check if the current issue already exists - if so, skip it.
|
||||
issue_id = hashlib.sha1(body.encode('utf-8')).hexdigest()
|
||||
body += '\n\n' + issue_id
|
||||
for current_issue in current_issues:
|
||||
if issue_id in current_issue['body']:
|
||||
print(f'Skipping issue {i + 1} of {len(new_issues)} (already exists)')
|
||||
break
|
||||
else:
|
||||
new_issue_body = {'title': title, 'body': body, 'labels': issue['labels']}
|
||||
|
||||
# We need to check if any assignees/milestone specified exist, otherwise issue creation will fail.
|
||||
if 'assignees' in issue:
|
||||
valid_assignees = []
|
||||
for assignee in issue['assignees']:
|
||||
assignee_url = f'{base_url}{repo}/assignees/{assignee}'
|
||||
assignee_request = requests.get(url=assignee_url, headers=issue_headers)
|
||||
if assignee_request.status_code == 204:
|
||||
valid_assignees.append(assignee)
|
||||
else:
|
||||
print('Assignee doesn\'t exist! Dropping this assignee!')
|
||||
new_issue_body['assignees'] = valid_assignees
|
||||
|
||||
if 'milestone' in issue:
|
||||
milestone_number = issue['milestone']
|
||||
milestone_url = f'{base_url}{repo}/milestones/{milestone_number}'
|
||||
milestone_request = requests.get(url=milestone_url, headers=issue_headers)
|
||||
if milestone_request.status_code == 200:
|
||||
new_issue_body['milestone'] = issue['milestone']
|
||||
else:
|
||||
print('Milestone doesn\'t exist! Dropping this parameter!')
|
||||
|
||||
new_issue_request = requests.post(url=issues_url, headers=issue_headers,
|
||||
data=json.dumps(new_issue_body))
|
||||
print(f'Creating issue {i + 1} of {len(new_issues)}')
|
||||
if new_issue_request.status_code == 201:
|
||||
print('Issue created')
|
||||
else:
|
||||
print('Issue could not be created')
|
||||
# Don't add too many issues too quickly.
|
||||
sleep(1)
|
||||
print('Creating issues complete')
|
||||
|
||||
# Close issues for removed TODOs if this is enabled.
|
||||
if close_issues:
|
||||
print('Start closing issues')
|
||||
for i, closed_issue in enumerate(closed_issues):
|
||||
title = closed_issue
|
||||
matched = 0
|
||||
issue_number = None
|
||||
# Compare the title of each closed issue with each issue in the issues list.
|
||||
for current_issue in current_issues:
|
||||
if current_issue['body'].startswith(title):
|
||||
matched += 1
|
||||
# If there are multiple issues with similar titles, don't try and close any.
|
||||
if matched > 1:
|
||||
print(f'Skipping issue {i + 1} of {len(closed_issues)} (multiple matches)')
|
||||
break
|
||||
issue_number = current_issue['number']
|
||||
else:
|
||||
if issue_number is None:
|
||||
continue
|
||||
# The titles match, so we will try and close the issue.
|
||||
update_issue_url = f'{base_url}{repo}/issues/{issue_number}'
|
||||
body = {'state': 'closed'}
|
||||
requests.patch(update_issue_url, headers=issue_headers, data=json.dumps(body))
|
||||
|
||||
issue_comment_url = f'{base_url}{repo}/issues/{issue_number}/comments'
|
||||
body = {'body': f'Closed in {sha}'}
|
||||
update_issue_request = requests.post(issue_comment_url, headers=issue_headers,
|
||||
data=json.dumps(body))
|
||||
print(f'Closing issue {i + 1} of {len(closed_issues)}')
|
||||
if update_issue_request.status_code == 201:
|
||||
print('Issue closed')
|
||||
else:
|
||||
print('Issue could not be closed')
|
||||
# Don't update too many issues too quickly.
|
||||
sleep(1)
|
||||
print('Closing issues complete')
|
||||
|
||||
|
||||
def clean_title(todo_search):
|
||||
identifier_pattern = re.compile(r'.+(?=\))')
|
||||
title_pattern = re.compile(r'(?<=\)[\s:]).+')
|
||||
cleaned_title = todo_search.group(0).lstrip()
|
||||
identifier_search = identifier_pattern.search(cleaned_title)
|
||||
title_search = title_pattern.search(cleaned_title)
|
||||
if identifier_search and title_search:
|
||||
cleaned_title = f'[{identifier_search.group(0)}] {title_search.group(0).lstrip()}'
|
||||
elif identifier_search:
|
||||
cleaned_title = identifier_search.group(0) # Shouldn't really arise.
|
||||
elif title_search:
|
||||
cleaned_title = title_search.group(0) # Shouldn't really arise.
|
||||
return cleaned_title
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
713
main.py
713
main.py
@ -9,318 +9,461 @@ from time import sleep
|
||||
from io import StringIO
|
||||
from ruamel.yaml import YAML
|
||||
import hashlib
|
||||
from enum import Enum
|
||||
|
||||
base_url = 'https://api.github.com/repos/'
|
||||
import fallback_parser
|
||||
|
||||
|
||||
def main():
|
||||
repo = os.getenv('INPUT_REPO')
|
||||
before = os.getenv('INPUT_BEFORE')
|
||||
sha = os.getenv('INPUT_SHA')
|
||||
comment_marker = os.getenv('INPUT_COMMENT_MARKER')
|
||||
label = os.getenv('INPUT_LABEL')
|
||||
token = os.getenv('INPUT_TOKEN')
|
||||
close_issues = os.getenv('INPUT_CLOSE_ISSUES') == 'true'
|
||||
auto_p = os.getenv('INPUT_AUTO_P') == 'true'
|
||||
line_break = '\n\n' if auto_p else '\n'
|
||||
class LineStatus(Enum):
|
||||
"""Represents the status of a line in a diff file."""
|
||||
ADDED = 0
|
||||
DELETED = 1
|
||||
UNCHANGED = 2
|
||||
|
||||
# Load a file so we can see what language each file is written in and apply highlighting later.
|
||||
languages_url = 'https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml'
|
||||
languages_request = requests.get(url=languages_url)
|
||||
languages_dict = None
|
||||
if languages_request.status_code == 200:
|
||||
languages_data = languages_request.text
|
||||
yaml = YAML(typ='safe')
|
||||
languages_dict = yaml.load(languages_data)
|
||||
|
||||
# Get the current issues so we can check we're not duplicating any, and so we can close any that have been removed.
|
||||
def get_current_issues(page=1):
|
||||
class Issue(object):
|
||||
"""Basic Issue model for collecting the necessary info to send to GitHub."""
|
||||
def __init__(self, title, labels, assignees, milestone, body, hunk, file_name, start_line, markdown_language,
|
||||
status):
|
||||
self.title = title
|
||||
self.labels = labels
|
||||
self.assignees = assignees
|
||||
self.milestone = milestone
|
||||
self.body = body
|
||||
self.hunk = hunk
|
||||
self.file_name = file_name
|
||||
self.start_line = start_line
|
||||
self.markdown_language = markdown_language
|
||||
self.status = status
|
||||
|
||||
|
||||
class GitHubClient(object):
|
||||
"""Basic client for getting the last diff and creating/closing issues."""
|
||||
existing_issues = []
|
||||
base_url = 'https://api.github.com/repos/'
|
||||
|
||||
def __init__(self):
|
||||
self.repo = os.getenv('INPUT_REPO', 'alstr/todo-test')
|
||||
self.before = os.getenv('INPUT_BEFORE')
|
||||
self.sha = os.getenv('INPUT_SHA')
|
||||
self.token = os.getenv('INPUT_TOKEN')
|
||||
self.issues_url = f'{self.base_url}{self.repo}/issues'
|
||||
self.issue_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'token {self.token}'
|
||||
}
|
||||
auto_p = os.getenv('INPUT_AUTO_P', 'true') == 'true'
|
||||
self.line_break = '\n\n' if auto_p else '\n'
|
||||
# Retrieve the existing repo issues now so we can easily check them later.
|
||||
self._get_existing_issues()
|
||||
|
||||
def get_last_diff(self):
|
||||
"""Get the last diff based on the SHA of the last two commits."""
|
||||
diff_url = f'{self.base_url}{self.repo}/compare/{self.before}...{self.sha}'
|
||||
diff_headers = {
|
||||
'Accept': 'application/vnd.github.v3.diff',
|
||||
'Authorization': f'token {self.token}'
|
||||
}
|
||||
diff_request = requests.get(url=diff_url, headers=diff_headers)
|
||||
if diff_request.status_code == 200:
|
||||
return diff_request.text
|
||||
raise Exception('Could not retrieve diff. Operation will abort.')
|
||||
|
||||
def _get_existing_issues(self, page=1):
|
||||
"""Populate the existing issues list."""
|
||||
params = {
|
||||
'per_page': 100,
|
||||
'page': page,
|
||||
'state': 'open',
|
||||
'labels': 'todo'
|
||||
}
|
||||
list_issues_request = requests.get(issues_url, headers=issue_headers, params=params)
|
||||
list_issues_request = requests.get(self.issues_url, headers=self.issue_headers, params=params)
|
||||
if list_issues_request.status_code == 200:
|
||||
current_issues.extend(list_issues_request.json())
|
||||
self.existing_issues.extend(list_issues_request.json())
|
||||
links = list_issues_request.links
|
||||
if 'next' in links:
|
||||
get_current_issues(page + 1)
|
||||
self._get_existing_issues(page + 1)
|
||||
|
||||
issues_url = f'{base_url}{repo}/issues'
|
||||
issue_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'token {token}'
|
||||
}
|
||||
current_issues = []
|
||||
get_current_issues()
|
||||
def create_issue(self, issue):
|
||||
"""Create a dict containing the issue details and send it to GitHub."""
|
||||
title = issue.title
|
||||
if len(title) > 80:
|
||||
# Title is too long.
|
||||
title = title[:80] + '...'
|
||||
url_to_line = f'https://github.com/{self.repo}/blob/{self.sha}/{issue.file_name}#L{issue.start_line}'
|
||||
body = (self.line_break.join(issue.body) + '\n\n'
|
||||
+ url_to_line + '\n\n'
|
||||
+ '```' + issue.markdown_language + '\n'
|
||||
+ issue.hunk + '\n'
|
||||
+ '```')
|
||||
|
||||
# Start to compare the latest and previous commit, to find any added or removed TODOs.
|
||||
diff_url = f'{base_url}{repo}/compare/{before}...{sha}'
|
||||
diff_headers = {
|
||||
'Accept': 'application/vnd.github.v3.diff',
|
||||
'Authorization': f'token {token}'
|
||||
}
|
||||
diff_request = requests.get(url=diff_url, headers=diff_headers)
|
||||
if diff_request.status_code == 200:
|
||||
diff = diff_request.text
|
||||
# Check if the current issue already exists - if so, skip it.
|
||||
issue_id = hashlib.sha1(body.encode('utf-8')).hexdigest()
|
||||
body += '\n\n' + issue_id
|
||||
for existing_issue in self.existing_issues:
|
||||
if issue_id in existing_issue['body']:
|
||||
print(f'Skipping issue (already exists)')
|
||||
return
|
||||
|
||||
header_pattern = re.compile(r'(?<=diff\s--git\s).+')
|
||||
hunk_start_pattern = re.compile(r'((?<=^@@\s).+(?=\s@@))')
|
||||
line_num_pattern = re.compile(r'(?<=\+).+')
|
||||
addition_pattern = re.compile(r'(?<=^\+).*')
|
||||
deletion_pattern = re.compile(r'(?<=^-).*')
|
||||
todo_pattern = re.compile(r'(?<=' + label + r'[(\s:]).+')
|
||||
comment_pattern = re.compile(r'(?<=' + comment_marker + r'\s).+')
|
||||
labels_pattern = re.compile(r'(?<=labels:).+')
|
||||
assignees_pattern = re.compile(r'(?<=assignees:).+')
|
||||
milestone_pattern = re.compile(r'(?<=milestone:).+')
|
||||
new_issue_body = {'title': title, 'body': body, 'labels': issue.labels}
|
||||
|
||||
new_issues = []
|
||||
closed_issues = []
|
||||
lines = []
|
||||
curr_issue = None
|
||||
|
||||
# Read the diff file one line at a time, checking for additions/deletions in each hunk.
|
||||
with StringIO(diff) as diff_file:
|
||||
curr_file = None
|
||||
previous_line_was_todo = False
|
||||
line_counter = None
|
||||
|
||||
# Used to check if the line passed in is a continuation of the previous line, returning True/False.
|
||||
# If True, the current issue is updated with the extra details from this line.
|
||||
def process_line(next_line):
|
||||
if previous_line_was_todo:
|
||||
if next_line.strip() == comment_marker:
|
||||
curr_issue['body'] += line_break
|
||||
return True
|
||||
comment_search = comment_pattern.search(next_line)
|
||||
if comment_search:
|
||||
comment = comment_search.group(0).lstrip()
|
||||
labels_search = labels_pattern.search(comment, re.IGNORECASE)
|
||||
if labels_search:
|
||||
labels = labels_search.group(0).lstrip().replace(', ', ',')
|
||||
labels = list(filter(None, labels.split(',')))
|
||||
curr_issue['labels'].extend(labels)
|
||||
else:
|
||||
assignees_search = assignees_pattern.search(comment, re.IGNORECASE)
|
||||
if assignees_search:
|
||||
assignees = assignees_search.group(0).lstrip().replace(', ', ',')
|
||||
assignees = list(filter(None, assignees.split(',')))
|
||||
curr_issue['assignees'] = assignees
|
||||
else:
|
||||
milestone_search = milestone_pattern.search(comment, re.IGNORECASE)
|
||||
if milestone_search:
|
||||
milestone = milestone_search.group(0).strip()
|
||||
if milestone.isdigit():
|
||||
curr_issue['milestone'] = int(milestone)
|
||||
else:
|
||||
curr_issue['body'] += line_break + comment
|
||||
return True
|
||||
return False
|
||||
|
||||
for n, line in enumerate(diff_file):
|
||||
# First look for a diff header so we can determine the file the changes relate to.
|
||||
encoded_line = line.encode('utf-8')
|
||||
cleaned_line = encoded_line.rstrip(b'\r\n').decode('utf-8')
|
||||
|
||||
header_search = header_pattern.search(cleaned_line)
|
||||
if header_search:
|
||||
files = header_search.group(0).split(' ')
|
||||
curr_file = files[1][2:]
|
||||
line_counter = None
|
||||
else:
|
||||
# Look for hunks so we can get the line numbers for the changes.
|
||||
hunk_search = hunk_start_pattern.search(cleaned_line)
|
||||
if hunk_search:
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
curr_issue = None
|
||||
|
||||
lines = []
|
||||
hunk = hunk_search.group(0)
|
||||
line_nums = line_num_pattern.search(hunk).group(0).split(',')
|
||||
hunk_start = int(line_nums[0])
|
||||
line_counter = hunk_start
|
||||
else:
|
||||
# Look for additions and deletions (specifically TODOs) within each hunk.
|
||||
addition_search = addition_pattern.search(cleaned_line)
|
||||
if addition_search:
|
||||
lines.append(cleaned_line[1:])
|
||||
addition = addition_search.group(0)
|
||||
todo_search = todo_pattern.search(addition, re.IGNORECASE)
|
||||
if todo_search:
|
||||
# A new item was found. Start recording so we can capture multiline TODOs.
|
||||
previous_line_was_todo = True
|
||||
todo = clean_title(todo_search)
|
||||
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
|
||||
curr_issue = {
|
||||
'labels': ['todo'],
|
||||
'todo': todo,
|
||||
'body': todo,
|
||||
'file': curr_file,
|
||||
'line_num': line_counter
|
||||
}
|
||||
line_counter += 1
|
||||
continue
|
||||
else:
|
||||
# This line isn't a new item. Let's check if it continues from the previous line.
|
||||
line_processed = process_line(addition)
|
||||
if line_processed:
|
||||
line_counter += 1
|
||||
continue
|
||||
if line_counter is not None:
|
||||
line_counter += 1
|
||||
else:
|
||||
deletion_search = deletion_pattern.search(cleaned_line)
|
||||
if deletion_search:
|
||||
deletion = deletion_search.group(0)
|
||||
todo_search = todo_pattern.search(deletion, re.IGNORECASE)
|
||||
if todo_search:
|
||||
todo = clean_title(todo_search)
|
||||
closed_issues.append(todo)
|
||||
else:
|
||||
lines.append(cleaned_line[1:])
|
||||
# Let's check if this line continues from a previous deletion.
|
||||
line_processed = process_line(cleaned_line)
|
||||
if line_processed:
|
||||
line_counter += 1
|
||||
continue
|
||||
if line_counter is not None:
|
||||
line_counter += 1
|
||||
if previous_line_was_todo:
|
||||
previous_line_was_todo = False
|
||||
|
||||
if curr_issue:
|
||||
curr_issue['hunk'] = lines
|
||||
new_issues.append(curr_issue)
|
||||
|
||||
# Create new issues for any newly added TODOs.
|
||||
print('Start creating issues')
|
||||
for i, issue in enumerate(new_issues):
|
||||
title = issue['todo']
|
||||
# Truncate the title if it's longer than 80 chars.
|
||||
if len(title) > 80:
|
||||
title = title[:80] + '...'
|
||||
file = issue['file']
|
||||
line = issue['line_num']
|
||||
body = issue['body'] + '\n\n' + f'https://github.com/{repo}/blob/{sha}/{file}#L{line}'
|
||||
if 'hunk' in issue:
|
||||
hunk = issue['hunk']
|
||||
hunk.pop(0)
|
||||
|
||||
file_name, extension = os.path.splitext(os.path.basename(file))
|
||||
markdown_language = None
|
||||
if languages_dict:
|
||||
for language in languages_dict:
|
||||
if ('extensions' in languages_dict[language]
|
||||
and extension in languages_dict[language]['extensions']):
|
||||
markdown_language = languages_dict[language]['ace_mode']
|
||||
if markdown_language:
|
||||
body += '\n\n' + '```' + markdown_language + '\n' + '\n'.join(hunk) + '\n' + '```'
|
||||
else:
|
||||
body += '\n\n' + '```' + '\n'.join(hunk) + '\n' + '```'
|
||||
|
||||
# Check if the current issue already exists - if so, skip it.
|
||||
issue_id = hashlib.sha1(body.encode('utf-8')).hexdigest()
|
||||
body += '\n\n' + issue_id
|
||||
for current_issue in current_issues:
|
||||
if issue_id in current_issue['body']:
|
||||
print(f'Skipping issue {i + 1} of {len(new_issues)} (already exists)')
|
||||
break
|
||||
# We need to check if any assignees/milestone specified exist, otherwise issue creation will fail.
|
||||
valid_assignees = []
|
||||
for assignee in issue.assignees:
|
||||
assignee_url = f'{self.base_url}{self.repo}/assignees/{assignee}'
|
||||
assignee_request = requests.get(url=assignee_url, headers=self.issue_headers)
|
||||
if assignee_request.status_code == 204:
|
||||
valid_assignees.append(assignee)
|
||||
else:
|
||||
new_issue_body = {'title': title, 'body': body, 'labels': issue['labels']}
|
||||
print(f'Assignee {assignee} does not exist! Dropping this assignee!')
|
||||
new_issue_body['assignees'] = valid_assignees
|
||||
|
||||
# We need to check if any assignees/milestone specified exist, otherwise issue creation will fail.
|
||||
if 'assignees' in issue:
|
||||
valid_assignees = []
|
||||
for assignee in issue['assignees']:
|
||||
assignee_url = f'{base_url}{repo}/assignees/{assignee}'
|
||||
assignee_request = requests.get(url=assignee_url, headers=issue_headers)
|
||||
if assignee_request.status_code == 204:
|
||||
valid_assignees.append(assignee)
|
||||
if issue.milestone:
|
||||
milestone_url = f'{self.base_url}{self.repo}/milestones/{issue.milestone}'
|
||||
milestone_request = requests.get(url=milestone_url, headers=self.issue_headers)
|
||||
if milestone_request.status_code == 200:
|
||||
new_issue_body['milestone'] = issue.milestone
|
||||
else:
|
||||
print(f'Milestone {issue.milestone} does not exist! Dropping this parameter!')
|
||||
|
||||
new_issue_request = requests.post(url=self.issues_url, headers=self.issue_headers,
|
||||
data=json.dumps(new_issue_body))
|
||||
|
||||
return new_issue_request.status_code
|
||||
|
||||
def close_issue(self, issue):
|
||||
"""Check to see if this issue can be found on GitHub and if so close it."""
|
||||
matched = 0
|
||||
issue_number = None
|
||||
for existing_issue in self.existing_issues:
|
||||
# This is admittedly a simple check that may not work in complex scenarios, but we can't deal with them yet.
|
||||
if existing_issue['title'] == issue.title:
|
||||
matched += 1
|
||||
# If there are multiple issues with similar titles, don't try and close any.
|
||||
if matched > 1:
|
||||
print(f'Skipping issue (multiple matches)')
|
||||
break
|
||||
issue_number = existing_issue['number']
|
||||
else:
|
||||
# The titles match, so we will try and close the issue.
|
||||
update_issue_url = f'{self.base_url}{self.repo}/issues/{issue_number}'
|
||||
body = {'state': 'closed'}
|
||||
requests.patch(update_issue_url, headers=self.issue_headers, data=json.dumps(body))
|
||||
|
||||
issue_comment_url = f'{self.base_url}{self.repo}/issues/{issue_number}/comments'
|
||||
body = {'body': f'Closed in {self.sha}'}
|
||||
update_issue_request = requests.post(issue_comment_url, headers=self.issue_headers,
|
||||
data=json.dumps(body))
|
||||
return update_issue_request.status_code
|
||||
return None
|
||||
|
||||
|
||||
class TodoParser(object):
|
||||
"""Parser for extracting information from a given diff file."""
|
||||
FILE_HUNK_PATTERN = r'(?<=diff)(.*?)(?=diff\s--git\s)'
|
||||
HEADER_PATTERN = r'(?<=--git).*?(?=$\n(index|new))'
|
||||
LINE_PATTERN = r'^.*$'
|
||||
FILENAME_PATTERN = re.compile(r'(?<=a/).+?(?=\sb/)')
|
||||
LINE_NUMBERS_PATTERN = re.compile(r'@@[\d\s,\-+]*\s@@.*')
|
||||
LINE_NUMBERS_INNER_PATTERN = re.compile(r'@@[\d\s,\-+]*\s@@')
|
||||
ADDITION_PATTERN = re.compile(r'(?<=^\+).*')
|
||||
DELETION_PATTERN = re.compile(r'(?<=^-).*')
|
||||
IDENTIFIER_PATTERN = re.compile(r'.+?(?=\))')
|
||||
LABELS_PATTERN = re.compile(r'(?<=labels:\s).+')
|
||||
ASSIGNEES_PATTERN = re.compile(r'(?<=assignees:\s).+')
|
||||
MILESTONE_PATTERN = re.compile(r'(?<=milestone:\s).+')
|
||||
|
||||
def __init__(self):
|
||||
# We could support more identifiers later quite easily.
|
||||
self.identifier = 'TODO'
|
||||
self.languages_dict = None
|
||||
|
||||
# Load the languages data for ascertaining file types.
|
||||
languages_url = 'https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml'
|
||||
languages_request = requests.get(url=languages_url)
|
||||
if languages_request.status_code == 200:
|
||||
languages_data = languages_request.text
|
||||
yaml = YAML(typ='safe')
|
||||
self.languages_dict = yaml.load(languages_data)
|
||||
|
||||
# Load the comment syntax data for identifying comments.
|
||||
with open('syntax.json', mode='r') as syntax_file:
|
||||
syntax_dict = json.loads(syntax_file.read())
|
||||
self.syntax_dict = syntax_dict
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
def parse(self, diff_file):
|
||||
issues = []
|
||||
|
||||
# The parser works by gradually breaking the diff file down into smaller and smaller segments.
|
||||
# At each level relevant information is extracted.
|
||||
|
||||
# First separate the diff into sections for each changed file.
|
||||
file_hunks = re.finditer(self.FILE_HUNK_PATTERN, diff_file.read(), re.DOTALL)
|
||||
last_end = None
|
||||
extracted_file_hunks = []
|
||||
for i, file_hunk in enumerate(file_hunks):
|
||||
extracted_file_hunks.append(file_hunk.group(0))
|
||||
last_end = file_hunk.end()
|
||||
diff_file.seek(0)
|
||||
extracted_file_hunks.append(diff_file.read()[last_end:])
|
||||
|
||||
code_blocks = []
|
||||
prev_block = None
|
||||
# Iterate through each section extracted above.
|
||||
for hunk in extracted_file_hunks:
|
||||
# Extract the file information so we can figure out the markdown language and comment syntax.
|
||||
header_search = re.search(self.HEADER_PATTERN, hunk, re.MULTILINE)
|
||||
if not header_search:
|
||||
continue
|
||||
files = header_search.group(0)
|
||||
|
||||
filename_search = re.search(self.FILENAME_PATTERN, files)
|
||||
if not filename_search:
|
||||
continue
|
||||
curr_file = filename_search.group(0)
|
||||
curr_markers, curr_markdown_language = self._get_file_details(curr_file)
|
||||
if not curr_markers or not curr_markdown_language:
|
||||
print(f'Could not check {curr_file} for TODOs as this language is not yet supported by default.')
|
||||
continue
|
||||
|
||||
# Break this section down into individual changed code blocks.
|
||||
line_numbers = re.finditer(self.LINE_NUMBERS_PATTERN, hunk)
|
||||
for i, line_numbers in enumerate(line_numbers):
|
||||
line_numbers_inner_search = re.search(self.LINE_NUMBERS_INNER_PATTERN, line_numbers.group(0))
|
||||
line_numbers_str = line_numbers_inner_search.group(0).strip('@@ -')
|
||||
start_line = line_numbers_str.split(' ')[1].strip('+')
|
||||
start_line = int(start_line.split(',')[0])
|
||||
|
||||
# Put this information into a temporary dict for simplicity.
|
||||
block = {
|
||||
'file': curr_file,
|
||||
'markers': curr_markers,
|
||||
'markdown_language': curr_markdown_language,
|
||||
'start_line': start_line,
|
||||
'hunk': hunk,
|
||||
'hunk_start': line_numbers.end(),
|
||||
'hunk_end': None
|
||||
}
|
||||
|
||||
prev_index = len(code_blocks) - 1
|
||||
# Set the end of the last code block based on the start of this one.
|
||||
if prev_block and prev_block['file'] == block['file']:
|
||||
code_blocks[prev_index]['hunk_end'] = line_numbers.start()
|
||||
code_blocks[prev_index]['hunk'] = (prev_block['hunk']
|
||||
[prev_block['hunk_start']:line_numbers.start()])
|
||||
elif prev_block:
|
||||
code_blocks[prev_index]['hunk'] = prev_block['hunk'][prev_block['hunk_start']:]
|
||||
|
||||
code_blocks.append(block)
|
||||
prev_block = block
|
||||
|
||||
if len(code_blocks) > 0:
|
||||
last_index = len(code_blocks) - 1
|
||||
last_block = code_blocks[last_index]
|
||||
code_blocks[last_index]['hunk'] = last_block['hunk'][last_block['hunk_start']:]
|
||||
|
||||
# Now for each code block, check for comments, then those comments for TODOs.
|
||||
for block in code_blocks:
|
||||
for marker in block['markers']:
|
||||
# Check if there are line or block comments.
|
||||
if marker['type'] == 'line':
|
||||
comment_pattern = r'(^[+\-\s]\s*' + marker['pattern'] + '.+$)'
|
||||
comments = re.finditer(comment_pattern, block['hunk'], re.MULTILINE)
|
||||
extracted_comments = []
|
||||
prev_comment = None
|
||||
for i, comment in enumerate(comments):
|
||||
if i == 0 or self.identifier in comment.group(0):
|
||||
extracted_comments.append([comment])
|
||||
else:
|
||||
print('Assignee doesn\'t exist! Dropping this assignee!')
|
||||
new_issue_body['assignees'] = valid_assignees
|
||||
|
||||
if 'milestone' in issue:
|
||||
milestone_number = issue['milestone']
|
||||
milestone_url = f'{base_url}{repo}/milestones/{milestone_number}'
|
||||
milestone_request = requests.get(url=milestone_url, headers=issue_headers)
|
||||
if milestone_request.status_code == 200:
|
||||
new_issue_body['milestone'] = issue['milestone']
|
||||
else:
|
||||
print('Milestone doesn\'t exist! Dropping this parameter!')
|
||||
|
||||
new_issue_request = requests.post(url=issues_url, headers=issue_headers,
|
||||
data=json.dumps(new_issue_body))
|
||||
print(f'Creating issue {i + 1} of {len(new_issues)}')
|
||||
if new_issue_request.status_code == 201:
|
||||
print('Issue created')
|
||||
if comment.start() == prev_comment.end() + 1:
|
||||
extracted_comments[len(extracted_comments) - 1].append(comment)
|
||||
prev_comment = comment
|
||||
for comment in extracted_comments:
|
||||
issue = self._extract_issue_if_exists(comment, marker, block)
|
||||
if issue:
|
||||
issues.append(issue)
|
||||
else:
|
||||
print('Issue could not be created')
|
||||
# Don't add too many issues too quickly.
|
||||
sleep(1)
|
||||
print('Creating issues complete')
|
||||
comment_pattern = (r'(?:[+\-\s]\s*' + marker['pattern']['start'] + r'.*?'
|
||||
+ marker['pattern']['end'] + ')')
|
||||
comments = re.finditer(comment_pattern, block['hunk'], re.DOTALL)
|
||||
extracted_comments = []
|
||||
for i, comment in enumerate(comments):
|
||||
if self.identifier in comment.group(0):
|
||||
extracted_comments.append([comment])
|
||||
|
||||
# Close issues for removed TODOs if this is enabled.
|
||||
if close_issues:
|
||||
print('Start closing issues')
|
||||
for i, closed_issue in enumerate(closed_issues):
|
||||
title = closed_issue
|
||||
matched = 0
|
||||
issue_number = None
|
||||
# Compare the title of each closed issue with each issue in the issues list.
|
||||
for current_issue in current_issues:
|
||||
if current_issue['body'].startswith(title):
|
||||
matched += 1
|
||||
# If there are multiple issues with similar titles, don't try and close any.
|
||||
if matched > 1:
|
||||
print(f'Skipping issue {i + 1} of {len(closed_issues)} (multiple matches)')
|
||||
for comment in extracted_comments:
|
||||
issue = self._extract_issue_if_exists(comment, marker, block)
|
||||
if issue:
|
||||
issues.append(issue)
|
||||
|
||||
# Strip some of the diff symbols so it can be included as a code snippet in the issue body.
|
||||
for i, issue in enumerate(issues):
|
||||
cleaned_hunk = re.sub(r'^.', '', issue.hunk, 0, re.MULTILINE)
|
||||
cleaned_hunk = re.sub(r'\n\sNo newline at end of file', '', cleaned_hunk, 0, re.MULTILINE)
|
||||
issue.hunk = cleaned_hunk
|
||||
return issues
|
||||
|
||||
def _get_file_details(self, file):
|
||||
"""Try and get the markdown language and comment syntax data for the given file."""
|
||||
file_name, extension = os.path.splitext(os.path.basename(file))
|
||||
if self.languages_dict:
|
||||
for language_name in self.languages_dict:
|
||||
if ('extensions' in self.languages_dict[language_name]
|
||||
and extension in self.languages_dict[language_name]['extensions']):
|
||||
for syntax_details in self.syntax_dict:
|
||||
if syntax_details['language'] == language_name:
|
||||
return syntax_details['markers'], self.languages_dict[language_name]['ace_mode']
|
||||
return None, None
|
||||
|
||||
def _extract_issue_if_exists(self, comment, marker, code_block):
|
||||
"""Check this comment for TODOs, and if found, build an Issue object."""
|
||||
issue = None
|
||||
for match in comment:
|
||||
lines = match.group().split('\n')
|
||||
for line in lines:
|
||||
line_status, committed_line = self._get_line_status(line)
|
||||
cleaned_line = self._clean_line(committed_line, marker)
|
||||
line_title, identifier = self._get_title(cleaned_line)
|
||||
if line_title:
|
||||
if identifier:
|
||||
issue_title = f'[{identifier}] {line_title}'
|
||||
else:
|
||||
issue_title = line_title
|
||||
issue = Issue(
|
||||
title=issue_title,
|
||||
labels=['todo'],
|
||||
assignees=[],
|
||||
milestone=None,
|
||||
body=[line_title],
|
||||
hunk=code_block['hunk'],
|
||||
file_name=code_block['file'],
|
||||
start_line=code_block['start_line'],
|
||||
markdown_language=code_block['markdown_language'],
|
||||
status=line_status
|
||||
)
|
||||
|
||||
# Calculate the file line number that this issue references.
|
||||
hunk_lines = re.finditer(self.LINE_PATTERN, code_block['hunk'], re.MULTILINE)
|
||||
start_line = code_block['start_line']
|
||||
for i, hunk_line in enumerate(hunk_lines):
|
||||
if hunk_line.group(0) == line:
|
||||
issue.start_line = start_line
|
||||
break
|
||||
issue_number = current_issue['number']
|
||||
else:
|
||||
if issue_number is None:
|
||||
continue
|
||||
# The titles match, so we will try and close the issue.
|
||||
update_issue_url = f'{base_url}{repo}/issues/{issue_number}'
|
||||
body = {'state': 'closed'}
|
||||
requests.patch(update_issue_url, headers=issue_headers, data=json.dumps(body))
|
||||
if i != 0 and (hunk_line.group(0).startswith('+') or not hunk_line.group(0).startswith('-')):
|
||||
start_line += 1
|
||||
|
||||
issue_comment_url = f'{base_url}{repo}/issues/{issue_number}/comments'
|
||||
body = {'body': f'Closed in {sha}'}
|
||||
update_issue_request = requests.post(issue_comment_url, headers=issue_headers,
|
||||
data=json.dumps(body))
|
||||
print(f'Closing issue {i + 1} of {len(closed_issues)}')
|
||||
if update_issue_request.status_code == 201:
|
||||
print('Issue closed')
|
||||
else:
|
||||
print('Issue could not be closed')
|
||||
# Don't update too many issues too quickly.
|
||||
sleep(1)
|
||||
print('Closing issues complete')
|
||||
elif issue:
|
||||
# Extract other issue information that may exist.
|
||||
line_labels = self._get_labels(cleaned_line)
|
||||
line_assignees = self._get_assignees(cleaned_line)
|
||||
line_milestone = self._get_milestone(cleaned_line)
|
||||
if line_labels:
|
||||
issue.labels.extend(line_labels)
|
||||
elif line_assignees:
|
||||
issue.assignees.extend(line_assignees)
|
||||
elif line_milestone and not issue.milestone:
|
||||
issue.milestone = line_milestone
|
||||
elif len(cleaned_line):
|
||||
issue.body.append(cleaned_line)
|
||||
return issue
|
||||
|
||||
def _get_line_status(self, comment):
|
||||
"""Return a Tuple indicating whether this is an addition/deletion/unchanged, plus the cleaned comment."""
|
||||
addition_search = self.ADDITION_PATTERN.search(comment)
|
||||
if addition_search:
|
||||
return LineStatus.ADDED, addition_search.group(0)
|
||||
else:
|
||||
deletion_search = self.DELETION_PATTERN.search(comment)
|
||||
if deletion_search:
|
||||
return LineStatus.DELETED, deletion_search.group(0)
|
||||
return LineStatus.UNCHANGED, comment[1:]
|
||||
|
||||
def clean_title(todo_search):
|
||||
identifier_pattern = re.compile(r'.+(?=\))')
|
||||
title_pattern = re.compile(r'(?<=\)[\s:]).+')
|
||||
cleaned_title = todo_search.group(0).lstrip()
|
||||
identifier_search = identifier_pattern.search(cleaned_title)
|
||||
title_search = title_pattern.search(cleaned_title)
|
||||
if identifier_search and title_search:
|
||||
cleaned_title = f'[{identifier_search.group(0)}] {title_search.group(0).lstrip()}'
|
||||
elif identifier_search:
|
||||
cleaned_title = identifier_search.group(0) # Shouldn't really arise.
|
||||
elif title_search:
|
||||
cleaned_title = title_search.group(0) # Shouldn't really arise.
|
||||
return cleaned_title
|
||||
@staticmethod
|
||||
def _clean_line(comment, marker):
|
||||
"""Remove unwanted symbols and whitespace."""
|
||||
comment = comment.strip()
|
||||
if marker['type'] == 'block':
|
||||
start_pattern = r'^' + marker['pattern']['start']
|
||||
end_pattern = marker['pattern']['end'] + r'$'
|
||||
comment = re.sub(start_pattern, '', comment)
|
||||
comment = re.sub(end_pattern, '', comment)
|
||||
# Some block comments might have an asterisk on each line.
|
||||
if '*' in start_pattern and comment.startswith('*'):
|
||||
comment = comment.lstrip('*')
|
||||
else:
|
||||
pattern = r'^' + marker['pattern']
|
||||
comment = re.sub(pattern, '', comment)
|
||||
return comment.strip()
|
||||
|
||||
def _get_title(self, comment):
|
||||
"""Check the passed comment for a new issue title (and identifier, if specified)."""
|
||||
title = None
|
||||
identifier = None
|
||||
title_pattern = re.compile(r'(?<=' + self.identifier + r'[(\s:]).+')
|
||||
title_search = title_pattern.search(comment, re.IGNORECASE)
|
||||
if title_search:
|
||||
title = title_search.group(0).strip()
|
||||
identifier_search = self.IDENTIFIER_PATTERN.search(title)
|
||||
if identifier_search:
|
||||
identifier = identifier_search.group(0)
|
||||
title = title.replace(identifier, '', 1).lstrip(':) ')
|
||||
return title, identifier
|
||||
|
||||
def _get_labels(self, comment):
|
||||
"""Check the passed comment for issue labels."""
|
||||
labels_search = self.LABELS_PATTERN.search(comment, re.IGNORECASE)
|
||||
labels = []
|
||||
if labels_search:
|
||||
labels = labels_search.group(0).replace(', ', ',')
|
||||
labels = list(filter(None, labels.split(',')))
|
||||
return labels
|
||||
|
||||
def _get_assignees(self, comment):
|
||||
"""Check the passed comment for issue assignees."""
|
||||
assignees_search = self.ASSIGNEES_PATTERN.search(comment, re.IGNORECASE)
|
||||
assignees = []
|
||||
if assignees_search:
|
||||
assignees = assignees_search.group(0).replace(', ', ',')
|
||||
assignees = list(filter(None, assignees.split(',')))
|
||||
return assignees
|
||||
|
||||
def _get_milestone(self, comment):
|
||||
"""Check the passed comment for a milestone."""
|
||||
milestone_search = self.MILESTONE_PATTERN.search(comment, re.IGNORECASE)
|
||||
milestone = None
|
||||
if milestone_search:
|
||||
milestone = milestone_search.group(0)
|
||||
if milestone.isdigit():
|
||||
milestone = int(milestone)
|
||||
return milestone
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
if os.getenv('INPUT_COMMENT_MARKER') and os.getenv('INPUT_LABEL'):
|
||||
# The user doesn't want to use the v3.x parser for whatever reason.
|
||||
fallback_parser.main()
|
||||
else:
|
||||
# Create a basic client for communicating with GitHub, automatically initialised with environment variables.
|
||||
client = GitHubClient()
|
||||
# Get the diff from the last pushed commit.
|
||||
last_diff = StringIO(client.get_last_diff())
|
||||
# Parse the diff for TODOs and create an Issue object for each.
|
||||
raw_issues = TodoParser().parse(last_diff)
|
||||
# Cycle through the Issue objects and create or close a corresponding GitHub issue for each.
|
||||
for j, raw_issue in enumerate(raw_issues):
|
||||
print(f'Processing issue {j + 1} of {len(raw_issues)}')
|
||||
if raw_issue.status == LineStatus.ADDED:
|
||||
status_code = client.create_issue(raw_issue)
|
||||
if status_code == 201:
|
||||
print('Issue created')
|
||||
else:
|
||||
print('Issue could not be created')
|
||||
elif raw_issue.status == LineStatus.DELETED and os.getenv('INPUT_CLOSE_ISSUES', 'true') == 'true':
|
||||
status_code = client.close_issue(raw_issue)
|
||||
if status_code == 201:
|
||||
print('Issue closed')
|
||||
else:
|
||||
print('Issue could not be closed')
|
||||
# Stagger the requests to be on the safe side.
|
||||
sleep(1)
|
||||
|
||||
231
syntax.json
Normal file
231
syntax.json
Normal file
@ -0,0 +1,231 @@
|
||||
[
|
||||
{
|
||||
"language": "Python",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "#"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "YAML",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "#"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Ruby",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "#"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "=begin",
|
||||
"end": "=end"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "PHP",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "#"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "<!--",
|
||||
"end": "-->"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "C",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "C++",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "C#",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Java",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JavaScript",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Kotlin",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Objective-C",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "SASS",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swift",
|
||||
"markers": [
|
||||
{
|
||||
"type": "line",
|
||||
"pattern": "//"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "/\\*",
|
||||
"end": "\\*/"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "HTML",
|
||||
"markers": [
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "<!--",
|
||||
"end": "-->"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "CSS",
|
||||
"markers": [
|
||||
{
|
||||
"type": "block",
|
||||
"pattern": {
|
||||
"start": "<!--",
|
||||
"end": "-->"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
102
tests/test_closed.diff
Normal file
102
tests/test_closed.diff
Normal file
@ -0,0 +1,102 @@
|
||||
diff --git a/tests/ExampleFile.java b/tests/ExampleFile.java
|
||||
index d340f6a..29b54da 100644
|
||||
--- a/tests/ExampleFile.java
|
||||
+++ b/tests/ExampleFile.java
|
||||
@@ -1,13 +1,5 @@
|
||||
package com.mydomain.myapp;
|
||||
|
||||
public class JavaTests {
|
||||
- // TODO: Expand this class
|
||||
- // Do it
|
||||
- // labels: urgent
|
||||
|
||||
- /*
|
||||
- TODO: Definitely expand this class
|
||||
- Do it ASAP
|
||||
- labels: urgent
|
||||
- */
|
||||
}
|
||||
\ No newline at end of file
|
||||
diff --git a/tests/example-file.css b/tests/example-file.css
|
||||
index 344fe46..0519500 100644
|
||||
--- a/tests/example-file.css
|
||||
+++ b/tests/example-file.css
|
||||
@@ -1,10 +1,4 @@
|
||||
body {
|
||||
- <!-- TODO: This is too bright -->
|
||||
background: red;
|
||||
- <!--
|
||||
- TODO: Insufficient contrast
|
||||
- Use another colour for the text
|
||||
- labels: UI
|
||||
- -->
|
||||
color: deeppink;
|
||||
}
|
||||
\ No newline at end of file
|
||||
diff --git a/tests/example-file.php b/tests/example-file.php
|
||||
index d36df2f..e77f4bb 100644
|
||||
--- a/tests/example-file.php
|
||||
+++ b/tests/example-file.php
|
||||
@@ -4,19 +4,8 @@
|
||||
|
||||
<?php
|
||||
echo 'Testing 123';
|
||||
- // TODO: Expand this page
|
||||
- // We aren't doing anything here yet
|
||||
-
|
||||
echo 'Testing 456';
|
||||
- # TODO: Definitely expand this
|
||||
- # There needs to be some point to this file
|
||||
-
|
||||
echo 'Testing 789'
|
||||
- /*
|
||||
- * TODO: Echo a greeting instead
|
||||
- * This should work with or without the asterisk on each line
|
||||
- * labels: help wanted
|
||||
- */
|
||||
?>
|
||||
|
||||
</body>
|
||||
diff --git a/tests/example_file.py b/tests/example_file.py
|
||||
index 525e25d..ba4e68d 100644
|
||||
--- a/tests/example_file.py
|
||||
+++ b/tests/example_file.py
|
||||
@@ -1,7 +1,2 @@
|
||||
def hello_world():
|
||||
- # TODO: Come up with a more imaginative greeting
|
||||
print('Hello world')
|
||||
-
|
||||
- # TODO: Do more stuff
|
||||
- # This function should probably do something more interesting
|
||||
- # labels: help wanted
|
||||
diff --git a/tests/example_file.rb b/tests/example_file.rb
|
||||
index e6da2ec..67f14dd 100644
|
||||
--- a/tests/example_file.rb
|
||||
+++ b/tests/example_file.rb
|
||||
@@ -1,14 +1,3 @@
|
||||
#!/usr/bin/ruby -w
|
||||
|
||||
-# TODO: Come up with a more imaginative greeting
|
||||
puts "Greetings"
|
||||
-
|
||||
-=begin
|
||||
-TODO: Do more stuff
|
||||
-This function should probably do something more interesting
|
||||
-labels: help wanted
|
||||
-=end
|
||||
-
|
||||
-# TODO: Do even more stuff
|
||||
-# This function should probably do something more interesting
|
||||
-# labels: help wanted
|
||||
diff --git a/tests/example_file.yaml b/tests/example_file.yaml
|
||||
index 6397789..494d42f 100644
|
||||
--- a/tests/example_file.yaml
|
||||
+++ b/tests/example_file.yaml
|
||||
@@ -1,7 +1,3 @@
|
||||
name: "TODO to Issue"
|
||||
-# TODO: Write a more interesting description
|
||||
description: "Converts IDE TODO comments to GitHub issues"
|
||||
author: "Alastair Mooney"
|
||||
-# TODO: Define inputs
|
||||
-# Need to do this before the action is released
|
||||
-# labels: urgent
|
||||
113
tests/test_new.diff
Normal file
113
tests/test_new.diff
Normal file
@ -0,0 +1,113 @@
|
||||
diff --git a/tests/ExampleFile.java b/tests/ExampleFile.java
|
||||
new file mode 100644
|
||||
index 0000000..d340f6a
|
||||
--- /dev/null
|
||||
+++ b/tests/ExampleFile.java
|
||||
@@ -0,0 +1,13 @@
|
||||
+package com.mydomain.myapp;
|
||||
+
|
||||
+public class JavaTests {
|
||||
+ // TODO: Expand this class
|
||||
+ // Do it
|
||||
+ // labels: urgent
|
||||
+
|
||||
+ /*
|
||||
+ TODO: Definitely expand this class
|
||||
+ Do it ASAP
|
||||
+ labels: urgent
|
||||
+ */
|
||||
+}
|
||||
\ No newline at end of file
|
||||
diff --git a/tests/example-file.css b/tests/example-file.css
|
||||
new file mode 100644
|
||||
index 0000000..344fe46
|
||||
--- /dev/null
|
||||
+++ b/tests/example-file.css
|
||||
@@ -0,0 +1,10 @@
|
||||
+body {
|
||||
+ <!-- TODO: This is too bright -->
|
||||
+ background: red;
|
||||
+ <!--
|
||||
+ TODO: Insufficient contrast
|
||||
+ Use another colour for the text
|
||||
+ labels: UI
|
||||
+ -->
|
||||
+ color: deeppink;
|
||||
+}
|
||||
\ No newline at end of file
|
||||
diff --git a/tests/example-file.php b/tests/example-file.php
|
||||
new file mode 100644
|
||||
index 0000000..d36df2f
|
||||
--- /dev/null
|
||||
+++ b/tests/example-file.php
|
||||
@@ -0,0 +1,23 @@
|
||||
+<!DOCTYPE html>
|
||||
+<html>
|
||||
+<body>
|
||||
+
|
||||
+<?php
|
||||
+ echo 'Testing 123';
|
||||
+ // TODO: Expand this page
|
||||
+ // We aren't doing anything here yet
|
||||
+
|
||||
+ echo 'Testing 456';
|
||||
+ # TODO: Definitely expand this
|
||||
+ # There needs to be some point to this file
|
||||
+
|
||||
+ echo 'Testing 789'
|
||||
+ /*
|
||||
+ * TODO: Echo a greeting instead
|
||||
+ * This should work with or without the asterisk on each line
|
||||
+ * labels: help wanted
|
||||
+ */
|
||||
+?>
|
||||
+
|
||||
+</body>
|
||||
+</html>
|
||||
\ No newline at end of file
|
||||
diff --git a/tests/example_file.py b/tests/example_file.py
|
||||
new file mode 100644
|
||||
index 0000000..525e25d
|
||||
--- /dev/null
|
||||
+++ b/tests/example_file.py
|
||||
@@ -0,0 +1,7 @@
|
||||
+def hello_world():
|
||||
+ # TODO: Come up with a more imaginative greeting
|
||||
+ print('Hello world')
|
||||
+
|
||||
+ # TODO: Do more stuff
|
||||
+ # This function should probably do something more interesting
|
||||
+ # labels: help wanted
|
||||
diff --git a/tests/example_file.rb b/tests/example_file.rb
|
||||
new file mode 100644
|
||||
index 0000000..e6da2ec
|
||||
--- /dev/null
|
||||
+++ b/tests/example_file.rb
|
||||
@@ -0,0 +1,14 @@
|
||||
+#!/usr/bin/ruby -w
|
||||
+
|
||||
+# TODO: Come up with a more imaginative greeting
|
||||
+puts "Greetings"
|
||||
+
|
||||
+=begin
|
||||
+TODO: Do more stuff
|
||||
+This function should probably do something more interesting
|
||||
+labels: help wanted
|
||||
+=end
|
||||
+
|
||||
+# TODO: Do even more stuff
|
||||
+# This function should probably do something more interesting
|
||||
+# labels: help wanted
|
||||
diff --git a/tests/example_file.yaml b/tests/example_file.yaml
|
||||
new file mode 100644
|
||||
index 0000000..6397789
|
||||
--- /dev/null
|
||||
+++ b/tests/example_file.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
+name: "TODO to Issue"
|
||||
+# TODO: Write a more interesting description
|
||||
+description: "Converts IDE TODO comments to GitHub issues"
|
||||
+author: "Alastair Mooney"
|
||||
+# TODO: Define inputs
|
||||
+# Need to do this before the action is released
|
||||
+# labels: urgent
|
||||
60
tests/test_todo_parser.py
Normal file
60
tests/test_todo_parser.py
Normal file
@ -0,0 +1,60 @@
|
||||
import unittest
|
||||
from main import TodoParser
|
||||
|
||||
|
||||
def count_issues_for_file_type(raw_issues, file_type):
|
||||
num_issues = 0
|
||||
for issue in raw_issues:
|
||||
if issue.markdown_language == file_type:
|
||||
num_issues += 1
|
||||
return num_issues
|
||||
|
||||
|
||||
class NewIssueTests(unittest.TestCase):
|
||||
# Check for newly added TODOs across the files specified (covers all current marker types).
|
||||
def setUp(self):
|
||||
diff_file = open('tests/test_new.diff', 'r')
|
||||
self.raw_issues = TodoParser().parse(diff_file)
|
||||
|
||||
def test_python_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'python'), 2)
|
||||
|
||||
def test_yaml_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'yaml'), 2)
|
||||
|
||||
def test_php_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'php'), 3)
|
||||
|
||||
def test_java_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'java'), 2)
|
||||
|
||||
def test_css_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'css'), 2)
|
||||
|
||||
def test_ruby_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'ruby'), 3)
|
||||
|
||||
|
||||
class ClosedIssueTests(unittest.TestCase):
|
||||
# Check for removed TODOs across the files specified (covers all current marker types).
|
||||
def setUp(self):
|
||||
diff_file = open('tests/test_closed.diff', 'r')
|
||||
self.raw_issues = TodoParser().parse(diff_file)
|
||||
|
||||
def test_python_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'python'), 2)
|
||||
|
||||
def test_yaml_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'yaml'), 2)
|
||||
|
||||
def test_php_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'php'), 3)
|
||||
|
||||
def test_java_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'java'), 2)
|
||||
|
||||
def test_css_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'css'), 2)
|
||||
|
||||
def test_ruby_issues(self):
|
||||
self.assertEqual(count_issues_for_file_type(self.raw_issues, 'ruby'), 3)
|
||||
Loading…
Reference in New Issue
Block a user