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