getting patches seems to work

This commit is contained in:
Mirek Kratochvil 2025-10-12 21:59:25 +02:00
parent a7309aa1c1
commit 82247f41ac

View file

@ -1,10 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys, os
import argparse import argparse
import requests
import hashlib import hashlib
import html2text
import os
import re
import requests
import sys
import time import time
from pathlib import Path
# NOTES: html2text: html2text # NOTES: html2text: html2text
# the replies are listed by context, should be link-listed to avoid issues, # the replies are listed by context, should be link-listed to avoid issues,
@ -17,6 +21,8 @@ DPASTE_URL = "https://dpaste.com" # TODO any good way to parametrize this?
STATUS_LENGTH_LIMIT = 400 # TODO obtain from instance STATUS_LENGTH_LIMIT = 400 # TODO obtain from instance
html2text.config.IGNORE_ANCHORS = True
def trace(x): def trace(x):
sys.stderr.write(sys.argv[0] + ": " + x + "\n") sys.stderr.write(sys.argv[0] + ": " + x + "\n")
@ -27,12 +33,12 @@ def api_token(args):
return args.debug_api_token return args.debug_api_token
if args.env_api_token: if args.env_api_token:
return os.environ["PATCHODON_API_TOKEN"] return os.environ["PATCHODON_API_TOKEN"]
raise ("API token not specified") raise "API token not specified"
def auth_headers(args): def auth_headers(args):
if not args.instance_url: if not args.instance_url:
raise ("mastodon instance not specified") raise "mastodon instance not specified"
token = api_token(args) token = api_token(args)
@ -41,9 +47,7 @@ def auth_headers(args):
def do_post_status(args, body, parent=None, optional=None): def do_post_status(args, body, parent=None, optional=None):
if len(body) > STATUS_LENGTH_LIMIT: if len(body) > STATUS_LENGTH_LIMIT:
raise ("required status body too long") raise "required status body too long"
headers = auth_headers(args)
st = body + ( st = body + (
"\n" + optional[0 : (STATUS_LENGTH_LIMIT - len(body) - 1)] "\n" + optional[0 : (STATUS_LENGTH_LIMIT - len(body) - 1)]
@ -55,11 +59,13 @@ def do_post_status(args, body, parent=None, optional=None):
data["in_reply_to_id"] = parent data["in_reply_to_id"] = parent
r = requests.post( r = requests.post(
args.instance_url + "/api/v1/statuses", data=data, headers=headers args.instance_url + "/api/v1/statuses",
data=data,
headers=auth_headers(args),
) )
if r.status_code != 200: if r.status_code != 200:
raise ("mastodon status posting failed ({r.status_code})") raise "mastodon status posting failed ({r.status_code})"
rj = r.json() rj = r.json()
return (rj["id"], rj["url"]) return (rj["id"], rj["url"])
@ -73,7 +79,7 @@ def do_pastebin_file(file):
r = requests.post( r = requests.post(
DPASTE_URL + "/api/v2/", DPASTE_URL + "/api/v2/",
data={ data={
"content": open(file, "r").read(), "content": Path(file).read_text(),
"syntax": "diff", "syntax": "diff",
"title": os.path.basename(file), "title": os.path.basename(file),
"expiry_days": 1, # TODO remove after testing "expiry_days": 1, # TODO remove after testing
@ -82,7 +88,7 @@ def do_pastebin_file(file):
) )
time.sleep(1.1) time.sleep(1.1)
if r.status_code != 201: if r.status_code != 201:
raise (f"dpaste POST failed for `{file}'") raise f"dpaste POST failed for `{file}'"
return r.headers["location"] + ".txt" return r.headers["location"] + ".txt"
@ -108,7 +114,7 @@ def do_post(args):
files = mapl(lambda x: x.rstrip(chars="\n"), sys.stdin.readlines()) files = mapl(lambda x: x.rstrip(chars="\n"), sys.stdin.readlines())
n_patches = len(files) n_patches = len(files)
hashes = mapl( hashes = mapl(
lambda x: hashlib.sha1(open(x, "r").read().encode()).hexdigest(), files lambda x: hashlib.sha1(Path(x).read_text().encode()).hexdigest(), files
) )
short_hashes = mapl(lambda x: x[0:8], hashes) short_hashes = mapl(lambda x: x[0:8], hashes)
full_hash = hashlib.sha1(" ".join(hashes).encode()).hexdigest() full_hash = hashlib.sha1(" ".join(hashes).encode()).hexdigest()
@ -117,7 +123,7 @@ def do_post(args):
parent_post_id, url = do_post_status( parent_post_id, url = do_post_status(
args, args,
f"{mayline(args.recipient)}{mayline(args.subject)}" f"{mayline(args.recipient)}{mayline(args.subject)}"
f"[patchodon hash {full_hash} / {' '.join(short_hashes)}]", f"[patchodon: {full_hash} / {' '.join(short_hashes)}]",
) )
for fn, pst, hsh, series in zip( for fn, pst, hsh, series in zip(
files, paste_raw_urls, hashes, range(n_patches) files, paste_raw_urls, hashes, range(n_patches)
@ -129,40 +135,141 @@ def do_post(args):
f"[patchodon {series+1}/{n_patches} {hsh}]\n" f"[patchodon {series+1}/{n_patches} {hsh}]\n"
f"{pst}\n", f"{pst}\n",
parent=parent_post_id, parent=parent_post_id,
optional=split_off_diff(open(fn, "r").read()), optional=split_off_diff(Path(fn).read_text()),
) )
print(url) print(url)
def find_head_post(args): def find_head_post(args):
headers = auth_headers(args)
r = requests.get( r = requests.get(
args.instance_id + "/api/v2/search", args.instance_url + "/api/v2/search",
headers=auth_headers(args),
params={"resolve": "true", "limit": "10", "q": args.patch_url}, params={"resolve": "true", "limit": "10", "q": args.patch_url},
) )
print(r.__dict__)
if r.status_code != 200: if r.status_code != 200:
raise ("status URL search failed!") raise "status URL search failed!"
sts = filter(lambda x: x["url"] == args.patch_url, r.json()["statuses"]) sts = list(
filter(lambda x: x["url"] == args.patch_url, r.json()["statuses"])
)
if len(sts) < 1: if len(sts) < 1:
raise ("status URL not found") raise "status URL not found"
if len(sts) > 1: if len(sts) > 1:
raise ("ambiguous status URL?") raise "ambiguous status URL?"
st = sts[0] st = sts[0]
return (st["id"], st["account"]["id"], st["content"]) return (st["id"], st["account"]["id"], st["content"])
def get_descendant_statuses(args, parent):
r = requests.get(
args.instance_url + f"/api/v1/statuses/{parent}/context",
headers=auth_headers(args),
)
if r.status_code != 200:
raise f"retrieval of context failed for {parent}"
rj = r.json()
return rj["descendants"] if "descendants" in rj else []
re_head = re.compile(
r"^\[patchodon: ([0-9a-f]{40}) /(( +[0-9a-f]{8})+)\]$", re.MULTILINE
)
re_patch = re.compile(
r"^\[patchodon ([0-9]+)/([0-9]+) ([0-9a-f]{40})\]"
r" *\n(https://dpaste.com/[a-zA-Z0-9]+\.txt)$",
re.MULTILINE,
)
def parse_matching_status(st, parent, account, n, total_n, short_hash):
if st["in_reply_to_id"] != parent:
trace(f"wrong reply in status {st['id']}")
return None
if st["account"]["id"] != account:
trace(f"bad account in status {st['id']}")
return None
st_content = html2text.html2text(st["content"])
match = re_patch.search(st_content)
if not match:
return None
gs = match.groups()
if gs[0] != str(n) or gs[1] != str(total_n):
trace(f"patch mis-ordered in status {st['id']}")
return None
long_hash = gs[2]
if long_hash[0:8] != short_hash:
trace(f"patch hash mismatch in status {st['id']}")
return None
url = gs[3]
r = requests.get(url)
if r.status_code != 200:
trace(f"could not get patch from status {st['id']} via {url}")
return None
if long_hash != hashlib.sha1(r.text.encode()).hexdigest():
trace(f"patch hash differs from file in status {st['id']}")
return None
return (st["id"], r.text)
def do_get(args): def do_get(args):
st_id, st_acct_id, st_content = find_head_post(args) st_id, st_acct_id, st_content_html = find_head_post(args)
st_content = html2text.html2text(st_content_html)
# parse out the hash and subhashes # parse out the hash and subhashes
match = re_head.search(st_content)
if not match:
raise "no patchodon header found"
full_hash = match.groups()[0]
short_hashes = list(
filter(lambda x: len(x) > 0, match.groups()[1].split(" "))
)
patches = [None for _ in short_hashes]
n_patches = len(patches)
assert n_patches > 0
parent = st_id
for i, short_hash in enumerate(short_hashes):
trace(f"getting patch {i+1} ({short_hash})...")
# get context, all replies from the same author as the original status ID, subhashes must match # get context, all replies from the same author as the original status ID, subhashes must match
# repeat for all subhashes sts = get_descendant_statuses(args, parent)
ok_sts = list(
filter(
lambda x: x != None,
map(
lambda x: parse_matching_status(
x, parent, st_acct_id, i + 1, n_patches, short_hash
),
sts,
),
)
)
if len(ok_sts) == 0:
raise f"no suitable patches found for {i+1} ({short_hash})"
if len(ok_sts) > 1:
raise f"ambiguous statuses for patch {i+1} ({short_hash})"
ok_st_id, ok_st_patch = ok_sts[0]
parent = ok_st_id
patches[i] = ok_st_patch
# verify the full hash # verify the full hash
# pass as one blob to git-am OR throw to a directory hashes = list(map(lambda x: hashlib.sha1(x.encode()).hexdigest(), patches))
computed_full_hash = hashlib.sha1(" ".join(hashes).encode()).hexdigest()
if computed_full_hash != full_hash:
raise "hash checksums do not match!"
# print out stuff
if args.out_prefix:
for i, patch in enumerate(patches):
path = args.out_prefix + f"{i+1:04d}.patch"
if not args.overwrite and os.path.exists(path):
raise f"refusing to overwrite {path}"
Path(path).write_text(patch)
else:
for patch in patches:
sys.stdout.write(patch)
sys.stdout.write("\n") # be nice
def main(): def main():
@ -172,10 +279,8 @@ def main():
description="Publicly send and receive git patch series via Mastodon.", description="Publicly send and receive git patch series via Mastodon.",
) )
cmds = ap.add_subparsers(required=True, dest="command")
if "API token sources": if "API token sources":
group = cmds.add_mutually_exclusive_group() group = ap.add_mutually_exclusive_group()
group.add_argument( group.add_argument(
"--debug-api-token", "--debug-api-token",
help=( help=(
@ -190,7 +295,7 @@ def main():
help="get the API token from environment PATCHODON_API_TOKEN", help="get the API token from environment PATCHODON_API_TOKEN",
) )
cmds.add_argument( ap.add_argument(
"-i", "-i",
"--instance-url", "--instance-url",
help=( help=(
@ -198,6 +303,8 @@ def main():
), ),
) )
cmds = ap.add_subparsers(required=True, dest="command")
if "POST command": if "POST command":
post = cmds.add_parser("post") post = cmds.add_parser("post")
post.add_argument( post.add_argument(
@ -237,24 +344,13 @@ def main():
" should contain the patch hash)" " should contain the patch hash)"
), ),
) )
if "output possibilities": get.add_argument(
group = get.add_mutually_exclusive_group()
group.add_argument(
"-a",
"--run-git-am",
action="store_true",
help=(
"apply the patches immediately with git-am instead of"
" storing them in a directory"
),
)
group.add_argument(
"-C", "-C",
"--out-prefix", "--out-prefix",
default="./patchodon-",
help=( help=(
"write the numbered patchfiles to files with a given prefix" "instead of writing to stdout (for piping to git-am), write"
" (the default `./patchodon-' will produce files like" " the numbered patchfiles to files with a given prefix"
" (specifying `./patchodon-' will produce files like"
" `./patchodon-0001.patch')" " `./patchodon-0001.patch')"
), ),
) )
@ -277,6 +373,8 @@ def main():
) )
args = ap.parse_args() args = ap.parse_args()
# TODO patch from config if found
if args.command == "post": if args.command == "post":
do_post(args) do_post(args)
elif args.command == "get": elif args.command == "get":