From 7a03ee6d20670a28a6aaf15891b6c9c46ff9aa96 Mon Sep 17 00:00:00 2001 From: rajeshwar-hyphun Date: Mon, 28 Sep 2026 17:13:30 +0530 Subject: [PATCH 1/8] feat(git): AI-draft issues and PRs, and refine their lists and details Add a Generate action to the Git page's New issue and New pull request forms. An optional Notes field plus the branch, its commits (against the chosen base for a PR), and its changed-file stat feed a one-shot, tool-free `pi -p` call (new `issue_message.rs`), falling back to a local heuristic when pi is missing, unauthenticated, or times out. The draft fills the editable title and Markdown body; Create stays a separate deliberate press. Tighten the Issues and Pull requests lists for scanning: labels collapse to a `+N` chip, every row ends in a fixed relative-time column, closed titles de-emphasize, and the PR row drops its duplicated state pill. Give the pull-request detail the issue detail's reading-column + metadata-rail layout, with a rail summarizing review, checks, changes, branches, and the timeline. Update INTENT.md implementation status, README features/roadmap, docs/git-page.md, and the CHANGELOG [Unreleased] entry; register the new strings in en.yml and the generated locales. --- CHANGELOG.md | 7 + INTENT.md | 9 + README.md | 3 +- crates/orbit-pi/docs/git-page.md | 25 +- crates/orbit-pi/locales/de.yml | 18 + crates/orbit-pi/locales/en.yml | 18 + crates/orbit-pi/locales/es.yml | 18 + crates/orbit-pi/locales/fr.yml | 18 + crates/orbit-pi/locales/it.yml | 18 + crates/orbit-pi/locales/ja.yml | 18 + crates/orbit-pi/locales/ko.yml | 18 + crates/orbit-pi/locales/pt-BR.yml | 18 + crates/orbit-pi/locales/ru.yml | 18 + crates/orbit-pi/locales/zh-CN.yml | 18 + crates/orbit-pi/src/git_panel/mod.rs | 441 +++++++++++++++++--- crates/orbit-pi/src/issue_message.rs | 582 +++++++++++++++++++++++++++ crates/orbit-pi/src/main.rs | 1 + 17 files changed, 1184 insertions(+), 64 deletions(-) create mode 100644 crates/orbit-pi/src/issue_message.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d58f1e..13945e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 / diff --git a/INTENT.md b/INTENT.md index 26cd53e..e36de8c 100644 --- a/INTENT.md +++ b/INTENT.md @@ -252,6 +252,15 @@ 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. 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..8f7520f 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, and optional notes **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..f06228c 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,27 @@ 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. + +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..e3a8096 100644 --- a/crates/orbit-pi/locales/de.yml +++ b/crates/orbit-pi/locales/de.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..3d27cdc 100644 --- a/crates/orbit-pi/locales/en.yml +++ b/crates/orbit-pi/locales/en.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..54025a3 100644 --- a/crates/orbit-pi/locales/es.yml +++ b/crates/orbit-pi/locales/es.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..30f9094 100644 --- a/crates/orbit-pi/locales/fr.yml +++ b/crates/orbit-pi/locales/fr.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..1ddbdf8 100644 --- a/crates/orbit-pi/locales/it.yml +++ b/crates/orbit-pi/locales/it.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..8fa8050 100644 --- a/crates/orbit-pi/locales/ja.yml +++ b/crates/orbit-pi/locales/ja.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..2db0d99 100644 --- a/crates/orbit-pi/locales/ko.yml +++ b/crates/orbit-pi/locales/ko.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..9cfdfc8 100644 --- a/crates/orbit-pi/locales/pt-BR.yml +++ b/crates/orbit-pi/locales/pt-BR.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..156f65b 100644 --- a/crates/orbit-pi/locales/ru.yml +++ b/crates/orbit-pi/locales/ru.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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..d623372 100644 --- a/crates/orbit-pi/locales/zh-CN.yml +++ b/crates/orbit-pi/locales/zh-CN.yml @@ -661,6 +661,17 @@ 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.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 +709,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 +1219,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/git_panel/mod.rs b/crates/orbit-pi/src/git_panel/mod.rs index d2b6c98..a48b858 100644 --- a/crates/orbit-pi/src/git_panel/mod.rs +++ b/crates/orbit-pi/src/git_panel/mod.rs @@ -33,6 +33,7 @@ use crate::commit_message; use crate::gh; 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, @@ -253,6 +254,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,6 +277,10 @@ 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, @@ -362,6 +371,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 +403,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 +479,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,6 +495,8 @@ 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, branch: None, @@ -567,10 +592,12 @@ 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.gh_probed = false; if self.open { @@ -944,6 +971,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 +984,49 @@ impl GitPanel { ); } + /// 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 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, + ) { + Ok(draft) => draft, + Err(_) => issue_message::heuristic(&cwd, DraftKind::Issue, &hint, None), + } + }) + .await; + let _ = this.update(cx, |panel, cx| { + panel.issue_generating = false; + set_composer_text(&panel.issue_new_title, &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; @@ -1157,6 +1228,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 +1241,55 @@ 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 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(), + ) { + Ok(draft) => draft, + Err(_) => issue_message::heuristic( + &cwd, + DraftKind::PullRequest, + &hint, + base.as_deref(), + ), + } + }) + .await; + let _ = this.update(cx, |panel, cx| { + panel.pr_generating = false; + set_composer_text(&panel.pr_new_title, &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,9 +1687,11 @@ 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; cx.notify(); } @@ -4250,6 +4373,7 @@ impl GitPanel { theme, cx.listener(|this, _: &ClickEvent, _, cx| { this.issue_new_open = true; + this.issue_generating = false; this.issue_detail = None; cx.notify(); }), @@ -4650,22 +4774,59 @@ 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.)) .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(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() @@ -4860,6 +5021,7 @@ impl GitPanel { cx.listener(|this, _: &ClickEvent, _, cx| { this.pr_new_base = Some(this.default_base()); this.pr_new_open = true; + this.pr_generating = false; this.pr_detail = None; cx.notify(); }), @@ -4949,12 +5111,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,12 +5637,103 @@ 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() } @@ -5490,26 +5742,64 @@ impl GitPanel { .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.)) .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() @@ -6578,21 +6868,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 +6931,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 +6966,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 +6977,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 +6998,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 +7089,17 @@ 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)); +} + /// 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 +7212,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 +7247,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 +7270,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 +7286,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..4c53fd3 --- /dev/null +++ b/crates/orbit-pi/src/issue_message.rs @@ -0,0 +1,582 @@ +//! 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::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", + } + } + + fn heading(self) -> &'static str { + match self { + Self::Issue => "issue", + Self::PullRequest => "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. +pub fn generate( + cwd: &Path, + provider: Option<&str>, + model: Option<&str>, + kind: DraftKind, + hint: &str, + base: Option<&str>, +) -> Result { + let context = collect(cwd, base); + let system = system_prompt(kind); + let prompt = user_prompt(kind, hint, &context); + 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 +/// built from the branch's commits and changed files. Always editable, never +/// empty when there is anything to say. +pub fn heuristic(cwd: &Path, kind: DraftKind, hint: &str, base: Option<&str>) -> Draft { + let context = collect(cwd, base); + let hint = hint.trim(); + let title = heuristic_title(kind, hint, &context); + let body = 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. +fn system_prompt(kind: DraftKind) -> String { + 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() + ) +} + +/// The user message: repository context, the user's notes, and a closing +/// reminder. `hint` is optional and only shapes the draft when present. +fn user_prompt(kind: DraftKind, hint: &str, context: &RepoContext) -> 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"); + } + + prompt.push_str(&format!( + "\n\ + Write a {heading} draft from the context above.\n\ + Title first, then a blank line, then the Markdown body.\n\ + ONLY return a single markdown code block, NO OTHER PROSE!\n\ + ```text\n\ + title goes here\n\n\ + body goes here\n\ + ```\n\ + ", + heading = kind.heading() + )); + 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() +} + +/// 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); + 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); + assert!(system.contains("GitHub issue")); + assert!(system.contains("EXACTLY one fenced")); + } +} diff --git a/crates/orbit-pi/src/main.rs b/crates/orbit-pi/src/main.rs index 1cfba0d..b11af02 100644 --- a/crates/orbit-pi/src/main.rs +++ b/crates/orbit-pi/src/main.rs @@ -64,6 +64,7 @@ mod git_panel; mod highlight; mod http; mod i18n; +mod issue_message; mod layout; mod mentions; mod message_scroller; From 7c11f75d50b128431d433db4a3a3cc3865d636ad Mon Sep 17 00:00:00 2001 From: rajeshwar-hyphun Date: Mon, 28 Sep 2026 17:30:42 +0530 Subject: [PATCH 2/8] feat(git): generate issue/PR drafts against the repository's templates Discover the repository's own templates and use them in the Git page's New issue / New pull request forms. `gh_templates.rs` mirrors GitHub's lookup: `.github/ISSUE_TEMPLATE/` (Markdown with YAML front matter, and YAML issue forms, `config.yml` skipped) plus the legacy single-file locations, and `.github/PULL_REQUEST_TEMPLATE.md` plus its variants. YAML issue forms are flattened into a Markdown skeleton (field labels as headings, descriptions as guidance comments, dropdown/checkbox options as comments and checkboxes). A form with exactly one template applies it automatically; with several, a new Template chip opens a picker including Blank. Selecting a template prefills the title prefix (`bug: `) and the body, and its `labels:` are passed to `gh` on Create. The Generate prompt now hands the model the selected template and instructs it to fill every heading faithfully while preserving checkboxes and sections; the offline heuristic drops the notes (or branch commit subjects) into the template's first section instead. Update docs/git-page.md, INTENT.md, README.md, and the CHANGELOG [Unreleased] entry; add the three new strings to en.yml and the generated locales. Tests cover Markdown/front-matter/issue-form parsing, the legacy lookup, and this repository's own templates. --- CHANGELOG.md | 7 + INTENT.md | 13 +- README.md | 2 +- crates/orbit-pi/docs/git-page.md | 18 + crates/orbit-pi/locales/de.yml | 3 + crates/orbit-pi/locales/en.yml | 3 + crates/orbit-pi/locales/es.yml | 3 + crates/orbit-pi/locales/fr.yml | 3 + crates/orbit-pi/locales/it.yml | 3 + crates/orbit-pi/locales/ja.yml | 3 + crates/orbit-pi/locales/ko.yml | 3 + crates/orbit-pi/locales/pt-BR.yml | 3 + crates/orbit-pi/locales/ru.yml | 3 + crates/orbit-pi/locales/zh-CN.yml | 3 + crates/orbit-pi/src/gh.rs | 5 + crates/orbit-pi/src/gh_templates.rs | 630 +++++++++++++++++++++++++++ crates/orbit-pi/src/git_panel/mod.rs | 411 ++++++++++++++++- crates/orbit-pi/src/issue_message.rs | 177 ++++++-- crates/orbit-pi/src/main.rs | 1 + 19 files changed, 1242 insertions(+), 52 deletions(-) create mode 100644 crates/orbit-pi/src/gh_templates.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 13945e7..18a3e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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, diff --git a/INTENT.md b/INTENT.md index e36de8c..58605f9 100644 --- a/INTENT.md +++ b/INTENT.md @@ -256,10 +256,15 @@ The Git page's **Issues** and **Pull requests** tabs gained a **Generate** actio **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. 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). +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. diff --git a/README.md b/README.md index 8f7520f..03873a6 100644 --- a/README.md +++ b/README.md @@ -60,7 +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, and optional notes +- 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** diff --git a/crates/orbit-pi/docs/git-page.md b/crates/orbit-pi/docs/git-page.md index f06228c..3b100e3 100644 --- a/crates/orbit-pi/docs/git-page.md +++ b/crates/orbit-pi/docs/git-page.md @@ -37,6 +37,24 @@ commits (against the chosen base for a PR), and its changed-file stat — never 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 diff --git a/crates/orbit-pi/locales/de.yml b/crates/orbit-pi/locales/de.yml index e3a8096..d026445 100644 --- a/crates/orbit-pi/locales/de.yml +++ b/crates/orbit-pi/locales/de.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/en.yml b/crates/orbit-pi/locales/en.yml index 3d27cdc..615e034 100644 --- a/crates/orbit-pi/locales/en.yml +++ b/crates/orbit-pi/locales/en.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/es.yml b/crates/orbit-pi/locales/es.yml index 54025a3..afb074a 100644 --- a/crates/orbit-pi/locales/es.yml +++ b/crates/orbit-pi/locales/es.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/fr.yml b/crates/orbit-pi/locales/fr.yml index 30f9094..268f017 100644 --- a/crates/orbit-pi/locales/fr.yml +++ b/crates/orbit-pi/locales/fr.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/it.yml b/crates/orbit-pi/locales/it.yml index 1ddbdf8..e0301e5 100644 --- a/crates/orbit-pi/locales/it.yml +++ b/crates/orbit-pi/locales/it.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/ja.yml b/crates/orbit-pi/locales/ja.yml index 8fa8050..cedcc1c 100644 --- a/crates/orbit-pi/locales/ja.yml +++ b/crates/orbit-pi/locales/ja.yml @@ -662,6 +662,9 @@ 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: "生成中…" diff --git a/crates/orbit-pi/locales/ko.yml b/crates/orbit-pi/locales/ko.yml index 2db0d99..07c86ab 100644 --- a/crates/orbit-pi/locales/ko.yml +++ b/crates/orbit-pi/locales/ko.yml @@ -662,6 +662,9 @@ 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: "생성 중…" diff --git a/crates/orbit-pi/locales/pt-BR.yml b/crates/orbit-pi/locales/pt-BR.yml index 9cfdfc8..69b0d68 100644 --- a/crates/orbit-pi/locales/pt-BR.yml +++ b/crates/orbit-pi/locales/pt-BR.yml @@ -662,6 +662,9 @@ 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…" diff --git a/crates/orbit-pi/locales/ru.yml b/crates/orbit-pi/locales/ru.yml index 156f65b..621bf29 100644 --- a/crates/orbit-pi/locales/ru.yml +++ b/crates/orbit-pi/locales/ru.yml @@ -662,6 +662,9 @@ 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: "Генерация…" diff --git a/crates/orbit-pi/locales/zh-CN.yml b/crates/orbit-pi/locales/zh-CN.yml index d623372..6407531 100644 --- a/crates/orbit-pi/locales/zh-CN.yml +++ b/crates/orbit-pi/locales/zh-CN.yml @@ -662,6 +662,9 @@ 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: "正在生成…" 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 a48b858..3906aed 100644 --- a/crates/orbit-pi/src/git_panel/mod.rs +++ b/crates/orbit-pi/src/git_panel/mod.rs @@ -31,6 +31,7 @@ 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}; @@ -132,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 { @@ -284,6 +292,19 @@ pub struct GitPanel { 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)>, @@ -499,6 +520,11 @@ impl GitPanel { 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, @@ -599,6 +625,11 @@ impl GitPanel { 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); @@ -647,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), @@ -954,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 { @@ -984,6 +1021,276 @@ 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; + 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. + 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 mut menu = context_menu_surface(div().id(id), &theme) + .absolute() + .top(px(46.)) + .left(px(20.)) + .w(px(300.)) + .max_h(px(320.)) + .overflow_y_scroll() + .flex() + .flex_col() + .occlude() + .on_mouse_down_out(cx.listener(|this, _: &MouseDownEvent, _, cx| { + this.template_menu = None; + cx.notify(); + })) + .child(menu_header(tr!("git_panel.template"), &theme)); + + menu = menu.child( + context_menu_entry(div().id("git-template-blank"), &theme) + .cursor_pointer() + .hover(|s| s.bg(theme.overlay)) + .on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| this.set_template(target, None, cx)), + ) + .child(tr!("git_panel.template_blank")) + .child(div().flex_1()) + .when(current.is_none(), |row| { + row.child(icon( + "icons/check.svg", + context_menu::ICON.px(&theme), + theme.accent, + )) + }), + ); + if templates.is_empty() { + menu = menu.child( + context_menu_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); + menu = menu.child( + context_menu_entry( + div().id(gpui::ElementId::Name(format!("git-template-{index}").into())), + &theme, + ) + .cursor_pointer() + .hover(|s| s.bg(theme.overlay)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.set_template(target, Some(index), cx) + })) + .child( + div() + .flex() + .flex_col() + .gap(DynamicSpacing::Base03.px(&theme)) + .child(template.label()) + .child( + div() + .text_size(TextSize::XSmall.px(&theme)) + .text_color(theme.text_3) + .child(template.path.clone()), + ), + ) + .child(div().flex_1()) + .when(selected, |row| { + row.child(icon( + "icons/check.svg", + context_menu::ICON.px(&theme), + theme.accent, + )) + }), + ); + } + Some(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) { @@ -992,6 +1299,11 @@ impl GitPanel { } 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; @@ -1010,15 +1322,22 @@ impl GitPanel { DraftKind::Issue, &hint, None, + template.as_ref(), ) { Ok(draft) => draft, - Err(_) => issue_message::heuristic(&cwd, DraftKind::Issue, &hint, None), + 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, &result.title, cx); + 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(); @@ -1213,12 +1532,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 { @@ -1250,6 +1574,11 @@ impl GitPanel { 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; @@ -1268,6 +1597,7 @@ impl GitPanel { DraftKind::PullRequest, &hint, base.as_deref(), + template.as_ref(), ) { Ok(draft) => draft, Err(_) => issue_message::heuristic( @@ -1275,13 +1605,14 @@ impl GitPanel { 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, &result.title, cx); + 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(); @@ -1693,6 +2024,7 @@ impl GitPanel { self.pr_new_open = false; self.pr_generating = false; self.label_menu_open = false; + self.template_menu = None; cx.notify(); } had @@ -4371,12 +4703,7 @@ impl GitPanel { true, self.issue_busy, theme, - cx.listener(|this, _: &ClickEvent, _, cx| { - this.issue_new_open = true; - this.issue_generating = false; - this.issue_detail = None; - cx.notify(); - }), + cx.listener(|this, _: &ClickEvent, _, cx| this.open_issue_new(cx)), )) .into_any_element() } @@ -4801,6 +5128,7 @@ impl GitPanel { .px(DynamicSpacing::Base20.px(&theme)) .py(DynamicSpacing::Base16.px(&theme)) .max_w(px(820.)) + .relative() .child( div() .flex() @@ -4824,6 +5152,29 @@ impl GitPanel { 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(filter_toggle_chip( + "git-issue-template", + &self.issue_template_label(), + Some("icons/file.svg"), + self.issue_template.is_some(), + theme, + cx.listener(|this, _: &ClickEvent, _, cx| { + this.template_menu = Some(DraftForm::Issue); + cx.notify(); + }), + )), + ) .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(220.))) @@ -4860,7 +5211,7 @@ impl GitPanel { .flex_1() .min_h_0() .overflow_y_scroll() - .child(card) + .child(card.children(self.template_picker_popup(theme, DraftForm::Issue, cx))) .into_any_element() } @@ -5018,13 +5369,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_generating = false; - this.pr_detail = None; - cx.notify(); - }), + cx.listener(|this, _: &ClickEvent, _, cx| this.open_pull_new(cx)), )) .into_any_element() } @@ -5769,6 +6114,7 @@ impl GitPanel { .px(DynamicSpacing::Base20.px(&theme)) .py(DynamicSpacing::Base16.px(&theme)) .max_w(px(820.)) + .relative() .child( div() .flex() @@ -5828,6 +6174,17 @@ impl GitPanel { this.pr_new_draft = !this.pr_new_draft; cx.notify(); }), + )) + .child(filter_toggle_chip( + "git-pr-template", + &self.pr_template_label(), + Some("icons/file.svg"), + self.pr_template.is_some(), + theme, + cx.listener(|this, _: &ClickEvent, _, cx| { + this.template_menu = Some(DraftForm::PullRequest); + cx.notify(); + }), )), ) .child( @@ -5863,7 +6220,7 @@ impl GitPanel { .flex_1() .min_h_0() .overflow_y_scroll() - .child(card) + .child(card.children(self.template_picker_popup(theme, DraftForm::PullRequest, cx))) .into_any_element() } @@ -7100,6 +7457,20 @@ fn set_composer_text( 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. diff --git a/crates/orbit-pi/src/issue_message.rs b/crates/orbit-pi/src/issue_message.rs index 4c53fd3..0309743 100644 --- a/crates/orbit-pi/src/issue_message.rs +++ b/crates/orbit-pi/src/issue_message.rs @@ -12,6 +12,7 @@ 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; @@ -32,13 +33,6 @@ impl DraftKind { Self::PullRequest => "GitHub pull request", } } - - fn heading(self) -> &'static str { - match self { - Self::Issue => "issue", - Self::PullRequest => "pull request", - } - } } /// A generated draft: one title line plus a Markdown body. @@ -67,7 +61,8 @@ struct RepoContext { /// 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. +/// target branch when known; `template` is the repository template to fill, +/// when one is selected. pub fn generate( cwd: &Path, provider: Option<&str>, @@ -75,22 +70,32 @@ pub fn generate( kind: DraftKind, hint: &str, base: Option<&str>, + template: Option<&RepoTemplate>, ) -> Result { let context = collect(cwd, base); - let system = system_prompt(kind); - let prompt = user_prompt(kind, hint, &context); + 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 -/// built from the branch's commits and changed files. Always editable, never -/// empty when there is anything to say. -pub fn heuristic(cwd: &Path, kind: DraftKind, hint: &str, base: Option<&str>) -> 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 = heuristic_body(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 } } @@ -154,9 +159,10 @@ fn collect(cwd: &Path, base: Option<&str>) -> RepoContext { } /// The system rules. Output shape mirrors [`crate::commit_message`]: a single -/// fenced block so the parser is identical in spirit. -fn system_prompt(kind: DraftKind) -> String { - format!( +/// 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\ @@ -168,12 +174,23 @@ fn system_prompt(kind: DraftKind) -> String { 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, and a closing -/// reminder. `hint` is optional and only shapes the draft when present. -fn user_prompt(kind: DraftKind, hint: &str, context: &RepoContext) -> String { +/// 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)); @@ -212,17 +229,31 @@ fn user_prompt(kind: DraftKind, hint: &str, context: &RepoContext) -> String { 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\ - Write a {heading} draft from the context above.\n\ - Title first, then a blank line, then the Markdown body.\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\ ", - heading = kind.heading() + noun = kind.noun() )); prompt } @@ -296,6 +327,58 @@ fn humanize(branch: &str) -> String { .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 { @@ -566,7 +649,7 @@ mod tests { commits: vec!["feat: a".into()], changed: vec!["src/lib.rs | 2 +-".into()], }; - let prompt = user_prompt(DraftKind::PullRequest, "notes here", &context); + 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("")); @@ -575,8 +658,48 @@ mod tests { assert!(prompt.contains("src/lib.rs | 2 +-")); assert!(prompt.contains("ONLY return a single markdown code block")); - let system = system_prompt(DraftKind::Issue); + 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("