From 2bcd870e3f861a49220629bf0a752689b8f3c4b3 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 17 Mar 2026 23:23:30 +0000 Subject: [PATCH 1/4] feat: full python tui refactor feat: partial refactor to ihj --- apps/ihj/.gitignore | 1 + apps/ihj/commands/comment.py | 60 ++++++ apps/ihj/commands/upsert.py | 381 ++++++++++++++++++++++++++++++++++ apps/ihj/config/__init__.py | 0 apps/ihj/config/io.py | 22 ++ apps/ihj/config/processor.py | 63 ++++++ apps/ihj/constants.py | 19 ++ apps/ihj/ihj | 289 ++++++++++++++++++++++++++ apps/ihj/jira/__init__.py | 0 apps/ihj/jira/client.py | 87 ++++++++ apps/ihj/jira/hierarchy.py | 37 ++++ apps/ihj/jira/idempotency.py | 10 + apps/ihj/jira/jql_builder.py | 24 +++ apps/ihj/jira/markdown.py | 220 ++++++++++++++++++++ apps/ihj/jira/payloads.py | 112 ++++++++++ apps/ihj/jira/persistence.py | 26 +++ apps/ihj/tui/__init__.py | 0 apps/ihj/tui/formatters.py | 163 +++++++++++++++ apps/ihj/tui/terminal.py | 121 +++++++++++ apps/ihj/tui/view_builders.py | 220 ++++++++++++++++++++ 20 files changed, 1855 insertions(+) create mode 100644 apps/ihj/.gitignore create mode 100644 apps/ihj/commands/comment.py create mode 100644 apps/ihj/commands/upsert.py create mode 100644 apps/ihj/config/__init__.py create mode 100644 apps/ihj/config/io.py create mode 100644 apps/ihj/config/processor.py create mode 100644 apps/ihj/constants.py create mode 100755 apps/ihj/ihj create mode 100644 apps/ihj/jira/__init__.py create mode 100644 apps/ihj/jira/client.py create mode 100644 apps/ihj/jira/hierarchy.py create mode 100644 apps/ihj/jira/idempotency.py create mode 100644 apps/ihj/jira/jql_builder.py create mode 100644 apps/ihj/jira/markdown.py create mode 100644 apps/ihj/jira/payloads.py create mode 100644 apps/ihj/jira/persistence.py create mode 100644 apps/ihj/tui/__init__.py create mode 100644 apps/ihj/tui/formatters.py create mode 100644 apps/ihj/tui/terminal.py create mode 100644 apps/ihj/tui/view_builders.py diff --git a/apps/ihj/.gitignore b/apps/ihj/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/apps/ihj/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/apps/ihj/commands/comment.py b/apps/ihj/commands/comment.py new file mode 100644 index 0000000..76da536 --- /dev/null +++ b/apps/ihj/commands/comment.py @@ -0,0 +1,60 @@ +import os +import sys +import json + +from tui.terminal import notify_user, open_in_editor +from jira.client import update_issue + +from tui.formatters import clean_issue_key +from jira.markdown import markdown_to_adf +from jira.payloads import build_comment_payload +from constants import C + + +def execute_comment(args, server, token, cfg): + """The comment subcommand allows you to create a new comment on an issue.""" + editor_cmd = cfg.get("editor") or os.environ.get("EDITOR", "vim") + is_dry_run = "--dry-run" in args + use_toast = "--toast" in args + + clean_args = [a for a in args if not a.startswith("-")] + if len(clean_args) < 2: + notify_user("Issue key required for comment.", "Error", use_toast) + sys.exit(1) + + issue_key = clean_issue_key(clean_args[-1]) + + raw_comment = open_in_editor( + editor_cmd, "", prefix=f"j_comment_{issue_key}_", target_line=1 + ) + + final_comment = raw_comment.strip() + + if not final_comment: + if is_dry_run: + print(f"\n{C['dim']}Empty comment. Aborting.{C['reset']}") + else: + notify_user("Aborted: Comment is empty.", "Cancelled", use_toast) + sys.exit(0) + + adf_body = markdown_to_adf(final_comment) + payload = build_comment_payload(adf_body) + + if is_dry_run: + print( + f"\n{C['bg_gray']}{C['bold']}{C['cyan']} 🚧 DRY RUN: COMMENT PAYLOAD 🚧 {C['reset']}\n" + ) + print(f"{C['dim']}Endpoint:{C['reset']} POST issue/{issue_key}/comment") + print(json.dumps(payload, indent=2)) + input(f"\n{C['bold']}Dry run complete. Press Enter to return...{C['reset']}") + sys.exit(0) + + success = update_issue( + server, token, f"issue/{issue_key}/comment", payload, method="POST" + ) + + if success: + notify_user(f"Added comment to {issue_key}", "Jira Comment", use_toast) + else: + notify_user(f"Failed to add comment to {issue_key}", "Jira Error", use_toast) + sys.exit(1) diff --git a/apps/ihj/commands/upsert.py b/apps/ihj/commands/upsert.py new file mode 100644 index 0000000..1801d1e --- /dev/null +++ b/apps/ihj/commands/upsert.py @@ -0,0 +1,381 @@ +import os +import sys +import json + +from config.io import parse_yaml_string +from tui.terminal import prompt_numeric_menu, notify_user, open_in_editor +from jira.client import ( + search_jira, + update_issue, + fetch_transitions, + fetch_active_sprint, + add_to_sprint, +) + +from tui.formatters import clean_issue_key +from jira.markdown import adf_to_markdown, split_frontmatter_and_body, markdown_to_adf +from jira.payloads import ( + build_search_payload, + parse_search_response, + build_upsert_payload, + build_transition_payload, +) +from constants import C + + +def generate_schema(cache_dir, cfg, board_slug, board_cfg): + """Pure-ish: Generates the JSON Schema for the YAML language server dynamically.""" + schema_path = os.path.join(cache_dir, f"frontmatter.{board_slug}.schema.json") + types = [t["name"] for t in cfg.get("types", [])] + transitions = board_cfg.get("transitions", []) + + properties = { + "key": {"type": "string"}, + "summary": {"type": "string"}, + "type": {"type": "string", "enum": types}, + "priority": { + "type": "string", + "enum": ["Highest", "High", "Medium", "Low", "Lowest"], + }, + "status": {"type": "string", "enum": transitions} + if transitions + else {"type": "string"}, + "parent": {"type": "string"}, + "sprint": {"type": "boolean"}, + } + + for cf_name in cfg.get("custom_fields", {}).keys(): + if cf_name == "team": + properties["team"] = {"type": "boolean"} + else: + properties[cf_name] = {"type": "string"} + + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": properties, + "required": ["summary", "type"], + } + + with open(schema_path, "w") as f: + json.dump(schema, f, indent=2) + + return schema_path + + +def execute_upsert(args, server, token, cfg): + """The Imperative Shell for the edit/create story.""" + editor_cmd = cfg.get("editor") or os.environ.get("EDITOR", "vim") + + summary_val = "" + board_slug = None + clean_args = [] + + skip_next = False + for i, arg in enumerate(args): + if skip_next: + skip_next = False + continue + if arg in ("-s", "--summary"): + summary_val = args[i + 1] if i + 1 < len(args) else "" + skip_next = True + elif arg in ("-b", "--board"): + board_slug = args[i + 1] if i + 1 < len(args) else None + skip_next = True + else: + clean_args.append(arg) + + is_edit = "--edit" in clean_args + is_dry_run = "--dry-run" in clean_args + use_toast = "--toast" in clean_args + + if not board_slug: + board_slug = cfg.get("default_board", "ci") + + board_cfg = cfg.get("boards", {}).get(board_slug, {}) + project_key = board_cfg.get("project_key") + + cache_dir = os.path.expanduser("~/.local/state/j/cache") + schema_path = generate_schema(cache_dir, cfg, board_slug, board_cfg) + schema_header = f"# yaml-language-server: $schema=file://{schema_path}\n" + + current_status = None + + # ========================================== + # 1. SETUP & FETCH + # ========================================== + if is_edit: + positionals = [a for a in clean_args if not a.startswith("--")] + if not positionals: + notify_user("Issue key required for edit.", "Error", use_toast) + sys.exit(1) + + clean_key = clean_issue_key(positionals[-1]) + jql = f"key = {clean_key}" + + search_payload = build_search_payload( + jql, cfg.get("formatted_custom_fields", {}) + ) + raw_response = search_jira(server, token, search_payload) + issues, _, _ = parse_search_response(raw_response) + + if not issues: + notify_user(f"Could not find issue {clean_key}", "Edit Error", use_toast) + sys.exit(1) + + fields = issues[0].get("fields", {}) + + current_type = fields.get("issuetype", {}).get("name", "") + current_summary = summary_val if summary_val else fields.get("summary", "") + current_status = fields.get("status", {}).get("name", "") + current_priority = fields.get("priority", {}).get("name", "Medium") + current_parent = fields.get("parent", {}).get("key", "") + + md_desc = adf_to_markdown(fields.get("description")).strip() + + custom_fields_yaml = "" + for cf_name, cf_id in cfg.get("custom_fields", {}).items(): + if cf_name == "team": + custom_fields_yaml += "team: true\n" + else: + val = fields.get(f"customfield_{cf_id}") + if val: + if isinstance(val, dict): + val = val.get("value", "") or val.get("name", "") + custom_fields_yaml += f'{cf_name}: "{val}"\n' + + # Only insert parent if it actually exists + parent_yaml = f'parent: "{current_parent}"\n' if current_parent else "" + + initial_doc = ( + f"---\n" + f"{schema_header}" + f"key: {clean_key}\n" + f'type: "{current_type}"\n' + f'priority: "{current_priority}"\n' + f'status: "{current_status}"\n' + f"{parent_yaml}" + f"{custom_fields_yaml}" + f'summary: "{current_summary}"\n' + f"---\n\n" + f"{md_desc}" + ) + else: + types = [t["name"] for t in cfg.get("types", [])] + choice_idx = prompt_numeric_menu(types, "CREATE NEW ISSUE", color=C["green"]) + if choice_idx is None: + sys.exit(0) + + selected_type = types[choice_idx] + type_cfg = next( + (t for t in cfg.get("types", []) if t["name"] == selected_type), {} + ) + template_body = type_cfg.get("template", "").strip() + + custom_fields_yaml = ( + "team: true\n" if "team" in cfg.get("custom_fields", {}) else "" + ) + + initial_doc = ( + f"---\n" + f"{schema_header}" + f'type: "{selected_type}"\n' + f'priority: "Medium"\n' + f'status: "Backlog"\n' + f"{custom_fields_yaml}" + f'summary: "{summary_val}"\n' + f"---\n\n" + f"{template_body}" + ) + + # ========================================== + # 2. USER INTERACTION + # ========================================== + target_line = None + search_pattern = None + + if summary_val: + lines = initial_doc.split("\n") + dash_count = 0 + for idx, line in enumerate(lines): + if line.strip() == "---": + dash_count += 1 + if dash_count == 2: + target_line = idx + 2 + break + else: + search_pattern = "^summary:" + + raw_modified_doc = open_in_editor( + editor_cmd, initial_doc, target_line=target_line, search_pattern=search_pattern + ) + + # ========================================== + # 3. IDEMPOTENCY CHECK + # ========================================== + # If the user saved and quit without changing a single character, cleanly exit. + if raw_modified_doc.strip() == initial_doc.strip(): + print( + f"\n{C['dim']}No changes detected. Skipping update.{C['reset']}" + ) if is_dry_run else notify_user("No changes made.", "Skipped", use_toast) + sys.exit(0) + + # ========================================== + # 4. PARSING & TRANSFORMATION + # ========================================== + yaml_str, md_body = split_frontmatter_and_body(raw_modified_doc) + frontmatter_dict = parse_yaml_string(yaml_str) + + if not frontmatter_dict or not frontmatter_dict.get("summary"): + notify_user("Aborted: Summary is required.", "Upsert Cancelled", use_toast) + sys.exit(1) + + adf_body = markdown_to_adf(md_body) + + issue_key = frontmatter_dict.get("key") + pk_to_pass = None if issue_key else project_key + team_uuid = board_cfg.get("team_uuid") + + upsert_payload = build_upsert_payload( + frontmatter_dict, + adf_body, + cfg.get("types", []), + cfg.get("custom_fields", {}), + pk_to_pass, + team_uuid, + ) + + # ========================================== + # 5. NETWORK EXECUTION: UPSERT + # ========================================== + target_key = None + + if is_dry_run: + print( + f"\n{C['bg_gray']}{C['bold']}{C['cyan']} 🚧 DRY RUN: PRIMARY UPSERT PAYLOAD 🚧 {C['reset']}\n" + ) + print(json.dumps(upsert_payload, indent=2)) + target_key = issue_key.upper() if issue_key else "DRYRUN-999" + else: + if issue_key: + success = update_issue( + server, + token, + f"issue/{issue_key.upper()}", + upsert_payload, + method="PUT", + ) + if success: + notify_user(f"Successfully updated {issue_key}", "Jira Edit", use_toast) + target_key = issue_key.upper() + else: + notify_user(f"Failed to update {issue_key}", "Jira Error", use_toast) + sys.exit(1) + else: + response = update_issue( + server, token, "issue", upsert_payload, method="POST" + ) + if response and isinstance(response, dict) and "key" in response: + target_key = response["key"] + notify_user( + f"Successfully created {target_key}", "Jira Create", use_toast + ) + else: + notify_user("Failed to create issue", "Jira Error", use_toast) + sys.exit(1) + + # ========================================== + # 6. NETWORK EXECUTION: SPRINT ASSIGNMENT + # ========================================== + if frontmatter_dict.get("sprint"): + board_id = board_cfg.get("id") + if is_dry_run: + print( + f"\n{C['bg_gray']}{C['bold']}{C['blue']} 🏃 DRY RUN: SPRINT ADDITION 🏃 {C['reset']}\n" + ) + print(f"{C['dim']}Target Issue:{C['reset']} {target_key}") + print(f"{C['dim']}Board ID:{C['reset']} {board_id}") + else: + sprint_id = fetch_active_sprint(server, token, board_id) + if sprint_id: + s_success = add_to_sprint(server, token, sprint_id, target_key) + if s_success: + notify_user( + f"Added {target_key} to active sprint.", + "Sprint Update", + use_toast, + ) + else: + notify_user( + f"Failed to add {target_key} to sprint.", + "Sprint Error", + use_toast, + ) + else: + notify_user( + "No active sprint found for board.", "Sprint Warning", use_toast + ) + + # ========================================== + # 7. NETWORK EXECUTION: TRANSITION + # ========================================== + new_status = frontmatter_dict.get("status") + + if ( + new_status + and target_key + and new_status.lower() != (current_status or "").lower() + ): + if not is_edit and new_status.lower() == "backlog": + pass + else: + transitions = ( + fetch_transitions(server, token, target_key) + if target_key != "DRYRUN-999" + else [{"id": "99", "name": new_status}] + ) + + target_transition_id = None + for t in transitions: + if ( + t.get("name", "").lower() == new_status.lower() + or t.get("to", {}).get("name", "").lower() == new_status.lower() + ): + target_transition_id = t["id"] + break + + if target_transition_id: + t_payload = build_transition_payload(target_transition_id) + if is_dry_run: + print( + f"\n{C['bg_gray']}{C['bold']}{C['magenta']} 🚀 DRY RUN: STATUS TRANSITION 🚀 {C['reset']}\n" + ) + print(json.dumps(t_payload, indent=2)) + else: + t_success = update_issue( + server, + token, + f"issue/{target_key}/transitions", + t_payload, + method="POST", + ) + if t_success: + notify_user( + f"Moved {target_key} to {new_status}", + "Jira Upsert", + use_toast, + ) + else: + notify_user( + f"Failed to move {target_key} to {new_status}", + "Jira Error", + use_toast, + ) + else: + msg = f"Invalid status transition: '{new_status}'" + print( + f"\n{C['red']}{C['bold']} ⚠ WARNING: {msg} {C['reset']}" + ) if is_dry_run else notify_user(msg, "Jira Warning", use_toast) + + if is_dry_run: + input(f"\n{C['bold']}Dry run complete. Press Enter to return...{C['reset']}") diff --git a/apps/ihj/config/__init__.py b/apps/ihj/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ihj/config/io.py b/apps/ihj/config/io.py new file mode 100644 index 0000000..6daf58d --- /dev/null +++ b/apps/ihj/config/io.py @@ -0,0 +1,22 @@ +import os +import json +import subprocess + + +def load_raw_config_file(path): + """Locates and reads the YAML file into a raw dict. No logic.""" + if not os.path.exists(path): + raise FileNotFoundError(f"Config not found at {path}") + + cmd = ["yq", "eval", "-o=json", path] + return json.loads(subprocess.check_output(cmd, text=True)) + + +def parse_yaml_string(yaml_str): + """Pipes a YAML string through yq to return a Python dict.""" + if not yaml_str: + return {} + cmd = ["yq", "eval", "-o=json", "-"] + p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + out, _ = p.communicate(input=yaml_str) + return json.loads(out) diff --git a/apps/ihj/config/processor.py b/apps/ihj/config/processor.py new file mode 100644 index 0000000..32d429d --- /dev/null +++ b/apps/ihj/config/processor.py @@ -0,0 +1,63 @@ +import re +from constants import C + + +def validate_config(raw_cfg): + """ + Strictly checks for existence of required keys and + validates that JQL templates won't fail during interpolation. + """ + if "types" not in raw_cfg or not raw_cfg["types"]: + raise ValueError("Missing 'types' array in config.yaml.") + + if "custom_fields" not in raw_cfg: + raise ValueError("Missing 'custom_fields' configuration in config.yaml.") + + if "boards" not in raw_cfg or not raw_cfg["boards"]: + raise ValueError("Missing 'boards' configuration in config.yaml.") + + available_keys = set(raw_cfg["custom_fields"].keys()) + board_template_keys = {"id", "name", "project_key", "team_uuid", "slug"} + + for slug, board in raw_cfg["boards"].items(): + jql_templates = [board.get("jql", "")] + jql_templates.extend(board.get("modes", {}).values()) + + for template in jql_templates: + if not template or not str(template).strip(): + if template == board.get("jql"): + raise ValueError(f"Board '{slug}' is missing a base 'jql' string.") + continue + + found_vars = re.findall(r"\{(.*?)\}", template) + for var in found_vars: + if var not in available_keys and var not in board_template_keys: + raise ValueError( + f"JQL error in board '{slug}': '{var}' is not defined in " + "custom_fields or board metadata." + ) + + return True + + +def parse_config(raw_cfg): + """Transforms validated raw data into the augmented app-ready version.""" + type_order_map = { + str(t.get("id")): ( + int(t.get("order", 100)), + C.get(t.get("color", "reset"), C["reset"]), + bool(t.get("has_children", False)), + ) + for t in raw_cfg["types"] + } + + formatted_cf = {} + for key, val in raw_cfg["custom_fields"].items(): + formatted_cf[key] = f"cf[{val}]" + formatted_cf[f"{key}_id"] = f"customfield_{val}" + + return { + **raw_cfg, + "formatted_custom_fields": formatted_cf, + "type_order_map": type_order_map, + } diff --git a/apps/ihj/constants.py b/apps/ihj/constants.py new file mode 100644 index 0000000..f115e2c --- /dev/null +++ b/apps/ihj/constants.py @@ -0,0 +1,19 @@ +C = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "italic": "\033[3m", + # Text Colors + "default": "\033[39m", # The true default text color of your terminal theme + "white": "\033[37m", # Often renders as light gray + "bright_white": "\033[97m", # Crisp, high-intensity white + "gray": "\033[90m", + "blue": "\033[34m", + "green": "\033[32m", + "yellow": "\033[33m", + "red": "\033[31m", + "cyan": "\033[36m", + "magenta": "\033[35m", + # Backgrounds + "bg_gray": "\033[100m", +} diff --git a/apps/ihj/ihj b/apps/ihj/ihj new file mode 100755 index 0000000..2f34b38 --- /dev/null +++ b/apps/ihj/ihj @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# vim: set filetype=python : +import os +import sys +import re +import json +import time + +from config.io import load_raw_config_file +from config.processor import validate_config, parse_config + +from jira.jql_builder import build_jql +from jira.payloads import ( + build_search_payload, + parse_search_response, + build_transition_payload, + build_assignment_payload, + filter_transitions, +) + +from commands.upsert import execute_upsert +from commands.comment import execute_comment + +from jira.client import search_jira, fetch_transitions, fetch_myself_api, update_issue +from jira.hierarchy import build_export_hierarchy +from jira.persistence import save_export_state, write_export_output, build_metadata + +from tui.view_builders import build_issue_registry, build_fzf_preview +from tui.terminal import ( + get_terminal_width, + prompt_numeric_menu, + launch_fzf, + notify_user, + copy_to_clipboard, +) +from tui.formatters import clean_issue_key + +from constants import C + +CONFIG_DIR = os.path.expanduser("~/.config/j") +CACHE_DIR = os.path.expanduser("~/.local/state/j/cache") +EXECUTABLE = sys.argv[0] + + +def get_board_data(server, token, board_cfg, mode, custom_fields, force_refresh=False): + """Orchestrates Cache vs Network.""" + slug = board_cfg.get("slug") + cache_file = os.path.join(CACHE_DIR, f"{slug}_{mode}.json") + + if not force_refresh and os.path.exists(cache_file): + age = int(time.time() - os.path.getmtime(cache_file)) + if age < 900: # 15 minutes + with open(cache_file, "r") as f: + return json.load(f), age + + jql = build_jql(board_cfg, mode, custom_fields) + + all_issues = [] + next_token = None + while True: + payload = build_search_payload(jql, custom_fields, next_token) + raw_response = search_jira(server, token, payload) + issues, next_token, is_last = parse_search_response(raw_response) + all_issues.extend(issues) + if is_last: + break + + with open(cache_file, "w") as f: + json.dump(all_issues, f) + + return all_issues, 0 + + +def get_my_account_id(server, token): + """Handles 1-week caching for the user's account ID.""" + cache_file = os.path.join(CACHE_DIR, "myself.json") + if ( + os.path.exists(cache_file) + and int(time.time() - os.path.getmtime(cache_file)) < 604800 + ): + with open(cache_file, "r") as f: + return json.load(f).get("accountId") + + data = fetch_myself_api(server, token) + if data and "accountId" in data: + with open(cache_file, "w") as f: + json.dump(data, f) + return data["accountId"] + return None + + +def main(): + os.makedirs(CONFIG_DIR, exist_ok=True) + os.makedirs(CACHE_DIR, exist_ok=True) + + try: + target_path = f"{CONFIG_DIR}/config.yaml" + + raw_cfg = load_raw_config_file(target_path) + validate_config(raw_cfg) + cfg = parse_config(raw_cfg) + except Exception as e: + sys.exit(f"Setup Error: {e}") + + server = cfg.get("server") + token = os.environ.get("JIRA_BEARER_TOKEN") + if not token: + sys.exit("Error: JIRA_BEARER_TOKEN environment variable not set.") + + args = sys.argv[1:] + use_toast = "--toast" in args + + clean_args_bg = [a for a in args if a != "--toast"] + + if "--edit" in args or "--create" in args: + execute_upsert(args, server, token, cfg) + sys.exit(0) + + if "--comment" in args: + execute_comment(args, server, token, cfg) + sys.exit(0) + + if "--open" in args: + clean_key = clean_issue_key(clean_args_bg[-1]) + import webbrowser + + webbrowser.open(f"{server}/browse/{clean_key}") + sys.exit(0) + + if "--assign" in args: + clean_key = clean_issue_key(clean_args_bg[-1]) + account_id = get_my_account_id(server, token) + if account_id: + payload = build_assignment_payload(account_id) + success = update_issue( + server, token, f"issue/{clean_key}/assignee", payload, method="PUT" + ) + if success: + notify_user( + "Assigned to you.", f"{clean_key} Updated", use_toast=use_toast + ) + else: + notify_user( + "Failed to assign.", f"{clean_key} Error", use_toast=use_toast + ) + sys.exit(0) + + if "--branch" in args: + slug, mode, raw_key = clean_args_bg[-3], clean_args_bg[-2], clean_args_bg[-1] + clean_key = clean_issue_key(raw_key) + cache_file = os.path.join(CACHE_DIR, f"{slug}_{mode}.json") + + if os.path.exists(cache_file): + with open(cache_file, "r") as f: + issues = json.load(f) + + summary = next( + ( + i.get("fields", {}).get("summary") + for i in issues + if i.get("key") == clean_key + ), + None, + ) + if not summary: + summary = next( + ( + i.get("fields", {}) + .get("parent", {}) + .get("fields", {}) + .get("summary") + for i in issues + if i.get("fields", {}).get("parent", {}).get("key") == clean_key + ), + None, + ) + + if summary: + clean_summ = re.sub(r"[^a-z0-9]+", "-", summary.lower()).strip("-") + branch_cmd = f"git checkout -b {clean_key.lower()}-{clean_summ}" + copy_to_clipboard(branch_cmd) + notify_user(branch_cmd, "Branch Copied!", use_toast=use_toast) + sys.exit(0) + + notify_user( + f"Could not find {clean_key} in cache.", "Branch Error", use_toast=use_toast + ) + sys.exit(0) + + if "--transition" in args: + slug, raw_key = clean_args_bg[-2], clean_args_bg[-1] + clean_key = clean_issue_key(raw_key) + allowed = cfg.get("boards", {}).get(slug, {}).get("transitions", []) + + raw_transitions = fetch_transitions(server, token, clean_key) + filtered = filter_transitions(raw_transitions, allowed) + names = [t["name"] for t in filtered] + + choice_idx = prompt_numeric_menu(names, f"󰡃 TRANSITION: {clean_key}") + if choice_idx is not None: + payload = build_transition_payload(filtered[choice_idx]["id"]) + update_issue(server, token, f"issue/{clean_key}/transitions", payload) + sys.exit(0) + + if "--choose-mode" in args: + slug = clean_args_bg[-1] + modes = list(cfg["boards"][slug].get("modes", {}).keys()) + choice_idx = prompt_numeric_menu( + modes, "JIRA MODE SELECTOR", color=C["magenta"] + ) + if choice_idx is not None: + with open("/tmp/j_mode", "w") as f: + f.write(modes[choice_idx]) + sys.exit(0) + + is_export = "--export" in args + is_list = "--list" in args + + clean_args = [a for a in args if a not in ["--export", "--list", "--toast"]] + slug = clean_args[0] if len(clean_args) > 0 else cfg.get("default_board", "ci") + mode = clean_args[1] if len(clean_args) > 1 else "active" + + board_cfg = cfg["boards"].get(slug) + if not board_cfg: + sys.exit(f"Error: Board '{slug}' not found in config.") + board_cfg["slug"] = slug + + if not any( + x in args + for x in [ + "--branch", + "--assign", + "--transition", + "--choose-mode", + "--export", + "--edit", + "--create", + "--open", + ] + ): + with open("/tmp/j_mode", "w") as f: + f.write(mode) + + raw_issues, age = get_board_data( + server, + token, + board_cfg, + mode, + cfg["formatted_custom_fields"], + force_refresh=is_export, + ) + + if is_export: + hierarchy, state_dict = build_export_hierarchy(raw_issues) + save_export_state(slug, state_dict) + meta = build_metadata(slug, board_cfg) + write_export_output({"metadata": meta, "issues": hierarchy}) + sys.exit(0) + + term_w = get_terminal_width() + registry = build_issue_registry(raw_issues, term_w) + lines, previews = build_fzf_preview(registry, cfg["type_order_map"], term_w) + + preview_path = os.path.join(CACHE_DIR, f"{slug}_previews.json") + with open(preview_path, "w") as f: + json.dump(previews, f) + + if is_list: + header = f"{C['bold']}{'ID':<12} P {'TYPE':<10} {'STATUS':<16} {'ASSIGNEE':<16} SUMMARY{C['reset']}" + print(header + "\n" + "\n".join(lines)) + sys.exit(0) + + bindings = { + "alt-r": f"reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-s": f"execute({EXECUTABLE} --choose-mode {slug})+reload({EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-a": f"execute-silent({EXECUTABLE} --assign {{1}} --toast)+reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-t": f"execute({EXECUTABLE} --transition {slug} {{1}})+reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-o": f"execute-silent({EXECUTABLE} --open {{1}})", + "alt-e": f"execute({EXECUTABLE} --edit -b {slug} {{1}} --toast)+reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-c": f"execute({EXECUTABLE} --comment {{1}} --toast)+reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + "alt-n": f"execute-silent({EXECUTABLE} --branch {slug} $(cat /tmp/j_mode) {{1}} --toast)", + "ctrl-n": f"execute({EXECUTABLE} --create -b {slug} --toast)+reload(rm -f {CACHE_DIR}/{slug}_*.json && {EXECUTABLE} --list {slug} $(cat /tmp/j_mode))", + } + + launch_fzf(lines, board_cfg.get("name", slug), mode, age, preview_path, bindings) + + +if __name__ == "__main__": + main() diff --git a/apps/ihj/jira/__init__.py b/apps/ihj/jira/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ihj/jira/client.py b/apps/ihj/jira/client.py new file mode 100644 index 0000000..c6a62bb --- /dev/null +++ b/apps/ihj/jira/client.py @@ -0,0 +1,87 @@ +import json +import urllib.request +import urllib.error + + +def search_jira(server, token, payload): + """Impure: Executes the search POST request.""" + url = f"{server}/rest/api/3/search/jql" + req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST") + req.add_header("Authorization", f"Basic {token}") + req.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + err_body = e.read().decode() + raise RuntimeError(f"Jira API Error ({e.code}): {err_body}") + + +def fetch_transitions(server, token, issue_key): + """Impure: GET transitions.""" + url = f"{server}/rest/api/3/issue/{issue_key}/transitions" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Basic {token}") + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()).get("transitions", []) + except Exception: + return [] + + +def fetch_myself_api(server, token): + """Impure: GET current user info (raw API call).""" + url = f"{server}/rest/api/3/myself" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Basic {token}") + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +def update_issue(server, token, endpoint, payload, method="POST"): + """Impure: POST/PUT for transitions, creations, or assignments.""" + url = f"{server}/rest/api/3/{endpoint}" + req = urllib.request.Request(url, data=json.dumps(payload).encode(), method=method) + req.add_header("Authorization", f"Basic {token}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as response: + body = response.read().decode() + if body: + return json.loads(body) + return True + except Exception as e: + print(f"API Error: {e}") + return False + + +def fetch_active_sprint(server, token, board_id): + """Impure: GET active sprint for a board via Agile API.""" + url = f"{server}/rest/agile/1.0/board/{board_id}/sprint?state=active" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Basic {token}") + try: + with urllib.request.urlopen(req) as r: + data = json.loads(r.read().decode()) + values = data.get("values", []) + if values: + return values[0].get("id") + except Exception as e: + print(f"Sprint Fetch Error: {e}") + return None + + +def add_to_sprint(server, token, sprint_id, issue_key): + """Impure: POST issue to sprint via Agile API.""" + url = f"{server}/rest/agile/1.0/sprint/{sprint_id}/issue" + payload = {"issues": [issue_key]} + req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST") + req.add_header("Authorization", f"Basic {token}") + req.add_header("Content-Type", "application/json") + try: + urllib.request.urlopen(req) + return True + except Exception as e: + print(f"Sprint Add Error: {e}") + return False diff --git a/apps/ihj/jira/hierarchy.py b/apps/ihj/jira/hierarchy.py new file mode 100644 index 0000000..b25ca85 --- /dev/null +++ b/apps/ihj/jira/hierarchy.py @@ -0,0 +1,37 @@ +from export.markdown import adf_to_markdown +from export.idempotency import generate_issue_hash + + +def build_export_hierarchy(raw_issues): + """Pure: Logic for nesting parents/children and hashing every node.""" + registry = {} + state_dict = {} + + for issue in raw_issues: + key = issue["key"] + f = issue.get("fields", {}) + + issue_data = { + "key": key, + "type": f.get("issuetype", {}).get("name", "Unknown"), + "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "description": adf_to_markdown(f.get("description")).strip(), + "children": [], + } + + state_dict[key] = generate_issue_hash(issue_data) + registry[key] = { + "data": issue_data, + "parent": (f.get("parent") or {}).get("key"), + } + + roots = [] + for key, item in registry.items(): + pk = item["parent"] + if pk and pk in registry: + registry[pk]["data"]["children"].append(item["data"]) + else: + roots.append(item["data"]) + + return roots, state_dict diff --git a/apps/ihj/jira/idempotency.py b/apps/ihj/jira/idempotency.py new file mode 100644 index 0000000..d8bfec7 --- /dev/null +++ b/apps/ihj/jira/idempotency.py @@ -0,0 +1,10 @@ +import json +import hashlib + + +def generate_issue_hash(issue_data): + """Pure: Deterministic hash for change detection.""" + # We remove 'children' from the hash so child changes don't flag the parent as 'dirty' + hash_copy = {k: v for k, v in issue_data.items() if k != "children"} + payload = json.dumps(hash_copy, sort_keys=True).encode("utf-8") + return hashlib.sha256(payload).hexdigest() diff --git a/apps/ihj/jira/jql_builder.py b/apps/ihj/jira/jql_builder.py new file mode 100644 index 0000000..8bbb695 --- /dev/null +++ b/apps/ihj/jira/jql_builder.py @@ -0,0 +1,24 @@ +def build_jql(board_cfg, mode, custom_field_map): + """ + Constructs the JQL string safely. + Acts as a final safety gate against empty queries. + """ + base_filter = board_cfg.get("jql", "") + + # CRITICAL SAFETY CHECK: Refuse to continue if base JQL is missing + if not base_filter or not str(base_filter).strip(): + raise ValueError( + f"Board '{board_cfg.get('slug')}' has no base JQL. Operation aborted for safety." + ) + + format_vars = {**custom_field_map, **board_cfg} + + mode_filter = board_cfg.get("modes", {}).get(mode, "") + + base_jql = base_filter.format(**format_vars) + mode_jql = mode_filter.format(**format_vars) if mode_filter else "" + + if mode_jql: + return f"{base_jql} AND ({mode_jql})" + + return base_jql diff --git a/apps/ihj/jira/markdown.py b/apps/ihj/jira/markdown.py new file mode 100644 index 0000000..eabedbd --- /dev/null +++ b/apps/ihj/jira/markdown.py @@ -0,0 +1,220 @@ +import re + + +def adf_to_markdown(node): + """Recursively converts Jira ADF into standard Markdown.""" + if not node or not isinstance(node, dict): + return "" + nt, content = node.get("type"), node.get("content", []) + res = "" + + if nt == "doc": + for c in content: + res += adf_to_markdown(c) + elif nt == "paragraph": + parts = [ + adf_to_markdown(c).strip() for c in content if adf_to_markdown(c).strip() + ] + if parts: + res = " ".join(parts) + "\n\n" + elif nt == "text": + t = node.get("text", "") + link_href = None + + for m in node.get("marks", []): + mt = m.get("type") + if mt == "strong": + t = f"**{t}**" + elif mt == "em": + t = f"*{t}*" + elif mt == "code": + t = f"`{t}`" + elif mt == "strike": + t = f"~~{t}~~" + elif mt == "link": + link_href = m.get("attrs", {}).get("href", "") + + if link_href: + t = f"[{t}]({link_href})" + + res = t + elif nt == "heading": + level = node.get("attrs", {}).get("level", 2) + inner = "".join([adf_to_markdown(c) for c in content]).strip() + res = f"\n{'#' * level} {inner}\n\n" + elif nt == "listItem": + res = "".join([adf_to_markdown(c) for c in content]).strip() + elif nt in ["bulletList", "orderedList"]: + items = [f"- {adf_to_markdown(c).strip()}" for c in content] + res = "\n" + "\n".join(items) + "\n\n" + elif nt == "codeBlock": + lang = node.get("attrs", {}).get("language", "") + code_text = "".join([c.get("text", "") for c in content]) + res = f"\n```{lang}\n{code_text}\n```\n\n" + return res + + +def _parse_inline_text(text, inherited_marks=None): + """ + Pure: Recursively parses flat text for inline markdown and nested marks. + Inherits marks (e.g., bold, link) as it dives deeper into nested syntax. + """ + if inherited_marks is None: + inherited_marks = [] + + nodes = [] + + # Priority matters here: Bold (** or __) must be checked before Italic (* or _) + pattern = re.compile( + r"(\*\*(.*?)\*\*|__(.*?)__)|" # Groups 1,2,3: Bold + r"(\*(.*?)\*|_(.*?)_)|" # Groups 4,5,6: Italic + r"(~~(.*?)~~)|" # Groups 7,8: Strike + r"(`(.*?)`)|" # Groups 9,10: Code + r"(\[(.*?)\]\((.*?)\))" # Groups 11,12,13: Link + ) + + pos = 0 + for m in pattern.finditer(text): + if m.start() > pos: + nodes.append( + { + "type": "text", + "text": text[pos : m.start()], + "marks": list(inherited_marks), + } + ) + + new_marks = list(inherited_marks) + inner_text = "" + + if m.group(1): # Bold + new_marks.append({"type": "strong"}) + inner_text = m.group(2) if m.group(2) is not None else m.group(3) + nodes.extend(_parse_inline_text(inner_text, new_marks)) + + elif m.group(4): # Italic + new_marks.append({"type": "em"}) + inner_text = m.group(5) if m.group(5) is not None else m.group(6) + nodes.extend(_parse_inline_text(inner_text, new_marks)) + + elif m.group(7): # Strike + new_marks.append({"type": "strike"}) + inner_text = m.group(8) + nodes.extend(_parse_inline_text(inner_text, new_marks)) + + elif m.group(9): # Code (We don't parse inside code blocks) + new_marks.append({"type": "code"}) + inner_text = m.group(10) + nodes.append({"type": "text", "text": inner_text, "marks": new_marks}) + + elif m.group(11): # Link + new_marks.append({"type": "link", "attrs": {"href": m.group(13)}}) + inner_text = m.group(12) + nodes.extend(_parse_inline_text(inner_text, new_marks)) + + pos = m.end() + + # 3. Append any trailing plain text + if pos < len(text): + nodes.append( + {"type": "text", "text": text[pos:], "marks": list(inherited_marks)} + ) + + # 4. Clean up empty nodes and strip empty marks arrays for Jira + final_nodes = [] + for n in nodes: + if n["text"]: # Ignore empty strings + if not n.get("marks"): + n.pop("marks", None) + final_nodes.append(n) + + return final_nodes if final_nodes else [{"type": "text", "text": text}] + + +def markdown_to_adf(md_text): + """Converts Markdown back into Jira ADF dicts, mapping symmetric elements.""" + if not md_text or not md_text.strip(): + return {"version": 1, "type": "doc", "content": []} + + code_blocks = {} + + def code_replacer(match): + placeholder = f"__CODE_BLOCK_{len(code_blocks)}__" + lang = match.group(1).strip() + code = match.group(2).strip() + code_blocks[placeholder] = (lang, code) + return f"\n\n{placeholder}\n\n" + + md_text = re.sub(r"```(.*)\n([\s\S]*?)```", code_replacer, md_text) + + blocks = md_text.strip().split("\n\n") + content = [] + + for block in blocks: + block = block.strip() + if not block: + continue + + if block in code_blocks: + lang, code = code_blocks[block] + content.append( + { + "type": "codeBlock", + "attrs": {"language": lang or "plain"}, + "content": [{"type": "text", "text": code}], + } + ) + elif block.startswith("#"): + m = re.match(r"^(#{1,6})\s+(.*)", block) + if m: + level = len(m.group(1)) + text = m.group(2) + content.append( + { + "type": "heading", + "attrs": {"level": level}, + "content": _parse_inline_text(text), + } + ) + elif ( + block.startswith("- ") + or block.startswith("* ") + or re.match(r"^\d+\.\s+", block) + ): + items = [] + for line in block.split("\n"): + line = line.strip() + if not line: + continue + clean_line = re.sub(r"^([-*]|\d+\.)\s+", "", line) + items.append( + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": _parse_inline_text(clean_line), + } + ], + } + ) + + is_ordered = bool(re.match(r"^\d+\.", block)) + content.append( + { + "type": "orderedList" if is_ordered else "bulletList", + "content": items, + } + ) + else: + content.append({"type": "paragraph", "content": _parse_inline_text(block)}) + + return {"version": 1, "type": "doc", "content": content} + + +def split_frontmatter_and_body(raw_text): + """Separates the YAML block string from the Markdown body string.""" + parts = raw_text.split("---", 2) + if len(parts) < 3: + return "", raw_text.strip() + return parts[1].strip(), parts[2].strip() diff --git a/apps/ihj/jira/payloads.py b/apps/ihj/jira/payloads.py new file mode 100644 index 0000000..07514e4 --- /dev/null +++ b/apps/ihj/jira/payloads.py @@ -0,0 +1,112 @@ +def build_search_payload(jql, custom_field_map, next_token=None): + """Builds the dict payload needed for the Jira search API.""" + fields_to_fetch = [ + "summary", + "issuetype", + "status", + "priority", + "parent", + "subtasks", + "description", + "assignee", + "comment", + "reporter", + "created", + "updated", + "labels", + "components", + ] + + if "epic_name_id" in custom_field_map: + fields_to_fetch.append(custom_field_map["epic_name_id"]) + if "epic_link_id" in custom_field_map: + fields_to_fetch.append(custom_field_map["epic_link_id"]) + + payload = {"jql": jql, "fields": fields_to_fetch, "maxResults": 100} + if next_token: + payload["nextPageToken"] = next_token + + return payload + + +def parse_search_response(response_body): + """Extracts issues and pagination state.""" + issues = response_body.get("issues", []) + next_token = response_body.get("nextPageToken") + is_last = response_body.get("isLast", True) + + if not next_token: + is_last = True + + return issues, next_token, is_last + + +def build_transition_payload(transition_id): + """Constructs body for status change.""" + return {"transition": {"id": transition_id}} + + +def build_assignment_payload(account_id): + """Constructs body for assignee change.""" + return {"accountId": account_id} + + +def filter_transitions(api_transitions, allowed_names): + """Filters API results against the user's YAML preference.""" + if not allowed_names: + return api_transitions + filtered = [] + for target in allowed_names: + for t in api_transitions: + if t.get("name", "").lower() == target.lower(): + filtered.append(t) + break + return filtered + + +def build_upsert_payload( + frontmatter_dict, + adf_description, + config_types, + custom_fields_map, + project_key=None, + team_uuid=None, +): + """Constructs the POST/PUT payload from the parsed frontmatter.""" + fields = { + "summary": frontmatter_dict.get("summary", "Untitled"), + "description": adf_description, + } + + issue_type = frontmatter_dict.get("type") + if issue_type: + type_id = next((t["id"] for t in config_types if t["name"] == issue_type), None) + if type_id: + fields["issuetype"] = {"id": str(type_id)} + + priority = frontmatter_dict.get("priority") + if priority: + fields["priority"] = {"name": priority} + + parent = frontmatter_dict.get("parent") + if parent: + fields["parent"] = {"key": parent.upper()} + + # Dynamically inject custom fields + for cf_name, cf_id in custom_fields_map.items(): + val = frontmatter_dict.get(cf_name) + if val is not None and val != "": + if cf_name == "team" and val is True and team_uuid: + fields[f"customfield_{cf_id}"] = str(team_uuid) + elif cf_name != "team": + fields[f"customfield_{cf_id}"] = str(val) + + if project_key: + fields["project"] = {"key": project_key} + + return {"fields": fields} + + +def build_comment_payload(adf_body): + """Constructs the POST payload for adding a comment.""" + return {"body": adf_body} diff --git a/apps/ihj/jira/persistence.py b/apps/ihj/jira/persistence.py new file mode 100644 index 0000000..ef51e18 --- /dev/null +++ b/apps/ihj/jira/persistence.py @@ -0,0 +1,26 @@ +import json +from datetime import datetime, timezone + + +def save_export_state(slug, state_dict): + """Writes the current hash map to a hidden state file.""" + filename = f".{slug}.state.json" + try: + with open(filename, "w") as f: + json.dump(state_dict, f, indent=2) + except IOError as e: + print(f"Warning: Could not save state file: {e}") + + +def write_export_output(output_dict): + """Finalizes the story by printing the JSON payload to stdout.""" + print(json.dumps(output_dict, indent=2)) + + +def build_metadata(slug, board_cfg): + """Pure helper to wrap the export with context.""" + return { + "board_slug": slug, + "project_key": board_cfg.get("project_key"), + "exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } diff --git a/apps/ihj/tui/__init__.py b/apps/ihj/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ihj/tui/formatters.py b/apps/ihj/tui/formatters.py new file mode 100644 index 0000000..6d6cddd --- /dev/null +++ b/apps/ihj/tui/formatters.py @@ -0,0 +1,163 @@ +import re +from constants import C +from datetime import datetime + + +def format_date(date_str): + """Pure: Converts Jira ISO timestamps to readable 'DD MMM YYYY' format.""" + if not date_str or date_str == "Unknown": + return "Unknown" + try: + dt = datetime.strptime(date_str[:10], "%Y-%m-%d") + return dt.strftime("%d %b %Y") + except Exception: + return date_str[:10] + + +def format_datetime(date_str): + """Pure: Converts Jira ISO timestamps to readable 'DD MMM YYYY, HH:MM' format.""" + if not date_str or date_str == "Unknown": + return "Unknown" + try: + # Jira format: 2024-03-24T14:30:00.000+0000 + # We grab the first 16 chars: "2024-03-24T14:30" + dt = datetime.strptime(date_str[:16], "%Y-%m-%dT%H:%M") + return dt.strftime("%d %b %Y, %H:%M") + except Exception: + return date_str[:16].replace("T", " ") + + +def wrap_text(text, width): + """Wraps text cleanly, ignoring ANSI codes.""" + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + wrapped_lines = [] + for line in text.split("\n"): + if not line: + wrapped_lines.append("") + continue + current_len, current_line = 0, "" + words = re.split(r"([ \t]+)", line) + for word in words: + vis_len = len(ansi_escape.sub("", word)) + if current_len + vis_len > width and current_len > 0: + if word.strip(): + wrapped_lines.append(current_line.rstrip()) + current_line, current_len = ( + word.lstrip(), + len(ansi_escape.sub("", word.lstrip())), + ) + else: + current_line += word + current_len += vis_len + if current_line: + wrapped_lines.append(current_line.rstrip()) + return "\n".join(wrapped_lines) + + +def adf_to_ansi(node, wrap_width=90): + """Converts ADF into ANSI formatted text.""" + if not node or not isinstance(node, dict): + return "" + nt, content = node.get("type"), node.get("content", []) + res = "" + + if nt == "doc": + for c in content: + res += adf_to_ansi(c, wrap_width) + + elif nt == "paragraph": + parts = [ + adf_to_ansi(c, wrap_width).strip() + for c in content + if adf_to_ansi(c, wrap_width).strip() + ] + if parts: + res = wrap_text(" ".join(parts), wrap_width) + "\n\n" + + elif nt == "text": + t = node.get("text", "") + for m in node.get("marks", []): + mt = m.get("type") + if mt == "strong": + t = f"{C['bold']}{t}{C['reset']}" + elif mt == "em": + t = f"{C['italic']}{t}{C['reset']}" + elif mt == "code": + t = f"{C['bg_gray']}{C['cyan']} {t} {C['reset']}" + elif mt == "strike": + t = f"{C['dim']}~{t}~{C['reset']}" + elif mt == "link": + href = m.get("attrs", {}).get("href", "") + t = f"{C['blue']}\033[4m{t}\033[24m{C['reset']} {C['dim']}({href}){C['reset']}" + res = t + + elif nt == "heading": + inner = "".join([adf_to_ansi(c, wrap_width) for c in content]).strip() + res = f"\n{C['cyan']}{C['bold']}{inner.upper()}{C['reset']}\n\n" + + elif nt in ["bulletList", "orderedList"]: + items = [ + f"{i + 1 if nt == 'orderedList' else '•'} {adf_to_ansi(c, wrap_width).strip()}" + for i, c in enumerate(content) + ] + res = "\n" + "\n".join(items) + "\n\n" + + elif nt == "listItem": + res = "".join([adf_to_ansi(c, wrap_width) for c in content]) + + elif nt == "codeBlock": + lang = node.get("attrs", {}).get("language", "code").upper() + code_text = "".join([c.get("text", "") for c in content]) + + # Build a beautiful, un-wrapped terminal code block + block = f"\n{C['bg_gray']}{C['bold']}  {lang} {C['reset']}\n" + for line in code_text.split("\n"): + line = line.replace("\t", " ") + block += f"{C['dim']}┃{C['reset']} {line}\n" + res = block + "\n" + + return res + + +def get_priority_icon(prio): + """Returns universally safe unicode icons for priorities.""" + p = prio.lower() if prio else "" + if any(x in p for x in ["crit", "highest", "blocker"]): + return f"{C['red']}{C['bold']}▲{C['reset']}" + if any(x in p for x in ["major", "high"]): + return f"{C['red']}▴{C['reset']}" + if any(x in p for x in ["medium"]): + return f"{C['yellow']}◆{C['reset']}" + if any(x in p for x in ["minor", "low"]): + return f"{C['blue']}▾{C['reset']}" + if any(x in p for x in ["lowest", "trivial"]): + return f"{C['cyan']}▼{C['reset']}" + return f"{C['gray']}−{C['reset']}" + + +def get_status_style(status): + """ + Universally safe unicode icons for statuses. + Relies on color to do the heavy lifting for meaning. + """ + s = status.lower() + + if any(x in s for x in ["done", "closed", "resolved", "complete"]): + return C["green"], "✔" + if any(x in s for x in ["block", "stop", "hold", "cancel"]): + return C["red"], "✘" + if any(x in s for x in ["review", "test", "qa", "verification"]): + return C["magenta"], "◉" + if any(x in s for x in ["progress", "doing", "active", "dev"]): + return C["blue"], "▶" + if any(x in s for x in ["refined", "ready", "approved"]): + return C["cyan"], "★" + if any(x in s for x in ["refinement", "grooming", "discovery", "triage"]): + return C["yellow"], "⚙" + return C["default"], "○" + + +def clean_issue_key(raw_string): + """Strips ANSI escape sequences and standardizes the Jira issue key.""" + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + return ansi_escape.sub("", raw_string).strip().upper() diff --git a/apps/ihj/tui/terminal.py b/apps/ihj/tui/terminal.py new file mode 100644 index 0000000..662a2c0 --- /dev/null +++ b/apps/ihj/tui/terminal.py @@ -0,0 +1,121 @@ +import os +import subprocess +import sys +import tempfile +import termios +import tty +import shutil + +from constants import C + + +def notify_user(msg, title="Jira CLI", use_toast=False): + """Sends a macOS desktop notification OR prints to console.""" + if use_toast: + safe_msg = msg.replace("\\", "\\\\").replace('"', '\\"') + safe_title = title.replace("\\", "\\\\").replace('"', '\\"') + subprocess.run( + [ + "osascript", + "-e", + f'display notification "{safe_msg}" with title "{safe_title}"', + ] + ) + else: + # Standard CLI output + print(f"\n{C['bold']}{C['cyan']}{title}:{C['reset']} {msg}\n") + + +def copy_to_clipboard(text): + """Pipes text to macOS pbcopy.""" + p = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE) + p.communicate(input=text.encode("utf-8")) + + +def get_terminal_width(): + """Returns the width of the current terminal window robustly.""" + return shutil.get_terminal_size((120, 24)).columns + + +def prompt_numeric_menu(options, title, color=C["blue"]): + """Numeric TUI selector using raw mode.""" + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + sys.stdout.write("\033[?1049h\033[2J\033[H") + sys.stdout.write(f"{C['bold']}{color} {title} {C['reset']}\r\n\r\n") + for i, opt in enumerate(options): + sys.stdout.write(f" [{i + 1}] {opt}\r\n") + sys.stdout.write(f"\r\n{C['dim']}Choice (q to cancel): {C['reset']}") + sys.stdout.flush() + + while True: + char = sys.stdin.read(1) + if char == "q" or char == "\x1b": + return None + if char.isdigit(): + idx = int(char) - 1 + if 0 <= idx < len(options): + return idx + finally: + sys.stdout.write("\033[?1049l") + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def launch_fzf(lines, board_name, mode_name, age, preview_path, bindings): + """Executes the main FZF interface.""" + list_header = f"{C['bold']}{'ID':<12} P {'TYPE':<10} {'STATUS':<16} {'ASSIGNEE':<16} SUMMARY{C['reset']}" + help_guide = f"{C['cyan']}Alt-R{C['reset']} Refresh | {C['cyan']}Alt-S{C['reset']} Mode | {C['cyan']}Alt-A{C['reset']} Assign | {C['cyan']}Alt-T{C['reset']} Move | {C['cyan']}Alt-O{C['reset']} Open | {C['cyan']}Alt-E{C['reset']} Edit | {C['cyan']}Alt-N{C['reset']} Branch" + + fzf_cmd = [ + "fzf", + "--header-lines=1", + "--reverse", + "--ansi", + "--border", + "--padding", + "1,2", + "--header", + help_guide, + "--border-label", + f" {board_name} | {mode_name.upper()} ({age}s) ", + "--preview", + f"jq -r '.\"{ {1} }\"' {preview_path}", + "--preview-window", + "top:60%:wrap", + ] + + for key, action in bindings.items(): + fzf_cmd.extend(["--bind", f"{key}:{action}"]) + + subprocess.run(fzf_cmd, input="\n".join([list_header] + lines), text=True) + + +def open_in_editor( + editor_cmd, initial_text, prefix="jira_", target_line=None, search_pattern=None +): + """Writes text to tempfile, hands TTY to editor, reads back result.""" + fd, path = tempfile.mkstemp(prefix=prefix, suffix=".md") + try: + with os.fdopen(fd, "w") as f: + f.write(initial_text) + + cmd = [editor_cmd] + + if any(v in editor_cmd.lower() for v in ["vim", "nvim", "vi"]): + if search_pattern: + cmd.extend( + ["-c", f"/{search_pattern}", "-c", "normal! $", "-c", "startinsert"] + ) + elif target_line: + cmd.extend([f"+{target_line}", "-c", "startinsert"]) + + cmd.append(path) + subprocess.run(cmd, check=True) + + with open(path, "r") as f: + return f.read() + finally: + if os.path.exists(path): + os.remove(path) diff --git a/apps/ihj/tui/view_builders.py b/apps/ihj/tui/view_builders.py new file mode 100644 index 0000000..3b56f85 --- /dev/null +++ b/apps/ihj/tui/view_builders.py @@ -0,0 +1,220 @@ +from constants import C +from tui.formatters import ( + adf_to_ansi, + get_priority_icon, + get_status_style, + format_date, + format_datetime, +) + + +def build_issue_registry(raw_issues, terminal_width): + """Organizes flat API issues into a dictionary with ANSI-formatted strings.""" + wrap_width = min(90, terminal_width - 6) + + # Safe, fixed-width divider to prevent FZF from triggering wrap artifacts (↳) + divider = f"{C['dim']}{'─' * 64}{C['reset']}" + + registry = {} + + for issue in raw_issues: + key, f = issue["key"], issue.get("fields", {}) + itype = f.get("issuetype", {}) + assignee = f.get("assignee") + clist = f.get("comment", {}).get("comments", []) + + # Format Comments + comments_ansi = "" + if clist: + comments_ansi = f"\n\n{divider}\n\n{C['yellow']}{C['bold']}󱠁 LATEST COMMENTS{C['reset']}\n\n" + for c in clist[-3:]: + auth = c.get("author", {}).get("displayName", "Unknown") + created_dt = format_datetime(c.get("created")) + body = adf_to_ansi(c.get("body"), wrap_width).strip() + comments_ansi += f"{C['bold']}{auth}{C['reset']} {C['dim']}• {created_dt}{C['reset']}\n{body}\n\n" + + desc = adf_to_ansi(f.get("description"), wrap_width).strip() + if not desc: + desc = f"{C['dim']}{C['italic']}No description provided.{C['reset']}" + + # Extract rich metadata + reporter = ( + f.get("reporter", {}).get("displayName", "Unassigned") + if f.get("reporter") + else "Unassigned" + ) + created = format_date(f.get("created")) + updated = format_date(f.get("updated")) + labels = ", ".join(f.get("labels", [])) + components = ", ".join([c.get("name") for c in f.get("components", [])]) + + registry[key] = { + "key": key, + "summary": f.get("summary"), + "description": desc, + "comments": comments_ansi, + "type": itype.get("name", "Unknown"), + "type_id": itype.get("id"), + "status": f.get("status", {}).get("name"), + "priority": f.get("priority", {}).get("name"), + "user": assignee.get("displayName") if assignee else "Unassigned", + "reporter": reporter, + "created": created, + "updated": updated, + "labels": labels, + "components": components, + "parent_key": (f.get("parent") or {}).get("key"), + "children": {}, + } + return registry + + +def build_fzf_preview(registry, type_order_map, terminal_width, team_name="TEAM"): + """ + Pure: Flattens the registry into FZF lines and rich preview blocks. + """ + lines, previews = [], {} + known_children = {k for k, v in registry.items() if v["parent_key"]} + SUMM_W = terminal_width - 65 + + # Safe, fixed-width divider to prevent FZF wrap arrows + divider = f"{C['dim']}{'─' * 64}{C['reset']}" + + # Nest the children + for key, issue in registry.items(): + pk = issue["parent_key"] + if pk and pk in registry: + registry[pk]["children"][key] = issue + + def flatten(nodes, depth=0): + sorted_nodes = sorted( + nodes, + key=lambda x: ( + type_order_map.get(str(x.get("type_id")), (100, "", False))[0], + x["key"], + ), + ) + + for n in sorted_nodes: + st_color, st_icon = get_status_style(n["status"]) + type_color = type_order_map.get( + str(n.get("type_id")), (100, C["reset"], False) + )[1] + + # ========================================== + # 1. BUILD PREVIEW BLOCK + # ========================================== + + bc = f" {C['dim']}❯{C['reset']} " + ident_line = ( + f"{C['magenta']} {team_name.upper()}{C['reset']}{bc}" + f"{C['bold']}{n['key']}{C['reset']}{bc}" + f"{type_color} {n['type'].upper()}{C['reset']}{bc}" + f"{st_color}{st_icon} {n['status'].upper()}{C['reset']}{bc}" + f"{get_priority_icon(n['priority'])} {n['priority'].upper()}" + ) + + # Helper to pad values so the 2-column grid aligns perfectly + def pad(text, width=22): + return ( + text[: width - 3] + "..." + if len(text) > width + else text.ljust(width) + ) + + u_val = pad(n["user"]) + r_val = pad(n["reporter"]) + + # --------------------------------------------------------- + # The Dashboard Grid (Left Prefix Width is exactly 14 chars) + # --------------------------------------------------------- + row1 = f"{C['cyan']} Assignee: {C['reset']}{u_val} {C['dim']} Created: {C['reset']}{n['created']}" + row2 = f"{C['dim']} Reporter: {C['reset']}{r_val} {C['dim']} Updated: {C['reset']}{n['updated']}" + + # Stack the header lines tightly + header_lines = [ident_line, row1, row2] + + # Full-width lines for lists + if n["components"]: + header_lines.append( + f"{C['blue']} Components: {C['reset']}{n['components']}" + ) + if n["labels"]: + header_lines.append( + f"{C['magenta']} Labels: {C['reset']}{n['labels']}" + ) + + # Assemble the Dashboard Header (NO extra linebreaks) + header_block = "\n".join(header_lines) + + # Cap the metadata with the divider and print the Summary as an UPPERCASE H1 Header + header_block += ( + f"\n{divider}\n{C['bold']}{n['summary'].upper()}{C['reset']}\n\n" + ) + + # --- Child Issues Section --- + children_ansi = "" + if n["children"]: + children_ansi = f"\n\n{divider}\n\n{C['blue']}{C['bold']}󰙔 CHILD ISSUES{C['reset']}\n\n" + sorted_children = sorted( + n["children"].values(), + key=lambda x: ( + type_order_map.get(str(x.get("type_id")), (100, "", False))[0], + x["key"], + ), + ) + for c in sorted_children: + c_st_color, c_st_icon = get_status_style(c["status"]) + c_type_color = type_order_map.get( + str(c.get("type_id")), (100, C["reset"], False) + )[1] + children_ansi += ( + f" {C['dim']}↳{C['reset']} " + f"{c_type_color}{c['key']:<11}{C['reset']} " + f"{c_st_color}{c_st_icon} {c['status'][:14]:<14}{C['reset']} " + f"{c['summary']}\n" + ) + + # Assemble Final Preview + previews[n["key"]] = ( + f"{header_block}{n['description']}{children_ansi}{n['comments']}" + ) + + # ========================================== + # 2. BUILD MAIN TUI LINE + # ========================================== + + indent = " " * depth + pref = "└─ " if depth > 0 else " " + + raw_summary = f"{indent}{pref}{n['summary']}" + if len(raw_summary) > SUMM_W: + display_summary = raw_summary[: SUMM_W - 3] + "..." + else: + display_summary = raw_summary + + if n["type"].lower() == "task": + summary_color = C["default"] + else: + summary_color = type_color + + display_summary = f"{summary_color}{display_summary}{C['reset']}" + + child_count = len(n["children"]) + if child_count > 0: + display_summary = f"{C['bold']}{display_summary} {C['dim']}({child_count} sub){C['reset']}" + + lines.append( + f"{type_color}{n['key']:<12}{C['reset']} " + f"{get_priority_icon(n['priority'])} " + f"{type_color}{n['type'][:10]:<10}{C['reset']} " + f"{st_color}{n['status'][:16]:<16}{C['reset']} " + f"{C['dim']}{n['user'][:16]:<16}{C['reset']} " + f"{display_summary}" + ) + + flatten(n["children"].values(), depth + 1) + + roots = [v for k, v in registry.items() if k not in known_children] + flatten(roots) + return lines, previews From f7c12988d473d4c8365c4efcfbf413fd4dd3b473 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 19 Mar 2026 13:46:07 +0000 Subject: [PATCH 2/4] chore: remove legacy jira app --- apps/jira/j | 30 ----- apps/jira/schemas/frontmatter.json | 67 ---------- apps/jira/subcommands/create.sh | 127 ------------------- apps/jira/subcommands/show.sh | 36 ------ apps/jira/templates/bug.md | 11 -- apps/jira/templates/epic.md | 5 - apps/jira/templates/story.md | 11 -- apps/jira/templates/task.md | 7 - apps/jira/utils/fzf/pipe_to_fzf.sh | 33 ----- apps/jira/utils/jira/append_template.sh | 11 -- apps/jira/utils/jira/build_frontmatter.sh | 17 --- apps/jira/utils/jira/get_active_sprint.sh | 13 -- apps/jira/utils/jira/get_filter.sh | 15 --- apps/jira/utils/jira/validate_param.sh | 8 -- apps/jira/utils/jira/validate_with_schema.sh | 32 ----- 15 files changed, 423 deletions(-) delete mode 100755 apps/jira/j delete mode 100755 apps/jira/schemas/frontmatter.json delete mode 100755 apps/jira/subcommands/create.sh delete mode 100755 apps/jira/subcommands/show.sh delete mode 100644 apps/jira/templates/bug.md delete mode 100644 apps/jira/templates/epic.md delete mode 100644 apps/jira/templates/story.md delete mode 100644 apps/jira/templates/task.md delete mode 100755 apps/jira/utils/fzf/pipe_to_fzf.sh delete mode 100755 apps/jira/utils/jira/append_template.sh delete mode 100755 apps/jira/utils/jira/build_frontmatter.sh delete mode 100755 apps/jira/utils/jira/get_active_sprint.sh delete mode 100755 apps/jira/utils/jira/get_filter.sh delete mode 100755 apps/jira/utils/jira/validate_param.sh delete mode 100755 apps/jira/utils/jira/validate_with_schema.sh diff --git a/apps/jira/j b/apps/jira/j deleted file mode 100755 index 86aed98..0000000 --- a/apps/jira/j +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -e -trap 'echo "An error occurred. Exiting..."' ERR - -# TODO: Write a bootstrap script that moves schemas and -# a base config file to XDG_CONFIG HOME, It should also -# copy subcommands and utils into XDG_DATA_HOME then update -# the J_ variables. - -# export J_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/j" -# export J_UTILS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/j/utils" - -export J_CONFIG_DIR="${HOME}/.dotfiles/apps/jira/" -export J_UTILS_DIR="${HOME}/.dotfiles/apps/jira/utils" -export J_SCHEMAS_DIR="${HOME}/.dotfiles/apps/jira/schemas" -export J_SUBCOMMANDS_DIR="${HOME}/.dotfiles/apps/jira/subcommands" - -# @cmd Show issues -# @arg args~ -show() { - "$J_SUBCOMMANDS_DIR/show.sh" "$@" -} - -# @cmd Create an issue -# @arg args~ -create() { - "$J_SUBCOMMANDS_DIR/create.sh" "$@" -} - -eval "$(argc --argc-eval "$0" "$@")" diff --git a/apps/jira/schemas/frontmatter.json b/apps/jira/schemas/frontmatter.json deleted file mode 100755 index a050f2e..0000000 --- a/apps/jira/schemas/frontmatter.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^(?!\\s*$).+" - }, - "summary": { - "type": "string", - "pattern": "^(?!\\s*$).+" - }, - "priority": { - "type": "string", - "enum": ["High", "Medium", "Low"] - }, - "status": { - "type": "string", - "enum": [ - "Backlog", - "Refined", - "Refinement in progress", - "Refinement in review", - "Selected for Refinement", - "In Progress", - "In Review", - "Ready for Development", - "Done", - "Wont do" - ] - }, - "type": { - "type": "string", - "enum": [ - "Task", - "Sub-task", - "Story", - "Bug", - "Epic", - "Spike", - "Initiative" - ] - }, - "id": { - "type": "string", - "pattern": "^[A-Z]+-[0-9]+$" - }, - "parent": { - "type": "string", - "pattern": "^[A-Z]+-[0-9]+$" - }, - "sprint": { - "type": "boolean" - } - }, - "required": ["summary", "status", "type"], - "allOf": [ - { - "if": { - "properties": { "type": { "const": "Epic" } } - }, - "then": { - "required": ["name"] - } - } - ] -} diff --git a/apps/jira/subcommands/create.sh b/apps/jira/subcommands/create.sh deleted file mode 100755 index 7e5e4ac..0000000 --- a/apps/jira/subcommands/create.sh +++ /dev/null @@ -1,127 +0,0 @@ -# @flag -s --sprint Add the issue to the active sprint -# @flag -b --bust Bust the cache -# @option --priority[=Medium|High|Low] -# @option --type[=Task|Epic|Bug|Story|Sub-task|Initiative] Specify the type of issue to create -# @option --parent Specify the parent issue -# @option --status[=Backlog|Refined|Refinement in progress|Refinement in review|Selected for Refinement|In Review|Ready for Development|Done|Wont do] -# @option --from-file Use an existing file to create an issue - -eval "$(argc --argc-eval "$0" "$@")" - -# shellcheck source=../utils/jira/append_template.sh -source "$J_UTILS_DIR/jira/append_template.sh" -# shellcheck source=../utils/jira/validate_param.sh -source "$J_UTILS_DIR/jira/validate_param.sh" -# shellcheck source=../utils/jira/validate_with_schema.sh -source "$J_UTILS_DIR/jira/validate_with_schema.sh" -# shellcheck source=../utils/jira/build_frontmatter.sh -source "$J_UTILS_DIR/jira/build_frontmatter.sh" -# shellcheck source=../utils/jira/get_active_sprint.sh -source "$J_UTILS_DIR/jira/get_active_sprint.sh" - -# shellcheck disable=SC2154 -if [[ -n "$argc_from_file" ]]; then - if [[ ! -f "$argc_from_file" ]]; then - echo "Error: File not found: $argc_from_file" >&2 - exit 1 - fi - tmpfile="$argc_from_file" -else - frontmatter_params=() - - # shellcheck disable=SC2154,SC2046,SC2086 - if [[ "$argc_type" == "Epic" ]]; then - frontmatter_params+=("name: ") - fi - - frontmatter_params+=( - "summary: " - "type: $argc_type" - "status: $argc_status" - ) - - # shellcheck disable=SC2154,SC2046,SC2086 - [[ "$argc_sprint" -eq 1 ]] && frontmatter_params+=("sprint: true") - - if [[ -n "$argc_parent" ]]; then - frontmatter_params+=("parent: $argc_parent") - fi - - tmpfile=$(mktemp /tmp/jira_create_"$(date +%Y%m%d_%H%M%S)".md) - - build_frontmatter "${frontmatter_params[@]}" >"$tmpfile" - append_template "$argc_type" "$tmpfile" - - nvim +2 "$tmpfile" -fi - -yaml=$(awk '/^---/{flag=!flag; next} flag' "$tmpfile") - -validated_yaml=$(validate_with_schema "$J_SCHEMAS_DIR/frontmatter.json" "$yaml") - -body=$(awk 'BEGIN {found=0} /^---/ {found+=1; next} found==2 {print}' "$tmpfile") -name=$(echo "$validated_yaml" | yq -r '.name') -parent=$(echo "$validated_yaml" | yq -r '.parent') -priority=$(echo "$validated_yaml" | yq -r '.priority') -sprint=$(echo "$validated_yaml" | yq -r '.sprint') -status=$(echo "$validated_yaml" | yq -r '.status') -summary=$(echo "$validated_yaml" | yq -r '.summary') -type=$(echo "$validated_yaml" | yq -r '.type') - -jira_params=() - -if [[ "$type" == "Epic" ]]; then - validate_param "$name" && jira_params+=("--name" "$name") -else - validate_param "$type" && jira_params+=("--type" "$type") - validate_param "$parent" && jira_params+=("--parent" "$parent") -fi - -validate_param "$summary" && jira_params+=("--summary" "$summary") -validate_param "$priority" && jira_params+=("--priority" "$priority") - -if [[ -n "$JIRA_TEAM" ]]; then - jira_params+=("--custom" "team=$JIRA_TEAM") -fi - -if [[ "$sprint" == "true" ]]; then - # shellcheck disable=SC2154,SC2046,SC2086 - if [[ "$argc_bust" == "1" ]]; then - sprint_id=$(get_active_sprint "force") - else - sprint_id=$(get_active_sprint) - fi - jira_params+=("--custom" "sprint=$sprint_id") -fi - -jira_params+=("--raw") - -echo "Creating $type in Jira... with ${jira_params[*]}" - -if [[ "$type" == "Epic" ]]; then - jira_output=$(echo "$body" | jira epic create "${jira_params[@]}" 2>&1) -else - jira_output=$(echo "$body" | jira issue create "${jira_params[@]}" 2>&1) -fi - -[[ "$J_DEBUG" = "1" ]] && echo "$jira_output" - -if [[ "$jira_output" =~ \{.*\"key\":\"([^\"]+)\".*\} ]]; then - issue_key="${BASH_REMATCH[1]}" - echo "Created issue: $issue_key" - echo "URL: $(jira open --no-browser "$issue_key")" - if [[ -n "$status" && "$status" != "null" && "$status" != "Backlog" ]]; then - echo "Transitioning $issue_key to status: $status" - jira issue move "$issue_key" "$status" || { - echo "Warning: Could not transition issue to '$status'. The issue was created successfully." - } - fi -else - echo "Error creating Jira issue." >&2 - echo "Jira output:" >&2 - echo "$jira_output" >&2 - echo "Your work has been saved to: $tmpfile" >&2 - echo "You can retry creating the issue with the following command:" >&2 - echo "j create --from-file $tmpfile" >&2 - exit 1 -fi diff --git a/apps/jira/subcommands/show.sh b/apps/jira/subcommands/show.sh deleted file mode 100755 index c2c7eb6..0000000 --- a/apps/jira/subcommands/show.sh +++ /dev/null @@ -1,36 +0,0 @@ -# @describe Show Jira issues based on various filters -# @arg subcommand[=current|all|open|backlog|closed] The subcommand to execute (required). -# @flag -a --any Show issues from any user (removes -a$(jira me) filter) - -eval "$(argc --argc-eval "$0" "$@")" - -# shellcheck source=../utils/jira/get_filter.sh -source "$J_UTILS_DIR/jira/get_filter.sh" -# shellcheck source=../utils/fzf/pipe_to_fzf.sh -source "$J_UTILS_DIR/fzf/pipe_to_fzf.sh" - -subcommand="$argc_subcommand" - -filter=$(get_filter "${argc_args:-}") - -case "$subcommand" in -all) - jira issue list --jql "$filter" --plain --columns id,type,summary,status,reporter,assignee | pipe_to_fzf - ;; -current) - jira sprint list --table --current --plain --columns=id,type,summary,status,reporter,assignee | pipe_to_fzf - ;; -open) - jira issue list --jql "$filter AND status IN ('Ready for Development', 'In Progress', 'In Review')" --plain --columns id,type,summary,status,reporter,assignee | pipe_to_fzf - ;; -backlog) - jira issue list --jql "$filter AND status IN ('Backlog', 'Selected for Refinement', 'Refinement in progress', 'Refinement in review', 'Refined')" --plain --columns id,type,summary,status,reporter,assignee | pipe_to_fzf - ;; -closed) - jira issue list --jql "$filter AND status IN ('Done', 'Wont Do')" --plain --columns id,type,summary,status,reporter,assignee | pipe_to_fzf - ;; -*) - echo "Error: Unknown subcommand '$subcommand'" >&2 - exit 1 - ;; -esac diff --git a/apps/jira/templates/bug.md b/apps/jira/templates/bug.md deleted file mode 100644 index ef584b9..0000000 --- a/apps/jira/templates/bug.md +++ /dev/null @@ -1,11 +0,0 @@ -**Bug Details:** - -**Steps to Reproduce:** - -1. -2. -3. - -**Expected Result:** - -**Actual Result:** diff --git a/apps/jira/templates/epic.md b/apps/jira/templates/epic.md deleted file mode 100644 index a68e0ac..0000000 --- a/apps/jira/templates/epic.md +++ /dev/null @@ -1,5 +0,0 @@ -**Summary:** - -**Expected outcome:** - -**Goal:** diff --git a/apps/jira/templates/story.md b/apps/jira/templates/story.md deleted file mode 100644 index a9cc486..0000000 --- a/apps/jira/templates/story.md +++ /dev/null @@ -1,11 +0,0 @@ -**Summary:** - -**As a** -**I want** -**So that** - -**Acceptance Criteria:** - -**Given** -**When** -**Then** diff --git a/apps/jira/templates/task.md b/apps/jira/templates/task.md deleted file mode 100644 index a180e57..0000000 --- a/apps/jira/templates/task.md +++ /dev/null @@ -1,7 +0,0 @@ -**Summary:** - -**Acceptance Criteria:** - -**Given** -**When** -**Then** diff --git a/apps/jira/utils/fzf/pipe_to_fzf.sh b/apps/jira/utils/fzf/pipe_to_fzf.sh deleted file mode 100755 index 74a162d..0000000 --- a/apps/jira/utils/fzf/pipe_to_fzf.sh +++ /dev/null @@ -1,33 +0,0 @@ -pipe_to_fzf() { - local fetch_command="jira issue view" - - local input - input=$(cat) - - local me - me=$(jira me) - - # Parse out the issue keys and pass to xargs to warm the bkt cache - echo "$input" | awk 'NR > 1 {print $1}' | xargs -n1 -P4 -I{} bkt --ttl=15m --warm -- bash -c "$fetch_command \"{}\"" - - echo "$input" | fzf \ - --ansi \ - --border \ - --padding 1,2 \ - --border-label ' Issues ' --input-label ' Search ' \ - --layout=reverse \ - --header-lines=1 \ - --preview-window=top:60%:wrap \ - --preview="bkt --ttl=15m --stale=1m -- bash -c \"$fetch_command {1}\" | glow --pager never -s dark" \ - --bind='alt-p:toggle-preview' \ - --bind='alt-b:preview-up' \ - --bind='alt-f:preview-down' \ - --bind="alt-a:execute(jira issue assign {1} $me)" \ - --bind='alt-e:execute(jira issue edit {1})' \ - --bind='alt-o:become(jira open {1})' \ - --bind='alt-u:become(jira open --no-browser {1} | pbcopy)' \ - --bind="alt-y:become[jira open --no-browser {1} | awk -F/ '{print \$NF}' | pbcopy ]" \ - --bind='alt-m:execute(jira issue move {1})' \ - --bind='alt-n:execute(echo {+} | awk -F"\t" '\''{issue=""; found=0; name=""; for(i=1;i<=NF;i++) { if(issue=="" && $i!="") issue=$i; else if(issue!="" && found==0 && $i!="") found=1; else if(issue!="" && found==1 && $i!="") {name=$i; break} } print issue "-" name}'\'' | tr "[:upper:]" "[:lower:]" | sed "s/ /-/g" | sed "s/[^a-z0-9-]//g" | xargs -I{} echo "git checkout -b {}" | pbcopy)' \ - --bind='alt-c:execute(nvim /tmp/jira_comment.md -c "set ft=markdown" && jira issue comment add {1} < /tmp/jira_comment.md && rm /tmp/jira_comment.md)' -} diff --git a/apps/jira/utils/jira/append_template.sh b/apps/jira/utils/jira/append_template.sh deleted file mode 100755 index e8c8637..0000000 --- a/apps/jira/utils/jira/append_template.sh +++ /dev/null @@ -1,11 +0,0 @@ -append_template() { - local type="$1" - local file="$2" - - case "$type" in - "Epic") cat "$J_CONFIG_DIR/templates/epic.md" >>"$file" ;; - "Story") cat "$J_CONFIG_DIR/templates/story.md" >>"$file" ;; - "Task") cat "$J_CONFIG_DIR/templates/task.md" >>"$file" ;; - "Bug") cat "$J_CONFIG_DIR/templates/bug.md" >>"$file" ;; - esac -} diff --git a/apps/jira/utils/jira/build_frontmatter.sh b/apps/jira/utils/jira/build_frontmatter.sh deleted file mode 100755 index 6e53e11..0000000 --- a/apps/jira/utils/jira/build_frontmatter.sh +++ /dev/null @@ -1,17 +0,0 @@ -build_frontmatter() { - local params=("$@") - local frontmatter="---" - - for param in "${params[@]}"; do - if [[ -z "$param" ]]; then - continue - fi - - frontmatter+=" -$param" - done - - frontmatter+=" ----" - echo -e "$frontmatter" -} diff --git a/apps/jira/utils/jira/get_active_sprint.sh b/apps/jira/utils/jira/get_active_sprint.sh deleted file mode 100755 index 3bce992..0000000 --- a/apps/jira/utils/jira/get_active_sprint.sh +++ /dev/null @@ -1,13 +0,0 @@ -get_active_sprint() { - local api_url=$JIRA_ACTIVE_SPRINT_URL - - local response - response=$(curl -s -X GET "$api_url" \ - -H "Accept: application/json" \ - -H "Authorization: Basic $JIRA_BEARER_TOKEN") - - local active_sprint - active_sprint=$(echo "$response" | jq '.values[0]') - - echo "$active_sprint" | jq -r '.id' -} diff --git a/apps/jira/utils/jira/get_filter.sh b/apps/jira/utils/jira/get_filter.sh deleted file mode 100755 index 8e02cc0..0000000 --- a/apps/jira/utils/jira/get_filter.sh +++ /dev/null @@ -1,15 +0,0 @@ -get_filter() { - local any_flag="$1" - - if [[ "$any_flag" == "1" ]]; then - local assignees - read -r -a assignees <<<"${JIRA_TEAM_MEMBERS//,/ }" - - local assignee_filter - assignee_filter=$(printf ',"%s"' "${assignees[@]}") - assignee_filter=${assignee_filter:1} # Remove leading comma - echo "assignee IN ($assignee_filter)" - else - echo "assignee = \"$(jira me)\"" - fi -} diff --git a/apps/jira/utils/jira/validate_param.sh b/apps/jira/utils/jira/validate_param.sh deleted file mode 100755 index 6a3111f..0000000 --- a/apps/jira/utils/jira/validate_param.sh +++ /dev/null @@ -1,8 +0,0 @@ -validate_param() { - local value="$1" - if [[ -n "$value" && "$value" != "null" ]]; then - return 0 - else - return 1 - fi -} diff --git a/apps/jira/utils/jira/validate_with_schema.sh b/apps/jira/utils/jira/validate_with_schema.sh deleted file mode 100755 index 3029117..0000000 --- a/apps/jira/utils/jira/validate_with_schema.sh +++ /dev/null @@ -1,32 +0,0 @@ -validate_with_schema() { - local schema_file="$1" - local input="$2" - - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Starting validation..." >&2 - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Schema file: $schema_file" >&2 - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Input length: ${#input}" >&2 - - local temp_data_file - temp_data_file=$(mktemp "/tmp/jira_frontmatter.yml") - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Temp file created: $temp_data_file" >&2 - - if [ ! -f "$temp_data_file" ]; then - echo "Failed to create temporary file with mktemp: $temp_data_file" >&2 - return 1 - fi - - echo "$input" >"$temp_data_file" - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Wrote input to temp file" >&2 - - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: File contents:" >&2 - [[ "$J_DEBUG" = "1" ]] && cat "$temp_data_file" >&2 - - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: Running jsonschema..." >&2 - jsonschema validate "$schema_file" "$temp_data_file" >&2 - local result=$? - [[ "$J_DEBUG" = "1" ]] && echo "DEBUG: jsonschema exit code: $result" >&2 - - cat "$temp_data_file" - rm -f "$temp_data_file" - return $result -} From 955c324cf975331c05f990c366ef5002e268cd93 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 19 Mar 2026 13:57:10 +0000 Subject: [PATCH 3/4] fix: imports and help menu fixes --- apps/ihj/jira/hierarchy.py | 4 ++-- apps/ihj/tui/terminal.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ihj/jira/hierarchy.py b/apps/ihj/jira/hierarchy.py index b25ca85..bb79d50 100644 --- a/apps/ihj/jira/hierarchy.py +++ b/apps/ihj/jira/hierarchy.py @@ -1,5 +1,5 @@ -from export.markdown import adf_to_markdown -from export.idempotency import generate_issue_hash +from jira.markdown import adf_to_markdown +from jira.idempotency import generate_issue_hash def build_export_hierarchy(raw_issues): diff --git a/apps/ihj/tui/terminal.py b/apps/ihj/tui/terminal.py index 662a2c0..ad08f1b 100644 --- a/apps/ihj/tui/terminal.py +++ b/apps/ihj/tui/terminal.py @@ -66,7 +66,7 @@ def prompt_numeric_menu(options, title, color=C["blue"]): def launch_fzf(lines, board_name, mode_name, age, preview_path, bindings): """Executes the main FZF interface.""" list_header = f"{C['bold']}{'ID':<12} P {'TYPE':<10} {'STATUS':<16} {'ASSIGNEE':<16} SUMMARY{C['reset']}" - help_guide = f"{C['cyan']}Alt-R{C['reset']} Refresh | {C['cyan']}Alt-S{C['reset']} Mode | {C['cyan']}Alt-A{C['reset']} Assign | {C['cyan']}Alt-T{C['reset']} Move | {C['cyan']}Alt-O{C['reset']} Open | {C['cyan']}Alt-E{C['reset']} Edit | {C['cyan']}Alt-N{C['reset']} Branch" + help_guide = f"{C['cyan']}Alt-R{C['reset']} Refresh | {C['cyan']}Alt-S{C['reset']} Mode | {C['cyan']}Alt-A{C['reset']} Assign | {C['cyan']}Alt-T{C['reset']} Transition | {C['cyan']}Alt-O{C['reset']} Open | {C['cyan']}Alt-E{C['reset']} Edit | {C['cyan']}Alt-C{C['reset']} Comment | {C['cyan']}Alt-N{C['reset']} Branch | {C['cyan']}Ctrl-N{C['reset']} New" fzf_cmd = [ "fzf", From 0e5e22ded495349bf869c340f6c992d01b6964bd Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 19 Mar 2026 13:57:35 +0000 Subject: [PATCH 4/4] chore: update stow for ihj --- stow.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/stow.sh b/stow.sh index 00949bb..e798cd9 100755 --- a/stow.sh +++ b/stow.sh @@ -9,19 +9,19 @@ DRY_RUN="" while getopts "unh" opt; do case $opt in - u) - MODE="-D" - ACTION_TEXT="Unlinking" - ;; - n) - DRY_RUN="--simulate" - echo "--- DRY RUN MODE ENABLED ---" - ;; - h) - echo "Usage: ./script.sh [-u] [-n]" - exit 0 - ;; - *) exit 1 ;; + u) + MODE="-D" + ACTION_TEXT="Unlinking" + ;; + n) + DRY_RUN="--simulate" + echo "--- DRY RUN MODE ENABLED ---" + ;; + h) + echo "Usage: ./script.sh [-u] [-n]" + exit 0 + ;; + *) exit 1 ;; esac done @@ -49,11 +49,11 @@ stow $DRY_RUN --dotfiles -v "$MODE" -t "$HOME" home echo "$ACTION_TEXT bin..." stow $DRY_RUN -v "$MODE" -t "$HOME/.local/bin" bin -J_LINK="$HOME/.local/bin/j" +IHJ_LINK="$HOME/.local/bin/ihj" if [[ "$MODE" == "-D" ]]; then - [[ -L "$J_LINK" ]] && [[ -z "$DRY_RUN" ]] && rm "$J_LINK" + [[ -L "$IHJ_LINK" ]] && [[ -z "$DRY_RUN" ]] && rm "$IHJ_LINK" elif [[ ! -f "bin/j" ]]; then - [[ -z "$DRY_RUN" ]] && ln -sf "$DOTFILES_DIR/apps/jira/j" "$J_LINK" + [[ -z "$DRY_RUN" ]] && ln -sf "$DOTFILES_DIR/apps/ihj/ihj" "$IHJ_LINK" fi if [[ -d "data" ]]; then