From f317ed3017272270f1a6917de923fece22b30aed Mon Sep 17 00:00:00 2001 From: Morgan McMillian Date: Mon, 12 Jul 2021 18:30:14 -0700 Subject: [PATCH] initial project commit --- LICENSE | 21 +++++ README.md | 14 +++ output.py | 78 ++++++++++++++++ setup.py | 16 ++++ srht.py | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 output.py create mode 100644 setup.py create mode 100644 srht.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3906dbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Morgan McMillian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a67c234 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# srht-cli + +A quick and dirty command line interface for sourcehut. + +**WARNING** + +!! This utility leverages the legacy sourcehut api and will most certainly at some point break. !! + +## Contributing + +Report bugs, send patches to [~thrrgilag/public-inbox@lists.sr.ht](https://lists.sr.ht/~thrrgilag/public-inbox). + +Discussion also in [thrrgilag's dev chat](https://thrrgilag.net/chat/dev). + diff --git a/output.py b/output.py new file mode 100644 index 0000000..b2e106c --- /dev/null +++ b/output.py @@ -0,0 +1,78 @@ +import json + +todo_url = "https://todo.sr.ht/" +git_url = "https://git.sr.ht/" +#print(json.dumps(events, indent=4)) + +def show_ticket(ticket): + assignees = [] + for a in ticket['assignees']: + assignees.append(a['canonical_name']) + if ticket['submitter']['type'] == "user": + submitter = ticket['submitter']['canonical_name'] + elif ticket['submitter']['type'] == "email": + submitter = ticket['submitter']['address'] + else: + submitter = ticket['submitter'] + print(f"[{ticket['ref']}]") + print() + print(f" status: {ticket['status']}") + print(f" resolution: {ticket['resolution']}") + print(f" submitter: {submitter}") + print(f" assigned: {assignees}") + print(f" created: {ticket['created']}") + print(f" updated: {ticket['updated']}") + print(f" labels: {ticket['labels']}") + print() + print(ticket['title']) + print() + print(ticket['description']) + +def show_comments(events): + print() + for event in reversed(events): + if "comment" not in event['event_type']: + continue + + comment = event['comment'] + submitter = comment['submitter']['canonical_name'] + old_res = event['old_resolution'] + new_res = event['new_resolution'] + old_status = event['old_status'] + new_status = event['new_status'] + print(f"{comment['id']} {submitter} @ {comment['created']}") + print(f"{old_status} -> {new_status}, {old_res} -> {new_res}") + print(f" {comment['text']}") + print() + +def show_tickets(tickets, closed): + #print(json.dumps(tickets, indent=4)) + for ticket in tickets: + #tr_name = ticket['tracker']['name'] + #tr_owner = ticket['tracker']['owner']['canonical_name'] + #ticket_url = todo_url + tr_owner + "/" + tr_name + "/" + str(ticket['id']) + if ticket['status'] == "resolved" and not closed: + continue + print(f"{ticket['id']}: {ticket['title']} ({ticket['status']})") + #print(ticket_url) + +def show_trackers(trackers, verbose): + for tracker in trackers: + print(f"{tracker['id']} {tracker['name']}") + if verbose: + print(f"{todo_url}{tracker['owner']['canonical_name']}/{tracker['name']}") + print(f" {tracker['description']}") + print() + +def show_labels(labels): + for label in labels: + print(f"{label['name']}") + +def show_repos(repos, verbose): + for repo in repos: + print(f"{repo['id']} {repo['name']}") + if verbose: + print(f"{git_url}{repo['owner']['canonical_name']}/{repo['name']}") + print(f" {repo['description']}") + print() + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e5b7769 --- /dev/null +++ b/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + +setup( + name='sr.ht cli', + version='0.1.0', + py_modules=['srht','output'], + install_requires=[ + 'click', + 'requests', + ], + entry_points={ + 'console_scripts': [ + 'srht = srht:cli', + ], + }, +) diff --git a/srht.py b/srht.py new file mode 100644 index 0000000..3a8d532 --- /dev/null +++ b/srht.py @@ -0,0 +1,264 @@ +import click +import requests +import json +import os +import output + +import importlib.metadata + +TICKET_STATUS = [ + "reported", + "confirmed", + "in_progress", + "pending", + "resolved" +] +TICKET_RESOLUTION = [ + "unresolved", + "fixed", + "implemented", + "wont_fix", + "by_design", + "invalid", + "duplicate", + "not_our_bug" +] + +access_token = os.environ["SOURCEHUT_CLI_ACCESS_TOKEN"] +username = "~"+os.environ["USER"] +base_url = "" + +@click.group() +@click.option('--user') +@click.option('--token') +@click.version_option(package_name='sr.ht-cli') +def cli(user, token): + global access_token + global username + if token is not None: + access_token = token + if user is not None: + username = "~"+user + +@cli.group() +def git(): + '''sourcehut git hosting service''' + global base_url + base_url = "https://git.sr.ht/api" + +@git.command("repos") +@click.option('--verbose', '-v', is_flag=True) +def get_repos(verbose): + '''List repositories''' + url = base_url + f"/{username}/repos" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_repos(r.json()['results'], verbose) + else: + print(r.status_code) + print(r.text) + +@git.command("webhooks") +@click.argument('repo') +def get_git_webhooks(repo): + '''List webhooks for a repository''' + url = base_url + f"/{username}/repos/{repo}/webhooks" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + print(json.dumps(r.json(), indent=4)) + else: + print(r.status_code) + print(r.text) + +@git.command("add-webhook") +@click.argument('repo') +@click.argument('url') +@click.option('--event', required=True, multiple=True) +def add_git_webhook(repo, url, event): + '''Add webhook to a repository''' + payload = {'url': url, 'events': event} + url = base_url + f"/{username}/repos/{repo}/webhooks" + headers = {'Authorization': "Bearer " + access_token} + r = requests.post(url, headers=headers, json=payload) + print(r.status_code) + print(r.text) + +@git.command("del-webhook") +@click.argument('repo') +@click.argument('hookid') +def del_git_webhook(repo, hookid): + '''Delete webhook from a repository''' + url = base_url + f"/{username}/repos/{repo}/webhooks/{hookid}" + headers = {'Authorization': "Bearer " + access_token} + r = requests.delete(url, headers=headers) + print(r.status_code) + print(r.text) + +@cli.group() +def todo(): + '''sourcehut ticket tracking service''' + global base_url + base_url = "https://todo.sr.ht/api" + +@todo.command("tickets") +@click.argument('tracker') +@click.option('--closed', is_flag=True) +def show_tickets(tracker, closed): + '''List tickets on a tracker''' + url = base_url + f"/user/{username}/trackers/{tracker}/tickets" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_tickets(r.json()['results'], closed) + else: + print(r.status_code) + print(r.text) + +@todo.command("submit") +@click.argument('tracker') +def submit_ticket(tracker): + '''Submit new ticket on a tracker''' + issue = click.edit() + if issue is None: + print("nothing saved, aborting...") + return + + title, description = issue.split('\n', 1) + description = description.lstrip() + + url = base_url + f"/user/{username}/trackers/{tracker}/tickets" + headers = {'Authorization': "Bearer " + access_token} + payload = {'title': title, 'description': description} + r = requests.post(url, headers=headers, json=payload) + print(r.status_code) + print(r.text) + +@todo.command() +@click.argument('tracker') +@click.argument('ticketid') +@click.option('--status', type=click.Choice(TICKET_STATUS)) +@click.option('--resolution', type=click.Choice(TICKET_RESOLUTION)) +@click.option('--label', multiple=True) +def comment(tracker, ticketid, status, resolution, label): + '''Comment, label, update ticket status''' + payload = {} + + comment = click.edit() + if comment is not None and len(comment) > 0: + payload["comment"] = comment + + if status is not None: + payload["status"] = status + + if label is not None: + payload["labels"] = label + + if resolution is not None: + # The legacy API is a bit odd and only works to set the status and + # resolution together to close or re-open. Status otherwise seems + # irrelevant. + if status != "reported": + payload["status"] = "resolved" + payload["resolution"] = resolution + + if len(payload) < 1: + print("nothing saved, aborting...") + return + + print(payload) + url = base_url + f"/user/{username}/trackers/{tracker}/tickets/{ticketid}" + headers = {'Authorization': "Bearer " + access_token} + r = requests.put(url, headers=headers, json=payload) + print(r.status_code) + print(r.text) + +@todo.command("show") +@click.argument('tracker') +@click.argument('ticketid') +def show_ticket(tracker, ticketid): + '''Show details of a ticket on a tracker''' + headers = {'Authorization': "Bearer " + access_token} + + url = base_url + f"/user/{username}/trackers/{tracker}/tickets/{ticketid}" + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_ticket(r.json()) + else: + print(r.status_code) + print(r.text) + return + + # fetch and display the events for the ticket as well + url = base_url + f"/user/{username}/trackers/{tracker}/tickets/{ticketid}/events" + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_comments(r.json()['results']) + else: + print(r.status_code) + print(r.text) + +@todo.command("trackers") +@click.option('--verbose', '-v', is_flag=True) +def get_trackers(verbose): + '''List trackers''' + url = base_url + f"/user/{username}/trackers" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_trackers(r.json()['results'], verbose) + else: + print(r.status_code) + print(r.text) + +@todo.command("labels") +@click.argument('tracker') +def get_labels(tracker): + '''List labels for a tracker''' + url = base_url + f"/user/{username}/trackers/{tracker}/labels" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + output.show_labels(r.json()['results']) + else: + print(r.status_code) + print(r.text) + +@todo.command("webhooks") +@click.argument('tracker') +def get_tracker_webhooks(tracker): + '''List webhooks for a tracker''' + url = base_url + f"/user/{username}/trackers/{tracker}/webhooks" + headers = {'Authorization': "Bearer " + access_token} + r = requests.get(url, headers=headers) + if r.status_code == 200: + print(json.dumps(r.json(), indent=4)) + else: + print(r.status_code) + print(r.text) + +@todo.command("del-webhook") +@click.argument('tracker') +@click.argument('hookid') +def del_tacker_webhook(tracker, hookid): + '''Delete webhook from a tracker''' + url = base_url + f"/user/{username}/trackers/{tracker}/webhooks/{hookid}" + headers = {'Authorization': "Bearer " + access_token} + r = requests.delete(url, headers=headers) + print(r.status_code) + print(r.text) + +@todo.command("add-webhook") +@click.argument('tracker') +@click.argument('url') +@click.option('--event', required=True, multiple=True) +def add_tacker_webhook(tracker, url, event): + '''Add webhook to a tracker''' + payload = {'url': url, 'events': event} + url = base_url + f"/user/{username}/trackers/{tracker}/webhooks" + headers = {'Authorization': "Bearer " + access_token} + r = requests.post(url, headers=headers, json=payload) + print(r.status_code) + print(r.text) +