From d3659534ef60d2bbd873869b6a9c0bbff04a4336 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sun, 21 Jun 2026 17:03:12 -0400 Subject: [PATCH] feat: parameterize create_issue.py, track manage_labels.py - create_issue.py: argparse single-issue creator mirroring create_pr.py (--remote {dadeschools,prgs}, --title/--body/--body-file, host overrides), replacing the one-shot hardcoded ROADMAP backfill batch. - manage_labels.py: add the label set + mapping tool (executable). Co-Authored-By: Claude Opus 4.8 (1M context) --- create_issue.py | 143 ++++++++++++++++++++++++++++++++--------------- manage_labels.py | 128 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 46 deletions(-) create mode 100755 manage_labels.py diff --git a/create_issue.py b/create_issue.py index fc48392..fa7aa48 100755 --- a/create_issue.py +++ b/create_issue.py @@ -1,53 +1,104 @@ -import subprocess -import os +#!/usr/bin/env python3 +"""Create a Gitea issue. + +Parameterized over title/body and the target Gitea instance, mirroring +create_pr.py. Two instances are known out of the box: + + dadeschools -> gitea.dadeschools.net / Contractor / Timesheet + prgs -> gitea.prgs.cc / Scaled-Tech-Consulting / Timesheet + +Auth is pulled from the macOS keychain via `git credential fill` for the +chosen host -- no tokens on the command line. + +Examples: + create_issue.py --title "PDF: open after creation" \\ + --body "Auto-open the generated PDF in the default viewer." + + create_issue.py --remote prgs --title "Fix X" --body-file desc.md +""" import sys +import json +import base64 +import argparse +import subprocess +import urllib.request +import urllib.error -# Define the issues to create based on ROADMAP.md missing/partially done items. -ISSUES = [ - ("PDF: Preview PDF before final save", "Show a preview or summary dialog before the PDF is fully committed to disk."), - ("PDF: Compare generated PDFs against known-good samples", "Add visual or byte-comparison tests to ensure PDF rendering doesn't drift."), - ("PDF: Validate week/date placement", "Add validation that the week date ranges are accurately placed inside the generated PDF fields."), - ("PDF: Validate total hours calculation", "Add backend validation to verify that total hours printed in the PDF are mathematically correct."), - ("PDF: Warn if total hours are not 40", "Add total-hours validation and user-facing warnings when generating timesheets that do not sum to exactly 40 hours."), - ("PDF: Support PTO, sick, holiday, and blank days", "Timesheet grid assumes default hours and lacks distinct categories. Add support for PTO, sick, holiday, and blank days."), - ("PDF: Open generated PDF after creation", "Provide an option or automatic behavior to open the PDF in the default viewer immediately after generating."), - ("Email: Preview email before draft creation", "Display the email body and recipients before launching Outlook."), - ("Email: Saved manager recipient", "Allow saving the manager's email address in settings so it populates automatically."), - ("Email: Saved email templates", "Add Outlook email template body customization in Settings."), - ("GUI: One-click generate this week's timesheet", "Add a quick-action button to generate the current week's timesheet with a single click."), - ("GUI: Duplicate previous week", "Add functionality to clone the hours and projects from the previous week's timesheet."), - ("GUI: Better success screen", "Improve the UI feedback shown after a successful generation."), - ("GUI: Dark-mode friendly UI", "Ensure the UI colors and styling are properly adapted for macOS dark mode."), - ("Data: Add ~/.timesheet/history.json configuration", "History tracking is currently using `.state/history.json`. Move this to a user-local `~/.timesheet/history.json` path."), - ("Data: Track generated PDF metadata", "Track generated PDF path, week range, total hours, created_at, and email mode in the history JSON."), - ("DevOps: Local / Gitea Validation Pipeline", "Lacks git pre-commit hooks and Gitea/Woodpecker pipeline configurations for running tests and pyright."), - ("Dev: Split large GUI files into smaller controllers", "Further decomposition of large GUI panels and controllers into smaller, more maintainable modules."), - ("Dev: Add tests for manage.sh", "Add test coverage for the command-line menu tool `manage.sh`."), - ("Dev: Add app versioning", "Implement dynamic/git tagging and formal app versioning (currently only basic versioning exists)."), - ("Dev: Add About dialog", "Add an About dialog in the GUI with version and author info."), - ("Dev: Add release notes", "Create a process for generating and displaying release notes on updates."), - ("Dev: Improve project structure", "Clean up helper scripts and organize the project root structure better.") -] +REMOTES = { + "dadeschools": {"host": "gitea.dadeschools.net", "org": "Contractor", + "repo": "Timesheet"}, + "prgs": {"host": "gitea.prgs.cc", "org": "Scaled-Tech-Consulting", + "repo": "Timesheet"}, +} -SCRIPT_PATH = "./scripts/create-issue.sh" -def main(): - if not os.path.exists(SCRIPT_PATH): - print(f"Error: Could not find {SCRIPT_PATH}. Run this from the repository root.") - sys.exit(1) - - success_count = 0 - for title, body in ISSUES: - print(f"Creating issue: {title}") - result = subprocess.run([SCRIPT_PATH, title, body], capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.strip()) - success_count += 1 +def get_credentials(host): + """Return (user, password) for `host` via `git credential fill`.""" + p = subprocess.Popen( + ["git", "credential", "fill"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + out, _ = p.communicate(f"protocol=https\nhost={host}\n\n") + user = password = "" + for line in out.splitlines(): + # split(maxsplit=1): tokens/passwords can themselves contain '='. + if line.startswith("username="): + user = line.split("=", 1)[1] + elif line.startswith("password="): + password = line.split("=", 1)[1] + return user, password + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Create a Gitea issue.") + parser.add_argument("--remote", choices=sorted(REMOTES), default="dadeschools", + help="Known Gitea instance (default: dadeschools).") + parser.add_argument("--host", help="Override the Gitea host.") + parser.add_argument("--org", help="Override the owner/org.") + parser.add_argument("--repo", help="Override the repository.") + parser.add_argument("--title", required=True, help="Issue title.") + parser.add_argument("--body", default="", help="Issue body text.") + parser.add_argument("--body-file", help="Read issue body from this file ('-' for stdin).") + args = parser.parse_args(argv) + + profile = REMOTES[args.remote] + host = args.host or profile["host"] + org = args.org or profile["org"] + repo = args.repo or profile["repo"] + + body = args.body + if args.body_file: + if args.body_file == "-": + body = sys.stdin.read() else: - print(f"FAILED to create issue: {title}") - print(result.stderr.strip()) - - print(f"\nFinished! Created {success_count} out of {len(ISSUES)} issues.") + with open(args.body_file, "r", encoding="utf-8") as fh: + body = fh.read() + + user, password = get_credentials(host) + if not user or not password: + print(f"Could not get credentials for {host} " + f"(no keychain entry? try a manual `git credential fill`).", + file=sys.stderr) + return 1 + + url = f"https://{host}/api/v1/repos/{org}/{repo}/issues" + payload = {"title": args.title, "body": body} + req = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + auth_b64 = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("utf-8") + req.add_header("Authorization", f"Basic {auth_b64}") + + try: + with urllib.request.urlopen(req) as response: + data = json.load(response) + print(f"Issue #{data.get('number')}: {data.get('html_url')}") + return 0 + except urllib.error.HTTPError as e: + print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) + return 1 + if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/manage_labels.py b/manage_labels.py new file mode 100755 index 0000000..98244b6 --- /dev/null +++ b/manage_labels.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Create a label set on a Gitea repo and apply labels to issues. + +Auth follows the project convention: credentials are pulled from the macOS +keychain via `git credential fill` (HTTPS), then sent as Basic auth. + +Usage: + ./manage_labels.py # create labels, then apply the mapping below + ./manage_labels.py --dry # print actions without writing +""" +import sys +import json +import base64 +import subprocess +import urllib.request +import urllib.error + +HOST = "gitea.dadeschools.net" +ORG = "Contractor" +REPO = "Timesheet" + +# Mirror of the eAgenda label set (i18n omitted as irrelevant to Timesheet). +LABELS = [ + {"name": "chore", "color": "5319e7", "description": ""}, + {"name": "critical", "color": "b60205", "description": ""}, + {"name": "epic", "color": "8250df", "description": ""}, + {"name": "important", "color": "fbca04", "description": ""}, + {"name": "nice-to-have", "color": "0e8a16", "description": ""}, + {"name": "status:in-progress", "color": "fefe2e", + "description": "Issue is being worked on"}, +] + +# issue number -> label names to apply +MAPPING = { + 23: ["chore"], + 22: ["chore"], + 21: ["chore"], + 20: ["chore", "status:in-progress"], + 19: ["chore"], + 18: ["chore"], + 17: ["important"], + 14: ["nice-to-have"], + 13: ["nice-to-have"], + 12: ["important"], + 11: ["important"], + 8: ["nice-to-have"], + 7: ["nice-to-have"], + 3: ["important"], + 2: ["important"], + 1: ["nice-to-have"], +} + +API = f"https://{HOST}/api/v1/repos/{ORG}/{REPO}" + + +def get_auth_header(): + p = subprocess.Popen( + ["git", "credential", "fill"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + out, _ = p.communicate(f"protocol=https\nhost={HOST}\n\n") + user = password = "" + for line in out.splitlines(): + if line.startswith("username="): + user = line.split("=", 1)[1] + if line.startswith("password="): + password = line.split("=", 1)[1] + if not user or not password: + print("Could not get credentials from git credential fill", file=sys.stderr) + sys.exit(1) + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return f"Basic {token}" + + +def api(method, path, auth, payload=None): + url = f"{API}{path}" + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", auth) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as r: + body = r.read().decode() + return json.loads(body) if body else None + except urllib.error.HTTPError as e: + print(f" HTTP {e.code} on {method} {path}: {e.read().decode()}", file=sys.stderr) + return None + + +def main(): + dry = "--dry" in sys.argv + auth = get_auth_header() + + # 1. Existing labels -> name:id + existing = api("GET", "/labels?limit=100", auth) or [] + by_name = {l["name"]: l["id"] for l in existing} + + # 2. Create missing labels + for spec in LABELS: + if spec["name"] in by_name: + print(f"label exists: {spec['name']}") + continue + if dry: + print(f"[dry] create label: {spec['name']} #{spec['color']}") + continue + created = api("POST", "/labels", auth, spec) + if created: + by_name[created["name"]] = created["id"] + print(f"created label: {created['name']} (id {created['id']})") + + # 3. Apply mapping + for issue, names in sorted(MAPPING.items(), reverse=True): + ids = [by_name[n] for n in names if n in by_name] + missing = [n for n in names if n not in by_name] + if missing: + print(f" #{issue}: skipping unknown labels {missing}", file=sys.stderr) + if dry: + print(f"[dry] #{issue} <- {names}") + continue + # PUT replaces the issue's labels with exactly this set (idempotent). + res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids}) + if res is not None: + applied = [l["name"] for l in res] + print(f"#{issue} labeled: {applied}") + + +if __name__ == "__main__": + main()