From bae094821171e70c1faa67876e9a0e1b0fe80a72 Mon Sep 17 00:00:00 2001 From: Dario Mory Date: Wed, 2 Sep 2026 08:16:12 +0700 Subject: [PATCH] feat(github): format issue footer with mapped provider handles and model references --- app/src-tauri/src/ai.rs | 23 +++-- app/src-tauri/src/github.rs | 116 +++++++++++++++++++++++++ app/src/issueFooter.test.ts | 101 +++++++++++++++++++++ app/src/issueFooter.ts | 47 ++++++++++ site/src/content/docs/github-issues.md | 2 +- 5 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 app/src/issueFooter.test.ts create mode 100644 app/src/issueFooter.ts diff --git a/app/src-tauri/src/ai.rs b/app/src-tauri/src/ai.rs index 73640a8..a9badba 100644 --- a/app/src-tauri/src/ai.rs +++ b/app/src-tauri/src/ai.rs @@ -401,12 +401,8 @@ fn assemble_draft( // Tags are deliberately absent here: they are applied as GitHub labels, so // repeating them as prose would be noise. - out.push_str("\n\n---\nFrom [sudonotes](https://sudonotes.com) · idea: "); - out.push_str(note_title); - if let Some(model) = model.filter(|m| !m.is_empty()) { - out.push_str(" · model: "); - out.push_str(model); - } + out.push_str("\n\n---\n"); + out.push_str(&crate::github::format_issue_footer(note_title, model)); out.push('\n'); IssueDraft { title, body: out } @@ -547,11 +543,24 @@ mod tests { assert_eq!(draft.title, "Mute closed bubbles"); assert!(draft.body.starts_with("# Mute closed bubbles")); assert!(draft.body.contains("idea: GitHub integration")); - assert!(draft.body.contains("model: deepseek/deepseek-chat")); + assert!(draft.body.contains("model: @deepseek-ai(deepseek-chat)")); // Tags belong on the issue as labels, not restated in the body. assert!(!draft.body.contains("tags:")); } + #[test] + fn omits_model_in_draft_footer_when_missing() { + let draft = local_draft( + "# Mute closed bubbles\n\nThey should dim.", + "GitHub integration", + None, + ); + + assert_eq!(draft.title, "Mute closed bubbles"); + assert!(draft.body.contains("From [sudonotes](https://sudonotes.com) · idea: GitHub integration")); + assert!(!draft.body.contains("· model:")); + } + #[test] fn shortens_an_overlong_fallback_title() { let long = "a ".repeat(60); diff --git a/app/src-tauri/src/github.rs b/app/src-tauri/src/github.rs index 51b158a..f7b8694 100644 --- a/app/src-tauri/src/github.rs +++ b/app/src-tauri/src/github.rs @@ -765,10 +765,126 @@ pub async fn fetch_issues(remote: &GithubRemote, numbers: &[u64]) -> Result String { + let key = provider.trim().to_ascii_lowercase(); + match key.as_str() { + "anthropic" => "@claude".to_string(), + "openai" => "@openai".to_string(), + "google" => "@google".to_string(), + "deepseek" => "@deepseek-ai".to_string(), + "meta" => "@meta".to_string(), + "mistral" => "@mistralai".to_string(), + _ => format!("@{key}"), + } +} + +/// Format a model specifier for GitHub issue footers, e.g. `@claude(claude-opus-5)`. +pub fn format_model_reference(model: &str) -> String { + let trimmed = model.trim(); + if let Some((provider, model_id)) = trimmed.split_once('/') { + let handle = format_provider_handle(provider); + let model_id = model_id.trim(); + format!("{handle}({model_id})") + } else { + let handle = format_provider_handle(trimmed); + format!("{handle}({trimmed})") + } +} + +/// Format the footer appended to GitHub issues created from idea bubbles. +/// +/// If a model is provided, includes ` · model: @()`. +/// If no model is assigned (None or empty), omits the model segment. +pub fn format_issue_footer(note_title: &str, model: Option<&str>) -> String { + let title = note_title.trim(); + let model = model.map(str::trim).filter(|m| !m.is_empty()); + match model { + Some(m) => format!( + "From [sudonotes](https://sudonotes.com) · idea: {title} · model: {}", + format_model_reference(m) + ), + None => format!("From [sudonotes](https://sudonotes.com) · idea: {title}"), + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn formats_provider_handles_correctly() { + assert_eq!(format_provider_handle("anthropic"), "@claude"); + assert_eq!(format_provider_handle("Anthropic"), "@claude"); + assert_eq!(format_provider_handle("openai"), "@openai"); + assert_eq!(format_provider_handle("google"), "@google"); + assert_eq!(format_provider_handle("deepseek"), "@deepseek-ai"); + assert_eq!(format_provider_handle("meta"), "@meta"); + assert_eq!(format_provider_handle("mistral"), "@mistralai"); + assert_eq!(format_provider_handle("cohere"), "@cohere"); + assert_eq!(format_provider_handle("qwen"), "@qwen"); + } + + #[test] + fn formats_model_references_correctly() { + assert_eq!( + format_model_reference("anthropic/claude-opus-5"), + "@claude(claude-opus-5)" + ); + assert_eq!( + format_model_reference("openai/gpt-4o"), + "@openai(gpt-4o)" + ); + assert_eq!( + format_model_reference("google/gemini-2.0-flash"), + "@google(gemini-2.0-flash)" + ); + assert_eq!( + format_model_reference("deepseek/deepseek-chat"), + "@deepseek-ai(deepseek-chat)" + ); + assert_eq!( + format_model_reference("meta/llama-3.3-70b-instruct"), + "@meta(llama-3.3-70b-instruct)" + ); + assert_eq!( + format_model_reference("mistral/mistral-large-2407"), + "@mistralai(mistral-large-2407)" + ); + assert_eq!( + format_model_reference("cohere/command-r-plus"), + "@cohere(command-r-plus)" + ); + assert_eq!( + format_model_reference("custom-model"), + "@custom-model(custom-model)" + ); + } + + #[test] + fn formats_issue_footer_with_and_without_models() { + assert_eq!( + format_issue_footer("Roadmap", Some("anthropic/claude-opus-5")), + "From [sudonotes](https://sudonotes.com) · idea: Roadmap · model: @claude(claude-opus-5)" + ); + assert_eq!( + format_issue_footer("Roadmap", Some("deepseek/deepseek-chat")), + "From [sudonotes](https://sudonotes.com) · idea: Roadmap · model: @deepseek-ai(deepseek-chat)" + ); + assert_eq!( + format_issue_footer("Roadmap", None), + "From [sudonotes](https://sudonotes.com) · idea: Roadmap" + ); + assert_eq!( + format_issue_footer("Roadmap", Some("")), + "From [sudonotes](https://sudonotes.com) · idea: Roadmap" + ); + assert_eq!( + format_issue_footer("Roadmap", Some(" ")), + "From [sudonotes](https://sudonotes.com) · idea: Roadmap" + ); + } + #[test] fn round_trips_an_issue_key() { let remote = GithubRemote { diff --git a/app/src/issueFooter.test.ts b/app/src/issueFooter.test.ts new file mode 100644 index 0000000..6042c69 --- /dev/null +++ b/app/src/issueFooter.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + formatIssueFooter, + formatModelReference, + formatProviderHandle, +} from "./issueFooter"; + +describe("issueFooter", () => { + describe("formatProviderHandle", () => { + it("maps known providers to GitHub handles", () => { + expect(formatProviderHandle("anthropic")).toBe("@claude"); + expect(formatProviderHandle("Anthropic")).toBe("@claude"); + expect(formatProviderHandle("openai")).toBe("@openai"); + expect(formatProviderHandle("OpenAI")).toBe("@openai"); + expect(formatProviderHandle("google")).toBe("@google"); + expect(formatProviderHandle("deepseek")).toBe("@deepseek-ai"); + expect(formatProviderHandle("meta")).toBe("@meta"); + expect(formatProviderHandle("mistral")).toBe("@mistralai"); + }); + + it("falls back to @ for unknown providers", () => { + expect(formatProviderHandle("cohere")).toBe("@cohere"); + expect(formatProviderHandle("qwen")).toBe("@qwen"); + expect(formatProviderHandle("custom-llm")).toBe("@custom-llm"); + }); + }); + + describe("formatModelReference", () => { + it("formats provider/model pairs into @handle(model_id)", () => { + expect(formatModelReference("anthropic/claude-opus-5")).toBe( + "@claude(claude-opus-5)", + ); + expect(formatModelReference("openai/gpt-4o")).toBe("@openai(gpt-4o)"); + expect(formatModelReference("google/gemini-2.0-flash")).toBe( + "@google(gemini-2.0-flash)", + ); + expect(formatModelReference("deepseek/deepseek-chat")).toBe( + "@deepseek-ai(deepseek-chat)", + ); + expect(formatModelReference("meta/llama-3.3-70b-instruct")).toBe( + "@meta(llama-3.3-70b-instruct)", + ); + expect(formatModelReference("mistral/mistral-large-2407")).toBe( + "@mistralai(mistral-large-2407)", + ); + expect(formatModelReference("cohere/command-r-plus")).toBe( + "@cohere(command-r-plus)", + ); + }); + + it("formats model specifiers without a slash", () => { + expect(formatModelReference("custom-model")).toBe( + "@custom-model(custom-model)", + ); + expect(formatModelReference("anthropic")).toBe("@claude(anthropic)"); + }); + }); + + describe("formatIssueFooter", () => { + it("formats footer with assigned model", () => { + expect( + formatIssueFooter("Roadmap", "anthropic/claude-opus-5"), + ).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap · model: @claude(claude-opus-5)", + ); + expect( + formatIssueFooter("Bug Tracker", "deepseek/deepseek-chat"), + ).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Bug Tracker · model: @deepseek-ai(deepseek-chat)", + ); + expect( + formatIssueFooter("Design System", "openai/gpt-4o"), + ).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Design System · model: @openai(gpt-4o)", + ); + expect( + formatIssueFooter("Search Pipeline", "cohere/command-r-plus"), + ).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Search Pipeline · model: @cohere(command-r-plus)", + ); + }); + + it("omits model segment when model is missing, null, or empty", () => { + expect(formatIssueFooter("Roadmap")).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap", + ); + expect(formatIssueFooter("Roadmap", null)).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap", + ); + expect(formatIssueFooter("Roadmap", undefined)).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap", + ); + expect(formatIssueFooter("Roadmap", "")).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap", + ); + expect(formatIssueFooter("Roadmap", " ")).toBe( + "From [sudonotes](https://sudonotes.com) · idea: Roadmap", + ); + }); + }); +}); diff --git a/app/src/issueFooter.ts b/app/src/issueFooter.ts new file mode 100644 index 0000000..2880437 --- /dev/null +++ b/app/src/issueFooter.ts @@ -0,0 +1,47 @@ +/** Map an LLM provider identifier to its official GitHub handle. */ +export function formatProviderHandle(provider: string): string { + const key = provider.trim().toLowerCase(); + switch (key) { + case "anthropic": + return "@claude"; + case "openai": + return "@openai"; + case "google": + return "@google"; + case "deepseek": + return "@deepseek-ai"; + case "meta": + return "@meta"; + case "mistral": + return "@mistralai"; + default: + return `@${key}`; + } +} + +/** Format a model specifier for GitHub issue footers, e.g. `@claude(claude-opus-5)`. */ +export function formatModelReference(model: string): string { + const trimmed = model.trim(); + const slash = trimmed.indexOf("/"); + if (slash >= 0) { + const provider = trimmed.slice(0, slash); + const modelId = trimmed.slice(slash + 1); + const handle = formatProviderHandle(provider); + return `${handle}(${modelId.trim()})`; + } + const handle = formatProviderHandle(trimmed); + return `${handle}(${trimmed})`; +} + +/** Format the footer appended to GitHub issues created from idea bubbles. + * + * If a model is provided, includes ` · model: @()`. + * If no model is assigned, omits the model segment. */ +export function formatIssueFooter(noteTitle: string, model?: string | null): string { + const title = noteTitle.trim(); + const cleanModel = model?.trim(); + if (cleanModel) { + return `From [sudonotes](https://sudonotes.com) · idea: ${title} · model: ${formatModelReference(cleanModel)}`; + } + return `From [sudonotes](https://sudonotes.com) · idea: ${title}`; +} diff --git a/site/src/content/docs/github-issues.md b/site/src/content/docs/github-issues.md index 9441c18..5c42733 100644 --- a/site/src/content/docs/github-issues.md +++ b/site/src/content/docs/github-issues.md @@ -96,7 +96,7 @@ A short footer is appended by sudonotes, recording the idea the bubble came from ``` --- -From [sudonotes](https://sudonotes.com) · idea: Roadmap · model: anthropic/claude-opus-5 +From [sudonotes](https://sudonotes.com) · idea: Roadmap · model: @claude(claude-opus-5) ``` Nothing is filed until you press **Create issue**. Afterwards the link is written into the note's