Files
galaxy/resources/bundled/skills/feedback/scripts/file_feedback_issue.py
T

309 lines
10 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import platform
import urllib.parse
import webbrowser
from pathlib import Path
import shutil
import subprocess
import sys
DEFAULT_REPO = "warpdotdev/warp"
DEFAULT_HOSTNAME = "github.com"
FEEDBACK_LABEL = "in-app-feedback"
# GitHub's new-issue page accepts a prefilled title and body via query
# parameters, but browsers and intermediate servers commonly cap URLs around
# 8 KB. Keep a conservative threshold that leaves headroom for the base URL
# and percent-encoding overhead.
MAX_PREFILL_URL_LENGTH = 8000
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"File a GitHub issue in warpdotdev/warp. The caller must choose "
"the filing method via --use: `gh` to create the issue directly with the "
"gh CLI, or `browser` to open the prefilled new-issue page in the browser "
"(used when attachments require manual upload via GitHub's web UI)."
)
)
parser.add_argument(
"--use",
dest="use_method",
required=True,
choices=["gh", "browser"],
help=(
"Filing method. `gh` creates the issue via the gh CLI (requires gh to be "
"installed and authenticated). `browser` opens the prefilled new-issue "
"page in the user's browser so attachments can be uploaded via GitHub's "
"web UI."
),
)
parser.add_argument("--title", required=True, help="Issue title.")
parser.add_argument(
"--body-file",
type=Path,
required=True,
help="Path to a UTF-8 file containing the issue body.",
)
return parser.parse_args()
def read_text(path: Path, field_name: str) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise SystemExit(f"Failed to read {field_name} from {path}: {exc}") from exc
def normalize_title(title: str) -> str:
normalized_title = " ".join(title.splitlines()).strip()
if not normalized_title:
raise SystemExit("Issue title must not be empty.")
return normalized_title
def normalize_body(body: str) -> str:
normalized_body = body.rstrip("\n")
if not normalized_body.strip():
raise SystemExit("Issue body must not be empty.")
return normalized_body
def gh_path_if_authenticated() -> str | None:
gh_path = shutil.which("gh")
if gh_path is None:
return None
auth_result = subprocess.run(
[gh_path, "auth", "status", "--hostname", DEFAULT_HOSTNAME],
capture_output=True,
text=True,
check=False,
)
if auth_result.returncode != 0:
return None
return gh_path
def create_issue_with_gh(
gh_path: str,
title: str,
body: str,
) -> tuple[str | None, str | None]:
command = [
gh_path, "issue", "create",
"--repo", DEFAULT_REPO,
"--title", title,
"--body", body,
"--label", FEEDBACK_LABEL,
]
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
error_output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part).strip()
return None, error_output or "gh issue create failed"
issue_url = result.stdout.strip().splitlines()[-1].strip()
if not issue_url:
return None, "gh issue create succeeded but did not return an issue URL"
return issue_url, None
def build_new_issue_url(title: str, body: str | None) -> str:
"""Build a GitHub new-issue URL with the provided title and optional body prefilled."""
base = f"https://{DEFAULT_HOSTNAME}/{DEFAULT_REPO}/issues/new"
params: list[tuple[str, str]] = [("title", title)]
if body is not None:
params.append(("body", body))
return f"{base}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
def browser_is_available() -> tuple[bool, str | None]:
"""Return whether opening a browser is likely to succeed on this system.
On macOS and Windows a GUI session is effectively always present for the
user running this script. On Linux and other unix-likes ``webbrowser.open``
can return True without actually opening a browser when no display server is
running (for example, in a headless SSH session), so we require ``DISPLAY``
or ``WAYLAND_DISPLAY`` to be set.
"""
system = platform.system()
if system in ("Darwin", "Windows"):
return True, None
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
return True, None
return (
False,
(
"No graphical display is available (neither DISPLAY nor WAYLAND_DISPLAY "
"is set), so the GitHub new-issue page cannot be opened in a browser."
),
)
def open_in_browser(url: str) -> bool:
try:
return webbrowser.open(url, new=2)
except webbrowser.Error:
return False
def fallback_to_browser(title: str, body: str) -> int:
"""Open the GitHub new-issue page with a prefilled title (and body when it fits).
Intended for the caller-selected ``--use browser`` path, which the feedback skill
uses when image attachments need to be uploaded via GitHub's web UI. When the full
prefill URL would exceed ``MAX_PREFILL_URL_LENGTH``, the body is omitted from the
URL and surfaced in the JSON result under a ``body`` field so the caller can tell
the user to paste it into the issue form manually.
When the browser cannot be opened, the function attempts a ``gh issue create``
fallback so the issue is still filed with the available text contents. The returned
payload includes ``browser_unavailable: true`` when that fallback is used, so the
caller can inform the user that the browser could not be opened and images were not
uploaded. If both the browser and ``gh`` are unavailable, filing fails.
"""
full_url = build_new_issue_url(title, body)
body_fits_in_url = len(full_url) <= MAX_PREFILL_URL_LENGTH
url_to_open = full_url if body_fits_in_url else build_new_issue_url(title, None)
browser_available, browser_unavailable_reason = browser_is_available()
base_payload: dict[str, object] = {
"method": "browser",
"repo": DEFAULT_REPO,
"url": url_to_open,
}
if not body_fits_in_url:
# Surface the body so the caller can instruct the user to paste it in.
base_payload["body"] = body
browser_failure_reason: str | None = None
if not browser_available:
browser_failure_reason = browser_unavailable_reason or "No display available."
elif not open_in_browser(url_to_open):
browser_failure_reason = "Unable to open a web browser for the prefilled new-issue page."
if browser_failure_reason is not None:
# Browser unavailable — try gh CLI as a fallback so the issue is still filed.
gh_path = gh_path_if_authenticated()
if gh_path is not None:
issue_url, _gh_error = create_issue_with_gh(gh_path, title, body)
if issue_url is not None:
print_result({
"status": "created",
"method": "gh",
"repo": DEFAULT_REPO,
"issue_url": issue_url,
"browser_unavailable": True,
"message": (
f"{browser_failure_reason} "
"The issue was filed programmatically with the available text contents. "
"Image attachments were not uploaded."
),
})
return 0
base_payload["status"] = "failed"
base_payload["error"] = (
browser_failure_reason
+ " Image attachments could not be handed off through the browser flow; "
"no issue has been filed."
)
print_result(base_payload)
return 1
# The browser path is only used by the skill when image attachments are
# present, so the user-facing message always references pasting/dropping them.
if body_fits_in_url:
message = (
"Opened the GitHub new-issue page in your browser with the title and body prefilled. "
"Paste or drag your attached screenshot(s) into the body at the placeholder line(s), "
"then submit the issue."
)
else:
message = (
"Opened the GitHub new-issue page in your browser with the title prefilled. "
"The drafted body was too long to include in the URL; paste the body (returned in "
"this result's `body` field) into the issue form first, then paste or drag your "
"attached screenshot(s) into the placeholder line(s), then submit."
)
base_payload["status"] = "browser_opened"
base_payload["message"] = message
print_result(base_payload)
return 0
def print_result(payload: dict[str, object]) -> None:
json.dump(payload, sys.stdout)
sys.stdout.write("\n")
def file_with_gh(title: str, body: str) -> int:
"""File the issue via the gh CLI. Returns status `unavailable` when gh isn't
installed or not authenticated; the caller is responsible for choosing a
different --use method in that case (no automatic fallback happens here).
"""
gh_path = gh_path_if_authenticated()
if gh_path is None:
print_result(
{
"status": "unavailable",
"method": "gh",
"repo": DEFAULT_REPO,
"message": f"GitHub CLI is not installed or not authenticated for {DEFAULT_HOSTNAME}.",
}
)
return 0
issue_url, gh_error = create_issue_with_gh(gh_path, title, body)
if issue_url is not None:
print_result(
{
"status": "created",
"method": "gh",
"repo": DEFAULT_REPO,
"issue_url": issue_url,
}
)
return 0
print_result(
{
"status": "failed",
"method": "gh",
"repo": DEFAULT_REPO,
"gh_error": gh_error,
}
)
return 1
def main() -> int:
args = parse_args()
title = normalize_title(args.title)
body = normalize_body(read_text(args.body_file, "body"))
if args.use_method == "browser":
return fallback_to_browser(title, body)
# argparse enforces choices=["gh", "browser"], so the remaining case is "gh".
return file_with_gh(title, body)
if __name__ == "__main__":
raise SystemExit(main())