From c7987858924608f8013e64260efb806e5ddeab9f Mon Sep 17 00:00:00 2001 From: Steven Van Dorp Date: Sun, 1 Feb 2026 15:13:01 +0100 Subject: Initial commit --- src/xpit_/create.py | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/xpit_/create.py (limited to 'src/xpit_/create.py') diff --git a/src/xpit_/create.py b/src/xpit_/create.py new file mode 100644 index 0000000..30d32fa --- /dev/null +++ b/src/xpit_/create.py @@ -0,0 +1,263 @@ +import os +import subprocess +import getpass +from sys import argv, stdout, stderr +from datetime import datetime, timezone +from . import args_helpers, identity, issue, issue_tracker + +help_text = """\ +Usage: xpit create [options] +Examples: + xpit create + xpit create -u Bob -i secret_bob_alias -r 20260101_120000:20260101_140000 -x + xpit create -e_ -t "Startup slow" -l performance,startup -d "The window takes 200ms to open." +Options: + -d, --description + The post body. + Defaults to 'Description goes here...' + -x, --disable-inline-annotations + Disable the explanatory ; annotations in newly created posts' headers. + -e, --editor + Which editor to open the issue in. + Pass '_' (underscore) to not open an editor. + If not specified, the editor is chosen in the following order: + 1. $XPIT_EDITOR + 2. $GIT_EDITOR + 3. $EDITOR + 4. $VISUAL + 5. + windows: "notepad" + other: "vi" + -i, --identity + Label for keypair used for unique identification and signature. + This is used to uniquely identify a user. + Keys are stored in '~/.config/xpit/identities/'. + Defaults to 'default'. + -l, --labels + Comma-separated list of labels applicable to this issue. + Not compatible with --reply-to. + Empty by default. + -r, --reply-to [:LOCAL_ID] + Reply to an issue or a specific reply within an issue. + -P, --skip-prompt + Skip interactive prompts for missing fields (title, description, labels). + Only applies when using `-e_` (no automatic editor). + -t, --title + The issue's title. + Not compatible with --reply-to. + Defaults to 'Title'. + -u, --username + The username to use for the issue. + If not specified, uses `getpass.getuser()`. + -h, --help + Print this help text. +""" + +class Args: + def __init__(self): + self.config_dir: str + self.editor: str + self.username: str + self.identity: str = "default" + self.reply_to: str | None = None + self.disable_inline_annotations: bool = False + + self.labels: bytes = b"" + self.skip_prompt: bool = False + self.description: bytes = b"Description goes here..." + self.title: bytes = b"Title" + + def parse(self) -> bool: + flag_value_map = { + "help|h": 0, + "description|d": 1, + "disable-inline-annotations|x": 0, + "editor|e": 1, + "identity|i": 1, + "labels|l": 1, + "reply|r": 1, + "skip-prompt|P": 0, + "title|t|": 1, + "username|u": 1, + } + res = args_helpers.parse_generic(flag_value_map, argv, 2) + _editor: str | None = None + _username: str | None = getpass.getuser() + if res is None: + return False + if "help" in res: + stdout.write(help_text) + return False + if "description" in res: + self.description = res["description"][0].encode("utf-8") + if "disable-inline-annotations" in res: + self.disable_inline_annotations = True + if "editor" in res: + _editor = res["editor"][0] + if "identity" in res: + self.identity = res["identity"][0] + if "labels" in res: + labels: str = res["labels"][0] + issue_tracker.load_config() + for label in labels.split(','): + if label.encode("utf-8") not in issue_tracker.config[b"allowed_labels"]: + stderr.write( + f"Label '{label}' not allowed.\nAllowed: " + +issue_tracker.config[b"allowed_labels"].decode("utf-8") + +'\n') + return False + self.labels = labels.encode("utf-8") + if "reply" in res: + self.reply_to = res["reply"][0] + if "skip-prompt" in res: + self.skip_prompt = True + if "title" in res: + self.title = res["title"][0].encode("utf-8") + if "username" in res: + _username = res["username"][0] + + if _username is None: + stderr.write("No username.") + return False + self.username = _username + + if _editor is None: + _editor = ( + os.environ.get("XPIT_EDITOR") + or os.environ.get("GIT_EDITOR") + or os.environ.get("EDITOR") + or os.environ.get("VISUAL") + or None + ) + if _editor is None: + if os.name == "nt": + _editor = "notepad" + else: + _editor = "vi" + self.editor = _editor + + if os.name == "nt": + appdata = os.getenv("APPDATA") + if appdata is None: + stderr.write("Couldn't find $APPDATA directory") + return False + self.config_dir = os.path.join(appdata, "xpit") + else: + home = os.getenv("HOME") + if home is None: + stderr.write("Couldn't find $HOME directory") + return False + self.config_dir = os.path.join(home, ".config", "xpit") + + return True +args = Args() + +def id_gen() -> str: + return datetime.now(timezone.utc).strftime(issue_tracker.config[b"id_format"].decode("utf-8")) + +def main() -> int: + if len(argv) < 2: + stdout.write(help_text) + return 1 + + if not args.parse(): + return 1 + + identities_dir = os.path.join(args.config_dir, "identities") + private_key, public_key = identity.load(identities_dir, args.identity) + _ = private_key + + issue_tracker.load_config() + if issue_tracker.dir is None: + stderr.write("xpit not initialized. Run `xpit init`.\n") + return 1 + + if args.reply_to is None: + issue_id = id_gen() + else: + issue_id = args.reply_to.split(":", 1)[0] + issue_dir = os.path.join(issue_tracker.dir, issue_id) + if args.reply_to is None: + if os.path.exists(issue_dir): + stderr.write(f"Failed to create issue {issue_id}: Already exists.\n") + return 1 + os.mkdir(issue_dir) + + issue_tracker_path = os.path.join(issue_dir, "issue.md") + if args.reply_to is not None: + if not os.path.isfile(issue_tracker_path): + stderr.write(f"Issue {issue_id} not found.\n") + return 1 + with open(issue_tracker_path, "r+b") as f: + src = f.read() + info, err = issue.parse(src) + if err is not None: + stderr.write(err.msg) + return 1 + if info.meta[b"status"] != b"open": + stderr.write(f"Issue {issue_id} already closed.\n") + return 1 + + identity_id = identity.id_from_pubkey(public_key) + # help_author = b" ; your user-ID" + # help_status = b" ; open | closed" + allowed_labels = issue_tracker.config[b"allowed_labels"] + help_labels = b" ; comma-separated list. Allowed labels are: "+allowed_labels + # help_id = b" ; this reply's local ID" + # help_reply = b" ; local ID of the post you're replying to" + if args.disable_inline_annotations: + help_labels = b"" + if args.editor == "_" and not args.skip_prompt: + if args.title is None: + args.title = input("Title: ").encode("utf-8") + if args.labels is None: + args.labels = input("Labels (Comma-separated): ").encode("utf-8") + if args.description is None: + args.description = input("Description: ").encode("utf-8") + with open(issue_tracker_path, "ab") as f: + ini_author = b"author=" + args.username.encode("utf-8") + b'#' + identity_id + b'\n' + if args.reply_to is None: + ini_status = b"status=open\n" + ini_labels = b"labels="+args.labels + help_labels + b'\n' + ini_add_after_edit = ini_author + ini_status + ini_add_now = ini_labels + if args.editor == "_": + ini_add_now = ini_add_after_edit + ini_add_now + msg = b"```ini\n"+ini_add_now+b"""``` + +# """+args.title+b""" + +"""+args.description+b""" + +""" + else: + ini_id = b"id="+id_gen().encode("utf-8")+b'\n' + reply_to = args.reply_to.split(":", 2) + ini_reply = b"" + if len(reply_to) == 2: + ini_reply = b"reply="+reply_to[1].encode("utf-8")+b'\n' + ini_add_after_edit = ini_author + ini_id + ini_add_now = ini_reply + if args.editor == "_": + ini_add_now = ini_add_after_edit + ini_add_now + msg = b"---\n```ini\n"+ini_add_now+b"```\n\n"+args.description+b"\n\n" + f.write(msg) + + if args.editor != "_": + subprocess.call([args.editor, issue_tracker_path]) + with open(issue_tracker_path, "r+b") as f: + src = f.read() + src = issue.clean_after_edit(src, ini_add_after_edit) + f.seek(0) + f.write(src) + f.truncate() + # TODO check if labels ok. Maybe we want to have some sort of `check` command + + relpath = os.path.relpath(issue_tracker_path, os.getcwd()) + if args.reply_to is None: + stdout.write(f"Created new issue ./{relpath}\n") + else: + stdout.write(f"Replied to {args.reply_to}\n") + + return 0 + -- cgit v1.2.3