use std::collections::BTreeMap;
use std::process::Command;
use crate::model::Status;
pub const PLACEHOLDERS: &[(&str, &str)] = &[
("Repo", "`owner/name` of the repository"),
("Workflow", "Workflow name"),
("Job", "Job id inside the workflow"),
("Status", "The reported status, verbatim"),
("StatusEmoji", "✅ / ❌ / ⚠️ / ⏭ for the reported status"),
("Actor", "User that triggered the run (git `user.name` outside Actions)"),
("Ref", "Full ref, e.g. `refs/heads/main`"),
("RefName", "Short ref name, e.g. `main`"),
("Branch", "Alias of `RefName`"),
("Sha", "Full commit SHA"),
("ShortSha", "First 7 characters of the SHA"),
("RunId", "Workflow run id (empty outside Actions)"),
("RunNumber", "Workflow run number (empty outside Actions)"),
("RunUrl", "Direct link to the run (empty outside Actions)"),
("EventName", "Event that triggered the run (empty outside Actions)"),
("ServerUrl", "GitHub server URL"),
];
#[derive(Debug, Clone, Default)]
pub struct Context {
values: BTreeMap<String, String>,
}
impl Context {
pub fn discover(status: Status, git_fallback: bool) -> Self {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let mut repo = env("GITHUB_REPOSITORY");
let mut sha = env("GITHUB_SHA");
let mut ref_name = env("GITHUB_REF_NAME");
let mut full_ref = env("GITHUB_REF");
let mut actor = env("GITHUB_ACTOR");
if git_fallback {
if repo.is_none() {
repo = git_repo_slug();
}
if sha.is_none() {
sha = git(&["rev-parse", "HEAD"]);
}
if ref_name.is_none() {
ref_name = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
}
if full_ref.is_none() {
full_ref = ref_name.as_ref().map(|r| format!("refs/heads/{r}"));
}
if actor.is_none() {
actor = git(&["config", "user.name"]);
}
}
let server_url =
env("GITHUB_SERVER_URL").unwrap_or_else(|| "https://github.com".to_string());
let run_id = env("GITHUB_RUN_ID");
let short_sha: Option<String> = sha.as_ref().map(|s| s.chars().take(7).collect());
let run_url = match (&run_id, &repo) {
(Some(id), Some(r)) => Some(format!("{server_url}/{r}/actions/runs/{id}")),
_ => None,
};
let mut values = BTreeMap::new();
let mut set = |k: &str, v: Option<String>| {
values.insert(k.to_string(), v.unwrap_or_default());
};
set("Repo", repo);
set("Workflow", env("GITHUB_WORKFLOW"));
set("Job", env("GITHUB_JOB"));
set("Status", Some(status.as_str().to_string()));
set("StatusEmoji", Some(status.emoji().to_string()));
set("Actor", actor);
set("Ref", full_ref);
set("RefName", ref_name.clone());
set("Branch", ref_name);
set("Sha", sha);
set("ShortSha", short_sha);
set("RunId", run_id);
set("RunNumber", env("GITHUB_RUN_NUMBER"));
set("RunUrl", run_url);
set("EventName", env("GITHUB_EVENT_NAME"));
set("ServerUrl", Some(server_url));
Context { values }
}
pub fn from_pairs<I, K, V>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
Context { values: pairs.into_iter().map(|(k, v)| (k.into(), v.into())).collect() }
}
pub fn get(&self, key: &str) -> Option<&str> {
if !is_known_placeholder(key) {
return None;
}
Some(self.values.get(key).map(String::as_str).unwrap_or(""))
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.values.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
}
pub fn is_known_placeholder(name: &str) -> bool {
PLACEHOLDERS.iter().any(|(k, _)| *k == name)
}
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn git_repo_slug() -> Option<String> {
let url = git(&["remote", "get-url", "origin"])?;
let path = match url.split_once("://") {
Some((_, rest)) => rest.split_once('/').map(|(_, p)| p.to_string())?,
None => url.split_once(':').map(|(_, p)| p.to_string())?,
};
let slug = path.strip_suffix(".git").unwrap_or(&path);
let parts: Vec<&str> = slug.trim_matches('/').split('/').collect();
if parts.len() < 2 {
return None;
}
Some(parts[parts.len() - 2..].join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_keys_are_not_resolvable() {
let ctx = Context::from_pairs([("Repo", "jtprogru/notiflow")]);
assert_eq!(ctx.get("Repo"), Some("jtprogru/notiflow"));
assert_eq!(ctx.get("Nope"), None);
}
#[test]
fn known_but_unset_key_resolves_to_empty() {
let ctx = Context::from_pairs([("Repo", "a/b")]);
assert_eq!(ctx.get("Workflow"), Some(""));
}
#[test]
fn every_placeholder_is_documented_once() {
let mut names: Vec<&str> = PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let count = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), count, "duplicate placeholder name");
}
}