Add first-class Actions management (#63)

This commit is contained in:
Georg Bauer
2026-08-15 20:06:52 +02:00
parent f84ab774e6
commit 59bcddfadf
32 changed files with 4039 additions and 275 deletions

View File

@@ -1,8 +1,35 @@
use std::collections::{BTreeMap, HashMap};
use reqwest::header::ACCEPT;
use crate::{Client, Error, Method, RepositoryId, Result, models, positive};
use crate::{Client, Error, Method, Page, RepositoryId, Result, models, positive, validate_page};
use gitea_openapi::apis;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionRunQuery {
pub event: Option<String>,
pub branch: Option<String>,
pub status: Option<String>,
pub actor: Option<String>,
pub head_sha: Option<String>,
pub page: i32,
pub limit: i32,
}
impl Default for ActionRunQuery {
fn default() -> Self {
Self {
event: None,
branch: None,
status: None,
actor: None,
head_sha: None,
page: 1,
limit: crate::DEFAULT_PAGE_SIZE,
}
}
}
#[derive(Debug)]
pub struct ActionRunDetails {
pub run: models::ActionWorkflowRun,
@@ -15,6 +42,20 @@ pub struct ActionJobLog {
pub text: String,
}
#[derive(Debug)]
pub struct ActionJobDetails {
pub job: models::ActionWorkflowJob,
pub text: String,
pub groups: Vec<ActionLogGroup>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionLogGroup {
pub name: String,
pub text: String,
pub duration_seconds: Option<u64>,
}
impl Client {
pub async fn action_workflows(
&self,
@@ -35,18 +76,28 @@ impl Client {
repository: &RepositoryId,
workflow: &str,
reference: &str,
inputs: &BTreeMap<String, String>,
) -> Result<()> {
let workflow = workflow.trim();
let reference = reference.trim();
if workflow.is_empty() || reference.is_empty() {
return Err(Error::InvalidInput(
"workflow and reference must not be empty".into(),
));
}
let mut dispatch = models::CreateActionWorkflowDispatch::new(reference.into());
dispatch.inputs = (!inputs.is_empty()).then(|| {
inputs
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<HashMap<_, _>>()
});
apis::repository_api::actions_dispatch_workflow(
&self.configuration(),
&repository.owner,
&repository.repository,
workflow,
Some(models::CreateActionWorkflowDispatch::new(reference.into())),
Some(dispatch),
)
.await
.map_err(Error::generated)
@@ -55,21 +106,23 @@ impl Client {
pub async fn action_runs(
&self,
repository: &RepositoryId,
) -> Result<Vec<models::ActionWorkflowRun>> {
query: &ActionRunQuery,
) -> Result<Page<models::ActionWorkflowRun>> {
validate_page(query.page, query.limit)?;
apis::repository_api::get_workflow_runs(
&self.configuration(),
&repository.owner,
&repository.repository,
None,
None,
None,
None,
None,
Some(1),
Some(100),
query.event.as_deref(),
query.branch.as_deref(),
query.status.as_deref(),
query.actor.as_deref(),
query.head_sha.as_deref(),
Some(query.page),
Some(query.limit),
)
.await
.map(|response| response.workflow_runs.unwrap_or_default())
.map(|response| Page::from_items(response.workflow_runs.unwrap_or_default(), query.limit))
.map_err(Error::generated)
}
@@ -91,24 +144,9 @@ impl Client {
.await
.map_err(Error::generated)
},
async {
apis::repository_api::list_workflow_run_jobs(
&self.configuration(),
&repository.owner,
&repository.repository,
run_id,
None,
Some(1),
Some(100),
)
.await
.map_err(Error::generated)
}
async { self.action_jobs(repository, run_id).await }
)?;
Ok(ActionRunDetails {
run,
jobs: jobs.jobs.unwrap_or_default(),
})
Ok(ActionRunDetails { run, jobs })
}
pub async fn action_run_logs(
@@ -117,41 +155,274 @@ impl Client {
run: i64,
) -> Result<Vec<ActionJobLog>> {
let run = action_run_id(run)?;
let jobs = apis::repository_api::list_workflow_run_jobs(
&self.configuration(),
&repository.owner,
&repository.repository,
run,
None,
Some(1),
Some(100),
)
.await
.map_err(Error::generated)?
.jobs
.unwrap_or_default();
let jobs = self.action_jobs(repository, run).await?;
let mut logs = Vec::with_capacity(jobs.len());
for job in jobs {
let id = job
.id
.ok_or_else(|| Error::Generated("Action job has no ID.".into()))?;
let endpoint = format!(
"repos/{}/{}/actions/jobs/{id}/logs",
apis::urlencode(&repository.owner),
apis::urlencode(&repository.repository),
);
let text = self
.execute(
self.request(Method::GET, &endpoint)?
.header(ACCEPT, "text/plain"),
)
.await?
.text()
.await?;
let text = self.action_job_log(repository, id).await?;
logs.push(ActionJobLog { job, text });
}
Ok(logs)
}
pub async fn action_job_log(&self, repository: &RepositoryId, job: i64) -> Result<String> {
positive(job, "job ID")?;
let endpoint = format!(
"repos/{}/{}/actions/jobs/{job}/logs",
apis::urlencode(&repository.owner),
apis::urlencode(&repository.repository),
);
self.execute(
self.request(Method::GET, &endpoint)?
.header(ACCEPT, "text/plain"),
)
.await?
.text()
.await
.map_err(Error::from)
}
pub async fn action_job(
&self,
repository: &RepositoryId,
job: i64,
) -> Result<models::ActionWorkflowJob> {
positive(job, "job ID")?;
apis::repository_api::get_workflow_job(
&self.configuration(),
&repository.owner,
&repository.repository,
&job.to_string(),
)
.await
.map_err(Error::generated)
}
pub async fn action_run(
&self,
repository: &RepositoryId,
run: i64,
) -> Result<models::ActionWorkflowRun> {
positive(run, "run ID")?;
apis::repository_api::get_workflow_run(
&self.configuration(),
&repository.owner,
&repository.repository,
&run.to_string(),
)
.await
.map_err(Error::generated)
}
pub async fn action_job_details(
&self,
repository: &RepositoryId,
run: i64,
job: i64,
) -> Result<ActionJobDetails> {
let (run, job, text) = tokio::try_join!(
self.action_run(repository, run),
self.action_job(repository, job),
self.action_job_log(repository, job),
)?;
let workflow = run
.path
.as_deref()
.and_then(|path| path.split('@').next())
.filter(|path| !path.is_empty())
.map(|path| {
if path.starts_with(".gitea/workflows/") {
path.to_owned()
} else {
format!(".gitea/workflows/{path}")
}
});
let steps = if let (Some(path), Some(reference), Some(job_name)) = (
workflow.as_deref(),
run.head_sha.as_deref(),
job.name.as_deref(),
) {
match self
.repository_file_at_ref(repository, path, Some(reference))
.await
{
Ok(yaml) => action_workflow_step_names(&yaml, job_name),
Err(_) => Vec::new(),
}
} else {
Vec::new()
};
let groups = group_action_log_with_steps(&text, &steps);
Ok(ActionJobDetails { job, text, groups })
}
async fn action_jobs(
&self,
repository: &RepositoryId,
run: i32,
) -> Result<Vec<models::ActionWorkflowJob>> {
let mut jobs = Vec::new();
for page in 1.. {
let mut batch = apis::repository_api::list_workflow_run_jobs(
&self.configuration(),
&repository.owner,
&repository.repository,
run,
None,
Some(page),
Some(100),
)
.await
.map_err(Error::generated)?
.jobs
.unwrap_or_default();
let complete = batch.len() < 100;
jobs.append(&mut batch);
if complete {
return Ok(jobs);
}
}
unreachable!()
}
}
pub fn parse_action_inputs(values: &[String]) -> Result<BTreeMap<String, String>> {
let mut inputs = BTreeMap::new();
for value in values {
for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) {
let (key, value) = line.split_once('=').ok_or_else(|| {
Error::InvalidInput(format!("workflow input must be KEY=VALUE: {line}"))
})?;
let key = key.trim();
if key.is_empty() {
return Err(Error::InvalidInput(
"workflow input key must not be empty".into(),
));
}
if inputs.insert(key.into(), value.trim().into()).is_some() {
return Err(Error::InvalidInput(format!(
"workflow input {key} was provided more than once"
)));
}
}
}
Ok(inputs)
}
pub fn group_action_log(log: &str) -> Vec<ActionLogGroup> {
group_action_log_with_steps(log, &[])
}
pub fn group_action_log_with_steps(log: &str, steps: &[String]) -> Vec<ActionLogGroup> {
let mut groups = Vec::new();
let mut name = "Set up job".to_string();
let mut lines = Vec::new();
let mut depth = 0_u32;
let mut step_index = None;
for line in log.lines() {
if let Some((prefix, label)) = log_group_marker(line) {
if depth == 0
&& let Some(step) = label
.strip_prefix("Run ")
.or_else(|| label.strip_prefix("Post "))
{
push_log_group(&mut groups, name, lines);
name = if label.starts_with("Post ") || step.starts_with("Post ") {
step_index = None;
"Complete job".into()
} else {
step_index = steps.iter().position(|name| name == step);
step.into()
};
lines = Vec::new();
} else {
lines.push(format!("{prefix}{label}"));
}
depth += 1;
} else if is_log_group_end(line) {
depth = depth.saturating_sub(1);
if depth == 0
&& let Some(index) = step_index
&& let Some(next) = steps.get(index + 1)
{
push_log_group(&mut groups, name, lines);
name = next.clone();
lines = Vec::new();
step_index = Some(index + 1);
}
} else if depth == 0 && log_message(line).starts_with("Run Post ") {
push_log_group(&mut groups, name, lines);
name = "Complete job".into();
lines = vec![line.to_string()];
step_index = None;
} else {
lines.push(line.to_string());
}
}
push_log_group(&mut groups, name, lines);
if groups.len() == 1 && groups[0].name == "Set up job" {
groups[0].name = "Job log".into();
}
groups
}
fn log_group_marker(line: &str) -> Option<(&str, &str)> {
["::group::", "##[group]"]
.into_iter()
.find_map(|marker| line.split_once(marker))
.map(|(prefix, label)| (prefix, label.trim()))
}
fn is_log_group_end(line: &str) -> bool {
line.contains("::endgroup::") || line.contains("##[endgroup]")
}
fn push_log_group(groups: &mut Vec<ActionLogGroup>, name: String, lines: Vec<String>) {
if !lines.is_empty() {
let duration_seconds = log_duration(&lines);
if let Some(group) = groups.last_mut()
&& group.name == name
{
group.text.push('\n');
group.text.push_str(&lines.join("\n"));
group.duration_seconds =
log_duration(&group.text.lines().map(str::to_owned).collect::<Vec<_>>());
return;
}
groups.push(ActionLogGroup {
name,
text: lines.join("\n"),
duration_seconds,
});
}
}
fn action_workflow_step_names(yaml: &[u8], job: &str) -> Vec<String> {
let Ok(value) = serde_yaml::from_slice::<serde_yaml::Value>(yaml) else {
return Vec::new();
};
value["jobs"][job]["steps"]
.as_sequence()
.into_iter()
.flatten()
.filter_map(|step| step["name"].as_str().map(str::to_owned))
.collect()
}
fn log_message(line: &str) -> &str {
line.split_once("Z ").map_or(line, |(_, message)| message)
}
fn log_duration(lines: &[String]) -> Option<u64> {
let start = lines.iter().find_map(|line| log_timestamp(line))?;
let end = lines.iter().rev().find_map(|line| log_timestamp(line))?;
Some(end.saturating_sub(start).max(1))
}
fn log_timestamp(line: &str) -> Option<u64> {
crate::parse_api_timestamp(line).and_then(|value| value.try_into().ok())
}
fn action_run_id(run: i64) -> Result<i32> {
@@ -170,6 +441,31 @@ mod tests {
use super::*;
use crate::Provider;
fn read_request(stream: &mut std::net::TcpStream) -> String {
let mut request = Vec::new();
loop {
let mut buffer = [0; 4096];
let length = stream.read(&mut buffer).unwrap();
request.extend_from_slice(&buffer[..length]);
let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
continue;
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")
.map(str::to_owned)
})
.and_then(|length| length.parse::<usize>().ok())
.unwrap_or(0);
if request.len() >= header_end + 4 + content_length {
return String::from_utf8(request).unwrap();
}
}
}
#[tokio::test]
async fn gets_every_job_log_for_a_run() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -210,4 +506,104 @@ mod tests {
assert!(requests[1].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs "));
assert!(requests[1].contains("accept: text/plain"));
}
#[tokio::test]
async fn dispatches_reference_and_inputs() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read_request(&mut stream);
write!(
stream,
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
request
});
let client =
Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap();
let repository = RepositoryId::new("hugo", "Gotcha").unwrap();
client
.dispatch_action_workflow(
&repository,
"release.yml",
"main",
&BTreeMap::from([
("channel".into(), "stable".into()),
("dry_run".into(), "false".into()),
]),
)
.await
.unwrap();
let request = server.join().unwrap();
assert!(request.starts_with(
"POST /api/v1/repos/hugo/Gotcha/actions/workflows/release.yml/dispatches "
));
assert!(request.contains(r#""ref":"main""#));
assert!(request.contains(r#""channel":"stable""#));
assert!(request.contains(r#""dry_run":"false""#));
}
#[test]
fn parses_dispatch_inputs_once() {
let inputs = parse_action_inputs(&[
"channel=stable".into(),
"notes=public update\ndry_run=false".into(),
])
.unwrap();
assert_eq!(inputs["channel"], "stable");
assert_eq!(inputs["notes"], "public update");
assert!(parse_action_inputs(&["channel".into()]).is_err());
assert!(parse_action_inputs(&["channel=a\nchannel=b".into()]).is_err());
}
#[test]
fn groups_runner_logs_into_setup_steps_and_completion() {
let groups = group_action_log(
"00 ::group::Runner Information\n01 runner\n02 ::endgroup::\n03 ::group::Run Check out repository\n04 checkout\n05 ::endgroup::\n06 ::group::Run Post Check out repository\n07 cleanup\n08 ::endgroup::\n",
);
assert_eq!(
groups
.iter()
.map(|group| group.name.as_str())
.collect::<Vec<_>>(),
["Set up job", "Check out repository", "Complete job"]
);
assert_eq!(groups[0].text, "00 Runner Information\n01 runner");
assert_eq!(groups[1].text, "04 checkout");
assert_eq!(groups[2].text, "07 cleanup");
let completion = group_action_log(
"::group::Run Post first\none\n::endgroup::\n::group::Run Post second\ntwo\n::endgroup::",
);
assert_eq!(completion.len(), 1);
assert_eq!(completion[0].name, "Complete job");
assert_eq!(completion[0].text, "one\ntwo");
let steps = action_workflow_step_names(
b"jobs:\n audit:\n steps:\n - name: Check out repository\n - name: Scan dependencies\n",
"audit",
);
let groups = group_action_log_with_steps(
"2026-08-10T03:17:25Z setup\n2026-08-10T03:17:26Z ::group::Run Check out repository\n2026-08-10T03:17:27Z checkout\n2026-08-10T03:17:28Z ::endgroup::\n2026-08-10T03:17:29Z scanning\n2026-08-10T03:17:32Z Run Post Check out repository\n2026-08-10T03:17:33Z cleanup\n",
&steps,
);
assert_eq!(
groups
.iter()
.map(|group| group.name.as_str())
.collect::<Vec<_>>(),
[
"Set up job",
"Check out repository",
"Scan dependencies",
"Complete job"
]
);
assert_eq!(groups[2].duration_seconds, Some(1));
assert_eq!(groups[3].duration_seconds, Some(1));
}
}

View File

@@ -197,6 +197,14 @@ pub fn parse_api_date(value: &str) -> Option<i64> {
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
}
pub fn parse_api_timestamp(value: &str) -> Option<i64> {
let date = parse_api_date(value)?;
let hour = value.get(11..13)?.parse::<i64>().ok()?;
let minute = value.get(14..16)?.parse::<i64>().ok()?;
let second = value.get(17..19)?.parse::<i64>().ok()?;
(hour < 24 && minute < 60 && second < 60).then_some(date + hour * 3_600 + minute * 60 + second)
}
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
let days = days + 719_468;
let era = days.div_euclid(146_097);
@@ -241,6 +249,11 @@ mod tests {
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
assert_eq!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z");
assert_eq!(
parse_api_timestamp("2024-02-29T12:34:56.123Z"),
Some(leap_day + 45_296)
);
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
assert_eq!(parse_api_timestamp("2024-02-29T25:00:00Z"), None);
}
}

View File

@@ -22,13 +22,16 @@ pub mod notifications;
mod pulls;
mod repositories;
pub use actions::{ActionJobLog, ActionRunDetails};
pub use actions::{
ActionJobDetails, ActionJobLog, ActionLogGroup, ActionRunDetails, ActionRunQuery,
group_action_log, group_action_log_with_steps, parse_action_inputs,
};
pub use activity::{ActivityFilter, ServerActivityPager};
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date,
api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp,
};
pub use issues::comment_can_edit;
pub use pulls::{PullFileSource, pull_file_source, pull_state};

View File

@@ -128,6 +128,31 @@ impl Client {
.map_err(Into::into)
}
pub async fn repository_file_at_ref(
&self,
repository: &RepositoryId,
path: &str,
reference: Option<&str>,
) -> Result<Vec<u8>> {
let endpoint = format!(
"repos/{}/{}/raw/{}",
apis::urlencode(&repository.owner),
apis::urlencode(&repository.repository),
encode_path(path),
);
let request = self.request(Method::GET, &endpoint)?;
let request = match reference {
Some(reference) => request.query(&[("ref", reference)]),
None => request,
};
self.execute(request)
.await?
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(Into::into)
}
pub async fn branch_history(
&self,
repository: &RepositoryId,

View File

@@ -175,3 +175,34 @@ async fn reads_file_with_spaces_from_raw_endpoint() {
)
);
}
#[tokio::test]
async fn reads_historical_file_from_requested_ref() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0; 4096];
let length = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..length]).into_owned();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\ncontents"
)
.unwrap();
request
});
let client = Client::new(&format!("http://{address}"), None).unwrap();
let repository = RepositoryId::new("gitea", "gitea").unwrap();
client
.repository_file_at_ref(&repository, ".gitea/workflows/ci.yml", Some("abc123"))
.await
.unwrap();
assert!(
server.join().unwrap().starts_with(
"GET /api/v1/repos/gitea/gitea/raw/.gitea%2Fworkflows%2Fci.yml?ref=abc123 "
)
);
}