use std::process::Command;
use anyhow::{bail, Context};
use reqwest::Method;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use sylphx_sdk_management::ManagementClient;
use url::Url;
pub(crate) struct PublishCandidateOptions<'a> {
pub project_id: &'a str,
pub repository: Option<&'a str>,
pub target_ref: &'a str,
pub base_revision: Option<&'a str>,
pub source_revision: Option<&'a str>,
pub tree_oid: Option<&'a str>,
pub work_item_id: &'a str,
pub producer_attempt_id: &'a str,
pub service_id: Option<&'a str>,
pub idempotency_key: Option<&'a str>,
pub org_id: Option<&'a str>,
}
fn git_output(args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("git")
.args(args)
.output()
.with_context(|| format!("run git {}", args.join(" ")))?;
if !output.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let value = String::from_utf8(output.stdout)
.context("git output is not utf-8")?
.trim()
.to_string();
if value.is_empty() {
bail!("git {} returned an empty value", args.join(" "));
}
Ok(value)
}
fn canonical_repository_identity(raw: &str) -> anyhow::Result<String> {
let raw = raw.trim().trim_end_matches(".git");
let path = if let Some(path) = raw.strip_prefix("git@") {
let (host, repo) = path
.split_once(':')
.context("scp-style git remote must contain ':'")?;
format!("{host}/{repo}")
} else if raw.contains("://") {
let parsed = Url::parse(raw).context("parse git remote URL")?;
if !matches!(parsed.scheme(), "https" | "http" | "ssh" | "git") {
bail!("git remote URL scheme is not a supported network provider");
}
if parsed.query().is_some() || parsed.fragment().is_some() {
bail!("git remote URL must not contain a query or fragment");
}
let host = parsed.host_str().context("git remote URL has no host")?;
let authority = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
format!("{authority}/{}", parsed.path().trim_matches('/'))
} else {
raw.to_string()
};
let normalized = path.trim_matches('/').trim_end_matches(".git").to_string();
if normalized.split('/').count() < 3 || normalized.contains(' ') || normalized.contains("..") {
bail!("git remote does not normalize to host/owner/repository");
}
Ok(normalized)
}
fn git_tree_evidence_digest(tree_oid: &str) -> anyhow::Result<String> {
let tree_oid = tree_oid.trim().to_ascii_lowercase();
if !matches!(tree_oid.len(), 40 | 64)
|| !tree_oid
.chars()
.all(|character| character.is_ascii_hexdigit())
{
bail!("tree oid must be a full 40- or 64-character hexadecimal object id");
}
Ok(format!(
"sha256:{:x}",
Sha256::digest(format!("platform.git-tree.v1:{tree_oid}").as_bytes())
))
}
fn full_git_revision(value: &str, name: &str) -> anyhow::Result<String> {
let value = value.trim().to_ascii_lowercase();
if value.len() != 40 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
bail!("{name} must resolve to a full 40-character Git revision");
}
Ok(value)
}
fn publish_body(options: &PublishCandidateOptions<'_>) -> anyhow::Result<Value> {
let repository = match options.repository {
Some(repository) => canonical_repository_identity(repository)?,
None => canonical_repository_identity(&git_output(&["remote", "get-url", "origin"])?)?,
};
let source_revision = full_git_revision(
&match options.source_revision {
Some(revision) => revision.to_string(),
None => git_output(&["rev-parse", "HEAD"])?,
},
"source revision",
)?;
let remote_target = format!("refs/remotes/origin/{}", options.target_ref);
let base_revision = full_git_revision(
&match options.base_revision {
Some(revision) => revision.to_string(),
None => git_output(&["rev-parse", &remote_target])?,
},
"base revision",
)?;
if source_revision == base_revision {
bail!("source revision equals the current target base; there is no Candidate to publish");
}
let tree_oid = match options.tree_oid {
Some(tree_oid) => tree_oid.to_string(),
None => git_output(&["rev-parse", &format!("{source_revision}^{{tree}}")])?,
};
let tree_digest = git_tree_evidence_digest(&tree_oid)?;
let idempotency_key = options
.idempotency_key
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"internal:{}:{source_revision}",
repository.to_ascii_lowercase()
)
});
let mut body = json!({
"content": {
"repository_identity": repository,
"target_ref": options.target_ref,
"base_revision": base_revision,
"source_revision": source_revision,
"tree_digest": tree_digest,
"work_item_id": options.work_item_id,
"producer_attempt_id": options.producer_attempt_id,
},
"origin": "internal_publish",
"idempotency_key": idempotency_key,
});
if let Some(service_id) = options.service_id {
body["service_id"] = json!(service_id);
}
Ok(body)
}
pub(crate) async fn publish_candidate(
client: &ManagementClient,
options: &PublishCandidateOptions<'_>,
) -> anyhow::Result<Value> {
let body = publish_body(options)?;
let path = format!("/projects/{}/candidates", options.project_id);
let (_status, response) = client
.api(Method::POST, &path, Some(&body), options.org_id)
.await
.context("publish Platform Candidate")?;
Ok(response)
}
pub(crate) async fn get_candidate(
client: &ManagementClient,
project_id: &str,
candidate_id: &str,
org_id: Option<&str>,
) -> anyhow::Result<Value> {
let path = format!("/projects/{project_id}/candidates/{candidate_id}");
let (_status, response) = client
.api(Method::GET, &path, None, org_id)
.await
.context("read Platform Candidate")?;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repository_identity_normalizes_common_git_remotes() {
assert_eq!(
canonical_repository_identity("git@github.com:SylphxAI/platform.git").unwrap(),
"github.com/SylphxAI/platform"
);
assert_eq!(
canonical_repository_identity("https://github.com/SylphxAI/platform.git").unwrap(),
"github.com/SylphxAI/platform"
);
assert_eq!(
canonical_repository_identity(
"https://x-access-token:do-not-leak@github.com/SylphxAI/platform.git"
)
.unwrap(),
"github.com/SylphxAI/platform"
);
assert_eq!(
canonical_repository_identity(
"ssh://git@github.example.com:2222/SylphxAI/platform.git"
)
.unwrap(),
"github.example.com:2222/SylphxAI/platform"
);
assert!(canonical_repository_identity("platform").is_err());
assert!(canonical_repository_identity("file:///tmp/platform").is_err());
}
#[test]
fn tree_evidence_digest_matches_platform_domain_contract() {
let oid = "a".repeat(40);
let expected = format!(
"sha256:{:x}",
Sha256::digest(format!("platform.git-tree.v1:{oid}").as_bytes())
);
assert_eq!(git_tree_evidence_digest(&oid).unwrap(), expected);
assert!(git_tree_evidence_digest("short").is_err());
}
#[test]
fn publish_body_contains_lineage_but_not_platform_policy_authority() {
let body = publish_body(&PublishCandidateOptions {
project_id: "prj_demo",
repository: Some("github.com/SylphxAI/platform"),
target_ref: "main",
base_revision: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
source_revision: Some("dddddddddddddddddddddddddddddddddddddddd"),
tree_oid: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
work_item_id: "wi_demo",
producer_attempt_id: "att_demo",
service_id: None,
idempotency_key: None,
org_id: None,
})
.unwrap();
assert_eq!(body["content"]["work_item_id"], "wi_demo");
assert_eq!(body["content"]["producer_attempt_id"], "att_demo");
assert!(body["content"].get("policy_profile_digest").is_none());
assert!(body.get("classification").is_none());
}
}