diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d58f1e..4115696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The Git page's **Issues** and **Pull requests** forms now discover the repository's own + templates (`.github/ISSUE_TEMPLATE/` Markdown and YAML issue forms, plus + `.github/PULL_REQUEST_TEMPLATE.md` and its variants). A lone template applies + automatically; with several, a **Template** picker (including **Blank**) chooses one. + The choice prefills the title prefix and body skeleton — a YAML issue form is flattened + into Markdown — and its `labels:` ride along on **Create**. + +- The Git page's **Issues** and **Pull requests** forms can now draft their title and + Markdown body with a **Generate** button. Add optional notes (or leave them blank) and + Orbit makes the same one-shot, tool-free `pi -p` call the commit-message generator uses, + reading the branch, its commits, and its changed files; when pi is missing or has no + credentials it falls back to a local heuristic built from those same facts. Nothing is + created until you review the draft and press **Create**. + - Every icon-only button across the app now carries the same native tooltip the send / stop button has — a localized label, usually with its keyboard shortcut (e.g. `Toggle Terminal (⌘J)`). Covers the top bar, sidebar, transcript copy / @@ -29,6 +43,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Images in issue/PR bodies, comments, and transcripts now render. The markdown image + element forced a definite `height: 0`, and gpui derives a missing width from a definite + height before the image decodes (0 × ratio), so a loaded screenshot collapsed to a 0×0 + box with only its border visible. The height is left auto now: the image scales to the + reading column and taffy keeps its aspect ratio. + +- Screenshots in replies load again when GitHub stored them as signed + `private-user-images.githubusercontent.com/…?jwt=…` URLs. That JWT expires within + minutes, so the app got a 404 and showed the alt-text fallback; the renderer rewrites + the URL to the durable `github.com/user-attachments/assets/` form encoded in the + filename, which serves the same image. + +- GitHub issue/PR bodies and comments now render screenshots that GitHub stored as raw + HTML `` tags when the line is inside a blockquote or list item — a quoted + bug report is a common case. Previously only a standalone top-level `` line became + an image, so these showed the tag as text; the whole tag/its URL rendered as a link. + +- The **New pull request** form's **Base branch** picker, and the **Template** pickers on + both new-issue/new-PR forms, no longer float over the wrong part of the form or paint + behind its fields. They are anchored to the chip that opens them (opening upward at the + bottom of the PR form), draw above the form, and use the top branch selector's dropdown + chip (glyph, value, chevron) and picker surface — list inset, rows, and search row for + the branch list. + +- The **Issues** tab's **Label** picker — the list filter chip and the detail view's + **Edit labels** button — no longer opens at the page's top-right over the issue list. + It anchors to the button that opened it, opens directly beneath it, and uses the same + picker surface and rows as the branch selector. + - The conversation rail's active tick now follows every navigation path, not just wheel and scrollbar gestures. Clicking a tick and pressing ⌘↑/⌘↓ scroll the list programmatically, which never fired gpui's scroll handler, so the diff --git a/INTENT.md b/INTENT.md index 26cd53e..58605f9 100644 --- a/INTENT.md +++ b/INTENT.md @@ -252,6 +252,20 @@ bar, sidebar, transcript copy / diff controls, composer add button, window capti buttons, Explorer, Review pane, Git, Usage, terminal, settings, and the picker / dialog controls, so no glyph is a guess. +The Git page's **Issues** and **Pull requests** tabs gained a **Generate** action: the +**New issue** and **New pull request** forms take optional free-form notes, then draft a +title and Markdown body with the same one-shot, tool-free `pi -p` call the commit-message +button uses (`issue_message.rs`), falling back to a local heuristic built from the branch's +commits and changed files when pi is unavailable. The forms also read the repository's own +templates (`gh_templates.rs`): a lone issue/PR template applies automatically, several are +chosen from a **Template** picker, the body is prefilled with the template's skeleton (YAML +issue forms are flattened to Markdown), the template's title prefix and labels are applied, +and the generator is told to fill the template faithfully. Those tabs were also tightened +for scanning — labels collapse to `+N`, every row ends in a fixed relative-time column, and +the PR list drops its duplicated state pill — and the PR detail now shares the issue +detail's reading-column + metadata-rail layout (review, checks, changes, branches, +timeline). + Composer sending is configurable in Settings → Agent → Behavior: Enter queues a follow-up by default or steers the running task, with Alt/Option+Enter selecting the opposite mode. The explicit ⌘⇧Enter / Ctrl+Shift+Enter steering shortcut stays fixed. The choice is a diff --git a/README.md b/README.md index bbc7870..03873a6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Usage — requests, tokens, cost, and cache, read from your own sessions: - Side pane with a live `git diff HEAD` of the workspace, refreshed when a run settles - Git page for Changes / History / Graph, staging, and commit +- Git page's Issues and Pull requests tabs: browse and file issues and PRs through `gh`, with an AI **Generate** action that drafts a title and body from the branch, its commits, optional notes, and the repository's own issue/PR template **Workbench** @@ -126,7 +127,7 @@ issue before starting. | Workflow modes | Composer | Start a session scoped to **Plan Mode**, **Build Mode**, or **Ask Mode** instead of one undifferentiated chat. | Shipped | — | | Follow-up on settle | Transcript | Show a queued follow-up inline once a run ends, not only in the compose queue. | Planned | — | | Suggested follow-ups | Transcript | Propose 2–3 context-grounded next prompts as composer inserts after a run settles. | Proposed | [#10](https://github.com/imrj05/orbit/issues/10) | -| GitHub client | Workbench | Browse and manage remote commits, graph, issues, and pull requests in-app. | In progress — issues and PRs tabs ship today | [#8](https://github.com/imrj05/orbit/issues/8) | +| GitHub client | Workbench | Browse and manage remote commits, graph, issues, and pull requests in-app. | In progress — issues and PRs tabs ship today, with AI-drafted issue/PR bodies | [#8](https://github.com/imrj05/orbit/issues/8) | | Pi extension support | Workbench | First-class list / install / enable / configure / debug of pi extensions, including community ones. | Proposed | [#7](https://github.com/imrj05/orbit/issues/7) | | Global default model | Models | Pin pi's global default model and thinking effort from within Orbit. | Proposed | [#9](https://github.com/imrj05/orbit/issues/9) | | Voice dictation | Composer | Dictate prompts into the composer. | Planned | — | diff --git a/crates/orbit-pi/docs/git-page.md b/crates/orbit-pi/docs/git-page.md index f6e8443..3b100e3 100644 --- a/crates/orbit-pi/docs/git-page.md +++ b/crates/orbit-pi/docs/git-page.md @@ -11,8 +11,8 @@ push** row or the top-bar Git affordance; `escape` / **Back** returns. | **Changes** | `git status --porcelain` | Staged/unstaged lists with per-file stage/unstage and confirmed discard; Include-unstaged toggle; commit bar (branch menu, conventional-commit input, **Generate**); a collapsible **Stashes** section (stash all, pop, apply, drop). | | **History** | `git log` | Commit rows with ref badges, author, relative time, paged. Clicking a row expands it in place: full body, author/committer facts, **Open on GitHub**, copy hash, and the changed files with a per-file actions menu. | | **Graph** | `git log --branches HEAD` (plus `--remotes --tags` with **All refs**) | Lane graph laid out by `git::layout_graph`, with the same expandable commit detail as History. | -| **Issues** | `gh issue …` | Open/Closed/All, search, label filter/picker, **New issue**, list, and a detail view with the rendered Markdown body, comments, a comment composer, **Close/Reopen**, **Edit labels**, and **Open on GitHub**. | -| **Pull requests** | `gh pr …` | Open/Closed/Merged/All, search, **New pull request** (title, body, base picker, draft), list with CI rollup and review-decision chips, and a detail view with description, checks, commits, changed files, comments, reviews, **Approve** / **Request changes**, **Merge** (method + delete-branch confirm), **Close/Reopen**, and **Checkout branch**. | +| **Issues** | `gh issue …` | Open/Closed/All, search, label filter/picker, **New issue** (with **Generate**), list, and a detail view with the rendered Markdown body, comments, a comment composer, **Close/Reopen**, **Edit labels**, and **Open on GitHub**. | +| **Pull requests** | `gh pr …` | Open/Closed/Merged/All, search, **New pull request** (title, body, base picker, draft, **Generate**), list with CI rollup and review-decision chips, and a detail view with description, checks, commits, changed files, comments, reviews, **Approve** / **Request changes**, **Merge** (method + delete-branch confirm), **Close/Reopen**, and **Checkout branch**. | The Issues and Pull requests tabs only appear when `gh` is installed. A missing `gh` or a signed-out CLI renders an honest setup state instead of an empty tab. @@ -27,6 +27,45 @@ Commit message auto-generates through `commit_message.rs` (`pi -p --no-tools --no-session --no-extensions --no-skills --no-context-files`, falling back to a local `type(scope): …` heuristic). +## Issue and pull-request draft generation + +The **New issue** and **New pull request** forms have a **Generate** button beside their +heading. An optional **Notes** field steers the draft; leave it blank to infer the subject +from the repository context. Generation reuses the commit-message pattern: a one-shot, +tool-free `pi -p` call (`issue_message.rs`) that reads the current branch, the branch's +commits (against the chosen base for a PR), and its changed-file stat — never writing to +the working tree or the active session. The model is asked for a single fenced block with +the title on the first line and a Markdown body below it. + +### Repository templates + +Both forms discover the repository's own templates (`gh_templates.rs`, mirroring GitHub's +lookup): the `.github/ISSUE_TEMPLATE/` directory (Markdown templates with YAML front +matter, and the newer YAML issue forms, `config.yml` skipped) plus the legacy +single-file locations, and `.github/PULL_REQUEST_TEMPLATE.md` plus its variants. A form +with exactly one template applies it automatically; with several, a **Template** chip +opens a picker (including a **Blank** choice). Choosing a template prefills the title +prefix (`bug: `) and the body skeleton, and **Create** passes the template's `labels:` to +`gh`. An issue form is flattened into Markdown — each field's label becomes a heading, +its description a guidance comment, dropdown/checkbox options rendered as comments and +checkboxes — since Orbit edits plain Markdown rather than GitHub's dynamic form. + +The **Generate** prompt hands the model the selected template and asks it to fill every +heading faithfully, preserving the template's checkboxes and sections. Without pi, the +heuristic drops the notes (or the branch's commit subjects) into the template's first +section. + +When pi is missing, unauthenticated, or times out, the button falls back to a local +heuristic: the title comes from the first note line, else the latest commit subject, else +the branch name read as words; the body lists the branch's commits and changed files under +`## Summary` / `## What happened`. Either way the result lands in the editable title and +body fields, and **Create** is still a separate, deliberate press. + +The Issues and Pull requests lists are built to scan: labels collapse to a `+N` chip, each +row ends in a fixed relative-time column, and closed items are de-emphasized. The issue +detail's reading-column + metadata-rail layout is now shared by the pull-request detail, +whose rail summarizes review, checks, changes, branches, and the timeline. + The branch chip opens a menu with **Merge branch…**, **Rebase onto…**, every local branch (checkout; delete uses safe `-d` first, then a confirmed `-D`), **New branch…**, and **Rename current branch…**. Merge can fast-forward, create diff --git a/crates/orbit-pi/locales/de.yml b/crates/orbit-pi/locales/de.yml index dd6ee49..d026445 100644 --- a/crates/orbit-pi/locales/de.yml +++ b/crates/orbit-pi/locales/de.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Zusammengeführt" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Generieren" +git_panel.generating_draft: "Wird generiert…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Zusammenfassung" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Überprüfen" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Branches" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} hat das Zeitlimit überschritten" errors.pi_process_error: "pi-Prozessfehler: %{error}" errors.pi_exited_with: "pi wurde mit %{status} beendet: %{stderr}" errors.commit_generation_timed_out: "Zeitlimit für die Commit-Nachrichtengenerierung überschritten" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Dateien filtern…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/en.yml b/crates/orbit-pi/locales/en.yml index d551409..615e034 100644 --- a/crates/orbit-pi/locales/en.yml +++ b/crates/orbit-pi/locales/en.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Merged" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Generate" +git_panel.generating_draft: "Generating…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Summary" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Review" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Branches" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} timed out" errors.pi_process_error: "pi process error: %{error}" errors.pi_exited_with: "pi exited with %{status}: %{stderr}" errors.commit_generation_timed_out: "commit message generation timed out" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Filter files…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/es.yml b/crates/orbit-pi/locales/es.yml index da648df..afb074a 100644 --- a/crates/orbit-pi/locales/es.yml +++ b/crates/orbit-pi/locales/es.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Fusionado" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Generar" +git_panel.generating_draft: "Generando…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Resumen" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Revisar" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Ramas" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} agotó el tiempo de espera" errors.pi_process_error: "error del proceso pi: %{error}" errors.pi_exited_with: "pi terminó con %{status}: %{stderr}" errors.commit_generation_timed_out: "se agotó el tiempo de generación del mensaje de confirmación" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Filtrar archivos…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/fr.yml b/crates/orbit-pi/locales/fr.yml index 935c0aa..268f017 100644 --- a/crates/orbit-pi/locales/fr.yml +++ b/crates/orbit-pi/locales/fr.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Fusionné" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Générer" +git_panel.generating_draft: "Génération…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Résumé" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Révision" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Branches" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} a expiré" errors.pi_process_error: "erreur du processus pi : %{error}" errors.pi_exited_with: "pi s'est arrêté avec %{status} : %{stderr}" errors.commit_generation_timed_out: "délai dépassé pour la génération du message de commit" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Filtrer les fichiers…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/it.yml b/crates/orbit-pi/locales/it.yml index 37c47ac..e0301e5 100644 --- a/crates/orbit-pi/locales/it.yml +++ b/crates/orbit-pi/locales/it.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Unito" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Genera" +git_panel.generating_draft: "Generazione…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Riepilogo" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Revisione" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Branch" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} è scaduto" errors.pi_process_error: "errore del processo pi: %{error}" errors.pi_exited_with: "pi è terminato con %{status}: %{stderr}" errors.commit_generation_timed_out: "timeout durante la generazione del messaggio di commit" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Filtra file…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/ja.yml b/crates/orbit-pi/locales/ja.yml index c32dd08..cedcc1c 100644 --- a/crates/orbit-pi/locales/ja.yml +++ b/crates/orbit-pi/locales/ja.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "マージしました" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "生成" +git_panel.generating_draft: "生成中…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "サマリー" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "レビュー" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "ブランチ" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} がタイムアウトしました" errors.pi_process_error: "pi プロセスエラー: %{error}" errors.pi_exited_with: "pi が %{status} で終了: %{stderr}" errors.commit_generation_timed_out: "コミットメッセージの生成がタイムアウトしました" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "ファイルを絞り込み…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/ko.yml b/crates/orbit-pi/locales/ko.yml index e4f2506..07c86ab 100644 --- a/crates/orbit-pi/locales/ko.yml +++ b/crates/orbit-pi/locales/ko.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "병합됨" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "생성" +git_panel.generating_draft: "생성 중…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "요약" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "검토" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "브랜치" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} 시간 초과" errors.pi_process_error: "pi 프로세스 오류: %{error}" errors.pi_exited_with: "pi가 %{status}(으)로 종료: %{stderr}" errors.commit_generation_timed_out: "커밋 메시지 생성 시간 초과" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "파일 필터…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/pt-BR.yml b/crates/orbit-pi/locales/pt-BR.yml index 9efa2cb..69b0d68 100644 --- a/crates/orbit-pi/locales/pt-BR.yml +++ b/crates/orbit-pi/locales/pt-BR.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Mesclado" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Gerar" +git_panel.generating_draft: "Gerando…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Resumo" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Revisar" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Branches" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} excedeu o tempo limite" errors.pi_process_error: "erro do processo do pi: %{error}" errors.pi_exited_with: "o pi encerrou com %{status}: %{stderr}" errors.commit_generation_timed_out: "a geração da mensagem de commit excedeu o tempo limite" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Filtrar arquivos…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/ru.yml b/crates/orbit-pi/locales/ru.yml index df9613a..621bf29 100644 --- a/crates/orbit-pi/locales/ru.yml +++ b/crates/orbit-pi/locales/ru.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "Слито" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "Сгенерировать" +git_panel.generating_draft: "Генерация…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "Сводка" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "Обзор" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "Ветки" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} превысил время ожидания" errors.pi_process_error: "ошибка процесса pi: %{error}" errors.pi_exited_with: "pi завершился с %{status}: %{stderr}" errors.commit_generation_timed_out: "время генерации сообщения коммита истекло" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "Фильтр файлов…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/locales/zh-CN.yml b/crates/orbit-pi/locales/zh-CN.yml index 729f035..6407531 100644 --- a/crates/orbit-pi/locales/zh-CN.yml +++ b/crates/orbit-pi/locales/zh-CN.yml @@ -661,6 +661,20 @@ git_panel.pr_merged: "已合并" git_panel.new_pull: "New pull request" git_panel.new_pull_title: "New pull request" git_panel.create_pull: "Create pull request" +git_panel.draft_notes_label: "Notes" +git_panel.template: "Template" +git_panel.template_blank: "Blank" +git_panel.template_none: "No template in this repository" +git_panel.draft_notes_placeholder: "Optional: describe the change, problem, or goal…" +git_panel.generate_draft: "生成" +git_panel.generating_draft: "正在生成…" +git_panel.draft_ready: "Draft ready — review before creating" +git_panel.draft_issue_fallback_title: "New issue" +git_panel.draft_pull_fallback_title: "New pull request" +git_panel.draft_what_happened: "What happened" +git_panel.draft_summary: "汇总" +git_panel.draft_placeholder: "Describe the change." +git_panel.draft_changed_files: "Changed files" git_panel.pr_created: "Pull request created" git_panel.pr_title_required: "A pull request needs a title" git_panel.pulls_unavailable: "Pull requests unavailable" @@ -698,6 +712,11 @@ git_panel.base_branch: "Base branch" git_panel.choose_base: "Choose base" git_panel.commits: "Commits" git_panel.checks: "Checks" +git_panel.review: "审阅" +git_panel.review_none: "No decision yet" +git_panel.no_checks: "No checks" +git_panel.branches: "分支" +git_panel.head_branch: "Head branch" git_panel.files_changed: "Files changed" git_panel.files_count: "%{count} files" git_panel.more_files: "%{count} more files not shown" @@ -1203,6 +1222,8 @@ errors.pi_timed_out: "pi %{args} 超时" errors.pi_process_error: "pi 进程错误:%{error}" errors.pi_exited_with: "pi 以 %{status} 退出:%{stderr}" errors.commit_generation_timed_out: "生成提交信息超时" +errors.no_draft: "the model returned an empty draft" +errors.draft_generation_timed_out: "draft generation timed out" explorer.files: "Files" explorer.filter_files: "筛选文件…" explorer.files_count: "%{count} files" diff --git a/crates/orbit-pi/src/gh.rs b/crates/orbit-pi/src/gh.rs index eedb4f2..cf2c086 100644 --- a/crates/orbit-pi/src/gh.rs +++ b/crates/orbit-pi/src/gh.rs @@ -746,6 +746,7 @@ pub fn create_pull( body: &str, base: &str, draft: bool, + labels: &[String], ) -> Result { let mut args: Vec = vec![ "pr".into(), @@ -762,6 +763,10 @@ pub fn create_pull( if draft { args.push("--draft".into()); } + for label in labels { + args.push("--label".into()); + args.push(label.clone()); + } let refs: Vec<&str> = args.iter().map(String::as_str).collect(); run(cwd, &refs) } diff --git a/crates/orbit-pi/src/gh_templates.rs b/crates/orbit-pi/src/gh_templates.rs new file mode 100644 index 0000000..13feca9 --- /dev/null +++ b/crates/orbit-pi/src/gh_templates.rs @@ -0,0 +1,630 @@ +//! Repository-provided issue and pull-request templates. +//! +//! GitHub looks for these in a handful of well-known places; Orbit mirrors that +//! lookup so the Git page's New issue / New pull request forms can prefill and +//! generate against the repository's own template. Both plain Markdown +//! templates (with optional YAML front matter) and the newer YAML "issue forms" +//! are supported; an issue form is flattened into a Markdown skeleton, since +//! Orbit renders a plain body field rather than GitHub's dynamic form. +//! +//! Everything here is pure filesystem reads and returns an empty list when the +//! repository has no templates — never an error. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Read at most this much of a template; anything larger is not a template. +const MAX_TEMPLATE_BYTES: u64 = 256 * 1024; + +/// The legacy single-file locations GitHub checks, in priority order. +const ISSUE_TEMPLATE_FILES: &[&str] = &[ + ".github/ISSUE_TEMPLATE.md", + ".github/issue_template.md", + ".github/ISSUE_TEMPLATE", + "docs/ISSUE_TEMPLATE.md", + "ISSUE_TEMPLATE.md", +]; + +/// The single-file pull-request template locations GitHub checks, in priority +/// order. +const PULL_TEMPLATE_FILES: &[&str] = &[ + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", + "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", +]; + +/// One repository template, flattened for the body field and the generator. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RepoTemplate { + /// A human label (`Bug report`, from the form's `name:`), or the filename. + pub name: String, + /// Workspace-relative path, for identifying the source. + pub path: String, + /// The Markdown skeleton to prefill the body with and to hand the model. + pub body: String, + /// A title prefix the template asks for (`bug: `). + pub title_prefix: Option, + /// Labels the template asks for. + pub labels: Vec, +} + +impl RepoTemplate { + /// A short label for a picker chip: the name, or the file stem. + pub fn label(&self) -> String { + if self.name.trim().is_empty() { + humanize( + Path::new(&self.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&self.path), + ) + } else { + self.name.clone() + } + } +} + +/// The repository's issue templates, in GitHub's lookup order: the +/// `.github/ISSUE_TEMPLATE/` directory (minus `config.yml`) if it has any +/// templates, else the first legacy single-file template. +pub fn issue_templates(root: &Path) -> Vec { + let dir = root.join(".github").join("ISSUE_TEMPLATE"); + let from_dir = templates_in_dir(root, &dir, true); + if !from_dir.is_empty() { + return from_dir; + } + first_template_file(root, ISSUE_TEMPLATE_FILES) +} + +/// The repository's pull-request templates: the `.github/PULL_REQUEST_TEMPLATE/` +/// directory if present, else the first single-file template. +pub fn pull_templates(root: &Path) -> Vec { + let dir = root.join(".github").join("PULL_REQUEST_TEMPLATE"); + let from_dir = templates_in_dir(root, &dir, false); + if !from_dir.is_empty() { + return from_dir; + } + first_template_file(root, PULL_TEMPLATE_FILES) +} + +/// Every template file directly inside `dir`, sorted by filename. `issue` skips +/// `config.yml`/`config.yaml` and only accepts `.md`/`.yml`/`.yaml`. +fn templates_in_dir(root: &Path, dir: &Path, issue: bool) -> Vec { + if !dir.is_dir() { + return Vec::new(); + } + let mut paths: Vec = match fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .collect(), + Err(_) => return Vec::new(), + }; + paths.sort_by_key(|a| file_name(a)); + + let mut out = Vec::new(); + for path in paths { + let file = file_name(&path); + let lower = file.to_ascii_lowercase(); + if issue && (lower == "config.yml" || lower == "config.yaml") { + continue; + } + let Some(content) = read_text(&path) else { continue }; + let rel = relative(root, &path); + if lower.ends_with(".md") { + out.push(parse_markdown(&content, &rel, &file)); + } else if issue && (lower.ends_with(".yml") || lower.ends_with(".yaml")) { + out.push(parse_issue_form(&content, &rel, &file)); + } + } + out +} + +/// The first existing template in `candidates`, parsed as Markdown. +fn first_template_file(root: &Path, candidates: &[&str]) -> Vec { + for candidate in candidates { + let path = root.join(candidate); + let Some(content) = read_text(&path) else { continue }; + let rel = relative(root, &path); + let file = file_name(&path); + return vec![parse_markdown(&content, &rel, &file)]; + } + Vec::new() +} + +fn read_text(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_TEMPLATE_BYTES { + return None; + } + let bytes = fs::read(path).ok()?; + String::from_utf8(bytes) + .ok() + .filter(|text| !text.trim().is_empty()) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string() +} + +/// A workspace-relative, forward-slash path (so it reads the same on Windows). +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// Parse a Markdown template, reading `name`/`title`/`labels` from YAML front +/// matter when present and treating the remainder as the body. +fn parse_markdown(content: &str, path: &str, fallback_name: &str) -> RepoTemplate { + let (front, body) = split_front_matter(content); + let mut name = None; + let mut title = None; + let mut labels = Vec::new(); + for line in front.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + match key.trim().to_ascii_lowercase().as_str() { + "name" => { + let value = scalar(value).trim().to_string(); + if !value.is_empty() { + name = Some(value); + } + } + "title" => title = Some(scalar(value)), + "labels" => labels = scalar(value).split(',').map(str::trim).filter_map(list_item).collect(), + _ => {} + } + } + RepoTemplate { + name: name.unwrap_or_else(|| humanize(fallback_name)), + path: path.to_string(), + body: body.trim().to_string(), + title_prefix: title.filter(|value| !value.trim().is_empty()), + labels, + } +} + +/// Split leading `---`-fenced YAML front matter from the body. Without a valid +/// fence the whole document is the body. +fn split_front_matter(content: &str) -> (String, String) { + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + let mut lines = content.lines(); + match lines.next() { + Some(first) if first.trim() == "---" => {} + _ => return (String::new(), content.to_string()), + } + let mut front = String::new(); + let mut body = String::new(); + let mut closed = false; + for line in lines { + if !closed && line.trim() == "---" { + closed = true; + continue; + } + if closed { + body.push_str(line); + body.push('\n'); + } else { + front.push_str(line); + front.push('\n'); + } + } + if !closed { + return (String::new(), content.to_string()); + } + (front, body) +} + +/// Strip a matching pair of surrounding quotes from a YAML scalar, preserving +/// inner whitespace (for titles like `"bug: "`). +fn scalar(value: &str) -> String { + let value = value.trim(); + let bytes = value.as_bytes(); + if value.len() >= 2 { + let first = bytes[0]; + let last = bytes[value.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return value[1..value.len() - 1].to_string(); + } + } + value.to_string() +} + +/// A `labels:` entry, tolerating `[bug, ui]`, `bug, ui`, or `bug`. +fn list_item(value: &str) -> Option { + let value = value.trim().trim_start_matches('[').trim_end_matches(']'); + let value = value.trim().trim_matches(['"', '\'']).trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +// ── YAML issue forms ────────────────────────────────────────────────── + +/// One `body:` entry of a YAML issue form, reduced to what the skeleton needs. +#[derive(Default)] +struct FormField { + kind: String, + label: Option, + description: Option, + placeholder: Option, + options: Vec, + value: Option, + render: Option, +} + +/// Flatten a GitHub issue form (`*.yml`) into a Markdown skeleton. Top-level +/// `name`/`title`/`labels` are read as metadata; each `body:` entry becomes a +/// heading, with descriptions as HTML comments and dropdown/checkbox options +/// rendered as comment lists and checkboxes. +fn parse_issue_form(content: &str, path: &str, fallback_name: &str) -> RepoTemplate { + let lines: Vec<&str> = content.lines().collect(); + let mut name = None; + let mut title = None; + let mut labels = Vec::new(); + let mut fields: Vec = Vec::new(); + let mut current: Option = None; + let mut in_body = false; + let mut i = 0usize; + + while i < lines.len() { + let raw = lines[i]; + let trimmed = raw.trim(); + if trimmed.is_empty() { + i += 1; + continue; + } + + if !in_body { + if trimmed == "body:" || trimmed.starts_with("body:") { + in_body = true; + i += 1; + continue; + } + if !raw.starts_with([' ', '\t']) { + if let Some((key, value)) = raw.split_once(':') { + match key.trim().to_ascii_lowercase().as_str() { + "name" => { + let value = scalar(value).trim().to_string(); + if !value.is_empty() { + name = Some(value); + } + } + "title" => title = Some(scalar(value)), + "labels" => { + labels = scalar(value) + .split(',') + .map(str::trim) + .filter_map(list_item) + .collect() + } + _ => {} + } + } + } + i += 1; + continue; + } + + // A new `body:` list entry. + if let Some(kind) = trimmed.strip_prefix("- type:") { + if let Some(field) = current.take() { + fields.push(field); + } + current = Some(FormField { + kind: kind.trim().to_ascii_lowercase(), + ..FormField::default() + }); + i += 1; + continue; + } + + let indent = raw.len() - raw.trim_start().len(); + let Some(field) = current.as_mut() else { + i += 1; + continue; + }; + if let Some(value) = trimmed.strip_prefix("- label:") { + field.options.push(scalar(value)); + i += 1; + } else if let Some(value) = trimmed.strip_prefix("- ") { + // A plain list item: a `dropdown` option (checkbox options carry a + // `label:` and are handled just above). + field.options.push(scalar(value)); + i += 1; + } else if let Some(value) = trimmed.strip_prefix("label:") { + field.label = Some(scalar(value)); + i += 1; + } else if let Some(value) = trimmed.strip_prefix("description:") { + field.description = Some(scalar(value)); + i += 1; + } else if let Some(value) = trimmed.strip_prefix("render:") { + field.render = Some(scalar(value)); + i += 1; + } else if let Some(value) = trimmed.strip_prefix("placeholder:") { + if is_block(value) { + field.placeholder = Some(consume_block(&lines, &mut i, indent)); + } else { + field.placeholder = Some(scalar(value)); + i += 1; + } + } else if let Some(value) = trimmed.strip_prefix("value:") { + if is_block(value) { + field.value = Some(consume_block(&lines, &mut i, indent)); + } else { + field.value = Some(scalar(value)); + i += 1; + } + } else { + i += 1; + } + } + if let Some(field) = current.take() { + fields.push(field); + } + + RepoTemplate { + name: name.unwrap_or_else(|| humanize(fallback_name)), + path: path.to_string(), + body: render_issue_form(&fields), + title_prefix: title.filter(|value| !value.trim().is_empty()), + labels, + } +} + +/// Whether a YAML value introduces a literal/folded block (`|`, `>-`, …). +fn is_block(value: &str) -> bool { + let value = value.trim(); + value.starts_with('|') || value.starts_with('>') +} + +/// Collect a YAML block scalar's indented lines and dedent them. Leaves `i` on +/// the first line that is not part of the block. +fn consume_block(lines: &[&str], i: &mut usize, key_indent: usize) -> String { + *i += 1; + let start = *i; + let mut end = start; + while end < lines.len() { + let raw = lines[end]; + if raw.trim().is_empty() { + end += 1; + continue; + } + let indent = raw.len() - raw.trim_start().len(); + if indent <= key_indent { + break; + } + end += 1; + } + let block = &lines[start..end]; + let min_indent = block + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.len() - line.trim_start().len()) + .min() + .unwrap_or(0); + let text = block + .iter() + .map(|line| { + if line.len() >= min_indent { + &line[min_indent..] + } else { + line.trim_start() + } + }) + .collect::>() + .join("\n"); + *i = end; + text.trim_end().to_string() +} + +/// Render the parsed form fields into a Markdown skeleton. +fn render_issue_form(fields: &[FormField]) -> String { + let mut out = String::new(); + for field in fields { + match field.kind.as_str() { + "markdown" => { + if let Some(value) = field.value.as_ref().filter(|v| !v.trim().is_empty()) { + out.push_str("\n\n"); + } + } + "checkboxes" => { + if let Some(label) = field.label.as_ref().filter(|l| !l.trim().is_empty()) { + out.push_str("### "); + out.push_str(label.trim()); + out.push_str("\n\n"); + } + for option in &field.options { + out.push_str("- [ ] "); + out.push_str(option); + out.push('\n'); + } + out.push('\n'); + } + _ => { + if let Some(label) = field.label.as_ref() { + out.push_str("### "); + out.push_str(label.trim()); + out.push_str("\n\n"); + } + if let Some(description) = field.description.as_ref() { + out.push_str("\n\n"); + } + if let Some(placeholder) = field.placeholder.as_ref() { + out.push_str(placeholder.trim()); + out.push_str("\n\n"); + } + if field.kind == "dropdown" && !field.options.is_empty() { + out.push_str("\n\n"); + } + if field.render.as_deref() == Some("shell") { + out.push_str("```shell\n\n```\n\n"); + } + } + } + } + out.trim_end().to_string() +} + +/// A filename reduced to a readable label (`bug_report.yml` → `Bug report`). +fn humanize(file_name: &str) -> String { + let stem = file_name + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(file_name); + let stem = if !stem.chars().any(|c| c.is_lowercase()) { + stem.to_ascii_lowercase() + } else { + stem.to_string() + }; + let spaced = stem.replace(['_', '-'], " "); + let mut chars = spaced.trim().chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn scratch(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "orbit-gh-templates-{}-{}", + std::process::id(), + name + )); + fs::remove_dir_all(&root).ok(); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn reads_a_plain_markdown_pull_template() { + let root = scratch("pr"); + fs::create_dir_all(root.join(".github")).unwrap(); + fs::write( + root.join(".github/PULL_REQUEST_TEMPLATE.md"), + "## Summary\n\n\n\n## Checklist\n\n- [ ] tests\n", + ) + .unwrap(); + let templates = pull_templates(&root); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].path, ".github/PULL_REQUEST_TEMPLATE.md"); + assert!(templates[0].body.starts_with("## Summary")); + assert!(templates[0].title_prefix.is_none()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn reads_front_matter_and_body_from_markdown() { + let root = scratch("md"); + fs::create_dir_all(root.join(".github/ISSUE_TEMPLATE")).unwrap(); + fs::write( + root.join(".github/ISSUE_TEMPLATE/bug.md"), + "---\nname: Bug report\nabout: Something broke\ntitle: \"bug: \"\nlabels: bug, needs-triage\n---\n\nDescribe the bug.\n", + ) + .unwrap(); + let templates = issue_templates(&root); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].name, "Bug report"); + assert_eq!(templates[0].title_prefix.as_deref(), Some("bug: ")); + assert_eq!(templates[0].labels, vec!["bug", "needs-triage"]); + assert_eq!(templates[0].body, "Describe the bug."); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn flattens_a_yaml_issue_form_and_skips_config() { + let root = scratch("form"); + fs::create_dir_all(root.join(".github/ISSUE_TEMPLATE")).unwrap(); + fs::write( + root.join(".github/ISSUE_TEMPLATE/config.yml"), + "blank_issues_enabled: true\n", + ) + .unwrap(); + fs::write( + root.join(".github/ISSUE_TEMPLATE/bug_report.yml"), + "name: Bug report\ndescription: Something broke.\ntitle: \"bug: \"\nlabels: [\"bug\"]\nbody:\n\ + \x20 - type: markdown\n attributes:\n value: |\n Thanks for reporting.\n\n\ + \x20 - type: textarea\n id: what\n attributes:\n label: What happened?\n description: A clear description.\n placeholder: |\n 1. Open\n 2. Click\n validations:\n required: true\n\n\ + \x20 - type: dropdown\n id: install\n attributes:\n label: How did you install?\n options:\n - cargo run\n - DMG\n\n\ + \x20 - type: checkboxes\n id: ack\n attributes:\n label: Acknowledgements\n options:\n - label: I searched existing issues\n required: true\n", + ) + .unwrap(); + + let templates = issue_templates(&root); + assert_eq!(templates.len(), 1, "config.yml must be skipped"); + let template = &templates[0]; + assert_eq!(template.name, "Bug report"); + assert_eq!(template.title_prefix.as_deref(), Some("bug: ")); + assert_eq!(template.labels, vec!["bug"]); + assert!(template.body.contains("Thanks for reporting."), "{}", template.body); + assert!(template.body.contains("### What happened?"), "{}", template.body); + assert!(template.body.contains(""), "{}", template.body); + assert!(template.body.contains("1. Open\n2. Click"), "{}", template.body); + assert!(template.body.contains("### How did you install?"), "{}", template.body); + assert!(template.body.contains(""), "{}", template.body); + assert!(template.body.contains("### Acknowledgements"), "{}", template.body); + assert!(template.body.contains("- [ ] I searched existing issues"), "{}", template.body); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn legacy_single_file_is_used_without_a_directory() { + let root = scratch("legacy"); + fs::write(root.join("ISSUE_TEMPLATE.md"), "Report it here.\n").unwrap(); + let templates = issue_templates(&root); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].path, "ISSUE_TEMPLATE.md"); + assert!(templates[0].body.contains("Report it here.")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn missing_templates_are_an_empty_list() { + let root = scratch("none"); + assert!(issue_templates(&root).is_empty()); + assert!(pull_templates(&root).is_empty()); + fs::remove_dir_all(root).ok(); + } + + /// A regression guard against this repository's real templates (two YAML + /// issue forms and a Markdown pull-request template). + #[test] + fn parses_this_repositorys_own_templates() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let issues = issue_templates(&root); + assert!( + issues.iter().any(|template| template.name == "Bug report"), + "bug_report.yml should be discovered: {issues:#?}" + ); + assert!( + issues + .iter() + .any(|template| template.name == "Feature request"), + "feature_request.yml should be discovered" + ); + assert!(!issues.iter().any(|template| template.path.ends_with("config.yml"))); + + let pulls = pull_templates(&root); + assert_eq!(pulls.len(), 1); + assert!(pulls[0].body.contains("## Summary"), "{}", pulls[0].body); + assert!(pulls[0].body.contains("- [ ] Bug fix"), "{}", pulls[0].body); + } +} diff --git a/crates/orbit-pi/src/git_panel/mod.rs b/crates/orbit-pi/src/git_panel/mod.rs index d2b6c98..7299649 100644 --- a/crates/orbit-pi/src/git_panel/mod.rs +++ b/crates/orbit-pi/src/git_panel/mod.rs @@ -31,8 +31,10 @@ use crate::app::{ }; use crate::commit_message; use crate::gh; +use crate::gh_templates::{self, RepoTemplate}; use crate::git::{self, CommitEntry, StatusRow}; use crate::git_ops::{self, InProgress}; +use crate::issue_message::{self, DraftKind}; use crate::theme::tokens::{ button, context_menu, input, picker, popover, ButtonSize, DynamicSpacing, IconSize, Radius, StyledExt, TextSize, @@ -131,6 +133,13 @@ enum BranchPrompt { Rename(String), } +/// Which New form's repository-template picker is open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DraftForm { + Issue, + PullRequest, +} + /// What to do once the user answers the "stage unstaged changes?" prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PendingAfterStage { @@ -253,6 +262,10 @@ pub struct GitPanel { issue_new_open: bool, issue_new_title: Entity, issue_new_body: Entity, + /// Optional free-form notes that steer the generated issue draft. + issue_new_hint: Entity, + /// True while a one-shot pi draft request runs for the issue form. + issue_generating: bool, // ── pull requests (gh) ── pulls: Vec, @@ -272,9 +285,26 @@ pub struct GitPanel { pr_new_open: bool, pr_new_title: Entity, pr_new_body: Entity, + /// Optional free-form notes that steer the generated PR draft. + pr_new_hint: Entity, + /// True while a one-shot pi draft request runs for the PR form. + pr_generating: bool, pr_new_base: Option, pr_new_draft: bool, + // ── repository templates (issue / pull request) ── + /// Issue templates discovered under `.github/ISSUE_TEMPLATE` (or the + /// legacy single-file locations). + issue_templates: Vec, + /// Pull-request templates discovered in the repository. + pr_templates: Vec, + /// The selected issue template (`None` = a blank body). + issue_template: Option, + /// The selected pull-request template (`None` = a blank body). + pr_template: Option, + /// Which New form's template menu is open, if any. + template_menu: Option, + // ── header ── branch: Option, ahead_behind: Option<(usize, usize)>, @@ -362,6 +392,12 @@ impl GitPanel { .with_key_context("Composer Picker") .with_max_lines(8) }); + let issue_new_hint = cx.new(|cx| { + crate::composer::ComposerInput::new(cx) + .with_placeholder_key("git_panel.draft_notes_placeholder") + .with_key_context("Composer Picker") + .with_max_lines(3) + }); let pr_search = cx.new(|cx| { crate::composer::ComposerInput::new(cx) .with_placeholder_key("git_panel.pr_search_placeholder") @@ -388,6 +424,12 @@ impl GitPanel { .with_key_context("Composer Picker") .with_max_lines(10) }); + let pr_new_hint = cx.new(|cx| { + crate::composer::ComposerInput::new(cx) + .with_placeholder_key("git_panel.draft_notes_placeholder") + .with_key_context("Composer Picker") + .with_max_lines(3) + }); let branch_filter = cx.new(|cx| { crate::composer::ComposerInput::new(cx) .with_placeholder_key("git_panel.search_branches") @@ -458,6 +500,8 @@ impl GitPanel { issue_new_open: false, issue_new_title, issue_new_body, + issue_new_hint, + issue_generating: false, pulls: Vec::new(), pulls_loading: false, pulls_error: None, @@ -472,8 +516,15 @@ impl GitPanel { pr_new_open: false, pr_new_title, pr_new_body, + pr_new_hint, + pr_generating: false, pr_new_base: None, pr_new_draft: false, + issue_templates: Vec::new(), + pr_templates: Vec::new(), + issue_template: None, + pr_template: None, + template_menu: None, branch: None, ahead_behind: None, has_commits: false, @@ -567,11 +618,18 @@ impl GitPanel { self.issues.clear(); self.issue_detail = None; self.issue_new_open = false; + self.issue_generating = false; self.issue_filter = gh::IssueFilter::default(); self.pulls.clear(); self.pr_detail = None; self.pr_new_open = false; + self.pr_generating = false; self.pr_filter = gh::PrFilter::default(); + self.issue_templates.clear(); + self.pr_templates.clear(); + self.issue_template = None; + self.pr_template = None; + self.template_menu = None; self.gh_probed = false; if self.open { self.refresh_all(cx); @@ -620,6 +678,7 @@ impl GitPanel { self.refresh_status(cx); self.refresh_branch(cx); self.refresh_gh(cx); + self.refresh_templates(cx); match self.tab { GitTab::History => self.refresh_history(cx), GitTab::Graph => self.refresh_graph(cx), @@ -927,12 +986,17 @@ impl GitPanel { return; } let body = self.issue_new_body.read(cx).text(); + let labels = self + .issue_template + .and_then(|index| self.issue_templates.get(index)) + .map(|template| template.labels.clone()) + .unwrap_or_default(); let Some(cwd) = self.cwd() else { return }; self.issue_busy = true; cx.notify(); self.spawn_data( cx, - move || gh::create_issue(&cwd, &title, &body, &[], &[]), + move || gh::create_issue(&cwd, &title, &body, &labels, &[]), |panel, result, cx| { panel.issue_busy = false; match result { @@ -944,6 +1008,7 @@ impl GitPanel { .issue_new_title .update(cx, |input, cx| input.clear(cx)); panel.issue_new_body.update(cx, |input, cx| input.clear(cx)); + panel.issue_new_hint.update(cx, |input, cx| input.clear(cx)); if let Some(number) = url.rsplit('/').next().and_then(|n| n.parse().ok()) { panel.open_issue(number, cx); } @@ -956,6 +1021,563 @@ impl GitPanel { ); } + /// Load the repository's issue and pull-request templates off-thread. + fn refresh_templates(&mut self, cx: &mut Context) { + let Some(cwd) = self.cwd() else { return }; + self.spawn_data( + cx, + move || { + Ok::<_, String>(( + gh_templates::issue_templates(&cwd), + gh_templates::pull_templates(&cwd), + )) + }, + |panel, result, cx| { + if let Ok((issues, pulls)) = result { + let issues_were_empty = panel.issue_templates.is_empty(); + let pulls_were_empty = panel.pr_templates.is_empty(); + panel.issue_templates = issues; + panel.pr_templates = pulls; + panel.issue_template = panel + .issue_template + .filter(|index| *index < panel.issue_templates.len()); + panel.pr_template = panel + .pr_template + .filter(|index| *index < panel.pr_templates.len()); + + // The templates can land after the form opened (they load + // off-thread). Apply the lone template the first time it + // becomes known, and only onto an untouched body. + let apply_issue = issues_were_empty + && panel.issue_new_open + && panel.issue_template.is_none() + && panel.issue_templates.len() == 1 + && panel.issue_new_body.read(cx).text().trim().is_empty(); + if apply_issue { + panel.apply_issue_template(Some(0), cx); + } + let apply_pull = pulls_were_empty + && panel.pr_new_open + && panel.pr_template.is_none() + && panel.pr_templates.len() == 1 + && panel.pr_new_body.read(cx).text().trim().is_empty(); + if apply_pull { + panel.apply_pull_template(Some(0), cx); + } + } + cx.notify(); + }, + ); + } + + /// The label for the issue form's template chip. + fn issue_template_label(&self) -> String { + match self.issue_template.and_then(|index| self.issue_templates.get(index)) { + Some(template) => template.label(), + None => tr!("git_panel.template_blank"), + } + } + + /// The label for the pull-request form's template chip. + fn pr_template_label(&self) -> String { + match self.pr_template.and_then(|index| self.pr_templates.get(index)) { + Some(template) => template.label(), + None => tr!("git_panel.template_blank"), + } + } + + /// Open the New issue form, applying the lone issue template when the repo + /// has exactly one (a chooser would just add a click). + fn open_issue_new(&mut self, cx: &mut Context) { + self.issue_new_open = true; + self.issue_generating = false; + self.issue_detail = None; + self.template_menu = None; + if self.issue_templates.is_empty() { + self.refresh_templates(cx); + } + let default = (self.issue_templates.len() == 1).then_some(0); + self.apply_issue_template(default, cx); + } + + /// Open the New pull request form, applying the lone PR template when the + /// repo has exactly one. + fn open_pull_new(&mut self, cx: &mut Context) { + self.pr_new_base = Some(self.default_base()); + self.pr_new_open = true; + self.pr_generating = false; + self.pr_detail = None; + self.template_menu = None; + self.ref_menu = None; + if self.pr_templates.is_empty() { + self.refresh_templates(cx); + } + let default = (self.pr_templates.len() == 1).then_some(0); + self.apply_pull_template(default, cx); + } + + /// Pick an issue template (`None` = blank), prefilling the title prefix and + /// the body. A blank pick only clears a body that still holds a template + /// skeleton, so typed prose is never lost. + fn apply_issue_template(&mut self, index: Option, cx: &mut Context) { + self.issue_template = index.filter(|index| *index < self.issue_templates.len()); + self.template_menu = None; + match self + .issue_template + .and_then(|index| self.issue_templates.get(index)) + .cloned() + { + Some(template) => { + self.prefill_title(&self.issue_new_title.clone(), template.title_prefix.as_deref(), cx); + set_composer_text(&self.issue_new_body, &template.body, cx); + } + None => self.clear_template_body(&self.issue_new_body.clone(), cx), + } + cx.notify(); + } + + /// Pick a pull-request template (`None` = blank). + fn apply_pull_template(&mut self, index: Option, cx: &mut Context) { + self.pr_template = index.filter(|index| *index < self.pr_templates.len()); + self.template_menu = None; + match self + .pr_template + .and_then(|index| self.pr_templates.get(index)) + .cloned() + { + Some(template) => { + self.prefill_title(&self.pr_new_title.clone(), template.title_prefix.as_deref(), cx); + set_composer_text(&self.pr_new_body, &template.body, cx); + } + None => self.clear_template_body(&self.pr_new_body.clone(), cx), + } + cx.notify(); + } + + /// Set the title to the template's prefix when the field is still empty. + fn prefill_title( + &self, + input: &Entity, + prefix: Option<&str>, + cx: &mut Context, + ) { + let Some(prefix) = prefix.filter(|value| !value.is_empty()) else { + return; + }; + if input.read(cx).text().trim().is_empty() { + set_composer_text(input, prefix, cx); + } + } + + /// Clear a body field only when it still matches a known template skeleton, + /// so switching back to Blank does not wipe the user's own words. + fn clear_template_body( + &self, + input: &Entity, + cx: &mut Context, + ) { + let current = input.read(cx).text(); + let is_skeleton = self + .issue_templates + .iter() + .chain(self.pr_templates.iter()) + .any(|template| template.body == current); + if is_skeleton { + set_composer_text(input, "", cx); + } + } + + fn set_template(&mut self, target: DraftForm, index: Option, cx: &mut Context) { + match target { + DraftForm::Issue => self.apply_issue_template(index, cx), + DraftForm::PullRequest => self.apply_pull_template(index, cx), + } + } + + /// The repository-template picker for either New form. Rendered inside the + /// form card (which is `relative`), so it tracks the chip without a global + /// anchor. + /// A template chip that owns its popover, so the menu anchors to the chip + /// itself rather than the form card. + fn template_selector( + &self, + theme: Theme, + target: DraftForm, + chip_id: &'static str, + cx: &mut Context, + ) -> AnyElement { + let label = match target { + DraftForm::Issue => self.issue_template_label(), + DraftForm::PullRequest => self.pr_template_label(), + }; + let open = self.template_menu == Some(target); + let popup = if open { + self.template_picker_popup(theme, target, cx) + } else { + None + }; + div() + .relative() + .flex() + .child(dropdown_chip( + chip_id, + &label, + "icons/file.svg", + open, + theme, + cx.listener(move |this, _: &ClickEvent, _, cx| { + this.template_menu = if this.template_menu == Some(target) { + None + } else { + Some(target) + }; + cx.notify(); + }), + )) + .children(popup) + .into_any_element() + } + + /// The repository-template picker, styled like the top branch selector + /// (picker surface, list inset, one picker row per entry) and anchored to + /// the chip that opened it. + fn template_picker_popup( + &self, + theme: Theme, + target: DraftForm, + cx: &mut Context, + ) -> Option { + if self.template_menu != Some(target) { + return None; + } + let (templates, current) = match target { + DraftForm::Issue => (&self.issue_templates, self.issue_template), + DraftForm::PullRequest => (&self.pr_templates, self.pr_template), + }; + let id = match target { + DraftForm::Issue => "git-issue-template-menu", + DraftForm::PullRequest => "git-pr-template-menu", + }; + let scroll_id = match target { + DraftForm::Issue => "git-issue-template-scroll", + DraftForm::PullRequest => "git-pr-template-scroll", + }; + + let mut list = div() + .id(scroll_id) + .w_full() + .max_h(px(300.)) + .overflow_y_scroll() + .py(picker::list_padding_y(&theme)) + .flex() + .flex_col(); + list = list.child( + picker_entry(div().id("git-template-blank"), &theme) + .cursor_pointer() + .text_color(theme.text) + .hover(|s| s.bg(theme.bg_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.set_template(target, None, cx) + })) + .child(icon( + "icons/file.svg", + context_menu::ICON.px(&theme), + theme.text_3, + )) + .child(div().flex_1().min_w_0().child(tr!("git_panel.template_blank"))) + .when(current.is_none(), |row| { + row.child(icon( + "icons/check.svg", + context_menu::ICON.px(&theme), + theme.accent, + )) + }), + ); + if templates.is_empty() { + list = list.child( + picker_entry(div(), &theme) + .text_color(theme.text_3) + .child(tr!("git_panel.template_none")), + ); + } + for (index, template) in templates.iter().enumerate() { + let selected = current == Some(index); + list = list.child( + picker_entry( + div().id(gpui::ElementId::Name(format!("git-template-{index}").into())), + &theme, + ) + .cursor_pointer() + .text_color(theme.text) + .hover(|s| s.bg(theme.bg_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.set_template(target, Some(index), cx) + })) + .child(icon( + "icons/file.svg", + context_menu::ICON.px(&theme), + theme.text_3, + )) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(template.label()) + .child( + div() + .overflow_hidden() + .whitespace_nowrap() + .text_size(TextSize::XSmall.px(&theme)) + .text_color(theme.text_3) + .child(template.path.clone()), + ), + ) + .when(selected, |row| { + row.child(icon( + "icons/check.svg", + context_menu::ICON.px(&theme), + theme.accent, + )) + }), + ); + } + + let menu = picker_surface(div().id(id), &theme) + .absolute() + .left(px(0.)) + .w(px(320.)) + .flex() + .flex_col() + .overflow_hidden() + .occlude() + .on_mouse_down_out(cx.listener(|this, _: &MouseDownEvent, _, cx| { + this.template_menu = None; + cx.notify(); + })) + .child(menu_header(tr!("git_panel.template"), &theme)) + .child(list); + // Anchor to the chip: the issue form's chip sits near the top, so the + // menu opens downward; the PR form's chip sits at the bottom, so it + // opens upward instead of being clipped by the scroll container. + let menu = match target { + DraftForm::Issue => menu.top(px(30.)), + DraftForm::PullRequest => menu.bottom(px(30.)), + }; + // `deferred` keeps the menu's layout in this subtree (so it anchors to + // the chip) but paints it after the form's other fields, which would + // otherwise draw over it. + Some(deferred(menu).into_any_element()) + } + + /// A base-branch chip that owns its popover, mirroring [`Self::template_selector`] + /// so the picker anchors to the chip. + fn base_branch_selector(&self, theme: Theme, cx: &mut Context) -> AnyElement { + let label = self + .pr_new_base + .clone() + .unwrap_or_else(|| tr!("git_panel.choose_base")); + let popup = if self.ref_menu == Some(RefTarget::PrBase) { + self.pr_base_popup(theme, cx) + } else { + None + }; + div() + .relative() + .flex() + .child(dropdown_chip( + "git-pr-new-base", + &label, + "icons/branch.svg", + self.ref_menu == Some(RefTarget::PrBase), + theme, + cx.listener(|this, _: &ClickEvent, window, cx| { + // mouse-down-out closes the picker; the chip's mouse-up would + // otherwise toggle it straight back open on the same gesture + // (see AGENT.md popovers). + const GESTURE: Duration = Duration::from_millis(200); + if let Some(dismissed) = this.menu_dismissed_at.take() { + if dismissed.elapsed() < GESTURE { + return; + } + } + let opening = this.ref_menu != Some(RefTarget::PrBase); + this.ref_menu = if opening { + Some(RefTarget::PrBase) + } else { + None + }; + if opening { + this.clear_branch_filter(cx); + window.focus(&this.branch_filter.read(cx).focus_handle(cx)); + } + cx.notify(); + }), + )) + .children(popup) + .into_any_element() + } + + /// The New-PR base picker, styled and anchored like the top branch + /// selector: a search row over the repository's local branches. + fn pr_base_popup(&self, theme: Theme, cx: &mut Context) -> Option { + if self.ref_menu != Some(RefTarget::PrBase) { + return None; + } + let needle = self.branch_filter.read(cx).text().trim().to_lowercase(); + let matches = |name: &str| needle.is_empty() || name.to_lowercase().contains(&needle); + let current = self.pr_new_base.clone(); + let mut locals: Vec = self + .refs + .iter() + .filter(|entry| entry.kind == git::RefKind::Branch && matches(entry.name.as_str())) + .map(|entry| entry.name.clone()) + .collect(); + locals.sort_by_key(|name| name.to_lowercase()); + + let mut list = div() + .id("git-pr-base-scroll") + .w_full() + .max_h(px(320.)) + .overflow_y_scroll() + .py(picker::list_padding_y(&theme)) + .flex() + .flex_col(); + if locals.is_empty() { + list = list.child( + picker_entry(div(), &theme) + .text_color(theme.text_3) + .child(tr!("git_panel.no_matching_branches")), + ); + } else { + list = list.child(branch_section_label( + &tr!("git_panel.local_branches"), + theme, + )); + for name in locals { + let selected = current.as_deref() == Some(name.as_str()); + let branch = name.clone(); + let id = gpui::ElementId::Name(format!("git-pr-base-{name}").into()); + list = list.child( + picker_entry(div().id(id), &theme) + .cursor_pointer() + .when(selected, |row| row.bg(theme.active).text_color(theme.active_fg)) + .when(!selected, |row| { + row.text_color(theme.text).hover(|s| s.bg(theme.bg_hover)) + }) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.pr_new_base = Some(branch.clone()); + this.close_ref_picker(cx); + })) + .child(icon( + "icons/branch.svg", + context_menu::ICON.px(&theme), + if selected { + theme.active_fg + } else { + theme.text_3 + }, + )) + .child( + div() + .min_w_0() + .flex_1() + .overflow_hidden() + .whitespace_nowrap() + .child(name.clone()), + ) + .when(selected, |row| { + row.child(icon( + "icons/check.svg", + context_menu::ICON.px(&theme), + theme.active_fg, + )) + }), + ); + } + } + + let menu = picker_surface(div().id("git-pr-base-menu"), &theme) + .absolute() + .bottom(px(30.)) + .left(px(0.)) + .w(px(320.)) + .flex() + .flex_col() + .overflow_hidden() + .occlude() + .on_mouse_down_out(cx.listener(|this, _: &MouseDownEvent, _, cx| { + this.close_ref_picker(cx); + })) + .child( + picker_search_frame(div(), &theme) + .child(icon( + "icons/search.svg", + input::ICON.px(&theme), + theme.text_3, + )) + .child(div().flex_1().min_w_0().child(self.branch_filter.clone())), + ) + .child(list); + Some(deferred(menu).into_any_element()) + } + + /// Ask pi for an issue draft (title + body) from the optional notes field + /// plus the repository context, falling back to a local heuristic. + fn generate_issue_draft(&mut self, cx: &mut Context) { + if self.issue_generating || self.issue_busy { + return; + } + let Some(cwd) = self.cwd() else { return }; + let hint = self.issue_new_hint.read(cx).text(); + let template = self + .issue_template + .and_then(|index| self.issue_templates.get(index)) + .cloned(); + let title_prefix = template.as_ref().and_then(|entry| entry.title_prefix.clone()); + let provider = self.provider.clone(); + let model = self.model.clone(); + self.issue_generating = true; + self.set_status(tr!("git_panel.generating_draft")); + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { + let provider = (!provider.is_empty()).then_some(provider.as_str()); + let model = (!model.is_empty()).then_some(model.as_str()); + match issue_message::generate( + &cwd, + provider, + model, + DraftKind::Issue, + &hint, + None, + template.as_ref(), + ) { + Ok(draft) => draft, + Err(_) => issue_message::heuristic( + &cwd, + DraftKind::Issue, + &hint, + None, + template.as_ref(), + ), + } + }) + .await; + let _ = this.update(cx, |panel, cx| { + panel.issue_generating = false; + set_composer_text(&panel.issue_new_title, &with_title_prefix(&title_prefix, &result.title), cx); + set_composer_text(&panel.issue_new_body, &result.body, cx); + panel.set_status(tr!("git_panel.draft_ready")); + cx.notify(); + }); + }) + .detach(); + } + fn set_issue_state(&mut self, state: gh::IssueState, cx: &mut Context) { if self.issue_filter.state == state { return; @@ -1142,12 +1764,17 @@ impl GitPanel { let body = self.pr_new_body.read(cx).text(); let base = self.pr_new_base.clone().unwrap_or_default(); let draft = self.pr_new_draft; + let labels = self + .pr_template + .and_then(|index| self.pr_templates.get(index)) + .map(|template| template.labels.clone()) + .unwrap_or_default(); let Some(cwd) = self.cwd() else { return }; self.pr_busy = true; cx.notify(); self.spawn_data( cx, - move || gh::create_pull(&cwd, &title, &body, &base, draft), + move || gh::create_pull(&cwd, &title, &body, &base, draft, &labels), |panel, result, cx| { panel.pr_busy = false; match result { @@ -1157,6 +1784,7 @@ impl GitPanel { panel.pr_new_open = false; panel.pr_new_title.update(cx, |input, cx| input.clear(cx)); panel.pr_new_body.update(cx, |input, cx| input.clear(cx)); + panel.pr_new_hint.update(cx, |input, cx| input.clear(cx)); if let Some(number) = url.rsplit('/').next().and_then(|n| n.parse().ok()) { panel.open_pull(number, cx); } @@ -1169,6 +1797,62 @@ impl GitPanel { ); } + /// Ask pi for a pull-request draft from the optional notes field plus the + /// branch's commits and diff against the chosen base. + fn generate_pull_draft(&mut self, cx: &mut Context) { + if self.pr_generating || self.pr_busy { + return; + } + let Some(cwd) = self.cwd() else { return }; + let hint = self.pr_new_hint.read(cx).text(); + let base = self.pr_new_base.clone(); + let template = self + .pr_template + .and_then(|index| self.pr_templates.get(index)) + .cloned(); + let title_prefix = template.as_ref().and_then(|entry| entry.title_prefix.clone()); + let provider = self.provider.clone(); + let model = self.model.clone(); + self.pr_generating = true; + self.set_status(tr!("git_panel.generating_draft")); + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { + let provider = (!provider.is_empty()).then_some(provider.as_str()); + let model = (!model.is_empty()).then_some(model.as_str()); + match issue_message::generate( + &cwd, + provider, + model, + DraftKind::PullRequest, + &hint, + base.as_deref(), + template.as_ref(), + ) { + Ok(draft) => draft, + Err(_) => issue_message::heuristic( + &cwd, + DraftKind::PullRequest, + &hint, + base.as_deref(), + template.as_ref(), + ), + } + }) + .await; + let _ = this.update(cx, |panel, cx| { + panel.pr_generating = false; + set_composer_text(&panel.pr_new_title, &with_title_prefix(&title_prefix, &result.title), cx); + set_composer_text(&panel.pr_new_body, &result.body, cx); + panel.set_status(tr!("git_panel.draft_ready")); + cx.notify(); + }); + }) + .detach(); + } + fn set_pr_state(&mut self, state: gh::PrState, cx: &mut Context) { if self.pr_filter.state == state { return; @@ -1566,10 +2250,14 @@ impl GitPanel { self.issue_detail = None; self.issue_detail_loading = false; self.issue_new_open = false; + self.issue_generating = false; self.pr_detail = None; self.pr_detail_loading = false; self.pr_new_open = false; + self.pr_generating = false; self.label_menu_open = false; + self.template_menu = None; + self.ref_menu = None; cx.notify(); } had @@ -4215,16 +4903,19 @@ impl GitPanel { theme, cx.listener(|this, _: &ClickEvent, _, cx| this.apply_issue_search(cx)), )) - .child(filter_chip( - "git-issue-label-filter", - &label_label, - "icons/chevron-down.svg", - theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.label_menu_open = !this.label_menu_open; - cx.notify(); - }), - )) + .child({ + let trigger = filter_chip( + "git-issue-label-filter", + &label_label, + "icons/chevron-down.svg", + theme, + cx.listener(|this, _: &ClickEvent, _, cx| { + this.label_menu_open = !this.label_menu_open; + cx.notify(); + }), + ); + self.label_selector(theme, trigger, true, cx) + }) .children(filter.is_active().then(|| { filter_chip( "git-issue-clear", @@ -4248,11 +4939,7 @@ impl GitPanel { true, self.issue_busy, theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.issue_new_open = true; - this.issue_detail = None; - cx.notify(); - }), + cx.listener(|this, _: &ClickEvent, _, cx| this.open_issue_new(cx)), )) .into_any_element() } @@ -4565,25 +5252,28 @@ impl GitPanel { .flex() .flex_col() .gap(DynamicSpacing::Base08.px(&theme)) - .child(action_button( - "git-issue-labels", - &tr!("git_panel.edit_labels"), - Some( - icon( - "icons/tag-01.svg", - ButtonSize::Medium.icon_size().px(&theme), - theme.text_2, - ) - .into_any_element(), - ), - false, - self.issue_busy, - theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.label_menu_open = !this.label_menu_open; - cx.notify(); - }), - )); + .child({ + let trigger = action_button( + "git-issue-labels", + &tr!("git_panel.edit_labels"), + Some( + icon( + "icons/tag-01.svg", + ButtonSize::Medium.icon_size().px(&theme), + theme.text_2, + ) + .into_any_element(), + ), + false, + self.issue_busy, + theme, + cx.listener(|this, _: &ClickEvent, _, cx| { + this.label_menu_open = !this.label_menu_open; + cx.notify(); + }), + ); + self.label_selector(theme, trigger, false, cx) + }); let timeline = div() .flex() .flex_col() @@ -4650,22 +5340,78 @@ impl GitPanel { } fn issue_new_view(&self, theme: Theme, cx: &mut Context) -> AnyElement { + let generate_leading = if self.issue_generating { + spinner( + "git-issue-generate-spinner", + ButtonSize::Medium.icon_size().px(&theme), + theme.accent, + theme, + ) + } else { + icon( + "icons/spark.svg", + ButtonSize::Medium.icon_size().px(&theme), + theme.accent, + ) + .into_any_element() + }; + let generate_label = if self.issue_generating { + tr!("git_panel.generating_draft") + } else { + tr!("git_panel.generate_draft") + }; let card = div() .flex() .flex_col() .gap(DynamicSpacing::Base12.px(&theme)) .px(DynamicSpacing::Base20.px(&theme)) .py(DynamicSpacing::Base16.px(&theme)) - .max_w(px(780.)) + .max_w(px(820.)) + .relative() .child( div() - .text_size(TextSize::Large.px(&theme)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.text) - .child(tr!("git_panel.new_issue_title")), + .flex() + .items_center() + .justify_between() + .gap(DynamicSpacing::Base12.px(&theme)) + .child( + div() + .text_size(TextSize::Large.px(&theme)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.text) + .child(tr!("git_panel.new_issue_title")), + ) + .child(action_button( + "git-issue-new-generate", + &generate_label, + Some(generate_leading), + false, + self.issue_busy || self.issue_generating, + theme, + cx.listener(|this, _: &ClickEvent, _, cx| this.generate_issue_draft(cx)), + )), ) + .child( + div() + .flex() + .items_center() + .gap(DynamicSpacing::Base08.px(&theme)) + .child( + div() + .text_size(TextSize::Small.px(&theme)) + .text_color(theme.text_3) + .child(tr!("git_panel.template")), + ) + .child(self.template_selector( + theme, + DraftForm::Issue, + "git-issue-template", + cx, + )), + ) + .child(composer_field(theme, self.issue_new_hint.clone(), px(0.))) .child(composer_field(theme, self.issue_new_title.clone(), px(0.))) - .child(composer_field(theme, self.issue_new_body.clone(), px(160.))) + .child(composer_field(theme, self.issue_new_body.clone(), px(220.))) .child( div() .flex() @@ -4695,39 +5441,62 @@ impl GitPanel { )), ); div() - .id("git-issue-new-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child(card) + .id("git-issue-new-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(card) + .into_any_element() + } + + /// The repo-label popover. On an open issue it toggles labels on that + /// issue; otherwise it sets the list's label filter. + /// Wrap a label trigger so its picker anchors to it and paints above the + /// page via `deferred`. `align_right` right-aligns the menu with the + /// trigger — the filter bar's chip sits near the right edge. + fn label_selector( + &self, + theme: Theme, + trigger: AnyElement, + align_right: bool, + cx: &mut Context, + ) -> AnyElement { + let popup = if self.label_menu_open { + self.label_popup(theme, align_right, cx) + } else { + None + }; + div() + .relative() + .flex() + .child(trigger) + .children(popup) .into_any_element() } - /// The repo-label popover. On an open issue it toggles labels on that + /// The repository-label picker. On an open issue it toggles labels on that /// issue; otherwise it sets the list's label filter. - fn label_picker_popup(&self, theme: Theme, cx: &mut Context) -> Option { + fn label_popup( + &self, + theme: Theme, + align_right: bool, + cx: &mut Context, + ) -> Option { if !self.label_menu_open { return None; } let detail = self.issue_detail.clone(); - let mut menu = context_menu_surface(div().id("git-label-menu"), &theme) - .absolute() - .top(px(42.)) - .right(px(12.)) - .w(px(240.)) + let mut list = div() + .id("git-label-scroll") + .w_full() .max_h(px(320.)) .overflow_y_scroll() + .py(picker::list_padding_y(&theme)) .flex() - .flex_col() - .occlude() - .on_mouse_down_out(cx.listener(|this, _: &MouseDownEvent, _, cx| { - this.label_menu_open = false; - cx.notify(); - })) - .child(menu_header(tr!("git_panel.labels"), &theme)); + .flex_col(); if self.issue_labels.is_empty() { - menu = menu.child( - context_menu_entry(div(), &theme) + list = list.child( + picker_entry(div(), &theme) .text_color(theme.text_3) .child(tr!("git_panel.no_labels")), ); @@ -4738,10 +5507,11 @@ impl GitPanel { .as_ref() .is_some_and(|issue| issue.labels.iter().any(|entry| entry.name == name)); let id = gpui::ElementId::Name(format!("git-label-{name}").into()); - menu = menu.child( - context_menu_entry(div().id(id), &theme) + list = list.child( + picker_entry(div().id(id), &theme) .cursor_pointer() - .hover(|s| s.bg(theme.overlay)) + .text_color(theme.text) + .hover(|s| s.bg(theme.bg_hover)) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { if this.issue_detail.is_some() { this.toggle_issue_label(name.clone(), cx); @@ -4760,7 +5530,24 @@ impl GitPanel { }), ); } - Some(menu.into_any_element()) + let menu = picker_surface(div().id("git-label-menu"), &theme) + .w(px(260.)) + .flex() + .flex_col() + .overflow_hidden() + .occlude() + .on_mouse_down_out(cx.listener(|this, _: &MouseDownEvent, _, cx| { + this.label_menu_open = false; + cx.notify(); + })) + .child(menu_header(tr!("git_panel.labels"), &theme)) + .child(list); + let wrapper = if align_right { + div().absolute().right(px(0.)).top(px(30.)) + } else { + div().absolute().left(px(0.)).top(px(30.)) + }; + Some(wrapper.child(deferred(menu)).into_any_element()) } // ── pull request rendering ───────────────────────────────────────── @@ -4857,12 +5644,7 @@ impl GitPanel { true, self.pr_busy, theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.pr_new_base = Some(this.default_base()); - this.pr_new_open = true; - this.pr_detail = None; - cx.notify(); - }), + cx.listener(|this, _: &ClickEvent, _, cx| this.open_pull_new(cx)), )) .into_any_element() } @@ -4949,12 +5731,11 @@ impl GitPanel { "icons/circle-x.svg" }; let mut content = div() + .flex_1() + .min_w(px(520.)) .flex() .flex_col() - .gap(DynamicSpacing::Base12.px(&theme)) - .px(DynamicSpacing::Base20.px(&theme)) - .py(DynamicSpacing::Base16.px(&theme)) - .max_w(px(780.)); + .gap(DynamicSpacing::Base12.px(&theme)); content = content.child( div() .flex() @@ -5476,40 +6257,166 @@ impl GitPanel { }), )), ); + // ── metadata rail ── + let review_body: AnyElement = review_chip(pull, theme).unwrap_or_else(|| { + div() + .text_size(TextSize::Small.px(&theme)) + .text_color(theme.text_3) + .child(tr!("git_panel.review_none")) + .into_any_element() + }); + let checks_body: AnyElement = match check_bucket_chip(pull.checks_summary(), theme) { + Some(chip) => chip, + None => div() + .text_size(TextSize::Small.px(&theme)) + .text_color(theme.text_3) + .child(tr!("git_panel.no_checks")) + .into_any_element(), + }; + let changes = div() + .flex() + .items_center() + .justify_between() + .gap(DynamicSpacing::Base08.px(&theme)) + .child(delta_stats(pull.additions, pull.deletions, theme)) + .child( + div() + .text_size(TextSize::Small.px(&theme)) + .text_color(theme.text_3) + .child(tr!("git_panel.files_count", count = pull.changed_files)), + ) + .into_any_element(); + let branches = div() + .flex() + .flex_col() + .gap(DynamicSpacing::Base06.px(&theme)) + .child(meta_time_row( + &tr!("git_panel.base_branch"), + &pull.base_ref, + theme, + )) + .child(meta_time_row( + &tr!("git_panel.head_branch"), + &pull.head_ref, + theme, + )) + .into_any_element(); + let timeline = div() + .flex() + .flex_col() + .gap(DynamicSpacing::Base06.px(&theme)) + .child(meta_time_row( + &tr!("git_panel.opened"), + &gh::relative_time(&pull.created_at), + theme, + )) + .child(meta_time_row( + &tr!("git_panel.updated"), + &gh::relative_time(&pull.updated_at), + theme, + )) + .into_any_element(); + let rail = div() + .flex_none() + .w(px(240.)) + .flex() + .flex_col() + .gap(DynamicSpacing::Base16.px(&theme)) + .child(meta_section(&tr!("git_panel.review"), review_body, theme)) + .child(meta_section(&tr!("git_panel.checks"), checks_body, theme)) + .child(meta_section(&tr!("git_panel.tab_changes"), changes, theme)) + .child(meta_section(&tr!("git_panel.branches"), branches, theme)) + .child(meta_section(&tr!("git_panel.timeline"), timeline, theme)); + div() .id("git-pr-detail-scroll") .flex_1() .min_h_0() .overflow_y_scroll() - .child(content) + .child( + div() + .w_full() + .flex() + .justify_center() + .px(DynamicSpacing::Base20.px(&theme)) + .py(DynamicSpacing::Base16.px(&theme)) + .child( + // The rail wraps under the reading column when the pane + // is too narrow to hold both (min column + rail + gap). + div() + .w_full() + .max_w(px(1040.)) + .flex() + .flex_wrap() + .items_start() + .gap(DynamicSpacing::Base20.px(&theme)) + .child(content) + .child(rail), + ), + ) .into_any_element() } fn pr_new_view(&self, theme: Theme, cx: &mut Context) -> AnyElement { - let base_label = self - .pr_new_base - .clone() - .unwrap_or_else(|| tr!("git_panel.choose_base")); + let generate_leading = if self.pr_generating { + spinner( + "git-pr-generate-spinner", + ButtonSize::Medium.icon_size().px(&theme), + theme.accent, + theme, + ) + } else { + icon( + "icons/spark.svg", + ButtonSize::Medium.icon_size().px(&theme), + theme.accent, + ) + .into_any_element() + }; + let generate_label = if self.pr_generating { + tr!("git_panel.generating_draft") + } else { + tr!("git_panel.generate_draft") + }; let card = div() .flex() .flex_col() .gap(DynamicSpacing::Base12.px(&theme)) .px(DynamicSpacing::Base20.px(&theme)) .py(DynamicSpacing::Base16.px(&theme)) - .max_w(px(780.)) + .max_w(px(820.)) + .relative() .child( div() - .text_size(TextSize::Large.px(&theme)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.text) - .child(tr!("git_panel.new_pull_title")), + .flex() + .items_center() + .justify_between() + .gap(DynamicSpacing::Base12.px(&theme)) + .child( + div() + .text_size(TextSize::Large.px(&theme)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.text) + .child(tr!("git_panel.new_pull_title")), + ) + .child(action_button( + "git-pr-new-generate", + &generate_label, + Some(generate_leading), + false, + self.pr_busy || self.pr_generating, + theme, + cx.listener(|this, _: &ClickEvent, _, cx| this.generate_pull_draft(cx)), + )), ) + .child(composer_field(theme, self.pr_new_hint.clone(), px(0.))) .child(composer_field(theme, self.pr_new_title.clone(), px(0.))) - .child(composer_field(theme, self.pr_new_body.clone(), px(160.))) + .child(composer_field(theme, self.pr_new_body.clone(), px(220.))) .child( div() .flex() .items_center() + .flex_wrap() .gap(DynamicSpacing::Base08.px(&theme)) .child( div() @@ -5517,17 +6424,7 @@ impl GitPanel { .text_color(theme.text_3) .child(tr!("git_panel.base_branch")), ) - .child(filter_toggle_chip( - "git-pr-new-base", - &base_label, - Some("icons/branch.svg"), - false, - theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.ref_menu = Some(RefTarget::PrBase); - cx.notify(); - }), - )) + .child(self.base_branch_selector(theme, cx)) .child(filter_toggle_chip( "git-pr-new-draft", &tr!("git_panel.pr_draft"), @@ -5538,6 +6435,12 @@ impl GitPanel { this.pr_new_draft = !this.pr_new_draft; cx.notify(); }), + )) + .child(self.template_selector( + theme, + DraftForm::PullRequest, + "git-pr-template", + cx, )), ) .child( @@ -5850,6 +6753,12 @@ impl GitPanel { /// The ref picker opened by "Merge branch…" / "Rebase onto…". fn ref_picker_popup(&self, theme: Theme, cx: &mut Context) -> Option { let target = self.ref_menu?; + // The New-PR base picker is rendered inside the form, anchored to its + // chip (see [`Self::base_branch_selector`]); this page-level popup only + // serves the Merge / Rebase targets launched from the branch bar. + if target == RefTarget::PrBase { + return None; + } let title = match target { RefTarget::Merge => tr!("git_panel.merge_branch"), RefTarget::Rebase => tr!("git_panel.rebase_onto"), @@ -6313,7 +7222,6 @@ impl Render for GitPanel { .children(self.ref_picker_popup(theme, cx)) .children(self.branch_menu(theme, cx)) .children(self.file_actions_menu(theme, cx)) - .children(self.label_picker_popup(theme, cx)) .children(self.stage_prompt_popup(theme, cx)) .children(self.confirm_popup(theme, cx)) .children(self.branch_prompt_popup(theme, window, cx)) @@ -6558,6 +7466,46 @@ fn filter_toggle_chip( .into_any_element() } +/// A chip that opens a picker, styled like the top branch selector: the glyph, +/// the current value, and a chevron, filling with the active color while its +/// menu is open. +fn dropdown_chip( + id: &'static str, + label: &str, + glyph: &'static str, + open: bool, + theme: Theme, + listener: impl Fn(&ClickEvent, &mut Window, &mut gpui::App) + 'static, +) -> AnyElement { + button_frame(div().id(id), &theme, ButtonSize::Medium) + .border_1() + .border_color(theme.border) + .bg(if open { theme.active } else { theme.bg_raised }) + .cursor_pointer() + .hover(|s| s.bg(theme.bg_hover)) + .on_click(listener) + .child(icon( + glyph, + ButtonSize::Medium.icon_size().px(&theme), + if open { theme.active_fg } else { theme.text_2 }, + )) + .child( + div() + .max_w(px(180.)) + .overflow_hidden() + .whitespace_nowrap() + .font_weight(FontWeight::MEDIUM) + .text_color(if open { theme.active_fg } else { theme.text }) + .child(label.to_string()), + ) + .child(icon( + "icons/chevron-down.svg", + IconSize::XSmall.px(&theme), + if open { theme.active_fg } else { theme.text_3 }, + )) + .into_any_element() +} + /// A small placeholder shown inside an expanding commit row while its detail /// loads (or when it could not be loaded). fn detail_note(theme: Theme, label: &str) -> AnyElement { @@ -6578,21 +7526,60 @@ fn detail_note(theme: Theme, label: &str) -> AnyElement { // ── Issues ───────────────────────────────────────────────────────── +/// The label chips for a list row: up to `take` real chips plus a `+N` +/// indicator when more are hidden, so a heavily labeled item never pushes the +/// author and time out of the row. +fn label_summary(labels: &[gh::GhLabel], take: usize, theme: Theme) -> Vec { + let mut chips: Vec = labels + .iter() + .take(take) + .map(|label| issue_label_chip(label, theme)) + .collect(); + if labels.len() > take { + chips.push( + div() + .h(px(18.)) + .px(DynamicSpacing::Base06.px(&theme)) + .rounded(Radius::Small.px(&theme)) + .border_1() + .border_color(theme.border) + .flex() + .items_center() + .text_size(TextSize::XSmall.px(&theme)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.text_3) + .child(format!("+{}", labels.len() - take)) + .into_any_element(), + ); + } + chips +} + +/// A right-aligned, fixed-width relative time so list rows line their times up +/// in a column the eye can scan. +fn row_time(value: &str, theme: Theme) -> AnyElement { + div() + .flex_none() + .w(px(64.)) + .text_right() + .whitespace_nowrap() + .text_size(TextSize::Small.px(&theme)) + .text_color(theme.text_3) + .child(value.to_string()) + .into_any_element() +} + /// One issue row in the list: state glyph, number/title, labels, author, /// comment count, and relative time. fn issue_row(issue: &gh::GhIssue, theme: Theme, cx: &Context) -> AnyElement { let number = issue.number; - let state_color = if issue.is_open() { + let open = issue.is_open(); + let state_color = if open { theme.add_green } else { theme.del_red }; - let state_label = if issue.is_open() { - tr!("git_panel.issue_open") - } else { - tr!("git_panel.issue_closed") - }; - let state_glyph = if issue.is_open() { + let state_glyph = if open { "icons/circle-dot.svg" } else { "icons/circle-check.svg" @@ -6602,7 +7589,7 @@ fn issue_row(issue: &gh::GhIssue, theme: Theme, cx: &Context) -> AnyEl .group("git-row") .mx(DynamicSpacing::Base12.px(&theme)) .px(DynamicSpacing::Base08.px(&theme)) - .py(DynamicSpacing::Base08.px(&theme)) + .py(DynamicSpacing::Base06.px(&theme)) .rounded(Radius::Large.px(&theme)) .flex() .items_center() @@ -6637,7 +7624,8 @@ fn issue_row(issue: &gh::GhIssue, theme: Theme, cx: &Context) -> AnyEl .overflow_hidden() .whitespace_nowrap() .text_size(TextSize::Small.px(&theme)) - .text_color(theme.text) + .font_weight(FontWeight::MEDIUM) + .text_color(if open { theme.text } else { theme.text_2 }) .child(issue.title.clone()), ), ) @@ -6647,13 +7635,7 @@ fn issue_row(issue: &gh::GhIssue, theme: Theme, cx: &Context) -> AnyEl .items_center() .gap(DynamicSpacing::Base06.px(&theme)) .min_w_0() - .children( - issue - .labels - .iter() - .take(4) - .map(|label| issue_label_chip(label, theme)), - ) + .children(label_summary(&issue.labels, 3, theme)) .child(author_avatar(&issue.author.login, "", theme)) .child( div() @@ -6674,21 +7656,10 @@ fn issue_row(issue: &gh::GhIssue, theme: Theme, cx: &Context) -> AnyEl theme.text_3, )) .child(issue.comments.len().to_string()), - ) - .child( - div() - .text_size(TextSize::Small.px(&theme)) - .text_color(theme.text_3) - .child(gh::relative_time(&issue.updated_at)), ), ), ) - .child(state_chip( - &state_label, - state_color, - Some(state_glyph), - theme, - )); + .child(row_time(&gh::relative_time(&issue.updated_at), theme)); if !issue.url.is_empty() { let url = issue.url.clone(); row = row.child( @@ -6776,6 +7747,31 @@ fn state_chip(label: &str, color: Hsla, glyph: Option<&'static str>, theme: Them .into_any_element() } +/// Replace a `ComposerInput`'s whole contents in one edit, so a generated +/// draft overwrites whatever the user had typed. +fn set_composer_text( + input: &Entity, + text: &str, + cx: &mut Context, +) { + let len = input.read(cx).text().len(); + input.update(cx, |field, cx| field.replace_range(0..len, text, cx)); +} + +/// Prepend a template's title prefix (`bug: `) to a generated title, unless the +/// title already carries it. +fn with_title_prefix(prefix: &Option, title: &str) -> String { + let Some(prefix) = prefix.as_deref().filter(|value| !value.trim().is_empty()) else { + return title.to_string(); + }; + let bare = prefix.trim_end(); + if title.starts_with(bare) { + title.to_string() + } else { + format!("{prefix}{title}") + } +} + /// A bordered field wrapper for a `ComposerInput` (issue title/body/comment). /// `min_h` is a multi-line field's taller minimum; below the input frame's /// own minimum it has no effect. @@ -6888,7 +7884,7 @@ fn pr_row(pull: &gh::GhPull, theme: Theme, cx: &Context) -> AnyElement .group("git-row") .mx(DynamicSpacing::Base12.px(&theme)) .px(DynamicSpacing::Base08.px(&theme)) - .py(DynamicSpacing::Base08.px(&theme)) + .py(DynamicSpacing::Base06.px(&theme)) .rounded(Radius::Large.px(&theme)) .flex() .items_center() @@ -6923,7 +7919,12 @@ fn pr_row(pull: &gh::GhPull, theme: Theme, cx: &Context) -> AnyElement .overflow_hidden() .whitespace_nowrap() .text_size(TextSize::Small.px(&theme)) - .text_color(theme.text) + .font_weight(FontWeight::MEDIUM) + .text_color(if pull.is_open() { + theme.text + } else { + theme.text_2 + }) .child(pull.title.clone()), ) .children(pull.is_draft.then(|| { @@ -6941,12 +7942,7 @@ fn pr_row(pull: &gh::GhPull, theme: Theme, cx: &Context) -> AnyElement .items_center() .gap(DynamicSpacing::Base06.px(&theme)) .min_w_0() - .children( - pull.labels - .iter() - .take(3) - .map(|label| issue_label_chip(label, theme)), - ) + .children(label_summary(&pull.labels, 3, theme)) .child(author_avatar(&pull.author.login, "", theme)) .child( div() @@ -6962,15 +7958,10 @@ fn pr_row(pull: &gh::GhPull, theme: Theme, cx: &Context) -> AnyElement ) .children(review_chip(pull, theme)) .children(check_bucket_chip(pull.checks_summary(), theme)) - .child(delta_stats(pull.additions, pull.deletions, theme)) - .child( - div() - .text_size(TextSize::Small.px(&theme)) - .text_color(theme.text_3) - .child(gh::relative_time(&pull.updated_at)), - ), + .child(delta_stats(pull.additions, pull.deletions, theme)), ), - ); + ) + .child(row_time(&gh::relative_time(&pull.updated_at), theme)); if !pull.url.is_empty() { let url = pull.url.clone(); row = row.child( diff --git a/crates/orbit-pi/src/issue_message.rs b/crates/orbit-pi/src/issue_message.rs new file mode 100644 index 0000000..0309743 --- /dev/null +++ b/crates/orbit-pi/src/issue_message.rs @@ -0,0 +1,705 @@ +//! GitHub issue / pull-request draft generation. +//! +//! Like [`crate::commit_message`], the primary path is a **one-shot, tool-free +//! pi call** (`pi -p --no-tools --no-session …`). It processes the prompt and +//! exits, so the working tree never touches the user's active session and no +//! tool can run in the workspace. Extensions stay enabled so a user's custom +//! providers resolve. When pi is missing, has no credentials, or times out, +//! [`heuristic`] derives an editable draft from the branch and its commits. + +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::gh_templates::RepoTemplate; +use crate::git; + +const MAX_CONTEXT_BYTES: usize = 24 * 1024; +const MAX_COMMITS: usize = 30; +const TIMEOUT: Duration = Duration::from_secs(180); + +/// Which draft the caller is asking for. Drives the prompt's framing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DraftKind { + Issue, + PullRequest, +} + +impl DraftKind { + fn noun(self) -> &'static str { + match self { + Self::Issue => "GitHub issue", + Self::PullRequest => "GitHub pull request", + } + } +} + +/// A generated draft: one title line plus a Markdown body. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Draft { + pub title: String, + pub body: String, +} + +impl Draft { + fn is_empty(&self) -> bool { + self.title.trim().is_empty() && self.body.trim().is_empty() + } +} + +/// Everything the prompt knows about the repository, sampled once so the +/// blocking git reads happen together. +#[derive(Default)] +struct RepoContext { + repo: String, + branch: Option, + base: Option, + commits: Vec, + changed: Vec, +} + +/// Generate a draft for `kind`. Blocking; call on the background executor. +/// `hint` is the user's free-form notes (may be empty); `base` is the PR's +/// target branch when known; `template` is the repository template to fill, +/// when one is selected. +pub fn generate( + cwd: &Path, + provider: Option<&str>, + model: Option<&str>, + kind: DraftKind, + hint: &str, + base: Option<&str>, + template: Option<&RepoTemplate>, +) -> Result { + let context = collect(cwd, base); + let system = system_prompt(kind, template.is_some()); + let prompt = user_prompt(kind, hint, &context, template); + let raw = run_pi(cwd, provider, model, &system, &prompt)?; + process_response(&raw).ok_or_else(|| tr!("errors.no_draft")) +} + +/// A no-model fallback: a title from the hint or branch, and a Markdown body. +/// With a repository template, the notes are dropped into its first section so +/// the result still follows the template's shape. +pub fn heuristic( + cwd: &Path, + kind: DraftKind, + hint: &str, + base: Option<&str>, + template: Option<&RepoTemplate>, +) -> Draft { + let context = collect(cwd, base); + let hint = hint.trim(); + let title = heuristic_title(kind, hint, &context); + let body = match template { + Some(template) => fill_first_section(&template.body, &template_seed(hint, &context)), + None => heuristic_body(kind, hint, &context), + }; + Draft { title, body } +} + +/// Sample repository identity, commits, and changed files in one pass. +fn collect(cwd: &Path, base: Option<&str>) -> RepoContext { + let repo = cwd + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let branch = git::current_branch(cwd); + let range = base + .filter(|base| !base.is_empty()) + .map(|base| format!("{base}..HEAD")); + let commits = match &range { + Some(range) => git::run_git( + cwd, + &["log", "--no-decorate", "--format=%s", "-n", "30", range], + ), + None => git::run_git( + cwd, + &["log", "--no-decorate", "--format=%s", "-n", "20"], + ), + } + .map(|out| { + out.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .take(MAX_COMMITS) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + let mut changed = Vec::new(); + let stat_args: Vec> = match &range { + Some(range) => vec![vec!["diff", "--stat", range]], + None => vec![ + vec!["diff", "--stat", "HEAD"], + vec!["diff", "--stat"], + vec!["diff", "--stat", "--cached"], + ], + }; + for args in stat_args { + if let Ok(out) = git::run_git(cwd, &args) { + for line in out.lines().map(str::trim).filter(|line| !line.is_empty()) { + if !changed.iter().any(|seen| seen == line) { + changed.push(line.to_string()); + } + } + } + } + changed.truncate(60); + + RepoContext { + repo, + branch, + base: base.map(str::to_string), + commits, + changed, + } +} + +/// The system rules. Output shape mirrors [`crate::commit_message`]: a single +/// fenced block so the parser is identical in spirit. When a repository +/// template is supplied, a rule is added to fill it faithfully. +fn system_prompt(kind: DraftKind, has_template: bool) -> String { + let mut prompt = format!( + "You are an AI assistant helping a software developer write a {noun}.\n\ + You excel at turning sparse notes and repository context into concise, concrete prose that a maintainer can act on.\n\n\ + # Rules:\n\ + 1. The TITLE is a single line: specific, no trailing period, no `Issue:`/`PR:`/`#` prefix, under ~72 characters.\n\ + 2. The BODY is GitHub-flavored Markdown. Lead with the *why*, then the *what*.\n\ + 3. For a pull request, include a `## Summary` section and a `## Testing` section when the context reveals how it was verified; otherwise omit Testing.\n\ + 4. For an issue, include `## What happened` and `## Expected` sections when they fit the notes.\n\ + 5. Use bullets and short headings. Do not pad. Do not invent facts that the provided context does not support.\n\ + 6. Never mention that the text was generated, and never add meta-commentary.\n\ + 7. Output EXACTLY one fenced ```text block: the title on the first line, a blank line, then the Markdown body. No other prose.\n", + noun = kind.noun() + ); + if has_template { + prompt.push_str( + "8. A REPOSITORY TEMPLATE is provided. Preserve its structure exactly: keep every heading and every `- [ ]` checkbox line as written, and write the content under the matching heading. Leave a checkbox unchecked unless the provided context proves it. Do not add, remove, or rename sections, and drop the template's guidance comments (``).\n", + ); + } + prompt +} + +/// The user message: repository context, the user's notes, the template (when +/// one is selected), and a closing reminder. +fn user_prompt( + kind: DraftKind, + hint: &str, + context: &RepoContext, + template: Option<&RepoTemplate>, +) -> String { + let mut prompt = String::new(); + prompt.push_str("\n"); + prompt.push_str(&format!("Repository name: {}\n", context.repo)); + prompt.push_str(&format!( + "Branch name: {}\n", + context.branch.as_deref().unwrap_or("(detached HEAD)") + )); + if let Some(base) = &context.base { + prompt.push_str(&format!("Target base branch: {base}\n")); + } + prompt.push_str("\n\n"); + + let hint = hint.trim(); + if !hint.is_empty() { + prompt.push_str("\n"); + prompt.push_str(&truncate(hint, MAX_CONTEXT_BYTES)); + prompt.push_str("\n\n\n"); + } else { + prompt.push_str("\n(none — infer the subject from the repository context)\n\n\n"); + } + + if !context.commits.is_empty() { + prompt.push_str("\n# COMMITS ON THIS BRANCH\n"); + for subject in &context.commits { + prompt.push_str(&format!("- {subject}\n")); + } + prompt.push_str("\n\n"); + } + + if !context.changed.is_empty() { + prompt.push_str("\n# CHANGED FILES\n"); + for line in &context.changed { + prompt.push_str(line); + prompt.push('\n'); + } + prompt.push_str("\n\n"); + } + + if let Some(template) = template { + prompt.push_str(&format!( + "\n\n", + template.label(), + truncate(template.body.trim(), MAX_CONTEXT_BYTES) + )); + } + + let reminder_tail = if template.is_some() { + "Fill in the REPOSITORY TEMPLATE above: one title line, a blank line, then the completed Markdown body with the template's headings and checkboxes preserved.\n" + } else { + "Write the draft from the context above: one title line, a blank line, then the Markdown body.\n" + }; + prompt.push_str(&format!( + "\n\ + {reminder_tail}\ + The result is a {noun}.\n\ + ONLY return a single markdown code block, NO OTHER PROSE!\n\ + ```text\n\ + title goes here\n\n\ + body goes here\n\ + ```\n\ + ", + noun = kind.noun() + )); + prompt +} + +/// The fallback title: the user's first note line when present, else the +/// branch name read as words, else the latest commit subject. +fn heuristic_title(kind: DraftKind, hint: &str, context: &RepoContext) -> String { + if let Some(line) = hint.lines().map(str::trim).find(|line| !line.is_empty()) { + return clean_title(line); + } + if let Some(subject) = context.commits.first() { + return clean_title(subject); + } + match &context.branch { + Some(branch) => clean_title(&humanize(branch)), + None => match kind { + DraftKind::Issue => tr!("git_panel.draft_issue_fallback_title"), + DraftKind::PullRequest => tr!("git_panel.draft_pull_fallback_title"), + }, + } +} + +/// The fallback body: a short summary, then the commit subjects and the +/// changed files, so the field is never blank. +fn heuristic_body(kind: DraftKind, hint: &str, context: &RepoContext) -> String { + let mut body = String::new(); + let hint_rest = hint + .lines() + .skip(1) + .map(str::trim) + .collect::>() + .join("\n") + .trim() + .to_string(); + if !hint_rest.is_empty() { + body.push_str(&hint_rest); + body.push_str("\n\n"); + } + let summary_heading = match kind { + DraftKind::Issue => tr!("git_panel.draft_what_happened"), + DraftKind::PullRequest => tr!("git_panel.draft_summary"), + }; + body.push_str(&format!("## {summary_heading}\n\n")); + if !context.commits.is_empty() { + for subject in context.commits.iter().take(10) { + body.push_str(&format!("- {subject}\n")); + } + } else if !hint.trim().is_empty() { + body.push_str(&format!("- {}\n", hint.trim())); + } else { + body.push_str(&format!("- {}\n", tr!("git_panel.draft_placeholder"))); + } + if !context.changed.is_empty() { + let files_heading = tr!("git_panel.draft_changed_files"); + body.push_str(&format!("\n## {files_heading}\n\n")); + for line in context.changed.iter().take(20) { + body.push_str(&format!("- `{line}`\n")); + } + } + body.trim_end().to_string() +} + +/// A branch name read as words (`feat/git-issues-pr-ui` → `git issues pr ui`). +fn humanize(branch: &str) -> String { + branch + .rsplit('/') + .next() + .unwrap_or(branch) + .replace(['-', '_'], " ") + .trim() + .to_string() +} + +/// The text the heuristic drops into a template's first section: the user's +/// notes when present, else the branch's commit subjects. +fn template_seed(hint: &str, context: &RepoContext) -> String { + if !hint.is_empty() { + return hint.to_string(); + } + context + .commits + .iter() + .take(10) + .map(|subject| format!("- {subject}")) + .collect::>() + .join("\n") +} + +/// Insert `text` under a template's first Markdown heading, leaving the rest of +/// the template intact. With no heading, the text is prepended. +fn fill_first_section(template: &str, text: &str) -> String { + if text.trim().is_empty() || template.trim().is_empty() { + return template.trim().to_string(); + } + let lines: Vec<&str> = template.lines().collect(); + let Some(heading) = lines + .iter() + .position(|line| line.trim_start().starts_with('#')) + else { + return format!("{}\n\n{}", text.trim(), template.trim()); + }; + let mut insert_at = heading + 1; + while insert_at < lines.len() && lines[insert_at].trim().is_empty() { + insert_at += 1; + } + let mut out: Vec = lines[..insert_at].iter().map(|line| line.to_string()).collect(); + if out.last().map(|line| !line.trim().is_empty()).unwrap_or(true) { + out.push(String::new()); + } + for line in text.trim().lines() { + out.push(line.to_string()); + } + if lines + .get(insert_at) + .map(|line| !line.trim().is_empty()) + .unwrap_or(false) + { + out.push(String::new()); + } + for line in &lines[insert_at..] { + out.push(line.to_string()); + } + out.join("\n").trim_end().to_string() +} + +/// Strip a list marker, a leading type prefix, and trailing punctuation so a +/// title reads as a title regardless of how it arrived. +fn clean_title(raw: &str) -> String { + let line = raw + .trim() + .trim_start_matches(['-', '*', '•', '#']) + .trim() + .trim_matches(['"', '`', '\'']); + let line = line + .strip_prefix("Title:") + .or_else(|| line.strip_prefix("title:")) + .map(str::trim) + .unwrap_or(line); + let mut title = line.trim_end_matches(['.', ':', ';']).trim().to_string(); + if title.len() > 120 { + let mut end = 120; + while end > 0 && !title.is_char_boundary(end) { + end -= 1; + } + title.truncate(end); + } + title +} + +fn truncate(text: &str, max: usize) -> String { + if text.len() <= max { + return text.to_string(); + } + let mut end = max; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + format!("{}\n… [truncated]", &text[..end]) +} + +fn run_pi( + cwd: &Path, + provider: Option<&str>, + model: Option<&str>, + system: &str, + prompt: &str, +) -> Result { + let bin = orbit_rpc::pi_binary(); + let mut command = Command::new(&bin); + command + .arg("-p") + // Extensions stay enabled so custom providers resolve (see + // `commit_message::run_pi`); every capability that could run code is + // still disabled. + .args([ + "--no-tools", + "--no-session", + "--no-skills", + "--no-context-files", + "--no-approve", + ]) + .arg("--system-prompt") + .arg(system) + .env("PI_SKIP_VERSION_CHECK", "1") + // `pi` is `#!/usr/bin/env node`; a bundled `.app` PATH lacks `node`. + .env("PATH", orbit_rpc::augmented_path(Path::new(&bin).parent())) + .env("NO_COLOR", "1") + .env("CI", "1") + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(provider) = provider.filter(|value| !value.is_empty()) { + command.args(["--provider", provider]); + } + if let Some(model) = model.filter(|value| !value.is_empty()) { + command.args(["--model", model]); + } + command.arg("--").arg(prompt); + orbit_rpc::hide_console(&mut command); + + let mut child = command + .spawn() + .map_err(|err| tr!("errors.failed_to_run", bin = bin, error = err))?; + let stdout = child.stdout.take().ok_or("pi stdout unavailable")?; + let stderr = child.stderr.take().ok_or("pi stderr unavailable")?; + let out_handle = std::thread::spawn(move || read_to_end(stdout)); + let err_handle = std::thread::spawn(move || read_to_end(stderr)); + + let deadline = Instant::now() + TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let out = out_handle.join().unwrap_or_default(); + let err = err_handle.join().unwrap_or_default(); + return if status.success() { + Ok(out) + } else { + Err(tr!( + "errors.pi_exited_with", + status = status.to_string(), + stderr = first_line(&err) + )) + }; + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(tr!("errors.draft_generation_timed_out")); + } + std::thread::sleep(Duration::from_millis(40)); + } + Err(err) => return Err(tr!("errors.pi_process_error", error = err)), + } + } +} + +fn read_to_end(mut reader: impl Read) -> String { + let mut buffer = Vec::new(); + let _ = reader.read_to_end(&mut buffer); + String::from_utf8_lossy(&buffer).into_owned() +} + +fn first_line(text: &str) -> String { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or(&tr!("commit_message.no_error_output")) + .to_string() +} + +/// Extract the title and body from the model output. Tolerates a preamble or +/// trailing prose around the fence, and a missing fence (title-first text). +fn process_response(raw: &str) -> Option { + let trimmed = raw.trim(); + let fenced = extract_fence(trimmed, "```text") + .or_else(|| extract_fence(trimmed, "```markdown")) + .or_else(|| extract_fence(trimmed, "```md")) + .or_else(|| extract_fence(trimmed, "```")); + let text = match fenced { + Some(body) => body, + None => { + let raw = trimmed.trim_matches('"').trim(); + // A lone, empty fence is not a draft; reject it rather than + // reading the marker itself as a title. + if raw.starts_with("```") { + return None; + } + raw.to_string() + } + }; + let mut lines = text.lines(); + let title = lines + .by_ref() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(clean_title) + .unwrap_or_default(); + let body = lines.collect::>().join("\n").trim().to_string(); + let draft = Draft { title, body }; + (!draft.is_empty()).then_some(draft) +} + +/// The body of the first fence opened with `marker` at the start of a line. +fn extract_fence(text: &str, marker: &str) -> Option { + let start = text.find(marker)?; + if start != 0 && !text[..start].ends_with('\n') { + return None; + } + let rest = &text[start + marker.len()..]; + let rest = rest + .strip_prefix("\r\n") + .or_else(|| rest.strip_prefix('\n'))?; + let end = rest.find("\n```")?; + let body = rest[..end].trim(); + (!body.is_empty()).then(|| body.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_title_and_body_from_a_text_fence() { + let draft = process_response("```text\nfeat: add issue generator\n\n## Summary\n\n- drafts a title\n```") + .unwrap(); + assert_eq!(draft.title, "feat: add issue generator"); + assert!(draft.body.contains("## Summary")); + assert!(draft.body.contains("- drafts a title")); + } + + #[test] + fn tolerates_preamble_and_trailing_prose() { + let raw = "Here you go:\n```text\nFix the flaky login test\n\n## What happened\n\nIt fails 1 in 10 runs.\n```\nLet me know!"; + let draft = process_response(raw).unwrap(); + assert_eq!(draft.title, "Fix the flaky login test"); + assert!(draft.body.contains("## What happened")); + } + + #[test] + fn accepts_markdown_fence_and_title_first_prose() { + let fenced = process_response("```markdown\nAdd retry to the uploader\n\n## Summary\n\nRetries twice.\n```") + .unwrap(); + assert_eq!(fenced.title, "Add retry to the uploader"); + + let prose = process_response("Add retry to the uploader\n\n## Summary\n\nRetries twice.").unwrap(); + assert_eq!(prose.title, "Add retry to the uploader"); + assert!(prose.body.contains("## Summary")); + } + + #[test] + fn clean_title_strips_prefixes_and_punctuation() { + assert_eq!(clean_title("- Title: fix the thing."), "fix the thing"); + assert_eq!(clean_title("`Add the widget`"), "Add the widget"); + assert_eq!(clean_title("## feat: do stuff:"), "feat: do stuff"); + } + + #[test] + fn process_response_rejects_blank_output() { + assert!(process_response(" \n ").is_none()); + assert!(process_response("```text\n```").is_none()); + } + + #[test] + fn heuristic_uses_the_hint_first_line_as_the_title() { + let context = RepoContext { + repo: "orbit".into(), + branch: Some("feat/git-issues-pr-ui".into()), + base: Some("main".into()), + commits: vec!["feat(git): tidy the issue list".into()], + changed: vec!["git_panel/mod.rs | 20 +++++".into()], + }; + assert_eq!( + heuristic_title(DraftKind::PullRequest, "Improve the pages\n\nMore.", &context), + "Improve the pages" + ); + let body = heuristic_body(DraftKind::PullRequest, "Improve the pages\n\nMore.", &context); + assert!(body.contains("More.")); + assert!(body.contains("## Summary")); + assert!(body.contains("- feat(git): tidy the issue list")); + } + + #[test] + fn heuristic_falls_back_to_the_branch_or_latest_commit() { + let context = RepoContext { + repo: "orbit".into(), + branch: Some("feat/git-issues-pr-ui".into()), + base: None, + commits: vec!["feat(git): tidy the issue list".into()], + changed: Vec::new(), + }; + assert_eq!( + heuristic_title(DraftKind::Issue, "", &context), + "feat(git): tidy the issue list" + ); + let no_commits = RepoContext { + branch: Some("fix/flaky-login".into()), + ..RepoContext::default() + }; + assert_eq!( + heuristic_title(DraftKind::Issue, "", &no_commits), + "flaky login" + ); + } + + #[test] + fn prompt_carries_context_and_reminder() { + let context = RepoContext { + repo: "orbit".into(), + branch: Some("feat/x".into()), + base: Some("main".into()), + commits: vec!["feat: a".into()], + changed: vec!["src/lib.rs | 2 +-".into()], + }; + let prompt = user_prompt(DraftKind::PullRequest, "notes here", &context, None); + assert!(prompt.contains("Repository name: orbit")); + assert!(prompt.contains("Target base branch: main")); + assert!(prompt.contains("")); + assert!(prompt.contains("notes here")); + assert!(prompt.contains("feat: a")); + assert!(prompt.contains("src/lib.rs | 2 +-")); + assert!(prompt.contains("ONLY return a single markdown code block")); + + let system = system_prompt(DraftKind::Issue, false); + assert!(system.contains("GitHub issue")); + assert!(system.contains("EXACTLY one fenced")); + assert!(!system.contains("REPOSITORY TEMPLATE")); + } + + #[test] + fn template_is_embedded_and_filled_faithfully() { + let template = RepoTemplate { + name: "Bug report".into(), + path: ".github/ISSUE_TEMPLATE/bug_report.yml".into(), + body: "### What happened?\n\n\n\n### Steps\n\n- [ ] searched\n".into(), + title_prefix: Some("bug: ".into()), + labels: vec!["bug".into()], + }; + let context = RepoContext { + repo: "orbit".into(), + commits: vec!["fix: guard input".into()], + ..RepoContext::default() + }; + let prompt = user_prompt(DraftKind::Issue, "the button is broken", &context, Some(&template)); + assert!(prompt.contains("