xbp 10.30.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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");
    }
}