Add project support

Closes #14
This commit is contained in:
alstr
2021-05-29 17:27:04 +01:00
parent 87d6da6e33
commit 52c5630654
4 changed files with 170 additions and 13 deletions

View File

@@ -10,3 +10,4 @@ jobs:
id: "todo" id: "todo"
with: with:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECTS_SECRET: ${{ secrets.PROJECTS_SECRET }}

View File

@@ -7,7 +7,7 @@ The new issue will contain a link to the line in the file containing the TODO, t
It will also close an issue when a `# TODO` is removed in a pushed commit. A comment will be posted 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. with the ref of the commit that it was closed by.
## Important information about v3.0 ## 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. 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.
@@ -26,6 +26,7 @@ A few basic tests are included if you would like to see how the new action works
- [Specifying Labels](#specifying-labels) - [Specifying Labels](#specifying-labels)
- [Specifying Assignees](#specifying-assignees) - [Specifying Assignees](#specifying-assignees)
- [Specifying Milestone](#specifying-milestone) - [Specifying Milestone](#specifying-milestone)
- [Specifying Projects](#specifying-projects)
- [Removing TODOs](#removing-todos) - [Removing TODOs](#removing-todos)
- [Updating TODOs](#updating-todos) - [Updating TODOs](#updating-todos)
- [Existing TODOs](#existing-todos) - [Existing TODOs](#existing-todos)
@@ -38,7 +39,7 @@ Create a workflow file in your .github/workflows directory as follows:
### workflow.yaml ### workflow.yaml
Latest version is `v3.0.5`. Latest version is `v4.0-alpha`.
name: "Workflow" name: "Workflow"
on: ["push"] on: ["push"]
@@ -48,7 +49,7 @@ Latest version is `v3.0.5`.
steps: steps:
- uses: "actions/checkout@master" - uses: "actions/checkout@master"
- name: "TODO to Issue" - name: "TODO to Issue"
uses: "alstr/todo-to-issue-action@v3.0.5" uses: "alstr/todo-to-issue-action@v4.0-alpha"
id: "todo" id: "todo"
with: with:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -73,6 +74,8 @@ Three other inputs are provided automatically by GitHub and should not be includ
| `BEFORE` | The SHA of the previous commit. | | `BEFORE` | The SHA of the previous commit. |
| `SHA` | The SHA of the latest commit. | | `SHA` | The SHA of the latest commit. |
There are additional inputs if you want to be able to assign issues to projects. See [Specifying Projects](#specifying-projects).
## Examples ## Examples
### Adding TODOs ### Adding TODOs
@@ -155,6 +158,27 @@ You can set the issue milestone by specifying the milestone ID. Only a single mi
If the milestone does not already exist, it will be dropped from the issue creation request. If the milestone does not already exist, it will be dropped from the issue creation request.
### Specifying Projects
**The action cannot access your projects by default. To enable access, you must [create an encrypted secret in your repo settings](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository), with the value set to a valid Personal Access Token. Then, assign the secret in the workflow file like `PROJECTS_SECRET: ${{ secrets.NAME_OF_MY_SECRET }}`. Do not enter the raw secret.**
def hello_world():
# TODO Come up with a more imaginative greeting
# Everyone uses hello world and it's boring.
# user projects: alstr/Test User Project/To Do
# org projects: alstrorg/Test Org Project/To Do
print('Hello world!')
You can assign the created issue to columns within user or organisation projects.
To assign to a user project, use the `user projects:` prefix. To assign to an organisation project, use `org projects:` prefix.
The syntax is `<user or org name>/project name/column name`. All three must be provided.
You can assign to multiple projects by using commas, for example: `user projects: alstr/Test User Project 1/To Do, alstr/Test User Project 2/Tasks`.
You can also specify default projects in your workflow file using `USER_PROJECTS` or `ORG_PROJECTS`. These will be applied automatically to every issue, but will be overrode by any specified within the TODO.
### Removing TODOs ### Removing TODOs
def hello_world(): def hello_world():

View File

@@ -37,3 +37,12 @@ inputs:
description: "For multiline TODOs, format each line as a new paragraph when creating the issue" description: "For multiline TODOs, format each line as a new paragraph when creating the issue"
required: true required: true
default: true default: true
PROJECTS_SECRET:
description: "Encrypted secret corresponding to your personal access token, e.g. ${{ secrets.MY_SECRET }} (do not enter the actual secret)"
required: false
USER_PROJECTS:
description: "Default user projects"
required: false
ORG_PROJECTS:
description: "Default organisation projects"
required: false

143
main.py
View File

@@ -21,12 +21,15 @@ class LineStatus(Enum):
class Issue(object): class Issue(object):
"""Basic Issue model for collecting the necessary info to send to GitHub.""" """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): def __init__(self, title, labels, assignees, milestone, user_projects, org_projects, body, hunk, file_name,
start_line, markdown_language, status):
self.title = title self.title = title
self.labels = labels self.labels = labels
self.assignees = assignees self.assignees = assignees
self.milestone = milestone self.milestone = milestone
self.user_projects = user_projects
self.org_projects = org_projects
self.body = body self.body = body
self.hunk = hunk self.hunk = hunk
self.file_name = file_name self.file_name = file_name
@@ -38,14 +41,15 @@ class Issue(object):
class GitHubClient(object): class GitHubClient(object):
"""Basic client for getting the last diff and creating/closing issues.""" """Basic client for getting the last diff and creating/closing issues."""
existing_issues = [] existing_issues = []
base_url = 'https://api.github.com/repos/' base_url = 'https://api.github.com/'
repos_url = f'{base_url}repos/'
def __init__(self): def __init__(self):
self.repo = os.getenv('INPUT_REPO') self.repo = os.getenv('INPUT_REPO')
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.token = os.getenv('INPUT_TOKEN') self.token = os.getenv('INPUT_TOKEN')
self.issues_url = f'{self.base_url}{self.repo}/issues' self.issues_url = f'{self.repos_url}{self.repo}/issues'
self.issue_headers = { self.issue_headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': f'token {self.token}' 'Authorization': f'token {self.token}'
@@ -57,7 +61,7 @@ class GitHubClient(object):
def get_last_diff(self): def get_last_diff(self):
"""Get the last diff based on the SHA of the last two commits.""" """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_url = f'{self.repos_url}{self.repo}/compare/{self.before}...{self.sha}'
diff_headers = { diff_headers = {
'Accept': 'application/vnd.github.v3.diff', 'Accept': 'application/vnd.github.v3.diff',
'Authorization': f'token {self.token}' 'Authorization': f'token {self.token}'
@@ -108,7 +112,7 @@ class GitHubClient(object):
# We need to check if any assignees/milestone specified exist, otherwise issue creation will fail. # We need to check if any assignees/milestone specified exist, otherwise issue creation will fail.
valid_assignees = [] valid_assignees = []
for assignee in issue.assignees: for assignee in issue.assignees:
assignee_url = f'{self.base_url}{self.repo}/assignees/{assignee}' assignee_url = f'{self.repos_url}{self.repo}/assignees/{assignee}'
assignee_request = requests.get(url=assignee_url, headers=self.issue_headers) assignee_request = requests.get(url=assignee_url, headers=self.issue_headers)
if assignee_request.status_code == 204: if assignee_request.status_code == 204:
valid_assignees.append(assignee) valid_assignees.append(assignee)
@@ -117,7 +121,7 @@ class GitHubClient(object):
new_issue_body['assignees'] = valid_assignees new_issue_body['assignees'] = valid_assignees
if issue.milestone: if issue.milestone:
milestone_url = f'{self.base_url}{self.repo}/milestones/{issue.milestone}' milestone_url = f'{self.repos_url}{self.repo}/milestones/{issue.milestone}'
milestone_request = requests.get(url=milestone_url, headers=self.issue_headers) milestone_request = requests.get(url=milestone_url, headers=self.issue_headers)
if milestone_request.status_code == 200: if milestone_request.status_code == 200:
new_issue_body['milestone'] = issue.milestone new_issue_body['milestone'] = issue.milestone
@@ -127,6 +131,16 @@ class GitHubClient(object):
new_issue_request = requests.post(url=self.issues_url, headers=self.issue_headers, new_issue_request = requests.post(url=self.issues_url, headers=self.issue_headers,
data=json.dumps(new_issue_body)) data=json.dumps(new_issue_body))
# Check if we should assign this issue to any projects.
if new_issue_request.status_code == 201 and (len(issue.user_projects) > 0 or len(issue.org_projects) > 0):
issue_json = new_issue_request.json()
issue_id = issue_json['id']
if len(issue.user_projects) > 0:
self.add_issue_to_projects(issue_id, issue.user_projects, 'user')
if len(issue.org_projects) > 0:
self.add_issue_to_projects(issue_id, issue.org_projects, 'org')
return new_issue_request.status_code return new_issue_request.status_code
def close_issue(self, issue): def close_issue(self, issue):
@@ -144,17 +158,92 @@ class GitHubClient(object):
issue_number = existing_issue['number'] issue_number = existing_issue['number']
else: else:
# The titles match, so we will try and close the issue. # The titles match, so we will try and close the issue.
update_issue_url = f'{self.base_url}{self.repo}/issues/{issue_number}' update_issue_url = f'{self.repos_url}{self.repo}/issues/{issue_number}'
body = {'state': 'closed'} body = {'state': 'closed'}
requests.patch(update_issue_url, headers=self.issue_headers, data=json.dumps(body)) 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' issue_comment_url = f'{self.repos_url}{self.repo}/issues/{issue_number}/comments'
body = {'body': f'Closed in {self.sha}'} body = {'body': f'Closed in {self.sha}'}
update_issue_request = requests.post(issue_comment_url, headers=self.issue_headers, update_issue_request = requests.post(issue_comment_url, headers=self.issue_headers,
data=json.dumps(body)) data=json.dumps(body))
return update_issue_request.status_code return update_issue_request.status_code
return None return None
def add_issue_to_projects(self, issue_id, projects, projects_type):
"""Attempt to add this issue to the specified user or organisation projects."""
projects_secret = os.getenv('INPUT_PROJECTS_SECRET', None)
if not projects_secret:
print('You need to create and set PROJECTS_SECRET to use projects')
return
projects_headers = {
'Accept': 'application/vnd.github.inertia-preview+json',
'Authorization': f'token {projects_secret}'
}
# Loop through all the projects that we should assign this issue to.
for i, project in enumerate(projects):
print(f'Adding issue to {projects_type} project {i + 1} of {len(projects)}')
project = project.replace(' / ', '/')
try:
entity_name, project_name, column_name = project.split('/')
except ValueError:
print('Invalid project syntax')
continue
entity_name = entity_name.strip()
project_name = project_name.strip()
column_name = column_name.strip()
if projects_type == 'user':
projects_url = f'{self.base_url}users/{entity_name}/projects'
elif projects_type == 'org':
projects_url = f'{self.base_url}orgs/{entity_name}/projects'
else:
return
# We need to use the project name to get its ID.
projects_request = requests.get(url=projects_url, headers=projects_headers)
if projects_request.status_code == 200:
projects_json = projects_request.json()
for project_dict in projects_json:
if project_dict['name'].lower() == project_name.lower():
project_id = project_dict['id']
break
else:
print('Project does not exist, skipping')
continue
else:
print('An error occurred, skipping')
continue
# Use the project ID and column name to get the column ID.
columns_url = f'{self.base_url}projects/{project_id}/columns'
columns_request = requests.get(url=columns_url, headers=projects_headers)
if columns_request.status_code == 200:
columns_json = columns_request.json()
for column_dict in columns_json:
if column_dict['name'].lower() == column_name.lower():
column_id = column_dict['id']
break
else:
print('Column does not exist, skipping')
continue
else:
print('An error occurred, skipping')
continue
# Use the column ID to assign the issue to the project.
new_card_url = f'{self.base_url}projects/columns/{column_id}/cards'
new_card_body = {
'content_id': int(issue_id),
'content_type': 'Issue'
}
new_card_request = requests.post(url=new_card_url, headers=projects_headers,
data=json.dumps(new_card_body))
if new_card_request.status_code == 201:
print('Issue card added to project')
else:
print('Issue card could not be added to project')
class TodoParser(object): class TodoParser(object):
"""Parser for extracting information from a given diff file.""" """Parser for extracting information from a given diff file."""
@@ -170,6 +259,8 @@ class TodoParser(object):
LABELS_PATTERN = re.compile(r'(?<=labels:\s).+') LABELS_PATTERN = re.compile(r'(?<=labels:\s).+')
ASSIGNEES_PATTERN = re.compile(r'(?<=assignees:\s).+') ASSIGNEES_PATTERN = re.compile(r'(?<=assignees:\s).+')
MILESTONE_PATTERN = re.compile(r'(?<=milestone:\s).+') MILESTONE_PATTERN = re.compile(r'(?<=milestone:\s).+')
USER_PROJECTS_PATTERN = re.compile(r'(?<=user projects:\s).+')
ORG_PROJECTS_PATTERN = re.compile(r'(?<=org projects:\s).+')
def __init__(self): def __init__(self):
# We could support more identifiers later quite easily. # We could support more identifiers later quite easily.
@@ -301,8 +392,10 @@ class TodoParser(object):
if issue: if issue:
issues.append(issue) issues.append(issue)
# Strip some of the diff symbols so it can be included as a code snippet in the issue body. default_user_projects = os.getenv('INPUT_USER_PROJECTS', None)
default_org_projects = os.getenv('INPUT_ORG_PROJECTS', None)
for i, issue in enumerate(issues): for i, issue in enumerate(issues):
# Strip some of the diff symbols so it can be included as a code snippet in the issue body.
# Strip removed lines. # Strip removed lines.
cleaned_hunk = re.sub(r'\n^-.*$', '', issue.hunk, 0, re.MULTILINE) cleaned_hunk = re.sub(r'\n^-.*$', '', issue.hunk, 0, re.MULTILINE)
# Strip leading symbols/whitespace. # Strip leading symbols/whitespace.
@@ -310,6 +403,14 @@ class TodoParser(object):
# Strip newline message. # Strip newline message.
cleaned_hunk = re.sub(r'\n\sNo newline at end of file', '', cleaned_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 issue.hunk = cleaned_hunk
# If no projects have been specified for this issue, assign any default projects that exist.
if len(issue.user_projects) == 0 and default_user_projects is not None:
separated_user_projects = self._get_projects(f'user projects: {default_user_projects}', 'user')
issue.user_projects = separated_user_projects
if len(issue.org_projects) == 0 and default_org_projects is not None:
separated_org_projects = self._get_projects(f'org projects: {default_org_projects}', 'user')
issue.org_projects = separated_org_projects
return issues return issues
def _get_file_details(self, file): def _get_file_details(self, file):
@@ -342,6 +443,8 @@ class TodoParser(object):
labels=['todo'], labels=['todo'],
assignees=[], assignees=[],
milestone=None, milestone=None,
user_projects=[],
org_projects=[],
body=[line_title], body=[line_title],
hunk=code_block['hunk'], hunk=code_block['hunk'],
file_name=code_block['file'], file_name=code_block['file'],
@@ -365,12 +468,18 @@ class TodoParser(object):
line_labels = self._get_labels(cleaned_line) line_labels = self._get_labels(cleaned_line)
line_assignees = self._get_assignees(cleaned_line) line_assignees = self._get_assignees(cleaned_line)
line_milestone = self._get_milestone(cleaned_line) line_milestone = self._get_milestone(cleaned_line)
user_projects = self._get_projects(cleaned_line, 'user')
org_projects = self._get_projects(cleaned_line, 'org')
if line_labels: if line_labels:
issue.labels.extend(line_labels) issue.labels.extend(line_labels)
elif line_assignees: elif line_assignees:
issue.assignees.extend(line_assignees) issue.assignees.extend(line_assignees)
elif line_milestone and not issue.milestone: elif line_milestone and not issue.milestone:
issue.milestone = line_milestone issue.milestone = line_milestone
elif user_projects:
issue.user_projects.extend(user_projects)
elif org_projects:
issue.org_projects.extend(org_projects)
elif len(cleaned_line): elif len(cleaned_line):
issue.body.append(cleaned_line) issue.body.append(cleaned_line)
return issue return issue
@@ -445,6 +554,20 @@ class TodoParser(object):
milestone = int(milestone) milestone = int(milestone)
return milestone return milestone
def _get_projects(self, comment, projects_type):
"""Check the passed comment for projects to link the issue to."""
projects = []
if projects_type == 'user':
projects_search = self.USER_PROJECTS_PATTERN.search(comment, re.IGNORECASE)
elif projects_type == 'org':
projects_search = self.ORG_PROJECTS_PATTERN.search(comment, re.IGNORECASE)
else:
return projects
if projects_search:
projects = projects_search.group(0).replace(', ', ',')
projects = list(filter(None, projects.split(',')))
return projects
if __name__ == "__main__": if __name__ == "__main__":
if os.getenv('INPUT_BEFORE') != '0000000000000000000000000000000000000000': if os.getenv('INPUT_BEFORE') != '0000000000000000000000000000000000000000':