use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde_json::{json, Value as JsonValue};
pub(crate) const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
pub(crate) const DEFAULT_MODEL: &str = "openai/gpt-4o-mini";
pub(crate) const DEFAULT_COMMIT_SYSTEM_PROMPT: &str = r#"You generate one git commit message for the current worktree.
Return strict JSON only. No markdown, no code fences, no explanation.
Schema:
{
"type": "feat|fix|docs|refactor|chore|test|build|ci|perf|style|revert",
"scope": "short lowercase scope or null",
"description": "imperative summary under 72 chars, no trailing period",
"body": ["optional paragraph", "optional paragraph"],
"breaking_change": false,
"breaking_description": "required only when breaking_change=true",
"footers": ["optional footer line"]
}
Rules:
- Follow Conventional Commits 1.0.0 exactly.
- Prefer feat for new capabilities, fix for bug repair, docs for docs-only changes, refactor for internal restructuring without behavior change, and ci/build/test/perf/style/chore when clearly better.
- Use a scope only when it is clearly anchored in the codebase.
- The description must be specific to the changed behavior or module, not generic.
- Prefer concrete module or behavior words from the worktree summary over vague nouns like support, changes, updates, or improvements.
- If the diff mostly restructures existing code paths or extracts helpers, prefer refactor instead of feat.
- Mention a breaking change only when the diff clearly changes a public or operator-facing contract.
- Use the provided file list, symbol list, and diff excerpt. Do not invent files, functions, types, or breaking changes.
- When a body is warranted, use 1-2 short paragraphs: first what changed concretely, then the impact or intent. Omit the body instead of writing filler."#;
pub(crate) const DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT: &str = r#"You are generating structured release notes for a GitHub release.
Return strict JSON only. No markdown, no code fences, no explanation.
Schema:
{
"sections": [
{
"title": "string",
"summary": "one short paragraph for the section",
"bullets": [
{
"commit_shas": ["shortsha1", "shortsha2"],
"summary": "one concise user-facing bullet"
}
]
}
]
}
Target rendered markdown shape:
# [version](release-url) - [repo](repository-url)
## What's Changed
Comparing changes since [previous-tag](previous-release-url).
### Section Title
Short paragraph summary for the section.
- [sha1](commit-url), [sha2](commit-url) Concise user-facing summary
- [#123](pull-url) Pull request title
- [SUI-1234](linear-url) Linear issue title
Rules:
- Focus on what changed for users or operators, not on mirroring the git log.
- Combine thematically similar commits into one bullet. Do not create one bullet per commit when several commits describe the same user-visible change.
- Specifically collapse repeated chat-related commits, deleted-message persistence commits, switch-component commits, upload UTF-8 fix commits, and Athena migration commits into one bullet each when they appear.
- Prefer section titles like Cases & Communication, Reliability, Athena Migration, Authentication & Security, Forms Platform, Administration, User Interface, and Documentation & Tooling when they fit.
- Use every provided short SHA exactly once across all bullets unless a commit is obviously trivial maintenance.
- Use only short SHAs from the provided list. Never invent SHAs.
- Keep sections readable: usually 3 to 7 sections, with 1 to 4 bullets each.
- Keep section summaries concise and high-level. Keep bullet summaries concise and user-facing."#;
fn build_chat_messages(system_prompt: Option<&str>, prompt: &str) -> Vec<JsonValue> {
let mut messages = Vec::new();
if let Some(system_prompt) = system_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
{
messages.push(json!({
"role": "system",
"content": system_prompt,
}));
}
messages.push(json!({
"role": "user",
"content": prompt,
}));
messages
}
pub(crate) async fn complete_prompt(
api_key: &str,
model: &str,
system_prompt: Option<&str>,
prompt: &str,
title: Option<&str>,
) -> Option<String> {
let trimmed_api_key = api_key.trim();
if trimmed_api_key.is_empty() {
return None;
}
let mut request = reqwest::Client::new()
.post(OPENROUTER_URL)
.header(AUTHORIZATION, format!("Bearer {}", trimmed_api_key))
.header(CONTENT_TYPE, "application/json");
if let Some(title) = title.map(str::trim).filter(|value| !value.is_empty()) {
request = request.header("X-OpenRouter-Title", title);
}
let response = request
.json(&json!({
"model": model,
"messages": build_chat_messages(system_prompt, prompt),
}))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let data: serde_json::Value = response.json().await.ok()?;
data.get("choices")?
.as_array()?
.first()?
.get("message")?
.get("content")?
.as_str()
.map(|content| content.trim().to_string())
}
pub(crate) async fn complete_user_prompt(
api_key: &str,
model: &str,
prompt: &str,
title: Option<&str>,
) -> Option<String> {
complete_prompt(api_key, model, None, prompt, title).await
}
#[cfg(test)]
mod tests {
use super::build_chat_messages;
#[test]
fn build_chat_messages_includes_system_prompt_when_present() {
let messages = build_chat_messages(Some("System prompt"), "User prompt");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "System prompt");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "User prompt");
}
#[test]
fn build_chat_messages_omits_blank_system_prompt() {
let messages = build_chat_messages(Some(" "), "User prompt");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"], "User prompt");
}
}