Publish training skills collection
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: gitea-issues
|
||||
description: Read, create, draft, claim, implement from, label, comment on, and manage Gitea repositories, issues, pull requests, and epic-style dependency links using the Gitea API. Use when the user asks to create a Gitea repository, publish a local repo to Gitea, inspect an issue, summarize an issue, create an issue, publish issues to Gitea, draft Gitea work items, create epic cards, attach child issues, take the next ready issue from an epic, create a branch or PR for an issue, or update ready/in-progress/review/done issue status labels.
|
||||
metadata:
|
||||
short-description: Work from Gitea issues
|
||||
---
|
||||
|
||||
# Gitea Issues
|
||||
|
||||
Use this skill to create repositories and to read, draft, create, claim, implement from, and update issues and pull requests in the user's Gitea repository.
|
||||
|
||||
## Safety
|
||||
|
||||
Never ask the user to paste tokens in chat. Authentication must come from environment variables, the current git remote, or a local env file.
|
||||
|
||||
The helpers resolve configuration in this order:
|
||||
|
||||
1. Explicit environment variables.
|
||||
2. The current repository's `origin` remote for `GITEA_BASE_URL`, `GITEA_OWNER`, and `GITEA_REPO`.
|
||||
3. `~/.config/gitea/gitea.env`.
|
||||
4. `~/.config/secrets/gitea.env`.
|
||||
5. The legacy fallback `~/.config/gitea/data-analysis-agent.env`.
|
||||
|
||||
A generic env file can look like:
|
||||
|
||||
```text
|
||||
export GITEA_BASE_URL="https://tea.example.com"
|
||||
export GITEA_TOKEN="..."
|
||||
```
|
||||
|
||||
Required values:
|
||||
|
||||
- `GITEA_BASE_URL`
|
||||
- `GITEA_OWNER` or a Gitea `origin` remote
|
||||
- `GITEA_REPO` or a Gitea `origin` remote
|
||||
- `GITEA_TOKEN`
|
||||
|
||||
Creating pull requests requires a token with repository read/write scope. Issue-only tokens can read and label issues but cannot call the Gitea pulls API.
|
||||
|
||||
Creating repositories requires a token with repository creation permission.
|
||||
|
||||
Before creating an issue, draft the title, body, and labels and ask for confirmation unless the user explicitly says to create it without confirmation.
|
||||
|
||||
## Create Repository
|
||||
|
||||
Use the bundled helper instead of hand-writing repository API calls:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/create_repo.py \
|
||||
--owner anodyine \
|
||||
--name project-name \
|
||||
--description "Short repository description"
|
||||
```
|
||||
|
||||
The helper reads environment variables first, then infers the owner from the
|
||||
current git remote or local env files, then falls back to the authenticated
|
||||
Gitea user. It returns the existing repository URL if the repo already exists.
|
||||
|
||||
When publishing a local directory, initialize Git locally, commit the current
|
||||
files, add the SSH remote returned by the user's Gitea host convention, and push
|
||||
the default branch after the repository exists.
|
||||
|
||||
## Issue Format
|
||||
|
||||
Use this structure by default:
|
||||
|
||||
```markdown
|
||||
## Problem
|
||||
|
||||
## Scope
|
||||
|
||||
## Acceptance Criteria
|
||||
```
|
||||
|
||||
Prefer this label vocabulary:
|
||||
|
||||
- `status/needs-triage`
|
||||
- `status/needs-info`
|
||||
- `status/ready-for-agent`
|
||||
- `status/ready-for-human`
|
||||
- `status/in-progress`
|
||||
- `status/review`
|
||||
- `status/done`
|
||||
- `status/wontfix`
|
||||
- `type/epic`
|
||||
- `type/feature`
|
||||
- `type/bug`
|
||||
- `type/refactor`
|
||||
- `type/docs`
|
||||
- `type/test`
|
||||
- `type/architecture`
|
||||
- `area/frontend`
|
||||
- `area/backend`
|
||||
- `area/docs`
|
||||
- `area/tests`
|
||||
- `area/agent-workflow`
|
||||
- `area/workflow`
|
||||
|
||||
## Create Issue
|
||||
|
||||
Use the bundled helper instead of hand-writing API calls:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/create_issue.py \
|
||||
--title "Issue title" \
|
||||
--body-file /path/to/body.md \
|
||||
--label status/ready-for-agent \
|
||||
--label type/docs
|
||||
```
|
||||
|
||||
The helper reads environment variables first, then infers repo identity from the current git remote, then falls back to the local env files. It fetches repository labels, maps label names to numeric IDs, creates the issue, and prints the issue URL.
|
||||
|
||||
If requested labels do not exist in Gitea, stop and tell the user which labels are missing. Do not create the issue with a silently incomplete label set.
|
||||
|
||||
Default to grouping implementation issues under an epic. Before creating new
|
||||
ready-for-agent work, either attach it to an existing focused epic or create a
|
||||
small epic for the related batch. Only create a standalone issue when the user
|
||||
explicitly asks for a one-off or no reasonable epic exists.
|
||||
|
||||
## Read Issue
|
||||
|
||||
Use the bundled read helper instead of hand-parsing API JSON:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5
|
||||
```
|
||||
|
||||
Include comments when needed:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5 --comments
|
||||
```
|
||||
|
||||
The helper prints a compact Markdown summary with issue metadata, labels, body, dependencies/blockers, and optional comments. Prefer this when using an issue as task input for coding work.
|
||||
|
||||
## Work the Next Ready Epic Issue
|
||||
|
||||
When asked to take the highest-priority ready task from an epic, treat labels as the agent-writeable source of truth. Gitea project columns are not available through this project's API. Use the epic body's `## Child Issues In Priority Order` list for priority, and select the first open child with `status/ready-for-agent`.
|
||||
|
||||
Use the workflow helper to find and claim the issue:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py next-ready \
|
||||
--epic 5 \
|
||||
--claim
|
||||
```
|
||||
|
||||
This prints the selected issue and moves it from `status/ready-for-agent` to `status/in-progress`. Then:
|
||||
|
||||
1. Read the selected issue with comments.
|
||||
2. Create a branch named exactly `ISSUE-X`, where `X` is the issue number.
|
||||
3. Attach that branch to the issue ref using `set-ref`.
|
||||
4. Implement the requested change, including relevant tests and modular docs.
|
||||
5. Run the issue-relevant automated tests.
|
||||
6. Commit with a concise, logical message.
|
||||
7. Push the branch.
|
||||
8. Create a Gitea pull request.
|
||||
9. Comment on the issue with the PR link.
|
||||
10. Move the issue to `status/review`, not `status/done`.
|
||||
11. Switch the local checkout back to `main` so the next agent starts new work from `main`.
|
||||
|
||||
The human reviewer marks the issue `status/done` after review and merge.
|
||||
|
||||
## YOLO Mode
|
||||
|
||||
Use YOLO mode only when the user explicitly asks for yolo/fully automated
|
||||
issue work or when a DAG/workflow parameter clearly enables it. The normal
|
||||
workflow above remains the default.
|
||||
|
||||
YOLO mode is for fast exploration where the agent should build, test, merge,
|
||||
delete the branch, and mark the issue done so the user can review the merged
|
||||
result later. It is appropriate for work the user has not yet deeply reviewed,
|
||||
prototypes, small follow-ups, and low-risk changes where post-merge review is
|
||||
acceptable.
|
||||
|
||||
In YOLO mode:
|
||||
|
||||
1. Claim the issue and move it to `status/in-progress`.
|
||||
2. Create and attach the `ISSUE-X` branch as usual.
|
||||
3. Implement the change, including relevant tests and docs.
|
||||
4. Run the issue-relevant automated tests.
|
||||
5. Commit and push the branch.
|
||||
6. Create the pull request.
|
||||
7. Merge the pull request immediately if it is mergeable.
|
||||
8. Delete the merged branch unless the user asks to keep it.
|
||||
9. Comment on the issue with the merged PR URL and verification summary.
|
||||
10. Move the issue to `status/done`.
|
||||
|
||||
Never use YOLO mode for protected, ambiguous, security-sensitive, destructive,
|
||||
or high-risk work unless the user explicitly accepts that risk. If tests fail,
|
||||
the PR is not mergeable, or the implementation required major unplanned
|
||||
decisions, stop at the normal review state instead of merging.
|
||||
|
||||
After creating the PR, use the helper to merge and close the loop:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
|
||||
--issue 6 \
|
||||
--pull 12
|
||||
```
|
||||
|
||||
By default this uses a merge commit and asks Gitea to delete the PR branch after
|
||||
merge. To keep the branch:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
|
||||
--issue 6 \
|
||||
--pull 12 \
|
||||
--no-delete-branch
|
||||
```
|
||||
|
||||
## Attach Branch or Tag Ref
|
||||
|
||||
After creating the issue branch, attach it to the issue so the Gitea sidebar does not show `No Branch/Tag Specified`:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-ref \
|
||||
--issue 6 \
|
||||
--ref ISSUE-6
|
||||
```
|
||||
|
||||
Use the exact branch name created for the issue. For the standard issue workflow, that branch is `ISSUE-X`, where `X` is the issue number.
|
||||
|
||||
## Update Issue Status
|
||||
|
||||
Use this helper instead of hand-writing label API calls:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
|
||||
--issue 6 \
|
||||
--status status/review
|
||||
```
|
||||
|
||||
The helper adds the requested status label and removes other `status/*` labels from the issue.
|
||||
|
||||
## Create Pull Request and Link Issue
|
||||
|
||||
Write the PR body to a temporary Markdown file so shell quoting does not damage the requested format, then create the PR:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py create-pr \
|
||||
--head ISSUE-6 \
|
||||
--base main \
|
||||
--title "Add reusable desktop UI test harness" \
|
||||
--body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
Use this PR body format when the user does not provide a different one:
|
||||
|
||||
````markdown
|
||||
What did you change?
|
||||
|
||||
Why did you change it this way rather than using a different strategy or pattern?
|
||||
|
||||
Pre-Test Setup:
|
||||
```bash
|
||||
git fetch origin
|
||||
git switch ISSUE-6
|
||||
git pull --ff-only origin ISSUE-6
|
||||
cd path/to/relevant/app-or-package
|
||||
```
|
||||
|
||||
Manual Test Plan:
|
||||
- [ ] Run the relevant test command.
|
||||
|
||||
```bash
|
||||
command-to-run
|
||||
```
|
||||
|
||||
- [ ] Open the changed UI or workflow.
|
||||
- [ ] Interact with the changed behavior step by step, such as navigating to links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback.
|
||||
- [ ] Confirm the expected result and note any visual or interaction issues.
|
||||
|
||||
Post-Merge Cleanup:
|
||||
```bash
|
||||
git switch main
|
||||
git pull origin main
|
||||
git branch -d ISSUE-6
|
||||
git push origin --delete ISSUE-6
|
||||
```
|
||||
````
|
||||
|
||||
The `Pre-Test Setup` block should use the actual PR branch name and relevant package path so the reviewer can paste it into a terminal before running tests. The manual test plan should contain only the verification steps and expected checks required to test the change. Include automated test commands when they are relevant, but also include human UI or workflow steps when the reviewer needs to inspect behavior directly: opening links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback or layout. Commands intended to be copied and run should be in fenced `bash` blocks, including commands in the manual test plan. The `Post-Merge Cleanup` block should use the actual PR branch name so the reviewer can paste it into a terminal after merging. After creating the PR, comment on the issue and move it to review:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py comment \
|
||||
--issue 6 \
|
||||
--body "Implemented in PR: <pull-request-url>"
|
||||
|
||||
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
|
||||
--issue 6 \
|
||||
--status status/review
|
||||
```
|
||||
|
||||
## Epic Issues
|
||||
|
||||
Gitea does not provide true nested epics in this project. Represent an epic as a normal issue plus dependency links:
|
||||
|
||||
- Create an epic issue with `type/epic` when available, or `type/architecture` if the label does not exist yet.
|
||||
- List child issues in the epic body under `## Child Issues In Priority Order` so agents can use `next-ready --epic`.
|
||||
- Attach each child issue as a dependency of the epic. Semantically, the epic is blocked until its child issues are complete.
|
||||
- Prefer one focused epic per delivery goal, such as "Frontend testing foundation".
|
||||
- When creating multiple follow-up issues, create the epic in the same turn and link the children before handing work to another agent.
|
||||
|
||||
Use the bundled dependency helper:
|
||||
|
||||
```bash
|
||||
python ~/.codex/skills/gitea-issues/scripts/add_dependency.py \
|
||||
--parent 5 \
|
||||
--child 4
|
||||
```
|
||||
|
||||
The parent is the blocked issue. The child is the issue it depends on.
|
||||
|
||||
Project boards are currently a manual UI step for this Gitea instance because its REST API does not expose project endpoints. Use labels and dependency links as the agent-writeable source of truth.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Gitea Issues"
|
||||
short_description: "Work Gitea issues through review or yolo merge"
|
||||
default_prompt: "Use $gitea-issues to read, draft, create, claim, label, comment on, and work ready-for-agent Gitea issues through branch and pull request workflows, including the default human-review path or explicit yolo merge mode."
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make one Gitea issue depend on another issue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gitea_common import DEFAULT_ENV_FILE, config
|
||||
|
||||
|
||||
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
|
||||
encoded_owner = urllib.parse.quote(owner, safe="")
|
||||
encoded_repo = urllib.parse.quote(repo, safe="")
|
||||
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
|
||||
|
||||
|
||||
def request_json(method: str, url: str, token: str, payload: dict[str, Any]) -> Any:
|
||||
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), method=method)
|
||||
request.add_header("Authorization", f"token {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
|
||||
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Make a Gitea issue depend on another issue.")
|
||||
parser.add_argument("--parent", type=int, required=True, help="Issue number that is blocked.")
|
||||
parser.add_argument("--child", type=int, required=True, help="Issue number the parent depends on.")
|
||||
parser.add_argument("--child-owner", help="Owner of the child issue repo. Defaults to GITEA_OWNER.")
|
||||
parser.add_argument("--child-repo", help="Repo of the child issue. Defaults to GITEA_REPO.")
|
||||
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = config(args.env_file.expanduser())
|
||||
child_owner = args.child_owner or cfg["GITEA_OWNER"]
|
||||
child_repo = args.child_repo or cfg["GITEA_REPO"]
|
||||
|
||||
issue = request_json(
|
||||
"POST",
|
||||
api_url(
|
||||
cfg["GITEA_BASE_URL"],
|
||||
cfg["GITEA_OWNER"],
|
||||
cfg["GITEA_REPO"],
|
||||
f"issues/{args.parent}/dependencies",
|
||||
),
|
||||
cfg["GITEA_TOKEN"],
|
||||
{"owner": child_owner, "repo": child_repo, "index": args.child},
|
||||
)
|
||||
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Gitea issue using env vars or a local env file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gitea_common import DEFAULT_ENV_FILE, config
|
||||
|
||||
|
||||
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=data, method=method)
|
||||
request.add_header("Authorization", f"token {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
if payload is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
|
||||
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
|
||||
encoded_owner = urllib.parse.quote(owner, safe="")
|
||||
encoded_repo = urllib.parse.quote(repo, safe="")
|
||||
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
|
||||
|
||||
|
||||
def get_label_ids(base_url: str, owner: str, repo: str, token: str, names: list[str]) -> list[int]:
|
||||
if not names:
|
||||
return []
|
||||
|
||||
labels = request_json("GET", api_url(base_url, owner, repo, "labels?limit=1000"), token)
|
||||
by_name = {label["name"]: label["id"] for label in labels}
|
||||
missing = [name for name in names if name not in by_name]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Missing Gitea labels: "
|
||||
+ ", ".join(missing)
|
||||
+ "\nCreate those labels first, then rerun the issue creation."
|
||||
)
|
||||
return [by_name[name] for name in names]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create an issue in the configured Gitea repo.")
|
||||
parser.add_argument("--title", required=True)
|
||||
parser.add_argument("--body", default="")
|
||||
parser.add_argument("--body-file", type=Path)
|
||||
parser.add_argument("--label", action="append", default=[])
|
||||
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = config(args.env_file.expanduser())
|
||||
body = args.body
|
||||
if args.body_file:
|
||||
body = args.body_file.read_text(encoding="utf-8")
|
||||
|
||||
label_ids = get_label_ids(
|
||||
cfg["GITEA_BASE_URL"],
|
||||
cfg["GITEA_OWNER"],
|
||||
cfg["GITEA_REPO"],
|
||||
cfg["GITEA_TOKEN"],
|
||||
args.label,
|
||||
)
|
||||
|
||||
issue = request_json(
|
||||
"POST",
|
||||
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], "issues"),
|
||||
cfg["GITEA_TOKEN"],
|
||||
{"title": args.title, "body": body, "labels": label_ids},
|
||||
)
|
||||
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or return a Gitea repository using env vars or a local env file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gitea_common import DEFAULT_ENV_FILE, auth_config
|
||||
|
||||
|
||||
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=data, method=method)
|
||||
request.add_header("Authorization", f"token {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
if payload is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise GiteaHTTPError(exc.code, error_body) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
|
||||
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
class GiteaHTTPError(Exception):
|
||||
def __init__(self, status: int, body: str) -> None:
|
||||
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
def api_root(base_url: str) -> str:
|
||||
return f"{base_url.rstrip('/')}/api/v1"
|
||||
|
||||
|
||||
def repo_url(base_url: str, owner: str, repo: str) -> str:
|
||||
encoded_owner = urllib.parse.quote(owner, safe="")
|
||||
encoded_repo = urllib.parse.quote(repo, safe="")
|
||||
return f"{api_root(base_url)}/repos/{encoded_owner}/{encoded_repo}"
|
||||
|
||||
|
||||
def get_authenticated_user(base_url: str, token: str) -> dict[str, Any]:
|
||||
return request_json("GET", f"{api_root(base_url)}/user", token)
|
||||
|
||||
|
||||
def get_repo(base_url: str, token: str, owner: str, repo: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return request_json("GET", repo_url(base_url, owner, repo), token)
|
||||
except GiteaHTTPError as exc:
|
||||
if exc.status == 404:
|
||||
return None
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
|
||||
def create_repo(
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
description: str,
|
||||
private: bool,
|
||||
default_branch: str,
|
||||
) -> dict[str, Any]:
|
||||
user = get_authenticated_user(base_url, token)
|
||||
login = user.get("login") or user.get("username") or ""
|
||||
payload = {
|
||||
"name": repo,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": False,
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
if owner == login:
|
||||
url = f"{api_root(base_url)}/user/repos"
|
||||
else:
|
||||
encoded_owner = urllib.parse.quote(owner, safe="")
|
||||
url = f"{api_root(base_url)}/orgs/{encoded_owner}/repos"
|
||||
|
||||
try:
|
||||
return request_json("POST", url, token, payload)
|
||||
except GiteaHTTPError as exc:
|
||||
if exc.status == 409:
|
||||
existing = get_repo(base_url, token, owner, repo)
|
||||
if existing:
|
||||
return existing
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create or return a Gitea repository.")
|
||||
parser.add_argument("--name", required=True, help="Repository name to create.")
|
||||
parser.add_argument("--owner", help="User or org owner. Defaults to GITEA_OWNER or authenticated user.")
|
||||
parser.add_argument("--description", default="")
|
||||
parser.add_argument("--private", action=argparse.BooleanOptionalAction, default=False)
|
||||
parser.add_argument("--default-branch", default="main")
|
||||
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = auth_config(args.env_file.expanduser())
|
||||
base_url = cfg["GITEA_BASE_URL"]
|
||||
token = cfg["GITEA_TOKEN"]
|
||||
owner = args.owner or cfg.get("GITEA_OWNER", "")
|
||||
if not owner:
|
||||
user = get_authenticated_user(base_url, token)
|
||||
owner = user.get("login") or user.get("username") or ""
|
||||
if not owner:
|
||||
raise SystemExit("Could not determine Gitea owner. Pass --owner.")
|
||||
|
||||
repo = get_repo(base_url, token, owner, args.name)
|
||||
if repo is None:
|
||||
repo = create_repo(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
owner=owner,
|
||||
repo=args.name,
|
||||
description=args.description,
|
||||
private=args.private,
|
||||
default_branch=args.default_branch,
|
||||
)
|
||||
|
||||
print(repo.get("html_url") or repo.get("clone_url") or json.dumps(repo, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared Gitea configuration helpers for issue scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DEFAULT_ENV_FILE = Path("~/.config/gitea/gitea.env").expanduser()
|
||||
SECRETS_ENV_FILE = Path("~/.config/secrets/gitea.env").expanduser()
|
||||
LEGACY_ENV_FILE = Path("~/.config/gitea/data-analysis-agent.env").expanduser()
|
||||
REQUIRED_ENV = ("GITEA_BASE_URL", "GITEA_OWNER", "GITEA_REPO", "GITEA_TOKEN")
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not path.exists():
|
||||
return values
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[len("export ") :]
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, raw_value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
try:
|
||||
parsed = shlex.split(raw_value.strip(), comments=False, posix=True)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Could not parse {path}: {exc}") from exc
|
||||
values[key] = parsed[0] if parsed else ""
|
||||
return values
|
||||
|
||||
|
||||
def discover_origin_config() -> dict[str, str]:
|
||||
try:
|
||||
remote = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
if not remote:
|
||||
return {}
|
||||
|
||||
parsed = urlparse(remote)
|
||||
host = ""
|
||||
path = ""
|
||||
if parsed.scheme in {"http", "https", "ssh"} and parsed.netloc:
|
||||
host = parsed.hostname or parsed.netloc.split("@")[-1].split(":")[0]
|
||||
path = parsed.path.lstrip("/")
|
||||
else:
|
||||
match = re.match(r"(?:(?P<user>[^@]+)@)?(?P<host>[^:]+):(?P<path>.+)$", remote)
|
||||
if match:
|
||||
host = match.group("host")
|
||||
path = match.group("path")
|
||||
|
||||
if not host or not path:
|
||||
return {}
|
||||
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"GITEA_BASE_URL": f"https://{host}",
|
||||
"GITEA_OWNER": parts[-2],
|
||||
"GITEA_REPO": parts[-1],
|
||||
}
|
||||
|
||||
|
||||
def config(env_file: Path | None = None) -> dict[str, str]:
|
||||
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
|
||||
file_values = load_env_file(env_path)
|
||||
if env_path == DEFAULT_ENV_FILE:
|
||||
secrets_values = load_env_file(SECRETS_ENV_FILE)
|
||||
legacy_values = load_env_file(LEGACY_ENV_FILE)
|
||||
file_values = {**legacy_values, **secrets_values, **file_values}
|
||||
|
||||
origin_values = discover_origin_config()
|
||||
base_url = (
|
||||
os.environ.get("GITEA_BASE_URL")
|
||||
or os.environ.get("GITEA_URL")
|
||||
or origin_values.get("GITEA_BASE_URL")
|
||||
or file_values.get("GITEA_BASE_URL")
|
||||
or file_values.get("GITEA_URL", "")
|
||||
)
|
||||
values = {
|
||||
"GITEA_BASE_URL": base_url,
|
||||
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
|
||||
or origin_values.get("GITEA_OWNER")
|
||||
or file_values.get("GITEA_OWNER", ""),
|
||||
"GITEA_REPO": os.environ.get("GITEA_REPO")
|
||||
or origin_values.get("GITEA_REPO")
|
||||
or file_values.get("GITEA_REPO", ""),
|
||||
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
|
||||
}
|
||||
missing = [key for key, value in values.items() if not value]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Missing required Gitea configuration: "
|
||||
+ ", ".join(missing)
|
||||
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
|
||||
+ "or run from a git repo with a Gitea origin."
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def auth_config(env_file: Path | None = None) -> dict[str, str]:
|
||||
"""Return Gitea API auth config without requiring a repository context."""
|
||||
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
|
||||
file_values = load_env_file(env_path)
|
||||
if env_path == DEFAULT_ENV_FILE:
|
||||
secrets_values = load_env_file(SECRETS_ENV_FILE)
|
||||
legacy_values = load_env_file(LEGACY_ENV_FILE)
|
||||
file_values = {**legacy_values, **secrets_values, **file_values}
|
||||
|
||||
origin_values = discover_origin_config()
|
||||
base_url = (
|
||||
os.environ.get("GITEA_BASE_URL")
|
||||
or os.environ.get("GITEA_URL")
|
||||
or origin_values.get("GITEA_BASE_URL")
|
||||
or file_values.get("GITEA_BASE_URL")
|
||||
or file_values.get("GITEA_URL", "")
|
||||
)
|
||||
values = {
|
||||
"GITEA_BASE_URL": base_url,
|
||||
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
|
||||
or origin_values.get("GITEA_OWNER")
|
||||
or file_values.get("GITEA_OWNER", ""),
|
||||
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
|
||||
}
|
||||
missing = [key for key in ("GITEA_BASE_URL", "GITEA_TOKEN") if not values[key]]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Missing required Gitea configuration: "
|
||||
+ ", ".join(missing)
|
||||
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
|
||||
+ "or run from a git repo with a Gitea origin."
|
||||
)
|
||||
return values
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gitea issue workflow helpers for ready-agent issue work."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gitea_common import DEFAULT_ENV_FILE, config
|
||||
STATUS_LABELS = {
|
||||
"status/needs-triage",
|
||||
"status/needs-info",
|
||||
"status/ready-for-agent",
|
||||
"status/ready-for-human",
|
||||
"status/in-progress",
|
||||
"status/review",
|
||||
"status/done",
|
||||
"status/wontfix",
|
||||
}
|
||||
|
||||
def api_url(cfg: dict[str, str], suffix: str) -> str:
|
||||
encoded_owner = urllib.parse.quote(cfg["GITEA_OWNER"], safe="")
|
||||
encoded_repo = urllib.parse.quote(cfg["GITEA_REPO"], safe="")
|
||||
return f"{cfg['GITEA_BASE_URL'].rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
|
||||
|
||||
|
||||
def request_json(method: str, cfg: dict[str, str], suffix: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(api_url(cfg, suffix), data=data, method=method)
|
||||
request.add_header("Authorization", f"token {cfg['GITEA_TOKEN']}")
|
||||
request.add_header("Accept", "application/json")
|
||||
if payload is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise GiteaError(exc.code, error_body) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
|
||||
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
def __init__(self, status: int, body: str) -> None:
|
||||
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
def labels_by_name(cfg: dict[str, str]) -> dict[str, dict[str, Any]]:
|
||||
labels = request_json("GET", cfg, "labels?limit=1000")
|
||||
return {label["name"]: label for label in labels}
|
||||
|
||||
|
||||
def issue_labels(issue: dict[str, Any]) -> set[str]:
|
||||
return {label["name"] for label in issue.get("labels", [])}
|
||||
|
||||
|
||||
def read_issue(cfg: dict[str, str], issue_number: int) -> dict[str, Any]:
|
||||
return request_json("GET", cfg, f"issues/{issue_number}")
|
||||
|
||||
|
||||
def child_issue_numbers(epic_body: str) -> list[int]:
|
||||
section_match = re.search(
|
||||
r"^## Child Issues.*?(?=^## |\Z)",
|
||||
epic_body,
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
source = section_match.group(0) if section_match else epic_body
|
||||
seen: set[int] = set()
|
||||
numbers: list[int] = []
|
||||
for match in re.finditer(r"#(\d+)", source):
|
||||
number = int(match.group(1))
|
||||
if number not in seen:
|
||||
numbers.append(number)
|
||||
seen.add(number)
|
||||
return numbers
|
||||
|
||||
|
||||
def set_status(cfg: dict[str, str], issue_number: int, status: str) -> dict[str, Any]:
|
||||
if status not in STATUS_LABELS:
|
||||
raise SystemExit(f"Unsupported status label: {status}")
|
||||
|
||||
labels = labels_by_name(cfg)
|
||||
missing = [label for label in [status, *STATUS_LABELS] if label not in labels]
|
||||
if missing:
|
||||
raise SystemExit("Missing Gitea labels: " + ", ".join(sorted(missing)))
|
||||
|
||||
issue = read_issue(cfg, issue_number)
|
||||
current = issue_labels(issue)
|
||||
if status not in current:
|
||||
request_json("POST", cfg, f"issues/{issue_number}/labels", {"labels": [labels[status]["id"]]})
|
||||
for label in sorted(current & (STATUS_LABELS - {status})):
|
||||
request_json("DELETE", cfg, f"issues/{issue_number}/labels/{labels[label]['id']}")
|
||||
return read_issue(cfg, issue_number)
|
||||
|
||||
|
||||
def set_ref(cfg: dict[str, str], issue_number: int, ref: str) -> dict[str, Any]:
|
||||
return request_json("PATCH", cfg, f"issues/{issue_number}", {"ref": ref})
|
||||
|
||||
|
||||
def print_issue_summary(issue: dict[str, Any]) -> None:
|
||||
labels = ", ".join(sorted(issue_labels(issue))) or "(none)"
|
||||
print(f"#{issue['number']} {issue['title']}")
|
||||
print(f"URL: {issue['html_url']}")
|
||||
print(f"Labels: {labels}")
|
||||
print(f"Ref: {issue.get('ref') or '(none)'}")
|
||||
|
||||
|
||||
def cmd_next_ready(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
epic = read_issue(cfg, args.epic)
|
||||
child_numbers = child_issue_numbers(epic.get("body") or "")
|
||||
for number in child_numbers:
|
||||
issue = read_issue(cfg, number)
|
||||
labels = issue_labels(issue)
|
||||
if issue.get("state") == "open" and "status/ready-for-agent" in labels:
|
||||
if args.claim:
|
||||
issue = set_status(cfg, number, "status/in-progress")
|
||||
print_issue_summary(issue)
|
||||
return 0
|
||||
print(f"No open child issue with status/ready-for-agent found under epic #{args.epic}.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def cmd_set_status(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
issue = set_status(cfg, args.issue, args.status)
|
||||
print_issue_summary(issue)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set_ref(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
issue = set_ref(cfg, args.issue, args.ref)
|
||||
print_issue_summary(issue)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_comment(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
|
||||
comment = request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": body})
|
||||
print(comment.get("html_url") or comment.get("url") or json.dumps(comment, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create_pr(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
|
||||
payload = {"base": args.base, "head": args.head, "title": args.title, "body": body}
|
||||
try:
|
||||
pr = request_json("POST", cfg, "pulls", payload)
|
||||
except GiteaError as exc:
|
||||
if exc.status != 409:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
owner = cfg["GITEA_OWNER"]
|
||||
pulls = request_json("GET", cfg, f"pulls?state=open&head={owner}:{args.head}&base={args.base}") or []
|
||||
if not pulls:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
pr = pulls[0]
|
||||
print(pr.get("html_url") or pr.get("url") or json.dumps(pr, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_yolo_merge(args: argparse.Namespace) -> int:
|
||||
cfg = config(args.env_file.expanduser())
|
||||
pr = request_json("GET", cfg, f"pulls/{args.pull}")
|
||||
if pr.get("state") != "open":
|
||||
raise SystemExit(f"Pull request #{args.pull} is not open.")
|
||||
if pr.get("merged"):
|
||||
raise SystemExit(f"Pull request #{args.pull} is already merged.")
|
||||
if pr.get("mergeable") is False:
|
||||
raise SystemExit(f"Pull request #{args.pull} is not mergeable.")
|
||||
|
||||
payload = {
|
||||
"Do": args.method,
|
||||
"delete_branch_after_merge": args.delete_branch,
|
||||
}
|
||||
try:
|
||||
request_json("POST", cfg, f"pulls/{args.pull}/merge", payload)
|
||||
except GiteaError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
pr_url = pr.get("html_url") or pr.get("url") or f"PR #{args.pull}"
|
||||
comment_body = args.comment or f"YOLO mode merged {pr_url} after automated implementation and verification."
|
||||
request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": comment_body})
|
||||
issue = set_status(cfg, args.issue, "status/done")
|
||||
print_issue_summary(issue)
|
||||
print(f"Merged PR: {pr_url}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Work with the configured Gitea issue workflow.")
|
||||
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
next_ready = subparsers.add_parser("next-ready", help="Find the highest-priority ready child issue in an epic.")
|
||||
next_ready.add_argument("--epic", type=int, required=True)
|
||||
next_ready.add_argument("--claim", action="store_true", help="Move the selected issue to status/in-progress.")
|
||||
next_ready.set_defaults(func=cmd_next_ready)
|
||||
|
||||
set_status_parser = subparsers.add_parser("set-status", help="Replace an issue status label.")
|
||||
set_status_parser.add_argument("--issue", type=int, required=True)
|
||||
set_status_parser.add_argument("--status", required=True)
|
||||
set_status_parser.set_defaults(func=cmd_set_status)
|
||||
|
||||
set_ref_parser = subparsers.add_parser("set-ref", help="Attach a branch or tag ref to an issue.")
|
||||
set_ref_parser.add_argument("--issue", type=int, required=True)
|
||||
set_ref_parser.add_argument("--ref", required=True)
|
||||
set_ref_parser.set_defaults(func=cmd_set_ref)
|
||||
|
||||
comment = subparsers.add_parser("comment", help="Add an issue comment.")
|
||||
comment.add_argument("--issue", type=int, required=True)
|
||||
comment.add_argument("--body", default="")
|
||||
comment.add_argument("--body-file", type=Path)
|
||||
comment.set_defaults(func=cmd_comment)
|
||||
|
||||
create_pr = subparsers.add_parser("create-pr", help="Create or return an existing open pull request.")
|
||||
create_pr.add_argument("--head", required=True)
|
||||
create_pr.add_argument("--base", default="main")
|
||||
create_pr.add_argument("--title", required=True)
|
||||
create_pr.add_argument("--body", default="")
|
||||
create_pr.add_argument("--body-file", type=Path)
|
||||
create_pr.set_defaults(func=cmd_create_pr)
|
||||
|
||||
yolo_merge = subparsers.add_parser(
|
||||
"yolo-merge",
|
||||
help="Merge a PR immediately, optionally delete its branch, comment, and mark the issue done.",
|
||||
)
|
||||
yolo_merge.add_argument("--issue", type=int, required=True)
|
||||
yolo_merge.add_argument("--pull", type=int, required=True)
|
||||
yolo_merge.add_argument(
|
||||
"--method",
|
||||
default="merge",
|
||||
choices=("merge", "rebase", "rebase-merge", "squash"),
|
||||
help="Gitea merge method.",
|
||||
)
|
||||
yolo_merge.add_argument(
|
||||
"--delete-branch",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Delete the PR branch after merge.",
|
||||
)
|
||||
yolo_merge.add_argument("--comment", default="")
|
||||
yolo_merge.set_defaults(func=cmd_yolo_merge)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read a Gitea issue and print a compact Markdown summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gitea_common import DEFAULT_ENV_FILE, config
|
||||
|
||||
|
||||
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
|
||||
encoded_owner = urllib.parse.quote(owner, safe="")
|
||||
encoded_repo = urllib.parse.quote(repo, safe="")
|
||||
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
|
||||
|
||||
|
||||
def request_json(method: str, url: str, token: str) -> Any:
|
||||
request = urllib.request.Request(url, method=method)
|
||||
request.add_header("Authorization", f"token {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
|
||||
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def issue_line(issue: dict[str, Any]) -> str:
|
||||
return f"#{issue['number']} {issue['title']} ({issue['state']}) - {issue['html_url']}"
|
||||
|
||||
|
||||
def print_issue(issue: dict[str, Any], dependencies: list[dict[str, Any]], comments: list[dict[str, Any]] | None) -> None:
|
||||
labels = [label["name"] for label in issue.get("labels", [])]
|
||||
|
||||
print(f"# Issue #{issue['number']}: {issue['title']}")
|
||||
print()
|
||||
print(f"- URL: {issue['html_url']}")
|
||||
print(f"- State: {issue['state']}")
|
||||
print(f"- Labels: {', '.join(labels) if labels else '(none)'}")
|
||||
print(f"- Created: {issue.get('created_at')}")
|
||||
print(f"- Updated: {issue.get('updated_at')}")
|
||||
print()
|
||||
print("## Body")
|
||||
print()
|
||||
print(issue.get("body") or "(empty)")
|
||||
print()
|
||||
print("## Dependencies")
|
||||
print()
|
||||
if dependencies:
|
||||
for dependency in sorted(dependencies, key=lambda item: item["number"]):
|
||||
print(f"- {issue_line(dependency)}")
|
||||
else:
|
||||
print("(none)")
|
||||
|
||||
if comments is not None:
|
||||
print()
|
||||
print("## Comments")
|
||||
print()
|
||||
if comments:
|
||||
for comment in comments:
|
||||
user = comment.get("user", {}).get("login", "unknown")
|
||||
print(f"### Comment by {user} at {comment.get('created_at')}")
|
||||
print()
|
||||
print(comment.get("body") or "(empty)")
|
||||
print()
|
||||
else:
|
||||
print("(none)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Read an issue in the configured Gitea repo.")
|
||||
parser.add_argument("--issue", type=int, required=True)
|
||||
parser.add_argument("--comments", action="store_true", help="Include issue comments.")
|
||||
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = config(args.env_file.expanduser())
|
||||
issue = request_json(
|
||||
"GET",
|
||||
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], f"issues/{args.issue}"),
|
||||
cfg["GITEA_TOKEN"],
|
||||
)
|
||||
dependencies = request_json(
|
||||
"GET",
|
||||
api_url(
|
||||
cfg["GITEA_BASE_URL"],
|
||||
cfg["GITEA_OWNER"],
|
||||
cfg["GITEA_REPO"],
|
||||
f"issues/{args.issue}/dependencies?limit=1000",
|
||||
),
|
||||
cfg["GITEA_TOKEN"],
|
||||
)
|
||||
comments = None
|
||||
if args.comments:
|
||||
comments = request_json(
|
||||
"GET",
|
||||
api_url(
|
||||
cfg["GITEA_BASE_URL"],
|
||||
cfg["GITEA_OWNER"],
|
||||
cfg["GITEA_REPO"],
|
||||
f"issues/{args.issue}/comments?limit=1000",
|
||||
),
|
||||
cfg["GITEA_TOKEN"],
|
||||
)
|
||||
|
||||
print_issue(issue, dependencies, comments)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user