Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions app/src-tauri/src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions app/src-tauri/src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -765,10 +765,126 @@ pub async fn fetch_issues(remote: &GithubRemote, numbers: &[u64]) -> Result<Vec<
Ok(found)
}

/// Map an LLM provider identifier to its official GitHub handle.
pub fn format_provider_handle(provider: &str) -> 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: @<provider_handle>(<model_id>)`.
/// 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 {
Expand Down
101 changes: 101 additions & 0 deletions app/src/issueFooter.test.ts
Original file line number Diff line number Diff line change
@@ -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 @<provider> 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",
);
});
});
});
47 changes: 47 additions & 0 deletions app/src/issueFooter.ts
Original file line number Diff line number Diff line change
@@ -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: @<provider_handle>(<model_id>)`.
* 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}`;
}
2 changes: 1 addition & 1 deletion site/src/content/docs/github-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down