diff --git a/CHANGELOG.md b/CHANGELOG.md index 9953c8a..518b2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -## v1.0.1 +## v1.0.2 + +* Added the rest of the integrations needed to completely manage gitlab issues. +* Added ability to get a file from a project. + +* ## v1.0.1 * Small bug fixes regarding Python 3 support diff --git a/actions/issue.close.yaml b/actions/issue.close.yaml new file mode 100644 index 0000000..1c4cce6 --- /dev/null +++ b/actions/issue.close.yaml @@ -0,0 +1,27 @@ +--- + +name: issue.close +description: "Close a GitLab issue" + +runner_type: python-script +entry_point: issue_close.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + issue_iid: + type: string + required: true + description: the iid of the issue in the project (not global unique id) + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.create.yaml b/actions/issue.create.yaml new file mode 100644 index 0000000..ec73826 --- /dev/null +++ b/actions/issue.create.yaml @@ -0,0 +1,36 @@ +--- + +name: issue.create +description: "Create a new GitLab issue" + +runner_type: python-script +entry_point: issue_create.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + title: + type: string + required: true + description: The issue title + description: + type: string + description: The issue description + assignee_ids: + type: string + description: Comma-separated list of user IDs to assign the issue to + labels: + type: string + description: Comma-separated list of label names + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.list.yaml b/actions/issue.list.yaml new file mode 100644 index 0000000..b4c3527 --- /dev/null +++ b/actions/issue.list.yaml @@ -0,0 +1,31 @@ +--- + +name: issue.list +description: "List all issues for a project with filtering by state" + +runner_type: python-script +entry_point: issue_list.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + state: + type: string + description: Filter issues by state + enum: + - opened + - closed + - all + default: all + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.note.create.yaml b/actions/issue.note.create.yaml new file mode 100644 index 0000000..5717df5 --- /dev/null +++ b/actions/issue.note.create.yaml @@ -0,0 +1,31 @@ +--- + +name: issue.note.create +description: "Add a note (comment) to a GitLab issue" + +runner_type: python-script +entry_point: issue_note_create.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + issue_iid: + type: string + required: true + description: the iid of the issue in the project (not global unique id) + body: + type: string + required: true + description: The content of the note/comment + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.notes.list.yaml b/actions/issue.notes.list.yaml new file mode 100644 index 0000000..0234f96 --- /dev/null +++ b/actions/issue.notes.list.yaml @@ -0,0 +1,27 @@ +--- + +name: issue.notes.list +description: "List all notes (comments) on a GitLab issue" + +runner_type: python-script +entry_point: issue_notes_list.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + issue_iid: + type: string + required: true + description: the iid of the issue in the project (not global unique id) + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.reopen.yaml b/actions/issue.reopen.yaml new file mode 100644 index 0000000..da1bed5 --- /dev/null +++ b/actions/issue.reopen.yaml @@ -0,0 +1,30 @@ +--- + +name: issue.reopen +description: "Reopen a closed GitLab issue with optional description update" + +runner_type: python-script +entry_point: issue_reopen.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + issue_iid: + type: string + required: true + description: the iid of the issue in the project (not global unique id) + description: + type: string + description: Optional new description for the issue when reopening + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue.update.yaml b/actions/issue.update.yaml new file mode 100644 index 0000000..eb56837 --- /dev/null +++ b/actions/issue.update.yaml @@ -0,0 +1,39 @@ +--- + +name: issue.update +description: "Update an existing GitLab issue" + +runner_type: python-script +entry_point: issue_update.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/group/project) + issue_iid: + type: string + required: true + description: the iid of the issue in the project (not global unique id) + title: + type: string + description: The new issue title + description: + type: string + description: The new issue description + assignee_ids: + type: string + description: Comma-separated list of user IDs to assign the issue to (empty string to clear) + labels: + type: string + description: Comma-separated list of label names (empty string to clear) + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/issue_close.py b/actions/issue_close.py new file mode 100644 index 0000000..165a86c --- /dev/null +++ b/actions/issue_close.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueClose(GitlabIssuesAPI): + + def run(self, url, project, issue_iid, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + issue = self.close(self.url, project, issue_iid) + + # Return formatted response + result = { + 'id': issue.get('iid'), + 'global_id': issue.get('id'), + 'title': issue.get('title'), + 'state': issue.get('state'), + 'web_url': issue.get('web_url'), + 'updated_at': issue.get('updated_at'), + 'closed_at': issue.get('closed_at') + } + + return True, result diff --git a/actions/issue_create.py b/actions/issue_create.py new file mode 100644 index 0000000..ebf2057 --- /dev/null +++ b/actions/issue_create.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueCreate(GitlabIssuesAPI): + + def run(self, url, project, title, description, assignee_ids, labels, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + # Parse assignee_ids if provided as comma-separated string + parsed_assignee_ids = None + if assignee_ids: + if isinstance(assignee_ids, str): + parsed_assignee_ids = [ + int(aid.strip()) for aid in assignee_ids.split(',') + ] + elif isinstance(assignee_ids, list): + parsed_assignee_ids = [int(aid) for aid in assignee_ids] + + # Parse labels if provided as comma-separated string + parsed_labels = None + if labels: + if isinstance(labels, str): + parsed_labels = [ + label.strip() for label in labels.split(',') + ] + elif isinstance(labels, list): + parsed_labels = labels + + issue = self.create( + self.url, + project, + title, + description=description, + assignee_ids=parsed_assignee_ids, + labels=parsed_labels + ) + + # Return formatted response + result = { + 'id': issue.get('iid'), + 'global_id': issue.get('id'), + 'title': issue.get('title'), + 'description': issue.get('description'), + 'state': issue.get('state'), + 'web_url': issue.get('web_url'), + 'created_at': issue.get('created_at') + } + + return True, result diff --git a/actions/issue_list.py b/actions/issue_list.py new file mode 100644 index 0000000..fd2e8b4 --- /dev/null +++ b/actions/issue_list.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueList(GitlabIssuesAPI): + + def run(self, url, project, state, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + issues = self.list(self.url, project, state=state) + + # Format the response to include name (title), ID (iid), and status (state) + formatted_issues = [] + for issue in issues: + formatted_issues.append({ + 'name': issue.get('title'), + 'id': issue.get('iid'), + 'status': issue.get('state'), + 'web_url': issue.get('web_url'), + 'created_at': issue.get('created_at'), + 'updated_at': issue.get('updated_at') + }) + + return True, formatted_issues diff --git a/actions/issue_note_create.py b/actions/issue_note_create.py new file mode 100644 index 0000000..061dcb2 --- /dev/null +++ b/actions/issue_note_create.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueNoteCreate(GitlabIssuesAPI): + + def run(self, url, project, issue_iid, body, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + note = self.create_note(self.url, project, issue_iid, body) + + # Return formatted response + result = { + 'id': note.get('id'), + 'body': note.get('body'), + 'author': { + 'id': note.get('author', {}).get('id'), + 'username': note.get('author', {}).get('username'), + 'name': note.get('author', {}).get('name') + }, + 'created_at': note.get('created_at'), + 'noteable_type': note.get('noteable_type'), + 'noteable_iid': note.get('noteable_iid') + } + + return True, result diff --git a/actions/issue_notes_list.py b/actions/issue_notes_list.py new file mode 100644 index 0000000..0a5df7b --- /dev/null +++ b/actions/issue_notes_list.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueNotesList(GitlabIssuesAPI): + + def run(self, url, project, issue_iid, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + notes = self.list_notes(self.url, project, issue_iid) + + # Format the response + formatted_notes = [] + for note in notes: + formatted_notes.append({ + 'id': note.get('id'), + 'body': note.get('body'), + 'author': { + 'id': note.get('author', {}).get('id'), + 'username': note.get('author', {}).get('username'), + 'name': note.get('author', {}).get('name') + }, + 'created_at': note.get('created_at'), + 'updated_at': note.get('updated_at'), + 'system': note.get('system', False), + 'noteable_type': note.get('noteable_type') + }) + + return True, formatted_notes diff --git a/actions/issue_reopen.py b/actions/issue_reopen.py new file mode 100644 index 0000000..297c9a4 --- /dev/null +++ b/actions/issue_reopen.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueReopen(GitlabIssuesAPI): + + def run(self, url, project, issue_iid, description, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + issue = self.reopen(self.url, project, issue_iid, description=description) + + # Return formatted response + result = { + 'id': issue.get('iid'), + 'global_id': issue.get('id'), + 'title': issue.get('title'), + 'description': issue.get('description'), + 'state': issue.get('state'), + 'web_url': issue.get('web_url'), + 'updated_at': issue.get('updated_at') + } + + return True, result diff --git a/actions/issue_update.py b/actions/issue_update.py new file mode 100644 index 0000000..86e7380 --- /dev/null +++ b/actions/issue_update.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +from lib.gitlab import GitlabIssuesAPI + + +class GitlabIssueUpdate(GitlabIssuesAPI): + + def run(self, url, project, issue_iid, title, description, + assignee_ids, labels, token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + # Parse assignee_ids if provided as comma-separated string + parsed_assignee_ids = None + if assignee_ids is not None: + if isinstance(assignee_ids, str): + if assignee_ids.strip(): # Only parse if not empty string + parsed_assignee_ids = [ + int(aid.strip()) for aid in assignee_ids.split(',') + ] + else: + parsed_assignee_ids = [] # Empty list clears assignees + elif isinstance(assignee_ids, list): + parsed_assignee_ids = [int(aid) for aid in assignee_ids] + + # Parse labels if provided as comma-separated string + parsed_labels = None + if labels is not None: + if isinstance(labels, str): + if labels.strip(): # Only parse if not empty string + parsed_labels = [ + label.strip() for label in labels.split(',') + ] + else: + parsed_labels = [] # Empty list clears labels + elif isinstance(labels, list): + parsed_labels = labels + + issue = self.update( + self.url, + project, + issue_iid, + title=title, + description=description, + assignee_ids=parsed_assignee_ids, + labels=parsed_labels + ) + + # Return formatted response + result = { + 'id': issue.get('iid'), + 'global_id': issue.get('id'), + 'title': issue.get('title'), + 'description': issue.get('description'), + 'state': issue.get('state'), + 'web_url': issue.get('web_url'), + 'updated_at': issue.get('updated_at') + } + + return True, result diff --git a/actions/lib/gitlab.py b/actions/lib/gitlab.py index 64834af..1611211 100644 --- a/actions/lib/gitlab.py +++ b/actions/lib/gitlab.py @@ -27,16 +27,17 @@ def wrap(*args, **kwargs): class RequestsMethod(object): @staticmethod - def method(method, url, verify_ssl=False, headers=None, params=None): + def method(method, url, verify_ssl=False, headers=None, params=None, json_data=None): methods = {'get': requests.get, - 'post': requests.post} + 'post': requests.post, + 'put': requests.put} if not params: params = dict() requests_method = methods.get(method) response = requests_method( - url, headers=headers, params=params, verify=verify_ssl) + url, headers=headers, params=params, json=json_data, verify=verify_ssl) if response.status_code: return response.json() @@ -63,9 +64,14 @@ def _get(self, url, endpoint, headers, params=None, *args, **kwargs): return RequestsMethod.method('get', api_url, self.verify_ssl, headers, params) @override_token - def _post(self, url, endpoint, headers, params=None, *args, **kwargs): + def _post(self, url, endpoint, headers, params=None, json_data=None, *args, **kwargs): api_url = '/'.join((url, self._api_ext, endpoint)) - return RequestsMethod.method('post', api_url, self.verify_ssl, headers, params) + return RequestsMethod.method('post', api_url, self.verify_ssl, headers, params, json_data) + + @override_token + def _put(self, url, endpoint, headers, params=None, json_data=None, *args, **kwargs): + api_url = '/'.join((url, self._api_ext, endpoint)) + return RequestsMethod.method('put', api_url, self.verify_ssl, headers, params, json_data) def get(self, *args, **kwargs): return self._get(*args, **kwargs) @@ -73,6 +79,9 @@ def get(self, *args, **kwargs): def post(self, *args, **kwargs): return self._post(*args, **kwargs) + def put(self, *args, **kwargs): + return self._put(*args, **kwargs) + class GitlabProjectsAPI(GitlabRestClient): @@ -85,6 +94,18 @@ def get(self, url, endpoint, **kwargs): quote_plus(endpoint)) return self._get(url, real_endpoint, token=self.token, headers=self._headers, **kwargs) + def get_file(self, url, project, file_path, ref='main', **kwargs): + real_endpoint = "{0}/{1}/repository/files/{2}".format( + self._api_endpoint, quote_plus(project), + quote_plus(file_path)) + + params = kwargs.get('params', {}) + params['ref'] = ref + kwargs['params'] = params + + return self._get(url, real_endpoint, token=self.token, + headers=self._headers, **kwargs) + class GitlabIssuesAPI(GitlabRestClient): @@ -98,6 +119,84 @@ def get(self, url, endpoint, issue_id, **kwargs): self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint, issue_id) return self._get(url, real_endpoint, token=self.token, headers=self._headers, **kwargs) + def list(self, url, endpoint, state=None, **kwargs): + real_endpoint = "{0}/{1}/{2}".format( + self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint) + + params = kwargs.get('params', {}) + if state: + params['state'] = state + kwargs['params'] = params + + return self._get(url, real_endpoint, token=self.token, + headers=self._headers, **kwargs) + + def create(self, url, endpoint, title, description=None, + assignee_ids=None, labels=None, **kwargs): + real_endpoint = "{0}/{1}/{2}".format( + self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint) + + json_data = {'title': title} + if description: + json_data['description'] = description + if assignee_ids: + json_data['assignee_ids'] = assignee_ids + if labels: + json_data['labels'] = ','.join(labels) if isinstance(labels, list) else labels + + json_data.update(kwargs) + + return self._post(url, real_endpoint, token=self.token, + headers=self._headers, json_data=json_data) + + def update(self, url, endpoint, issue_iid, title=None, description=None, + assignee_ids=None, labels=None, state_event=None, **kwargs): + real_endpoint = "{0}/{1}/{2}/{3}".format( + self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint, issue_iid) + + json_data = {} + if title: + json_data['title'] = title + if description is not None: + json_data['description'] = description + if assignee_ids is not None: + json_data['assignee_ids'] = assignee_ids + if labels is not None: + json_data['labels'] = ','.join(labels) if isinstance(labels, list) else labels + if state_event: + json_data['state_event'] = state_event + + json_data.update(kwargs) + + return self._put(url, real_endpoint, token=self.token, + headers=self._headers, json_data=json_data) + + def close(self, url, endpoint, issue_iid, **kwargs): + return self.update(url, endpoint, issue_iid, state_event='close', **kwargs) + + def reopen(self, url, endpoint, issue_iid, description=None, **kwargs): + update_kwargs = {'state_event': 'reopen'} + if description is not None: + update_kwargs['description'] = description + update_kwargs.update(kwargs) + + return self.update(url, endpoint, issue_iid, **update_kwargs) + + def list_notes(self, url, endpoint, issue_iid, **kwargs): + real_endpoint = "{0}/{1}/{2}/{3}/notes".format( + self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint, issue_iid) + return self._get(url, real_endpoint, token=self.token, headers=self._headers, **kwargs) + + def create_note(self, url, endpoint, issue_iid, body, **kwargs): + real_endpoint = "{0}/{1}/{2}/{3}/notes".format( + self._api_endpoint, quote_plus(endpoint), self._api_sub_endpoint, issue_iid) + + json_data = {'body': body} + json_data.update(kwargs) + + return self._post(url, real_endpoint, token=self.token, + headers=self._headers, json_data=json_data) + class GitlabPipelineAPI(GitlabRestClient): diff --git a/actions/project.file.get.yaml b/actions/project.file.get.yaml new file mode 100644 index 0000000..09db2f5 --- /dev/null +++ b/actions/project.file.get.yaml @@ -0,0 +1,35 @@ +--- + +name: project.file.get +description: "Get a file/template from a GitLab project repository" + +runner_type: python-script +entry_point: project_file_get.py + +parameters: + url: + type: string + description: Override the configured base url for gitlab + project: + type: string + required: true + description: the 'path_with_namespace' for the project (e.g., group/project) + file_path: + type: string + required: true + description: Path to file in repository (e.g., '.gitlab/issue_templates/bug.md') + ref: + type: string + description: Branch, tag, or commit SHA to get file from + default: main + decode_content: + type: boolean + description: Decode base64 content and include in response + default: true + token: + type: string + description: Override the configured token for the gitlab api + secret: true + verify_ssl: + type: boolean + default: False diff --git a/actions/project_file_get.py b/actions/project_file_get.py new file mode 100644 index 0000000..f536788 --- /dev/null +++ b/actions/project_file_get.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +import base64 +from lib.gitlab import GitlabProjectsAPI + + +class GitlabProjectFileGet(GitlabProjectsAPI): + + def run(self, url, project, file_path, ref, decode_content, + token, verify_ssl): + self.url = url or self.url + self.verify_ssl = verify_ssl or self.verify_ssl + self.token = token or self.token + + file_data = self.get_file(self.url, project, file_path, ref=ref) + + # Return formatted response + result = { + 'file_name': file_data.get('file_name'), + 'file_path': file_data.get('file_path'), + 'size': file_data.get('size'), + 'encoding': file_data.get('encoding'), + 'content': file_data.get('content'), + 'ref': file_data.get('ref'), + 'blob_id': file_data.get('blob_id'), + 'commit_id': file_data.get('commit_id'), + 'last_commit_id': file_data.get('last_commit_id') + } + + # Decode base64 content if requested + if decode_content and file_data.get('encoding') == 'base64': + try: + decoded_content = base64.b64decode( + file_data.get('content', '') + ).decode('utf-8') + result['content_decoded'] = decoded_content + except Exception as e: + result['content_decoded'] = None + result['decode_error'] = str(e) + + return True, result diff --git a/pack.yaml b/pack.yaml index 324c372..46731f5 100644 --- a/pack.yaml +++ b/pack.yaml @@ -3,7 +3,7 @@ name: gitlab description: GitLab Rest API keywords: - gitlab -version: 1.0.1 +version: 1.0.2 author: Daniel Chamot email: daniel@nullkarma.com python_versions: