222 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			222 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
| #!/usr/bin/env python3
 | |
| 
 | |
| import sys, os
 | |
| import argparse
 | |
| import requests
 | |
| import hashlib
 | |
| import time
 | |
| 
 | |
| # NOTES: html2text: html2text
 | |
| # the replies are listed by context, should be link-listed to avoid issues,
 | |
| # should specify next hash to provide some kind of a filter
 | |
| # visibility public+unlisted, all unlisted, all private, all direct
 | |
| 
 | |
| VERSION = "0.1.0"
 | |
| 
 | |
| DPASTE_URL = "https://dpaste.com"
 | |
| 
 | |
| STATUS_LENGTH_LIMIT = 400
 | |
| 
 | |
| 
 | |
| def trace(x):
 | |
|     sys.stderr.write(sys.argv[0] + ": " + x + "\n")
 | |
| 
 | |
| 
 | |
| def api_token(args):
 | |
|     if args.debug_api_token:
 | |
|         return args.debug_api_token
 | |
|     if args.env_api_token:
 | |
|         return os.environ["PATCHODON_API_TOKEN"]
 | |
|     raise ("API token not specified")
 | |
| 
 | |
| 
 | |
| def do_post_status(args, body, parent=None, optional=None):
 | |
|     if len(body) > STATUS_LENGTH_LIMIT:
 | |
|         raise ("required status body too long")
 | |
| 
 | |
|     if not args.instance_url:
 | |
|         raise ("mastodon instance not specified")
 | |
| 
 | |
|     token = api_token(args)
 | |
| 
 | |
|     headers = {"Authorization": f"Bearer {token}"}
 | |
|     st = body + (
 | |
|         "\n" + optional[0 : (STATUS_LENGTH_LIMIT - len(body) - 1)]
 | |
|         if optional
 | |
|         else ""
 | |
|     )
 | |
|     data = {status: st, visibility: "unlisted"}
 | |
|     if parent:
 | |
|         data["in_reply_to_id"] = parent
 | |
| 
 | |
|     r = requests.post(
 | |
|         args.instance_url + "/api/v1/statuses", data=data, headers=headers
 | |
|     )
 | |
|     if r.status_code != 200:
 | |
|         raise ("mastodon status posting failed ({r.status_code})")
 | |
| 
 | |
|     return r.json.id
 | |
| 
 | |
| 
 | |
| def do_pastebin_file(file):
 | |
|     # DPASTE API USE RULES:
 | |
|     # - user-agent must be set properly
 | |
|     # - 1 second between requests
 | |
|     trace(f"pasting {file}...")
 | |
|     r = requests.post(
 | |
|         DPASTE_URL + "/api/v2/",
 | |
|         data={
 | |
|             "contents": open(file, "r").read(),
 | |
|             "syntax": "diff",
 | |
|             "title": os.path.basename(file),
 | |
|             "expiry_days": 30,
 | |
|         },
 | |
|         headers={"User-agent": f"patchodon v{VERSION}"},
 | |
|     )
 | |
|     if r.status_code != 201:
 | |
|         raise (f"dpaste POST failed for `{file}'")
 | |
|     sleep(1.1)
 | |
|     return r.headers["location"] + ".txt"
 | |
| 
 | |
| 
 | |
| def split_off_diff(s):
 | |
|     return s.split("\ndiff --git ")[0]
 | |
| 
 | |
| 
 | |
| def do_post(args):
 | |
|     files = args.patchfile
 | |
|     if not files:
 | |
|         trace("reading patchfile series from stdin")
 | |
|         files = sys.stdin.readlines().map(lambda x: x.rstrip(chars="\n"))
 | |
|     n_patches = len(files)
 | |
|     hashes = files.map(lambda x: hashlib.sha1(open(x, "r").read()).hexdigest())
 | |
|     short_hashes = hashes.map(lambda x: x[0:8])
 | |
|     full_hash = hashlib.sha1(hashes.join(" ")).hexdigest()
 | |
|     paste_raw_urls = map(do_pastebin_file, files)
 | |
|     trace("posting the header...")
 | |
|     parent_post_id, url = do_post_status(
 | |
|         args,
 | |
|         args.subject
 | |
|         + f"\n[patchodon hash {full_hash} shorts {short_hashes.join(' ')}]",
 | |
|     )
 | |
