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) <noreply@anthropic.com>
This commit is contained in:
+97
-46
@@ -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())
|
||||
|
||||
Executable
+128
@@ -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()
|
||||
Reference in New Issue
Block a user