From 52c5630654673c7aedad5651d3155ed589d3cbb8 Mon Sep 17 00:00:00 2001 From: alstr Date: Sat, 29 May 2021 17:27:04 +0100 Subject: [PATCH] Add project support Closes #14 --- .github/workflows/workflow.yml | 1 + README.md | 30 ++++++- action.yml | 9 +++ main.py | 143 ++++++++++++++++++++++++++++++--- 4 files changed, 170 insertions(+), 13 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 31a1e8e..bca4be5 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -10,3 +10,4 @@ jobs: id: "todo" with: TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECTS_SECRET: ${{ secrets.PROJECTS_SECRET }} diff --git a/README.md b/README.md index 8ef1a47..4c8fa5a 100644 --- a/README.md +++ b/README.md @@ -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 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. @@ -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 Assignees](#specifying-assignees) - [Specifying Milestone](#specifying-milestone) + - [Specifying Projects](#specifying-projects) - [Removing TODOs](#removing-todos) - [Updating TODOs](#updating-todos) - [Existing TODOs](#existing-todos) @@ -38,7 +39,7 @@ Create a workflow file in your .github/workflows directory as follows: ### workflow.yaml -Latest version is `v3.0.5`. +Latest version is `v4.0-alpha`. name: "Workflow" on: ["push"] @@ -48,7 +49,7 @@ Latest version is `v3.0.5`. steps: - uses: "actions/checkout@master" - name: "TODO to Issue" - uses: "alstr/todo-to-issue-action@v3.0.5" + uses: "alstr/todo-to-issue-action@v4.0-alpha" id: "todo" with: 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. | | `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 ### 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. +### 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 `/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 def hello_world(): diff --git a/action.yml b/action.yml index 0750c37..3d4fb68 100644 --- a/action.yml +++ b/action.yml @@ -37,3 +37,12 @@ inputs: description: "For multiline TODOs, format each line as a new paragraph when creating the issue" required: 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 diff --git a/main.py b/main.py index d0b18c4..b69008b 100644 --- a/main.py +++ b/main.py @@ -21,12 +21,15 @@ class LineStatus(Enum): 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): + + def __init__(self, title, labels, assignees, milestone, user_projects, org_projects, body, hunk, file_name, + start_line, markdown_language, status): self.title = title self.labels = labels self.assignees = assignees self.milestone = milestone + self.user_projects = user_projects + self.org_projects = org_projects self.body = body self.hunk = hunk self.file_name = file_name @@ -38,14 +41,15 @@ class Issue(object): class GitHubClient(object): """Basic client for getting the last diff and creating/closing 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): self.repo = os.getenv('INPUT_REPO') 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.issues_url = f'{self.repos_url}{self.repo}/issues' self.issue_headers = { 'Content-Type': 'application/json', 'Authorization': f'token {self.token}' @@ -57,7 +61,7 @@ class GitHubClient(object): 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_url = f'{self.repos_url}{self.repo}/compare/{self.before}...{self.sha}' diff_headers = { 'Accept': 'application/vnd.github.v3.diff', '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. valid_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) if assignee_request.status_code == 204: valid_assignees.append(assignee) @@ -117,7 +121,7 @@ class GitHubClient(object): new_issue_body['assignees'] = valid_assignees 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) if milestone_request.status_code == 200: 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, 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 def close_issue(self, issue): @@ -144,17 +158,92 @@ class GitHubClient(object): 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}' + update_issue_url = f'{self.repos_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' + issue_comment_url = f'{self.repos_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 + 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): """Parser for extracting information from a given diff file.""" @@ -170,6 +259,8 @@ class TodoParser(object): LABELS_PATTERN = re.compile(r'(?<=labels:\s).+') ASSIGNEES_PATTERN = re.compile(r'(?<=assignees:\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): # We could support more identifiers later quite easily. @@ -301,8 +392,10 @@ class TodoParser(object): if 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): + # Strip some of the diff symbols so it can be included as a code snippet in the issue body. # Strip removed lines. cleaned_hunk = re.sub(r'\n^-.*$', '', issue.hunk, 0, re.MULTILINE) # Strip leading symbols/whitespace. @@ -310,6 +403,14 @@ class TodoParser(object): # Strip newline message. cleaned_hunk = re.sub(r'\n\sNo newline at end of file', '', cleaned_hunk, 0, re.MULTILINE) 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 def _get_file_details(self, file): @@ -342,6 +443,8 @@ class TodoParser(object): labels=['todo'], assignees=[], milestone=None, + user_projects=[], + org_projects=[], body=[line_title], hunk=code_block['hunk'], file_name=code_block['file'], @@ -365,12 +468,18 @@ class TodoParser(object): line_labels = self._get_labels(cleaned_line) line_assignees = self._get_assignees(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: 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 user_projects: + issue.user_projects.extend(user_projects) + elif org_projects: + issue.org_projects.extend(org_projects) elif len(cleaned_line): issue.body.append(cleaned_line) return issue @@ -445,6 +554,20 @@ class TodoParser(object): milestone = int(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 os.getenv('INPUT_BEFORE') != '0000000000000000000000000000000000000000':