Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: feedback
|
||||
description: Turn rough feedback about the Warp app into a filed GitHub issue or duplicate-issue response for `warpdotdev/warp`. Use when the user shares a Warp bug report, regression, confusing UX note, feature gap, or short complaint and wants it clarified into reproduction steps, expected vs actual behavior, concrete impact, and grounded source references from local Warp repos when available.
|
||||
---
|
||||
|
||||
# Feedback
|
||||
|
||||
Turn rough Warp app feedback into a crisp filed issue or duplicate-issue response for `warpdotdev/warp`.
|
||||
|
||||
Treat Warp client, Warp app, Warp terminal, and Warp UX feedback as `warpdotdev/warp` unless the user clearly asks for a different destination.
|
||||
|
||||
## Overview
|
||||
- Use the `gh` CLI to search for and fetch code from `warpdotdev/warp` when product or implementation context would improve the report.
|
||||
- If those repos are not available, draft the issue from the user's report alone rather than blocking on more context.
|
||||
- This skill is strictly for issue filing and duplicate detection. Never modify code, generate patches, propose implementation diffs, or open a pull request as part of this workflow.
|
||||
- If you cannot file an issue, say so explicitly in the response instead of attempting another side effect.
|
||||
- The helper script applies the `in-app-feedback` label to filed issues for tracking.
|
||||
|
||||
## Code access boundaries
|
||||
|
||||
This skill runs in environments where Warp source code may be present in the current working directory or on disk. The following rules apply unconditionally regardless of what source code is visible locally:
|
||||
|
||||
- **Never write, edit, or delete any source file.** Do not use Edit, Write, or any tool that modifies files on disk, even if asked to do so as part of filing feedback or "while you're in the code."
|
||||
- **Never create or modify any git artifact.** Do not stage files, create commits, create branches, produce patches, or modify any git state.
|
||||
- **Local source code is read-only context at most.** You may read local Warp source files (e.g., with Read or grep) only to find concrete file paths, symbol names, or setting names that would make a source reference more precise. Never read local files to produce a code fix or diff.
|
||||
- **Prefer `gh` CLI for code lookups.** Use `gh` to search and fetch code from `warpdotdev/warp` rather than reading the local checkout when both are available.
|
||||
- **The presence of local source code is not an invitation to fix it.** Observing that you are inside a Warp source directory changes nothing about the permitted outputs of this skill: issue filed, duplicate found, or explicit refusal.
|
||||
|
||||
Load the bundled reference files only when relevant:
|
||||
- platform and OS-version resolution, plus operating-system-specific behavior: `references/platforms.md`
|
||||
- logs and crash artifacts: `references/logs.md`
|
||||
- output calibration examples: `references/examples.md`
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Confirm scope and classify the report
|
||||
|
||||
- This skill only handles feedback about the Warp product that could plausibly be addressed by a code or docs change to the Warp client, server, or SDKs. Before drafting anything, verify the request is in scope.
|
||||
- **Decline and exit the skill (do not call the helper script) when the report is clearly out of scope.** Out-of-scope categories include, but are not limited to:
|
||||
- Account, billing, subscription, plan, credits, refund, or invoice questions.
|
||||
- Login, authentication, SSO, password, or session-expiry problems.
|
||||
- Requests to contact human support, sales, or legal.
|
||||
- General venting, praise, or commentary with no actionable product signal.
|
||||
- Questions about third-party tools or the user's own shell, machine, or network configuration that are not about Warp's behavior.
|
||||
- Anything the user explicitly says is not about Warp, or that they just want to talk through.
|
||||
- When you decline, respond in one or two sentences that (a) say you won't file an issue, (b) name the reason in plain language, and (c) point the user at the right channel: account/billing/support concerns go to the in-app Help menu or `support@warp.dev`, community discussion goes to the Warp Slack community, and security reports go to `security@warp.dev`. Do not apologize performatively and do not offer to retry the same flow.
|
||||
- Only if the request is in scope, classify it as `bug`, `regression`, `ux issue`, or `feature request` before drafting.
|
||||
|
||||
### 2. Ask only for missing facts that materially improve the draft
|
||||
|
||||
- Before drafting, decide whether the report already contains the minimum actionable information: what the user was doing, what they expected, and what happened (for bugs, regressions, and UX issues) or what they want to be able to do and why (for feature requests). If any of those pieces is missing, run a single focused clarifying round.
|
||||
- Use the `ask_user_question` tool for that round. Ask 3-4 high-value multiple-choice questions in a single call, focused on user experience and expectations: what the user was trying to do, what felt confusing or broken, what they expected to happen instead, where in the product they hit the issue, and how much it blocked them.
|
||||
- Follow the tool guidance where possible: only ask when necessary, do not add labels like `Select One` or `Select All that Apply`, and if fixed options are too limiting, include an `Other` option. If the user skips a question, proceed with your best judgment on what they did answer.
|
||||
- **Run at most one clarifying round.** If after that round the minimum actionable information is still missing, decline to file rather than drafting a weak issue. Tell the user in one or two sentences exactly which specifics would unblock a future report (for example: "A short description of what you were doing when it happened and what you expected instead would let us turn this into an actionable bug report."). Do not file a placeholder issue just to close the loop.
|
||||
- For bugs and regressions, first read `references/platforms.md` and try to resolve Warp version and operating system from the bundled version metadata and available context. Ask for reproduction steps only when they are not already clear, and for regressions in particular only when the flow is not readily available from the report or supporting context.
|
||||
- For crashes, startup failures, rendering bugs, sync issues, or hard-to-reproduce regressions, ask for logs or crash artifacts only when they are likely to help. Read `references/logs.md` only when needed.
|
||||
- If operating system version, Warp version, or operating-system-specific behavior is relevant, read `references/platforms.md` and follow the bundled metadata guidance there yourself when possible. Ask the user only if you still cannot determine the necessary platform details.
|
||||
|
||||
### 3. Check whether the feature or capability is already supported
|
||||
|
||||
- Before concluding that something is missing from Warp (feature requests, "it doesn't do X" complaints, "I wish it could Y" asks, or any UX complaint that could be explained by an existing setting or workflow), you **must** consult the docs first.
|
||||
- Call the `search_warp_documentation` tool with the user's own phrasing. If the first query is vague or returns nothing actionable, try one shorter variant that keeps the same user-visible problem.
|
||||
- If the search returns a clear match, respond with a concise, direct answer that cites the docs page (title + URL) and explains how the existing functionality addresses the user's ask. Do not file an issue and do not invoke the helper script.
|
||||
- If the search returns an ambiguous or partial match, briefly summarize what does exist and ask one clarifying question about whether that satisfies the user's intent before deciding whether to file.
|
||||
- If the search turns up nothing relevant, proceed to step 4. Do not invent workarounds, and do not imply a feature is missing when the docs already answer the question.
|
||||
- Docs-first checking applies primarily to feature-request-shaped reports. For reproducible bugs and regressions, skip ahead to step 4 unless docs would clarify whether the current behavior is intended.
|
||||
|
||||
### 4. Ground the report in product and code context when helpful
|
||||
|
||||
- Search the `warpdotdev/warp` repo via the `gh` CLI for matching product language, expected workflows, setting names, or UX intent when that context would make the draft more actionable.
|
||||
- Search the `warpdotdev/warp` repo via the `gh` CLI for matching components, settings surfaces, feature flags, and likely code paths when implementation context would help triage.
|
||||
- Add source references only when they point to real files, symbols, settings names, or spec text that plausibly relate to the feedback.
|
||||
- Never invent a root cause just to make the report sound complete.
|
||||
|
||||
### 5. Draft the issue
|
||||
|
||||
- Keep the title concrete and user-visible.
|
||||
- Rewrite rough notes into a polished issue body with the shared section structure below.
|
||||
- Preserve the user's meaning while making the report easier for an engineer to act on.
|
||||
- If the exact reproduction steps are still uncertain, write the best-supported scenario and call out what is still unknown.
|
||||
- Make the title specific enough that it can be used as the primary duplicate-detection query.
|
||||
|
||||
### 6. Handle image attachments, if present
|
||||
|
||||
- If the user's query includes one or more image attachments visible to you as multimodal context, apply the rules in this step in addition to the normal drafting workflow.
|
||||
- Incorporate what you can see in each image into the drafted issue body. At minimum, describe the relevant visual content in prose in the `Problem`, `Actual behavior`, or similar section so the report remains coherent even if the images do not end up attached to the filed issue.
|
||||
- In the `Artifacts` section, emit one entry per attached image, in the order you encountered them. Each entry must include both a **caption** describing what the screenshot depicts and a drop-target placeholder, so the issue body still conveys what each screenshot was meant to show even if the user never uploads the image. Use this format per image, numbered starting at 1:
|
||||
|
||||
```md
|
||||
**Screenshot 1:** <one-sentence caption describing what this screenshot shows, written from what you saw in multimodal context>.
|
||||
_Paste screenshot 1 here_
|
||||
```
|
||||
|
||||
Captions should be concrete (for example, "Agent footer with the Send button misaligned below the text input") rather than generic ("a screenshot" or "the bug"). Do not invent details that aren't visible; if an image is ambiguous or unreadable, say so in the caption.
|
||||
- When invoking the helper script (see the `Output` section below), pass `--use browser` instead of `--use gh`. This makes the script skip `gh issue create` and open the prefilled new-issue page in the browser so the user can upload images through GitHub's native drag-and-drop.
|
||||
- In your final response, explicitly instruct the user to paste or drag each attached image into the body at the corresponding `_Paste screenshot N here_` line(s) and then submit the issue. Reference the count of attached images so the user knows how many to paste. Do not claim the issue has been filed until the user submits.
|
||||
- If the user's query has no image attachments, do not add captions or placeholders, pass `--use gh` as usual, and do not add drag-and-drop instructions to your final response.
|
||||
|
||||
### 7. Check for likely duplicates before filing
|
||||
|
||||
- Before invoking `scripts/file_feedback_issue.py`, search issues in `warpdotdev/warp` for likely title matches using the drafted title as the primary query.
|
||||
- Use a lightweight title-based check only. Prefer precision over recall, and do not run a broad semantic fishing expedition.
|
||||
- Start with the exact drafted title. If the exact title returns no clear title match, try one shorter normalized variant that removes filler words while preserving the same user-visible problem.
|
||||
- A suitable command is:
|
||||
|
||||
```bash
|
||||
GH_PAGER=cat gh issue list \
|
||||
--repo warpdotdev/warp \
|
||||
--state all \
|
||||
--limit 10 \
|
||||
--search "<title> in:title" \
|
||||
--json number,title,url,state
|
||||
```
|
||||
|
||||
- Treat a result as a duplicate candidate only when the existing issue title clearly refers to the same underlying problem or request.
|
||||
- If you find a clear title match, do not file a new issue. Respond by pointing the user to the existing issue and explain briefly why it appears to match.
|
||||
- If no clear title match is found, proceed to file the new issue.
|
||||
|
||||
## Issue Structure
|
||||
Use these sections in order when they apply:
|
||||
|
||||
- Summary
|
||||
- Problem
|
||||
- Reproduction steps or desired workflow
|
||||
- Artifacts
|
||||
- Warp version
|
||||
- Operating system
|
||||
For bugs, regressions, and UX issues, also include:
|
||||
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
|
||||
If you found grounded repo evidence, append:
|
||||
|
||||
- Possible source references
|
||||
|
||||
Section rules:
|
||||
- If a required field is unknown, say `Unknown`.
|
||||
- If an optional section does not apply, omit it.
|
||||
- If no artifacts are attached, say `None attached`.
|
||||
- For feature requests, the `Problem` section can describe the current friction or missing capability, and the `Reproduction steps or desired workflow` section can describe the desired flow instead of literal repro steps.
|
||||
- For feature requests, `Expected behavior` and `Actual behavior` are usually omitted unless they genuinely clarify the request.
|
||||
|
||||
## Source Reference Rules
|
||||
|
||||
- Prefer concrete file paths, symbols, settings names, and spec headings over broad guesses like "probably in the terminal code."
|
||||
- Cite spec references when they clarify the intended workflow, wording, or product expectation.
|
||||
- Cite implementation references when they localize the likely surface area or affected component.
|
||||
- Keep each reference brief and explain why it is relevant.
|
||||
- Omit references that are speculative, weakly related, or only tangentially connected.
|
||||
|
||||
## Writing Rules
|
||||
|
||||
- Turn vague feedback into specific behavior without changing the user's meaning.
|
||||
- Convert fuzzy narratives into numbered reproduction steps when the sequence can be inferred responsibly.
|
||||
- Call out severity, frequency, and regression context when available.
|
||||
- Be explicit about uncertainty or missing details instead of smoothing them over.
|
||||
- Do not mention this skill, private internal discussion, or speculative implementation theories in the issue body.
|
||||
- Do not pad the issue with source references unless they genuinely improve debugging or triage.
|
||||
- Never treat this workflow as permission to implement a fix. This skill may only file an issue, decline to file, or point to an existing issue.
|
||||
- Never suggest code changes, patches, or implementation diffs in the issue body, in your response, or as a follow-up action — even informally or "as a starting point." If the user asks for a fix, decline and redirect them to the filed issue.
|
||||
- **Refuse to file in three situations:** (1) the report is out of scope per step 1, (2) the minimum actionable information is still missing after the single clarifying round in step 2, or (3) step 3 found a clear docs match for what the user is asking about. In each case, explain the decision in one or two plain sentences and do not call the helper script. Never imply a feature is missing when docs already answer the question, and never file a placeholder issue just to acknowledge the user.
|
||||
|
||||
## Output
|
||||
|
||||
Use the bundled helper script `scripts/file_feedback_issue.py` to file the issue in `warpdotdev/warp` instead of calling `gh` directly. The script requires a `--use` flag that selects the filing method explicitly:
|
||||
|
||||
- `--use gh`: creates the issue with `gh issue create`. Requires `gh` to be installed and authenticated for `github.com`. Prints a `created` result with `issue_url` on success, or `unavailable` when `gh` is missing or unauthenticated. Does not silently fall back to the browser.
|
||||
- `--use browser`: opens the prefilled new-issue page in the browser so the user can upload image attachments via GitHub's web UI. Prints a `browser_opened` result on success. If the browser cannot be opened, automatically falls back to `gh issue create` and prints a `created` result with `browser_unavailable: true`; if both are unavailable, prints `failed`. Use this whenever the user attached one or more images to the query.
|
||||
- The script always targets `warpdotdev/warp` on `github.com`.
|
||||
|
||||
Write the final body to a temporary UTF-8 file and pass the final title directly as an argument. When the user has no image attachments:
|
||||
|
||||
```bash
|
||||
python3 scripts/file_feedback_issue.py \
|
||||
--use gh \
|
||||
--title "<title>" \
|
||||
--body-file <body-file>
|
||||
```
|
||||
|
||||
When the user has one or more image attachments:
|
||||
|
||||
```bash
|
||||
# Opens the prefilled new-issue page in the browser so the user can drop
|
||||
# their images into the issue body via GitHub's web UI.
|
||||
python3 scripts/file_feedback_issue.py \
|
||||
--use browser \
|
||||
--title "<title>" \
|
||||
--body-file <body-file>
|
||||
```
|
||||
|
||||
The title and body should be structured as follows:
|
||||
|
||||
Issue title: `<title>`
|
||||
|
||||
Issue body:
|
||||
|
||||
```md
|
||||
<!-- warp-feedback-skill:v1 -->
|
||||
## Summary
|
||||
...
|
||||
|
||||
## Problem
|
||||
...
|
||||
<!-- Include these sections for bugs, regressions, and UX issues when applicable. -->
|
||||
|
||||
## Expected behavior
|
||||
...
|
||||
|
||||
## Actual behavior
|
||||
...
|
||||
|
||||
## Reproduction steps or desired workflow
|
||||
1. ...
|
||||
|
||||
## Artifacts
|
||||
...
|
||||
|
||||
## Warp version
|
||||
...
|
||||
|
||||
## Operating system
|
||||
...
|
||||
|
||||
<!-- Omit this section if there are no grounded references. -->
|
||||
## Possible source references
|
||||
- path or symbol: why it may be relevant
|
||||
```
|
||||
|
||||
After completing the duplicate-check and filing workflow:
|
||||
- If the duplicate-check step finds an existing matching issue, respond with the existing issue link and a brief summary (2–4 sentences max) explaining that a likely duplicate already exists, including the matching title, whether that issue is open or closed, and why it appears to match. Do not create another issue.
|
||||
|
||||
- If the JSON result has `status: "created"` and `browser_unavailable: true`, explicitly tell the user that the browser could not be opened (include the `message` field from the result) and that the issue was filed programmatically with the available text contents. Make clear that image attachments were not uploaded to the issue. Then provide the issue link and a brief summary (3–5 sentences max) of what was filed.
|
||||
- If the JSON result has `status: "created"` (without `browser_unavailable`), respond with only the created issue link and a brief summary (3–5 sentences max) of what was filed: the classification, the core problem, and any notable missing details.
|
||||
- If the JSON result has `status: "unavailable"`, say that you could not file the issue because the GitHub CLI was not installed or authenticated, and include the returned message.
|
||||
- If the JSON result has `status: "browser_opened"` (seen when `--use browser` was used for image-bearing feedback), do not claim the issue has been filed. Pass the returned `message` to the user, and explicitly instruct them to paste or drag each attached image into the placeholder line(s) in the issue body and then submit the issue. When the result includes a `body` field (the drafted body was too long to prefill in the URL), surface that body so the user can paste it into the issue form before attaching images.
|
||||
- If the JSON result has `status: "failed"`, say that you could not file the issue because the filing flow failed, and include the returned `error` or `gh_error` message. When `--use browser` was used and filing failed (meaning both the browser and the `gh` CLI fallback were unavailable), make it explicit that image attachments were not handed off and no issue was filed.
|
||||
- If issue filing is not possible for any other reason, say explicitly that no issue was filed.
|
||||
|
||||
Read `references/examples.md` only if you need a compact example of the expected polish level.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Examples
|
||||
|
||||
Use these only to calibrate tone and structure. Do not copy them mechanically.
|
||||
|
||||
## Example 1: Bug
|
||||
|
||||
Rough feedback:
|
||||
|
||||
`Warp loses my selected theme after restart on macOS. I changed it twice and it keeps going back.`
|
||||
|
||||
Draft shape:
|
||||
|
||||
- classification: `bug`
|
||||
- title: `Theme selection resets after restarting Warp on macOS`
|
||||
- missing details: Warp version, macOS version, whether the issue reproduces every restart
|
||||
|
||||
Key body moves:
|
||||
|
||||
- turn the complaint into a concrete restart flow
|
||||
- state the expected persisted theme behavior
|
||||
- mark unknown version details as `Unknown`
|
||||
|
||||
## Example 2: Feature Request
|
||||
|
||||
Rough feedback:
|
||||
|
||||
`The command palette is too hard to scan. I want better grouping for settings-related actions.`
|
||||
|
||||
Draft shape:
|
||||
|
||||
- classification: `feature request`
|
||||
- title: `Improve Command Palette grouping for settings-related actions`
|
||||
- missing details: concrete commands that are hardest to find, screenshots if the user has them
|
||||
|
||||
Key body moves:
|
||||
|
||||
- describe the current friction instead of inventing reproduction steps
|
||||
- focus the request on the desired workflow and user impact
|
||||
- draft immediately unless a specific missing fact blocks clarity
|
||||
@@ -0,0 +1,33 @@
|
||||
# Log And Crash Artifact Guidance
|
||||
|
||||
Use this only for crashes, startup failures, rendering bugs, sync issues, or hard-to-reproduce regressions.
|
||||
|
||||
- Ask for logs only when they are likely to improve the report.
|
||||
- Note in the issue that logs or crash reports were attached, but do not claim they contain console input or output.
|
||||
- In the `Artifacts` section, mention the exact file names or bundles that were attached.
|
||||
|
||||
macOS paths and commands:
|
||||
|
||||
- Logs live under `~/Library/Logs/`
|
||||
- Stable app logs are typically `~/Library/Logs/warp.log*`
|
||||
- Preview app logs are typically `~/Library/Logs/warp_preview.log*`
|
||||
- Stable zip command: `zip -j ~/Desktop/warp-logs.zip ~/Library/Logs/warp.log*`
|
||||
- Preview zip command: `zip -j ~/Desktop/warp_preview-logs.zip ~/Library/Logs/warp_preview.log*`
|
||||
- If Warp still opens, the user can search `View Warp Logs` in the Command Palette
|
||||
- Crash reports may also exist under `~/Library/Logs/DiagnosticReports/` as Warp `.ips` files
|
||||
|
||||
Linux paths:
|
||||
|
||||
- Logs live under Warp's state directory.
|
||||
- Stable app logs are typically `~/.local/state/warp-terminal/warp.log*`
|
||||
- Preview app logs are typically `~/.local/state/warp-terminal-preview/warp_preview.log*`
|
||||
- If the exact channel is unclear, ask the user to open the nearest `warp*.log*` files under `~/.local/state/`
|
||||
|
||||
Windows paths:
|
||||
|
||||
- Logs live under Warp's local app data state directory.
|
||||
- Stable app logs are typically `%LOCALAPPDATA%\warp\Warp\data\logs\warp.log*`
|
||||
- Preview app logs are typically `%LOCALAPPDATA%\warp\WarpPreview\data\logs\warp_preview.log*`
|
||||
- If the exact channel is unclear, ask the user to look under `%LOCALAPPDATA%\warp\` for the relevant `Warp*` folder and attach the matching `warp*.log*` files from its `data\logs\` directory
|
||||
|
||||
If no artifacts are available, say so plainly instead of implying they were checked.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Platform Guidance
|
||||
|
||||
Use this only when the operating system, Warp version, or operating-system-specific behavior is relevant and missing.
|
||||
|
||||
- Resolve the execution surface first: packaged native Warp app or web session.
|
||||
- Prefer the bundled helper scripts and metadata files over prose or ad hoc shell inspection.
|
||||
- If the user already gave a sufficiently specific OS version or Warp version, do not ask again.
|
||||
- Include both OS name and version in the `Operating system` section when available.
|
||||
- Include the `Warp version` section when available, and note when the report is about a web session rather than a packaged native install.
|
||||
|
||||
## Operating system
|
||||
|
||||
Resolve the OS from the machine where the reported behavior actually happens. Do not substitute the OS of a different host, container, or remote target unless that is where the bug occurs.
|
||||
Run the bundled helper script when you need to resolve OS name and version:
|
||||
|
||||
```bash
|
||||
python3 scripts/resolve_platform.py
|
||||
```
|
||||
|
||||
Use the script output directly when filling `Operating system`. Ask the user only if Python is unavailable or the output still does not identify the relevant environment precisely enough.
|
||||
|
||||
## Warp version
|
||||
|
||||
For packaged native Warp installs, read the bundled version metadata file directly:
|
||||
The bundled version metadata file lives at `../../metadata/version.json` relative to the skill root. Read its `warp_version` field and use that value directly.
|
||||
|
||||
Use the file contents directly when filling `Warp version`. Ask the user only if Python is unavailable, the bundled metadata file is missing or unreadable, or the report is about a browser or web session rather than a packaged native install.
|
||||
|
||||
- Browser or web session with no local Warp executable: use the version or build identifier from the session URL or surrounding session metadata when present. If there is no concrete version string, record that it was a web session and leave `Warp version` as `Unknown` rather than guessing.
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def linux_info() -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
for line in os_release.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
fields[key] = value.strip().strip(chr(34))
|
||||
|
||||
return {
|
||||
"os": fields.get("PRETTY_NAME") or fields.get("NAME") or "Linux",
|
||||
"os_version": fields.get("VERSION_ID") or fields.get("VERSION") or platform.release(),
|
||||
"kernel": platform.release(),
|
||||
}
|
||||
|
||||
|
||||
def mac_info() -> dict[str, str]:
|
||||
build = None
|
||||
result = subprocess.run(
|
||||
["sw_vers", "-buildVersion"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
build = result.stdout.strip() or None
|
||||
|
||||
return {
|
||||
"os": "macOS",
|
||||
"os_version": platform.mac_ver()[0] or None,
|
||||
"os_build": build,
|
||||
}
|
||||
|
||||
|
||||
def windows_info() -> dict[str, str]:
|
||||
version = sys.getwindowsversion()
|
||||
return {
|
||||
"os": "Windows",
|
||||
"os_version": f"{version.major}.{version.minor}.{version.build}",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
info = mac_info()
|
||||
elif system == "Linux":
|
||||
info = linux_info()
|
||||
elif system == "Windows":
|
||||
info = windows_info()
|
||||
else:
|
||||
info = {
|
||||
"os": system or "Unknown",
|
||||
"os_version": platform.version() or None,
|
||||
}
|
||||
|
||||
json.dump({k: v for k, v in info.items() if v}, sys.stdout, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for file_feedback_issue.py.
|
||||
|
||||
Run with:
|
||||
python3 resources/channel-gated-skills/dogfood/feedback/scripts/test_file_feedback_issue.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
MODULE_PATH = SCRIPT_DIR / "file_feedback_issue.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("file_feedback_issue", MODULE_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
ffi = load_module()
|
||||
|
||||
|
||||
class BuildNewIssueUrlTests(unittest.TestCase):
|
||||
def test_url_includes_repo_and_title(self):
|
||||
url = ffi.build_new_issue_url("Hello world", None)
|
||||
self.assertTrue(url.startswith(f"https://{ffi.DEFAULT_HOSTNAME}/{ffi.DEFAULT_REPO}/issues/new?"))
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["Hello world"])
|
||||
self.assertNotIn("body", qs)
|
||||
|
||||
def test_url_includes_body_when_provided(self):
|
||||
url = ffi.build_new_issue_url("T", "body text with spaces & symbols?")
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["T"])
|
||||
self.assertEqual(qs["body"], ["body text with spaces & symbols?"])
|
||||
|
||||
def test_special_characters_are_percent_encoded(self):
|
||||
url = ffi.build_new_issue_url("crash: `foo`", "<script>alert(1)</script>")
|
||||
# Spaces should be %20, not +, because we use quote_via=quote.
|
||||
self.assertIn("%20", url)
|
||||
self.assertNotIn("+", url.split("?", 1)[1])
|
||||
# Angle brackets and backticks are percent-encoded.
|
||||
self.assertIn("%3C", url)
|
||||
self.assertIn("%3E", url)
|
||||
|
||||
|
||||
class FallbackToBrowserTests(unittest.TestCase):
|
||||
def _run_fallback(
|
||||
self,
|
||||
title,
|
||||
body,
|
||||
open_ok=True,
|
||||
browser_available=(True, None),
|
||||
gh_path=None,
|
||||
gh_create_result=(None, "gh not configured"),
|
||||
):
|
||||
with mock.patch.object(
|
||||
ffi, "open_in_browser", return_value=open_ok
|
||||
) as open_mock, mock.patch.object(
|
||||
ffi, "browser_is_available", return_value=browser_available
|
||||
), mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value=gh_path
|
||||
), mock.patch.object(
|
||||
ffi, "create_issue_with_gh", return_value=gh_create_result
|
||||
):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.fallback_to_browser(title, body)
|
||||
payload = json.loads(buf.getvalue().strip())
|
||||
return rc, payload, open_mock
|
||||
|
||||
def test_short_body_is_embedded_in_url(self):
|
||||
rc, payload, open_mock = self._run_fallback("t", "small body")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
# Short bodies fit in the URL; no `body` field is surfaced separately.
|
||||
self.assertNotIn("body", payload)
|
||||
open_mock.assert_called_once()
|
||||
called_url = open_mock.call_args.args[0]
|
||||
parsed = urllib.parse.urlparse(called_url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["t"])
|
||||
self.assertEqual(qs["body"], ["small body"])
|
||||
|
||||
def test_long_body_surfaces_body_in_payload_and_opens_title_only_url(self):
|
||||
huge_body = "A" * (ffi.MAX_PREFILL_URL_LENGTH + 500)
|
||||
rc, payload, open_mock = self._run_fallback("t", huge_body)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
# Long bodies are returned in the payload so the caller can instruct
|
||||
# the user to paste them; no clipboard copy happens.
|
||||
self.assertEqual(payload["body"], huge_body)
|
||||
called_url = open_mock.call_args.args[0]
|
||||
parsed = urllib.parse.urlparse(called_url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["t"])
|
||||
self.assertNotIn("body", qs)
|
||||
|
||||
def test_browser_failure_falls_back_to_gh_when_available(self):
|
||||
rc, payload, _ = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
open_ok=False,
|
||||
gh_path="/usr/bin/gh",
|
||||
gh_create_result=("https://github.com/warpdotdev/warp/issues/7", None),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/7"))
|
||||
self.assertTrue(payload.get("browser_unavailable"))
|
||||
self.assertIn("attachment", payload["message"].lower())
|
||||
|
||||
def test_browser_failure_reports_failed_when_gh_also_unavailable(self):
|
||||
rc, payload, open_mock = self._run_fallback("t", "small body", open_ok=False)
|
||||
# Filing failed, so exit code must be non-zero for shell callers that
|
||||
# check `$?` to distinguish "issue filed" from "issue not filed".
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
self.assertIn("url", payload)
|
||||
# Failure messaging must acknowledge attachments so the user understands
|
||||
# why the image workflow couldn't complete.
|
||||
self.assertIn("attachment", payload["error"].lower())
|
||||
|
||||
def test_browser_unavailable_falls_back_to_gh_when_available(self):
|
||||
rc, payload, open_mock = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
browser_available=(False, "No DISPLAY"),
|
||||
gh_path="/usr/bin/gh",
|
||||
gh_create_result=("https://github.com/warpdotdev/warp/issues/8", None),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/8"))
|
||||
self.assertTrue(payload.get("browser_unavailable"))
|
||||
self.assertIn("No DISPLAY", payload["message"])
|
||||
self.assertIn("attachment", payload["message"].lower())
|
||||
open_mock.assert_not_called()
|
||||
|
||||
def test_browser_unavailable_reports_failed_when_gh_also_unavailable(self):
|
||||
rc, payload, open_mock = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
browser_available=(False, "No DISPLAY"),
|
||||
)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
self.assertIn("No DISPLAY", payload["error"])
|
||||
self.assertIn("attachment", payload["error"].lower())
|
||||
open_mock.assert_not_called()
|
||||
|
||||
|
||||
class BrowserIsAvailableTests(unittest.TestCase):
|
||||
def test_darwin_is_always_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Darwin"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_windows_is_always_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Windows"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_linux_without_display_is_unavailable(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertFalse(ok)
|
||||
self.assertIsNotNone(reason)
|
||||
|
||||
def test_linux_with_display_is_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {"DISPLAY": ":0"}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_linux_with_wayland_display_is_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
|
||||
class FileWithGhTests(unittest.TestCase):
|
||||
def _run(self, **mocks):
|
||||
patches = []
|
||||
for attr, value in mocks.items():
|
||||
patches.append(mock.patch.object(ffi, attr, return_value=value))
|
||||
buf = io.StringIO()
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.file_with_gh("hello", "body")
|
||||
return rc, json.loads(buf.getvalue().strip())
|
||||
|
||||
def test_reports_unavailable_when_gh_missing(self):
|
||||
rc, payload = self._run(gh_path_if_authenticated=None)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "unavailable")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertIn("message", payload)
|
||||
|
||||
def test_creates_when_gh_available(self):
|
||||
rc, payload = self._run(
|
||||
gh_path_if_authenticated="/usr/bin/gh",
|
||||
create_issue_with_gh=(
|
||||
"https://github.com/warpdotdev/warp/issues/42",
|
||||
None,
|
||||
),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/42"))
|
||||
|
||||
def test_reports_failed_when_gh_create_fails(self):
|
||||
rc, payload = self._run(
|
||||
gh_path_if_authenticated="/usr/bin/gh",
|
||||
create_issue_with_gh=(None, "gh failed to create issue"),
|
||||
)
|
||||
# Filing failed, so exit code must be non-zero.
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertEqual(payload["gh_error"], "gh failed to create issue")
|
||||
|
||||
|
||||
class MainTests(unittest.TestCase):
|
||||
"""Tests for the --use dispatch in main().
|
||||
|
||||
The caller must pick a filing method explicitly; main() does not fall back
|
||||
between the two paths.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.body_file = SCRIPT_DIR / "_tmp_body.txt"
|
||||
self.body_file.write_text("my body", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.body_file.unlink(missing_ok=True)
|
||||
|
||||
def _run_main(self, argv_extras):
|
||||
argv = [
|
||||
"file_feedback_issue.py",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
*argv_extras,
|
||||
]
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.main()
|
||||
return rc, json.loads(buf.getvalue().strip())
|
||||
|
||||
def test_use_gh_creates_when_gh_available(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value="/usr/bin/gh"
|
||||
), mock.patch.object(
|
||||
ffi,
|
||||
"create_issue_with_gh",
|
||||
return_value=(
|
||||
"https://github.com/warpdotdev/warp/issues/999",
|
||||
None,
|
||||
),
|
||||
):
|
||||
rc, payload = self._run_main(["--use", "gh"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/999"))
|
||||
|
||||
def test_use_gh_reports_unavailable_and_does_not_open_browser(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value=None
|
||||
), mock.patch.object(ffi, "open_in_browser", return_value=True) as open_mock:
|
||||
rc, payload = self._run_main(["--use", "gh"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "unavailable")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
# Critical invariant: --use gh must not silently fall back to the browser.
|
||||
open_mock.assert_not_called()
|
||||
|
||||
def test_use_browser_does_not_touch_gh(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated"
|
||||
) as gh_mock, mock.patch.object(
|
||||
ffi, "create_issue_with_gh"
|
||||
) as create_mock, mock.patch.object(
|
||||
ffi, "browser_is_available", return_value=(True, None)
|
||||
), mock.patch.object(ffi, "open_in_browser", return_value=True) as open_mock:
|
||||
rc, payload = self._run_main(["--use", "browser"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
open_mock.assert_called_once()
|
||||
gh_mock.assert_not_called()
|
||||
create_mock.assert_not_called()
|
||||
|
||||
def test_missing_use_flag_exits_with_argparse_error(self):
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"file_feedback_issue.py",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
],
|
||||
), mock.patch.object(sys, "stderr", stderr), contextlib.suppress(SystemExit):
|
||||
ffi.main()
|
||||
self.assertIn("--use", stderr.getvalue())
|
||||
|
||||
def test_invalid_use_value_is_rejected(self):
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"file_feedback_issue.py",
|
||||
"--use",
|
||||
"carrier-pigeon",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
],
|
||||
), mock.patch.object(sys, "stderr", stderr), contextlib.suppress(SystemExit):
|
||||
ffi.main()
|
||||
self.assertIn("invalid choice", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user