const DECLARATION: &str = "org.tangled.repo.declaration";
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
use anyhow::Context as _;
use knot_types::{AccountDid, RepoDid};
use crate::config::ValidatedConfig;
use crate::registry::format_rfc3339;
pub async fn record(
config: Arc<ValidatedConfig>,
repo_did: &str,
publish: bool,
) -> anyhow::Result<()> {
let repo_did = RepoDid::new(repo_did.to_string()).context("invalid repo DID")?;
let plan = build_record_plan(&config, &repo_did)?;
println!("{}", serde_json::to_string_pretty(&plan.payload)?);
if publish {
let owner_account =
AccountDid::new(plan.owner.clone()).context("invalid owner DID in plan")?;
let mut session = crate::oauth::ensure_auth_for_owner(&config, &owner_account, true)
.await?
.context("OAuth session required for publishing")?;
put_declaration(&plan, &mut session).await?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"published": true,
}))?
);
}
Ok(())
}
pub(crate) struct RecordPlan {
rkey: String,
owner: String,
payload: serde_json::Value,
}
fn build_record_plan(
config: &Arc<ValidatedConfig>,
repo_did: &RepoDid,
) -> anyhow::Result<RecordPlan> {
let layout = config.layout()?;
let meta_path = layout.meta_path(&config.knot_did)?;
let meta_repo = layout
.bootstrap_meta(&config.knot_did)
.context("open meta.git")?;
let registry_state = crate::registry::load_state(&meta_repo)?;
let (name, created_at_iso, owner_str) = if let Some(record) = registry_state
.as_ref()
.and_then(|reg| reg.record_of(repo_did))
{
(
record.name.to_string(),
format_rfc3339(record.created_at.get()),
record.owner.to_string(),
)
} else {
let index = knot_index::Index::new(&meta_path, layout);
let _ = index.rebuild();
match index.ownership_of(repo_did) {
knot_index::Resolved::Ready(Some(ownership)) => {
let rkey = ownership.rkey.to_string();
let owner = ownership.owner.to_string();
let now = super::unix_now().get();
(rkey, format_rfc3339(now), owner)
}
_ => anyhow::bail!("repo {repo_did} is not registered"),
}
};
// bobbin looks the manifest up by its lowercased name.
let rkey = name.to_lowercase();
let payload = serde_json::json!({
"collection": DECLARATION,
"rkey": rkey,
"record": {
"$type": DECLARATION,
"repo": repo_did.as_str(),
"createdAt": created_at_iso,
}
});
Ok(RecordPlan {
rkey,
owner: owner_str,
payload,
})
}
pub(crate) async fn publish_owned_by(config: Arc<ValidatedConfig>, owner: &AccountDid) {
let repos: anyhow::Result<Vec<String>> = (|| {
let layout = config.layout()?;
let index = knot_index::Index::new(&layout.meta_path(&config.knot_did)?, layout);
index.rebuild()?;
Ok(index
.hosted_repos()
.into_iter()
.filter(|did| {
matches!(index.ownership_of(did),
knot_index::Resolved::Ready(Some(ownership)) if ownership.owner.to_string() == owner.as_str())
})
.map(|did| did.to_string())
.collect())
})();
match repos {
Ok(repos) => publish_all(config, &repos).await,
Err(error) => eprintln!("warn: record publish for {owner}: {error:#}"),
}
}
pub(crate) async fn publish_all(config: Arc<ValidatedConfig>, changed_repos: &[String]) {
let (layout, meta_path) = match (|| -> anyhow::Result<_> {
let layout = config.layout()?;
let meta_path = layout.meta_path(&config.knot_did)?;
Ok((layout, meta_path))
})() {
Ok(res) => res,
Err(error) => {
eprintln!("warn: record publish: {error:#}");
return;
}
};
let index = knot_index::Index::new(&meta_path, layout.clone());
let _ = index.rebuild();
let mut targets = Vec::new();
for did in index.hosted_repos() {
if !changed_repos
.iter()
.any(|repo_did| repo_did == did.as_str())
{
continue;
}
let knot_index::Resolved::Ready(Some(ownership)) = index.ownership_of(&did) else {
continue;
};
let Ok(owner_account) = AccountDid::new(ownership.owner.to_string()) else {
continue;
};
let plan = match build_record_plan(&config, &did) {
Ok(plan) => plan,
Err(error) => {
eprintln!("warn: record publish {}: {error:#}", did.as_str());
continue;
}
};
targets.push((did, owner_account, plan));
}
if targets.is_empty() {
return;
}
let mut sessions: HashMap<String, Option<crate::oauth::StoredSession>> = HashMap::new();
for (_, owner_account, _) in &targets {
let owner_key = owner_account.to_string();
if let Entry::Vacant(e) = sessions.entry(owner_key) {
let session = match crate::oauth::get_or_refresh_session(&config, owner_account).await {
Ok(Some(s)) => Some(s),
Ok(None) => {
eprintln!(
"record publish: skipped (no OAuth session for owner {})",
e.key()
);
None
}
Err(error) => {
eprintln!("warn: oauth session lookup for {}: {error:#}", e.key());
None
}
};
e.insert(session);
}
}
let publish_futures = targets
.into_iter()
.filter_map(|(did, owner_account, plan)| {
let session = sessions.get(&owner_account.to_string())?.clone()?;
Some(async move {
let mut session = session;
match put_declaration(&plan, &mut session).await {
Ok(()) => {
eprintln!("published repo declaration for {} via OAuth", did.as_str())
}
Err(error) => eprintln!("warn: record publish {}: {error:#}", did.as_str()),
}
})
});
futures::future::join_all(publish_futures).await;
}
async fn put_declaration(
plan: &RecordPlan,
session: &mut crate::oauth::StoredSession,
) -> anyhow::Result<()> {
crate::oauth::put_record_oauth(session, DECLARATION, &plan.rkey, &plan.payload["record"])
.await
.map(drop)
}