Add first-class Actions CLI support

This commit is contained in:
Georg Bauer
2026-08-09 16:03:18 +02:00
parent 8ffb20a0d7
commit fd79939751
7 changed files with 531 additions and 54 deletions

213
crates/gitea/src/actions.rs Normal file
View File

@@ -0,0 +1,213 @@
use reqwest::header::ACCEPT;
use crate::{Client, Error, Method, RepositoryId, Result, models, positive};
use gitea_openapi::apis;
#[derive(Debug)]
pub struct ActionRunDetails {
pub run: models::ActionWorkflowRun,
pub jobs: Vec<models::ActionWorkflowJob>,
}
#[derive(Debug)]
pub struct ActionJobLog {
pub job: models::ActionWorkflowJob,
pub text: String,
}
impl Client {
pub async fn action_workflows(
&self,
repository: &RepositoryId,
) -> Result<Vec<models::ActionWorkflow>> {
apis::repository_api::actions_list_repository_workflows(
&self.configuration(),
&repository.owner,
&repository.repository,
)
.await
.map(|response| response.workflows.unwrap_or_default())
.map_err(Error::generated)
}
pub async fn dispatch_action_workflow(
&self,
repository: &RepositoryId,
workflow: &str,
reference: &str,
) -> Result<()> {
if workflow.is_empty() || reference.is_empty() {
return Err(Error::InvalidInput(
"workflow and reference must not be empty".into(),
));
}
apis::repository_api::actions_dispatch_workflow(
&self.configuration(),
&repository.owner,
&repository.repository,
workflow,
Some(models::CreateActionWorkflowDispatch::new(reference.into())),
)
.await
.map_err(Error::generated)
}
pub async fn action_runs(
&self,
repository: &RepositoryId,
) -> Result<Vec<models::ActionWorkflowRun>> {
apis::repository_api::get_workflow_runs(
&self.configuration(),
&repository.owner,
&repository.repository,
None,
None,
None,
None,
None,
Some(1),
Some(100),
)
.await
.map(|response| response.workflow_runs.unwrap_or_default())
.map_err(Error::generated)
}
pub async fn action_run_details(
&self,
repository: &RepositoryId,
run: i64,
) -> Result<ActionRunDetails> {
positive(run, "run ID")?;
let run_id = action_run_id(run)?;
let (run, jobs) = tokio::try_join!(
async {
apis::repository_api::get_workflow_run(
&self.configuration(),
&repository.owner,
&repository.repository,
&run.to_string(),
)
.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)
}
)?;
Ok(ActionRunDetails {
run,
jobs: jobs.jobs.unwrap_or_default(),
})
}
pub async fn action_run_logs(
&self,
repository: &RepositoryId,
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 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?;
logs.push(ActionJobLog { job, text });
}
Ok(logs)
}
}
fn action_run_id(run: i64) -> Result<i32> {
positive(run, "run ID")?;
i32::try_from(run).map_err(|_| Error::InvalidInput("run ID is too large".into()))
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
use super::*;
use crate::Provider;
#[tokio::test]
async fn gets_every_job_log_for_a_run() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let mut requests = Vec::new();
for (content_type, body) in [
(
"application/json",
r#"{"total_count":1,"jobs":[{"id":139,"name":"audit"}]}"#,
),
("text/plain", "scanner output\nexit code 1\n"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0; 4096];
let length = stream.read(&mut buffer).unwrap();
requests.push(String::from_utf8_lossy(&buffer[..length]).into_owned());
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
}
requests
});
let client =
Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap();
let repository = RepositoryId::new("hugo", "Gotcha").unwrap();
let logs = client.action_run_logs(&repository, 95).await.unwrap();
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].job.name.as_deref(), Some("audit"));
assert_eq!(logs[0].text, "scanner output\nexit code 1\n");
let requests = server.join().unwrap();
assert!(requests[0].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/runs/95/jobs?"));
assert!(requests[1].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs "));
assert!(requests[1].contains("accept: text/plain"));
}
}

View File

@@ -11,6 +11,7 @@ use gitea_openapi::apis;
pub use gitea_openapi::models;
pub use models::{Repository, ServerVersion, User};
mod actions;
pub mod activity;
mod config;
pub mod diff;
@@ -20,6 +21,7 @@ mod milestones;
mod pulls;
mod repositories;
pub use actions::{ActionJobLog, ActionRunDetails};
pub use activity::ActivityFilter;
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{