From 71525bc349f8bdf435f82d3137e4d8be99b30321 Mon Sep 17 00:00:00 2001 From: Steve Beaulac Date: Tue, 6 Oct 2026 14:06:01 -0400 Subject: [PATCH] feat(feedback-bundle): commit-linked Gitea tickets with file attachments New model-invoked skill + feedback_bundle.py: creates a Gitea issue whose body links the current HEAD commit and uploads logs/screenshots as issue assets. Handles label-name-to-ID resolution, tea-config token lookup, and the disabled-attachments (403/404) case. Index updated in root and engineering READMEs. --- README.md | 2 + skills/engineering/README.md | 1 + skills/engineering/feedback-bundle/SKILL.md | 40 ++++ .../feedback-bundle/feedback_bundle.py | 183 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 skills/engineering/feedback-bundle/SKILL.md create mode 100644 skills/engineering/feedback-bundle/feedback_bundle.py diff --git a/README.md b/README.md index e6d9e27..bd1c7a8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Skills are grouped by invocation type. [User-invoked](docs/invocation.md) skills ## Credits Many of these skills were created or originated by the following people and organizations: + - [Matt Pocock](http://mattpocock.com) - [poteto](https://github.com/poteto?tab=repositories) @@ -84,6 +85,7 @@ Many of these skills were created or originated by the following people and orga ### Engineering +- [feedback-bundle](skills/engineering/feedback-bundle/SKILL.md) — Post a Gitea feedback issue linked to the current commit and attach logs or screenshots. Use when the user reports a problem and asks to file it with evidence, open a bug ticket, or says “feedback-bundle” or “attach the log.” - [code-review](skills/engineering/code-review/SKILL.md) — Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X". - [codebase-design](skills/engineering/codebase-design/SKILL.md) — Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary. - [diagnosing-bugs](skills/engineering/diagnosing-bugs/SKILL.md) — Diagnosis loop for hard bugs and performance regressions. Use when the user says "diagnose"/"debug this", or reports something broken/throwing/failing/slow. diff --git a/skills/engineering/README.md b/skills/engineering/README.md index fdb3beb..433cd56 100644 --- a/skills/engineering/README.md +++ b/skills/engineering/README.md @@ -22,6 +22,7 @@ Daily code work. ## Model-invoked +- [feedback-bundle](feedback-bundle/SKILL.md) — Post a Gitea feedback issue linked to the current commit and attach logs or screenshots. Use when the user reports a problem and asks to file it with evidence, open a bug ticket, or says “feedback-bundle” or “attach the log.” - [code-review](code-review/SKILL.md) — Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X". - [codebase-design](codebase-design/SKILL.md) — Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary. - [diagnosing-bugs](diagnosing-bugs/SKILL.md) — Diagnosis loop for hard bugs and performance regressions. Use when the user says "diagnose"/"debug this", or reports something broken/throwing/failing/slow. diff --git a/skills/engineering/feedback-bundle/SKILL.md b/skills/engineering/feedback-bundle/SKILL.md new file mode 100644 index 0000000..03351c8 --- /dev/null +++ b/skills/engineering/feedback-bundle/SKILL.md @@ -0,0 +1,40 @@ +--- +name: feedback-bundle +description: Post a Gitea feedback issue linked to the current commit and attach logs or screenshots. Use when the user reports a problem and asks to file it with evidence, open a bug ticket, or says "feedback-bundle" or "attach the log". +--- + +# Feedback Bundle + +Create a Gitea issue in the current repository, linked to its current `HEAD`, with optional files attached. + +## Run + +Run from the repository the issue should belong to, using `feedback_bundle.py` beside this file: + +```sh +python3 /feedback_bundle.py "Describe the problem" @app.log @screenshot.png +``` + +The script requires Python `requests`. Without `--token`, it reads the matching host token from `~/.config/tea/config.yml` and requires PyYAML. The default repository is read from `git remote get-url origin`. + +Common options: + +```sh +python3 /feedback_bundle.py "Description" @log.txt \ + --title "Short issue title" --label bug --dry-run +``` + +- `@file` — attach an existing file; paths are resolved from the current working directory. The `@` is optional. +- `--title` — override the default title (the description's first line). +- `--label` — add a label; repeat to add more. Labels are resolved against the repository's Gitea labels; unknown labels are skipped with a warning. +- `--repo` — override the remote URL. Use a full Git remote URL (SSH, SCP-style, or HTTPS), not an `owner/repo` slug. +- `--token` — provide a Gitea token instead of reading tea config. +- `--dry-run` — print the issue preview without creating an issue or uploading files. Authentication is still loaded; labels require an API request. + +## What happens + +1. The script reads `HEAD`, repository host/owner/name, and authentication. +2. It creates an issue whose body includes a link to that exact commit. +3. It uploads each attachment to the issue and adds download links to the body. + +Gitea only accepts issue attachments after an issue exists. If uploads fail because attachments are disabled (403/404), the issue already exists without the files; enable attachments on the Gitea instance, then attach the files to that issue manually. The instance setting is `[attachment] ENABLED = true` in `app.ini` (or `ATTACHMENTS_ENABLED`), not a web UI toggle. diff --git a/skills/engineering/feedback-bundle/feedback_bundle.py b/skills/engineering/feedback-bundle/feedback_bundle.py new file mode 100644 index 0000000..b0fd50f --- /dev/null +++ b/skills/engineering/feedback-bundle/feedback_bundle.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Post a feedback ticket to a Gitea repo, linked to the current commit, with +files attached to the issue. + + feedback_bundle.py [--title T] [--label L ...] [--repo REMOTE_URL] [--token T] + [--dry-run] [@file ...] + +Flow (Gitea attaches files to an existing issue, there is no standalone upload +endpoint): create the issue, then POST each file to +/repos/{owner}/{repo}/issues/{number}/assets (multipart field "attachment"). +The ticket body links to `git HEAD` and lists the attached files. + +If the assets endpoint returns 403/404 the instance has attachments disabled +(admin `[attachment] ENABLED = true` in app.ini — not a web-UI setting); the +script reports this instead of posting a ticket with no attachments. +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys + +try: + import requests +except ImportError: # pragma: no cover + sys.exit("requests is required: pip install requests") + + +def run(*args: str) -> str: + return subprocess.run(args, capture_output=True, text=True, check=True).stdout.strip() + + +def parse_remote(remote: str | None) -> tuple[str, str, str]: + """(host, owner, repo) from a git remote URL (scp, ssh, or https forms).""" + url = remote or run("git", "remote", "get-url", "origin") + u = re.sub(r"^[a-z]+://", "", url.strip(), flags=re.I) # strip scheme + u = u.split("@", 1)[-1] # strip user@ + u = u.replace(".git", "").rstrip("/") + host, rest = (u.split(":", 1) if ":" in u else u.split("/", 1)) + owner, repo = rest.split("/", 1) + return host, owner, repo + + +def load_token(host: str, token: str | None) -> str: + if token: + return token + cfg = os.path.expanduser("~/.config/tea/config.yml") + if not os.path.exists(cfg): + sys.exit("no --token and no tea config found; cannot authenticate") + import yaml + + data = yaml.safe_load(open(cfg).read()) + for login in data.get("logins", []): + if login.get("url").rstrip("/").endswith(host) and login.get("token"): + return login["token"] + sys.exit(f"no stored token for host {host}; pass --token") + + +def resolve_labels(host, token, owner, repo, names): + """Gitea's issue API wants label IDs, not names. Map names to IDs; warn on + unknown labels and drop them.""" + if not names: + return [] + url = f"https://{host}/api/v1/repos/{owner}/{repo}/labels" + r = requests.get(url, headers={"Authorization": f"token {token}"}) + if r.status_code >= 400: + sys.exit(f"could not list labels ({r.status_code}): {r.text}") + by_name = {l["name"]: l["id"] for l in r.json()} + ids = [] + for n in names: + if n in by_name: + ids.append(by_name[n]) + else: + print(f"warning: unknown label '{n}', skipping", file=sys.stderr) + return ids + + +def create_issue(host, token, owner, repo, title, body, label_ids): + url = f"https://{host}/api/v1/repos/{owner}/{repo}/issues" + r = requests.post(url, json={"title": title, "body": body, "labels": label_ids}, + headers={"Authorization": f"token {token}"}) + if r.status_code >= 400: + sys.exit(f"issue create failed ({r.status_code}): {r.text}") + return r.json() + + +def attach(host, token, owner, repo, issue_number, path): + """Attach one file to an issue. Returns the browser_download_url, or None + on 403/404 (attachments disabled on this instance).""" + url = f"https://{host}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/assets" + with open(path, "rb") as f: + r = requests.post(url, files={"attachment": f}, + params={"name": os.path.basename(path)}, + headers={"Authorization": f"token {token}"}) + if r.status_code in (403, 404): + return None + r.raise_for_status() + return r.json().get("browser_download_url") + + +def main() -> int: + ap = argparse.ArgumentParser(description="Post a commit-linked feedback ticket with attachments.") + ap.add_argument("description", help="ticket description (text before any @file)") + ap.add_argument("files", nargs="*", help="files to attach (@name or name)") + ap.add_argument("--title", help="ticket title (default: first line of description)") + ap.add_argument("--label", action="append", default=[], help="label to add (repeatable)") + ap.add_argument("--repo", help="Git remote URL (default: git remote origin)") + ap.add_argument("--token", help="Gitea token (default: tea config for the host)") + ap.add_argument("--dry-run", action="store_true", help="print the ticket without posting it") + args = ap.parse_args() + + files = [f.lstrip("@") for f in args.files] + for p in files: + if not os.path.isfile(p): + sys.exit(f"not a file: {p}") + + host, owner, repo = parse_remote(args.repo) if args.repo else (None, None, None) + if not host: + host, owner, repo = parse_remote(None) + + token = load_token(host, args.token) + sha = run("git", "rev-parse", "HEAD") + short = sha[:7] + commit_url = f"https://{host}/{owner}/{repo}/commit/{sha}" + + desc = args.description.strip() + title = (args.title or desc).strip().splitlines()[0] if desc else "(no description)" + labels = resolve_labels(host, token, owner, repo, [l for l in args.label if l]) + seed = f"{desc}\n\n**Linked commit:** [{short}]({commit_url}) (`{sha}`)" + + # Preview only: no issue is created and nothing is uploaded. + if args.dry_run: + att_line = "\n".join(f"- {os.path.basename(p)}" for p in files) or "(none)" + body = seed + "\n\n**Attachments:**\n" + att_line + print("=== DRY RUN (nothing created) ===") + print(f"repo: {owner}/{repo}") + print(f"commit: {commit_url}") + print(f"title: {title}") + print(f"labels: {[l for l in args.label if l] or '(none)'}") + print(f"attach: {att_line}") + print("=== body ===") + print(body) + return 0 + + # Create the issue first (Gitea attaches files to an existing issue). + issue = create_issue(host, token, owner, repo, title, seed, labels) + number = issue["number"] + + attached = [] + missing = False + for p in files: + url = attach(host, token, owner, repo, number, p) + if url is None: + missing = True + else: + attached.append((os.path.basename(p), url)) + + if missing and not attached: + print( + "ERROR: attachment endpoint returned 403/404 — attachments are disabled on this " + "instance (admin setting `[attachment] ENABLED = true` in app.ini; there is no " + "web-UI toggle). Enable it and re-run. The issue was created but has no attachments.", + file=sys.stderr, + ) + return 2 + + if attached: + body = seed + "\n\n**Attachments:**\n" + "\n".join(f"- [{n}]({u})" for n, u in attached) + patch_url = f"https://{host}/api/v1/repos/{owner}/{repo}/issues/{number}" + requests.patch(patch_url, json={"body": body}, + headers={"Authorization": f"token {token}"}) + if missing: + print(f"warning: {sum(1 for _ in range(0)) + (len(files) - len(attached))} file(s) could not be attached (instance may have attachments disabled)", + file=sys.stderr) + + print(f"Created: {issue.get('html_url', '')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())