use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use onetaskgraph_plugin_api::{SourceError, Visibility, WriteTarget};
use secrecy::ExposeSecret;
use serde_json::{Value, json};
use tokio::sync::OnceCell;
use crate::{GitHubProjectsSource, RepositoryTarget, accounting, graphql};
#[derive(Default)]
pub(crate) struct Hold {
board: Arc<OnceCell<Visibility>>,
repositories: BTreeMap<RepositoryTarget, Arc<OnceCell<Visibility>>>,
}
tokio::task_local! {
static OPERATION_READS: BTreeMap<usize, Arc<std::sync::Mutex<Hold>>>;
}
impl GitHubProjectsSource {
pub(crate) fn scope_visibility_reads<'a>(
&'a self,
operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let mut reads = OPERATION_READS.try_with(Clone::clone).unwrap_or_default();
reads.insert(
std::ptr::from_ref(self) as usize,
Arc::new(std::sync::Mutex::new(Hold::default())),
);
OPERATION_READS.scope(reads, operation).await;
})
}
pub(crate) fn is_declared_private(&self) -> bool {
self.declared_private.load(Ordering::SeqCst)
}
fn held_cell(
&self,
repository: Option<&RepositoryTarget>,
) -> Option<Arc<OnceCell<Visibility>>> {
let scoped = OPERATION_READS
.try_with(|reads| reads.get(&(std::ptr::from_ref(self) as usize)).cloned())
.ok()
.flatten()?;
let mut hold = scoped
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Some(match repository {
None => Arc::clone(&hold.board),
Some(repository) => {
Arc::clone(hold.repositories.entry(repository.clone()).or_default())
}
})
}
pub(crate) async fn write_visibility(
&self,
target: &WriteTarget<'_>,
) -> Result<Visibility, SourceError> {
let repository = self.visibility_repository(target).await?;
self.visibility_in_repository(repository).await
}
pub(crate) async fn write_visibility_with_project(
&self,
target: &WriteTarget<'_>,
project: &WriteTarget<'_>,
) -> Result<Visibility, SourceError> {
match target {
WriteTarget::New { repositories, .. } => {
let parent = self.visibility_repository(project).await?;
let repository = self
.creation_rule_in_repository(repositories, parent, "a new item")
.await?;
self.visibility_in_repository(Some(repository)).await
}
WriteTarget::Existing(_) => self.write_visibility(target).await,
}
}
async fn visibility_in_repository(
&self,
repository: Option<RepositoryTarget>,
) -> Result<Visibility, SourceError> {
let (board, repository) = tokio::join!(self.board_visibility(), async {
match &repository {
Some(repository) => self.repository_visibility(repository).await,
None => Ok(Visibility::Private),
}
});
Ok(match (board?, repository?) {
(Visibility::Private, Visibility::Private) => Visibility::Private,
(Visibility::Unknown, _) | (_, Visibility::Unknown) => Visibility::Unknown,
_ => Visibility::Public,
})
}
async fn visibility_repository(
&self,
target: &WriteTarget<'_>,
) -> Result<Option<RepositoryTarget>, SourceError> {
match target {
WriteTarget::Existing(id) => self
.bound_item(id)
.await?
.and_then(|item| item.own_repository)
.map(|origin| origin_target(&origin))
.transpose(),
WriteTarget::New {
repositories,
project,
} => self
.creation_rule(repositories, *project, "a new item")
.await
.map(Some),
}
}
async fn board_visibility(&self) -> Result<Visibility, SourceError> {
match self.held_cell(None) {
Some(cell) => cell
.get_or_try_init(|| self.read_board_visibility())
.await
.copied(),
None => self.read_board_visibility().await,
}
}
async fn read_board_visibility(&self) -> Result<Visibility, SourceError> {
let answer = self
.graphql(
graphql::PROJECT_VISIBILITY,
json!({"owner": self.owner, "number": self.project_number}),
)
.await
.map_err(|error| match error {
SourceError::Auth { message } if message.contains("read:project") => {
SourceError::Auth {
message: format!(
"source {} cannot read whether its project is public: the credential \
in {} lacks the `read:project` scope that read needs; next: grant that \
scope to that credential",
self.name, self.credential_name
),
}
}
other => other,
})?;
match answer.pointer("/visibility/projectV2/public") {
Some(Value::Bool(true)) => Ok(Visibility::Public),
Some(Value::Bool(false)) => Ok(Visibility::Private),
_ => Err(SourceError::Malformed {
message: format!(
"source {} read no `public` for project {} of {}",
self.name, self.project_number, self.owner
),
}),
}
}
pub(crate) async fn repository_visibility(
&self,
repository: &RepositoryTarget,
) -> Result<Visibility, SourceError> {
match self.held_cell(Some(repository)) {
Some(cell) => cell
.get_or_try_init(|| self.read_repository_visibility(repository))
.await
.copied(),
None => self.read_repository_visibility(repository).await,
}
}
async fn read_repository_visibility(
&self,
repository: &RepositoryTarget,
) -> Result<Visibility, SourceError> {
let mut url = self.endpoint.clone();
url.set_path(&format!("/repos/{}/{}", repository.owner, repository.name));
url.set_query(None);
if !self.loopback() {
url.set_host(Some("api.github.com"))
.map_err(|error| SourceError::Config {
message: format!("cannot select GitHub API host: {error}"),
})?;
}
let endpoint =
accounting::Endpoint::parse(accounting::Method::Get, "/repos/{owner}/{repo}")
.expect("a literal REST endpoint template");
let unreadable = |why: String| SourceError::Unavailable {
message: format!(
"source {} could not read whether repository {} is public: {why}",
self.name,
repository.slug()
),
};
let response = self
.client()?
.get(url)
.bearer_auth(self.token.expose_secret())
.header("accept", "application/vnd.github+json")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
self.ledger
.record(accounting::Request::rest(endpoint).finished(
accounting::Outcome::Refused,
accounting::RateLimit::default(),
));
return Err(unreadable(error.to_string()));
}
};
let status = response.status();
let limits = accounting::RateLimit::read(|header| {
response
.headers()
.get(header)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
});
let body = response.bytes().await;
self.ledger
.record(accounting::Request::rest(endpoint).finished(
if status.is_success() {
accounting::Outcome::Answered
} else {
accounting::Outcome::Refused
},
limits,
));
let body = body.map_err(|error| unreadable(error.to_string()))?;
if !status.is_success() {
return Err(unreadable(format!("GitHub answered HTTP {status}")));
}
let value: Value =
serde_json::from_slice(&body).map_err(|error| unreadable(error.to_string()))?;
match (value.get("visibility"), value.get("private")) {
(Some(Value::String(named)), _) => match named.as_str() {
"public" => Ok(Visibility::Public),
"private" | "internal" => Ok(Visibility::Private),
other => Err(unreadable(format!(
"its answer named the visibility {other:?}, which is none of public, private \
and internal"
))),
},
(None, Some(Value::Bool(false))) => Ok(Visibility::Public),
(None, Some(Value::Bool(true))) => Ok(Visibility::Private),
_ => Err(unreadable("its answer named no visibility".to_owned())),
}
}
}
fn origin_target(
origin: &onetaskgraph_plugin_api::Repository,
) -> Result<RepositoryTarget, SourceError> {
RepositoryTarget::from_origin(origin).map_err(|message| SourceError::Refused { message })
}