309 lines
10 KiB
Python
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())
|