|     for fn, pst, hsh, series in zip(
 | |
|         files, paste_raw_urls, hashes, range(n_patches)
 | |
|     ):
 | |
|         trace(f"posting patch {series+1}/{n_patches}...")
 | |
|         parent_post_id, _ = do_post_status(
 | |
|             args,
 | |
|             f"[patchodon {series+1}/{n_patches} {hsh}\n{pst}\n",
 | |
|             parent=parent_post_id,
 | |
|             optional=split_off_diff(open(fn, "r").read()),
 | |
|         )
 | |
|     print(url)
 | |
| 
 | |
| 
 | |
| def do_get(args):
 | |
|     pass
 | |
| 
 | |
| 
 | |
| def main():
 | |
|     ap = argparse.ArgumentParser(
 | |
|         prog=sys.argv[0],
 | |
|         epilog="patchodon.py version " + VERSION + " is a free software.",
 | |
|         description="Publicly send and receive git patch series via Mastodon.",
 | |
|     )
 | |
| 
 | |
|     cmds = ap.add_subparsers(required=True, dest="command")
 | |
| 
 | |
|     if "POST command":
 | |
|         post = cmds.add_parser("post")
 | |
|         if "API token sources":
 | |
|             group = post.add_mutually_exclusive_group()
 | |
|             group.add_argument(
 | |
|                 "--debug-api-token",
 | |
|                 help=(
 | |
|                     "specify the API token (not very secure, good for debugging"
 | |
|                     " only)"
 | |
|                 ),
 | |
|             )
 | |
|             group.add_argument(
 | |
|                 "-e",
 | |
|                 "--env-api-token",
 | |
|                 action="store_true",
 | |
|                 help="get the API token from environment PATCHODON_API_TOKEN",
 | |
|             )
 | |
| 
 | |
|         post.add_argument(
 | |
|             "-i", "--instance-url", help="mastodon instance URL to post to"
 | |
|         )
 | |
|         post.add_argument(
 | |
|             "-s",
 | |
|             "--subject",
 | |
|             default="",
 | |
|             help=(
 | |
|                 "start of the initial post (e.g. to @tag someone and name the"
 | |
|                 " project and topic)"
 | |
|             ),
 | |
|         )
 | |
|         post.add_argument(
 | |
|             "patchfile",
 | |
|             nargs="*",
 | |
|             help=(
 | |
|                 "filenames of the patch series; taken from stdin if none are"
 | |
|                 " specified (useful for piping the output of git-format-patch"
 | |
|                 " into patchodon)"
 | |
|             ),
 | |
|         )
 | |
| 
 | |
|     if "GET command":
 | |
|         get = cmds.add_parser("get")
 | |
|         get.add_argument(
 | |
|             "patch_url",
 | |
|             help=(
 | |
|                 "root URL of the status where the patch was posted (the status"
 | |
|                 " should contain the patch hash)"
 | |
|             ),
 | |
|         )
 | |
|         if "output possibilities":
 | |
|             group = get.add_mutually_exclusive_group()
 | |
|             group.add_argument(
 | |
|                 "-a",
 | |
|                 "--am",
 | |
|                 help=(
 | |
|                     "apply the patches immediately with git-am instead of"
 | |
|                     " storing them in a directory"
 | |
|                 ),
 | |
|             )
 | |
|             group.add_argument(
 | |
|                 "-C",
 | |
|                 "--out-dir",
 | |
|                 help=(
 | |
|                     "output the patches into a given directory (by default, `.'"
 | |
|                 ),
 | |
|             )
 | |
|             get.add_argument(
 | |
|                 "--overwrite",
 | |
|                 action="store_true",
 | |
|                 help="overwrite existing patch files instead of renaming",
 | |
|             )
 | |
| 
 | |
|     ap.add_argument(
 | |
|         "-c",
 | |
|         "--config",
 | |
|         help=(
 | |
|             "specify a custom config INI file (it can specify a section"
 | |
|             " [patchodon] with keys instance_url and api_token), defaults to"
 | |
|             " `$HOME/.patchodon.ini'; specify /dev/null to avoid reading"
 | |
|             " configs"
 | |
|         ),
 | |
|     )
 | |
|     args = ap.parse_args()
 | |
| 
 | |
|     if args.command == "post":
 | |
|         do_post(args)
 | |
|     elif args.command == "get":
 | |
|         do_get(args)
 | |
|     else:
 | |
|         raise ("fatal: args borked")
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     main()
 |