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,7 +1,82 @@
use gotcha_gitea::{
Client, DEFAULT_PAGE_SIZE as PAGE_SIZE, IssueQuery, RepositoryId, api_date, diff, models,
ActionJobDetails, ActionRunDetails, ActionRunQuery, Client, DEFAULT_PAGE_SIZE as PAGE_SIZE,
IssueQuery, Page as GiteaPage, RepositoryId, api_date, diff, models, parse_action_inputs,
};
pub async fn load_action_workflows(
server: &Server,
owner: &str,
repository: &str,
) -> Result<Vec<models::ActionWorkflow>, String> {
client(server)?
.action_workflows(&scope(owner, repository)?)
.await
.map_err(message)
}
pub async fn dispatch_action_workflow(
server: &Server,
owner: &str,
repository: &str,
workflow: &str,
reference: &str,
inputs: &[String],
) -> Result<(), String> {
client(server)?
.dispatch_action_workflow(
&scope(owner, repository)?,
workflow,
reference,
&parse_action_inputs(inputs).map_err(message)?,
)
.await
.map_err(message)
}
pub async fn load_action_runs(
server: &Server,
owner: &str,
repository: &str,
page: i32,
) -> Result<GiteaPage<models::ActionWorkflowRun>, String> {
client(server)?
.action_runs(
&scope(owner, repository)?,
&ActionRunQuery {
page,
limit: PAGE_SIZE,
..Default::default()
},
)
.await
.map_err(message)
}
pub async fn load_action_run(
server: &Server,
owner: &str,
repository: &str,
run: i64,
) -> Result<ActionRunDetails, String> {
client(server)?
.action_run_details(&scope(owner, repository)?, run)
.await
.map_err(message)
}
pub async fn load_action_job_details(
server: &Server,
owner: &str,
repository: &str,
run: i64,
job: i64,
) -> Result<ActionJobDetails, String> {
client(server)?
.action_job_details(&scope(owner, repository)?, run, job)
.await
.map_err(message)
}
use crate::{
domain::{
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,

View File

@@ -0,0 +1,75 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
pub async fn action_workflows(
&self,
owner: String,
repository: String,
) -> Result<Vec<ActionWorkflowRow>, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
Ok(action_workflow_rows(
load_action_workflows(&self.server()?, owner, repository).await?,
))
}
pub async fn dispatch_action_workflow(
&self,
owner: String,
repository: String,
workflow: String,
reference: String,
inputs: Vec<String>,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
dispatch_action_workflow(
&self.server()?,
owner,
repository,
&workflow,
&reference,
&inputs,
)
.await
.map_err(Into::into)
}
pub async fn action_runs(
&self,
owner: String,
repository: String,
page: u32,
) -> Result<ActionRunListPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
let page = load_action_runs(&self.server()?, owner, repository, valid_page(page)?).await?;
Ok(ActionRunListPage {
rows: action_run_rows(&page.items),
has_more: page.has_more,
})
}
pub async fn action_run(
&self,
owner: String,
repository: String,
run: i64,
) -> Result<ActionRunPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
Ok(action_run_page(
load_action_run(&self.server()?, owner, repository, run).await?,
))
}
pub async fn action_job_log(
&self,
owner: String,
repository: String,
run: i64,
job: i64,
) -> Result<ActionJobLogPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
Ok(action_job_log_page(
load_action_job_details(&self.server()?, owner, repository, run, job).await?,
))
}
}

View File

@@ -1,3 +1,4 @@
mod actions;
mod content;
mod issues;
mod milestones;

View File

@@ -229,9 +229,25 @@ impl GotchaCore {
pull_status: state.preferences.pull_status.clone(),
appearance: state.preferences.appearance.index() as u32,
notifications_enabled: state.preferences.notifications_enabled,
primary_destinations: state.preferences.primary_destinations.clone(),
}
}
pub fn set_primary_destinations(
&self,
destinations: Vec<PrimaryDestination>,
) -> Result<(), GotchaError> {
let mut normalized = destinations;
if normalize_primary_destinations(&mut normalized) {
return Err(
"Choose each navigation destination once, with no more than four selected.".into(),
);
}
let mut state = self.state.lock().unwrap();
state.preferences.primary_destinations = normalized;
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
if !matches!(status.as_str(), "open" | "closed") {
return Err("Unsupported issue status.".into());

View File

@@ -5,7 +5,7 @@ use std::{
pub use gotcha_gitea::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
civil_from_days, days_from_civil, parse_api_date,
civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp,
};
use serde::{Deserialize, Serialize};
@@ -74,6 +74,8 @@ pub struct Preferences {
pub notifications_enabled: bool,
#[serde(default)]
pub notification_cursors: BTreeMap<String, String>,
#[serde(default = "default_primary_destinations")]
pub primary_destinations: Vec<crate::PrimaryDestination>,
}
impl Default for Preferences {
@@ -90,10 +92,32 @@ impl Default for Preferences {
appearance: AppearanceMode::default(),
notifications_enabled: false,
notification_cursors: BTreeMap::new(),
primary_destinations: default_primary_destinations(),
}
}
}
pub fn default_primary_destinations() -> Vec<crate::PrimaryDestination> {
vec![
crate::PrimaryDestination::Issues,
crate::PrimaryDestination::Repositories,
crate::PrimaryDestination::PullRequests,
crate::PrimaryDestination::Milestones,
]
}
pub fn normalize_primary_destinations(destinations: &mut Vec<crate::PrimaryDestination>) -> bool {
let original = destinations.clone();
let mut unique = Vec::with_capacity(destinations.len().min(4));
for destination in destinations.drain(..) {
if !unique.contains(&destination) && unique.len() < 4 {
unique.push(destination);
}
}
*destinations = unique;
*destinations != original
}
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct IssueFilter {
#[serde(default)]
@@ -222,4 +246,32 @@ mod tests {
""
);
}
#[test]
fn primary_destinations_are_ordered_unique_and_limited() {
let migrated: Preferences = serde_json::from_str("{}").unwrap();
assert_eq!(
migrated.primary_destinations,
default_primary_destinations()
);
let mut destinations = vec![
crate::PrimaryDestination::Actions,
crate::PrimaryDestination::Actions,
crate::PrimaryDestination::Issues,
crate::PrimaryDestination::Repositories,
crate::PrimaryDestination::PullRequests,
crate::PrimaryDestination::Milestones,
];
assert!(normalize_primary_destinations(&mut destinations));
assert_eq!(
destinations,
[
crate::PrimaryDestination::Actions,
crate::PrimaryDestination::Issues,
crate::PrimaryDestination::Repositories,
crate::PrimaryDestination::PullRequests,
]
);
}
}

View File

@@ -1,3 +1,4 @@
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use thiserror::Error;
@@ -14,11 +15,23 @@ use storage::*;
uniffi::setup_scaffolding!();
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, uniffi::Enum)]
#[serde(rename_all = "snake_case")]
pub enum PrimaryDestination {
Issues,
Repositories,
PullRequests,
Milestones,
Actions,
ServerActivity,
}
#[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum RepositoryPane {
Issues,
Commits,
Milestones,
Actions,
}
#[derive(Clone, Copy, Debug, uniffi::Enum)]
@@ -46,13 +59,14 @@ impl From<gotcha_gitea::Provider> for ServerProvider {
}
impl RepositoryPane {
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];
const ALL: [Self; 4] = [Self::Issues, Self::Commits, Self::Milestones, Self::Actions];
const fn key(self) -> &'static str {
match self {
Self::Issues => "issues",
Self::Commits => "commits",
Self::Milestones => "milestones",
Self::Actions => "actions",
}
}
}
@@ -81,6 +95,7 @@ pub struct Settings {
pub pull_status: String,
pub appearance: u32,
pub notifications_enabled: bool,
pub primary_destinations: Vec<PrimaryDestination>,
}
#[derive(uniffi::Object)]

View File

@@ -5,6 +5,7 @@ use gotcha_gitea::{
use crate::domain::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
parse_api_timestamp,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
@@ -35,6 +36,84 @@ pub struct RepositoryRow {
pub description: String,
pub meta: String,
pub favorite: bool,
pub default_branch: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum ActionState {
Queued,
Waiting,
InProgress,
Succeeded,
Failed,
Cancelled,
Skipped,
Unknown,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionWorkflowRow {
pub id: String,
pub name: String,
pub path: String,
pub state: String,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionRunRow {
pub id: i64,
pub number: i64,
pub title: String,
pub state: ActionState,
pub status: String,
pub conclusion: String,
pub branch: String,
pub event: String,
pub meta: String,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionStepRow {
pub name: String,
pub state: ActionState,
pub meta: String,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionJobRow {
pub id: i64,
pub name: String,
pub state: ActionState,
pub meta: String,
pub steps: Vec<ActionStepRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionRunListPage {
pub rows: Vec<ActionRunRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionRunPage {
pub run: ActionRunRow,
pub jobs: Vec<ActionJobRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionJobLogPage {
pub job: ActionJobRow,
pub text: String,
pub groups: Vec<ActionLogGroupRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionLogGroupRow {
pub name: String,
pub text: String,
pub line_count: u64,
pub state: ActionState,
pub duration: String,
}
#[derive(Clone, uniffi::Record)]
@@ -404,6 +483,7 @@ pub struct WidgetPullPage {
pub rows: Vec<PullRow>,
}
mod actions;
mod details;
mod files;
mod helpers;
@@ -411,6 +491,7 @@ mod home;
mod lists;
mod notifications;
pub use actions::*;
pub use details::*;
pub use files::*;
pub use helpers::compact_date;

View File

@@ -0,0 +1,240 @@
use gotcha_gitea::{ActionJobDetails, ActionRunDetails, models};
use super::*;
pub fn action_workflow_rows(workflows: Vec<models::ActionWorkflow>) -> Vec<ActionWorkflowRow> {
workflows
.into_iter()
.map(|workflow| {
let path = workflow.path.unwrap_or_default();
ActionWorkflowRow {
id: workflow.id.unwrap_or_else(|| path.clone()),
name: workflow.name.unwrap_or_else(|| "Unnamed workflow".into()),
path,
state: workflow.state.unwrap_or_else(|| "unknown".into()),
}
})
.collect()
}
pub fn action_run_rows(runs: &[models::ActionWorkflowRun]) -> Vec<ActionRunRow> {
runs.iter().map(action_run_row).collect()
}
pub fn action_run_page(details: ActionRunDetails) -> ActionRunPage {
ActionRunPage {
run: action_run_row(&details.run),
jobs: details.jobs.iter().map(action_job_row).collect(),
}
}
pub fn action_job_log_page(details: ActionJobDetails) -> ActionJobLogPage {
let job = action_job_row(&details.job);
let groups = details
.groups
.into_iter()
.map(|group| ActionLogGroupRow {
state: action_log_group_state(&job, &group.name, &group.text),
duration: group
.duration_seconds
.map(format_duration)
.unwrap_or_default(),
line_count: group.text.lines().count() as u64,
name: group.name,
text: group.text,
})
.collect();
ActionJobLogPage {
job,
text: details.text,
groups,
}
}
fn format_duration(seconds: u64) -> String {
if seconds >= 3_600 {
format!("{}h {}m", seconds / 3_600, seconds % 3_600 / 60)
} else if seconds >= 60 {
format!("{}m {}s", seconds / 60, seconds % 60)
} else {
format!("{seconds}s")
}
}
fn action_log_group_state(job: &ActionJobRow, name: &str, text: &str) -> ActionState {
if let Some(step) = job
.steps
.iter()
.find(|step| step.name.eq_ignore_ascii_case(name))
{
return step.state;
}
if text.contains("::error::")
|| text.contains("##[error]")
|| text.contains("Process completed with exit code")
|| text.contains("Job failed")
{
ActionState::Failed
} else if name == "Complete job" {
job.state
} else if matches!(job.state, ActionState::Succeeded | ActionState::Failed) {
ActionState::Succeeded
} else {
ActionState::Unknown
}
}
fn action_run_row(run: &models::ActionWorkflowRun) -> ActionRunRow {
let status = run.status.clone().unwrap_or_else(|| "unknown".into());
let conclusion = run.conclusion.clone().unwrap_or_default();
let duration = duration_between(run.started_at.as_deref(), run.completed_at.as_deref());
ActionRunRow {
id: run.id.unwrap_or_default(),
number: run.run_number.unwrap_or_default(),
title: run
.display_title
.clone()
.or_else(|| run.path.clone())
.unwrap_or_else(|| "Workflow run".into()),
state: action_state(&status, &conclusion),
status,
conclusion,
branch: run.head_branch.clone().unwrap_or_default(),
event: run.event.clone().unwrap_or_default(),
meta: [
duration.as_deref().unwrap_or_default(),
run.started_at.as_deref().unwrap_or_default(),
run.actor
.as_ref()
.and_then(|actor| actor.login.as_deref())
.unwrap_or_default(),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(" · "),
}
}
fn action_job_row(job: &models::ActionWorkflowJob) -> ActionJobRow {
let status = job.status.as_deref().unwrap_or("unknown");
let conclusion = job.conclusion.as_deref().unwrap_or_default();
let duration = duration_between(job.started_at.as_deref(), job.completed_at.as_deref());
ActionJobRow {
id: job.id.unwrap_or_default(),
name: job.name.clone().unwrap_or_else(|| "Job".into()),
state: action_state(status, conclusion),
meta: [
status,
conclusion,
duration.as_deref().unwrap_or_default(),
job.runner_name.as_deref().unwrap_or_default(),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(" · "),
steps: job
.steps
.as_deref()
.unwrap_or_default()
.iter()
.map(|step| {
let status = step.status.as_deref().unwrap_or("unknown");
let conclusion = step.conclusion.as_deref().unwrap_or_default();
let duration =
duration_between(step.started_at.as_deref(), step.completed_at.as_deref());
ActionStepRow {
name: step.name.clone().unwrap_or_else(|| "Step".into()),
state: action_state(status, conclusion),
meta: [status, conclusion, duration.as_deref().unwrap_or_default()]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(" · "),
}
})
.collect(),
}
}
fn duration_between(start: Option<&str>, end: Option<&str>) -> Option<String> {
let seconds = parse_api_timestamp(end?)?.checked_sub(parse_api_timestamp(start?)?)?;
u64::try_from(seconds).ok().map(format_duration)
}
fn action_state(status: &str, conclusion: &str) -> ActionState {
match conclusion {
"success" => ActionState::Succeeded,
"failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed,
"cancelled" => ActionState::Cancelled,
"skipped" | "neutral" => ActionState::Skipped,
_ => match status {
"queued" | "requested" => ActionState::Queued,
"waiting" | "pending" | "blocked" => ActionState::Waiting,
"in_progress" | "running" => ActionState::InProgress,
"success" => ActionState::Succeeded,
"failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed,
"cancelled" => ActionState::Cancelled,
"skipped" | "neutral" => ActionState::Skipped,
_ => ActionState::Unknown,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_progress_and_terminal_conclusions() {
assert_eq!(action_state("queued", ""), ActionState::Queued);
assert_eq!(action_state("waiting", ""), ActionState::Waiting);
assert_eq!(action_state("in_progress", ""), ActionState::InProgress);
assert_eq!(action_state("completed", "success"), ActionState::Succeeded);
assert_eq!(action_state("completed", "failure"), ActionState::Failed);
assert_eq!(
action_state("completed", "cancelled"),
ActionState::Cancelled
);
let mut workflow = models::ActionWorkflow::new();
workflow.path = Some(".gitea/workflows/ci.yml".into());
assert_eq!(
action_workflow_rows(vec![workflow])[0].id,
".gitea/workflows/ci.yml"
);
let mut job = models::ActionWorkflowJob::new();
job.id = Some(1);
job.name = Some("build".into());
job.status = Some("completed".into());
job.conclusion = Some("failure".into());
let mut step = models::ActionWorkflowStep::new();
step.name = Some("Test".into());
step.status = Some("completed".into());
step.conclusion = Some("failure".into());
job.steps = Some(vec![step]);
let log = action_job_log_page(ActionJobDetails {
job,
text: "one\ntwo".into(),
groups: vec![
gotcha_gitea::ActionLogGroup {
name: "Test".into(),
text: "one\ntwo".into(),
duration_seconds: Some(65),
},
gotcha_gitea::ActionLogGroup {
name: "Set up job".into(),
text: "ready".into(),
duration_seconds: Some(1),
},
],
});
assert_eq!(log.groups[0].name, "Test");
assert_eq!(log.groups[0].line_count, 2);
assert_eq!(log.groups[0].state, ActionState::Failed);
assert_eq!(log.groups[0].duration, "1m 5s");
assert_eq!(log.groups[1].state, ActionState::Succeeded);
}
}

View File

@@ -18,6 +18,7 @@ pub fn repository_rows(
name: repository.name,
owner: repository.owner,
description: repository.description,
default_branch: repository.default_branch,
})
.collect()
}

View File

@@ -13,7 +13,7 @@ use security_framework::passwords::{
use crate::{
RepositoryPane,
domain::{Preferences, Server, open_status},
domain::{Preferences, Server, normalize_primary_destinations, open_status},
};
pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String {
@@ -81,6 +81,7 @@ pub fn load_preferences(storage_directory: Option<&str>) -> Result<Preferences,
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
preferences.pull_status = open_status();
}
let navigation_migrated = normalize_primary_destinations(&mut preferences.primary_destinations);
let mut credentials_migrated = false;
for server in &mut preferences.servers {
if server.credential_account.is_empty() {
@@ -90,7 +91,7 @@ pub fn load_preferences(storage_directory: Option<&str>) -> Result<Preferences,
server.token = String::from_utf8(load_server_token(server)?)
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
}
if storage_migrated || favorites_migrated || credentials_migrated {
if storage_migrated || favorites_migrated || navigation_migrated || credentials_migrated {
save_preferences(&preferences)?;
}
Ok(preferences)
@@ -304,6 +305,7 @@ mod tests {
assert_eq!(
favorites,
[
"actions|https://gitea.example.com|octo/demo".to_string(),
"commits|https://gitea.example.com|octo/demo".to_string(),
"issues|https://gitea.example.com|octo/demo".to_string(),
"milestones|https://gitea.example.com|octo/demo".to_string(),

View File

@@ -1,8 +1,9 @@
use std::error::Error;
use gotcha_gitea::{
ActionJobLog, ActionRunDetails, Client,
ActionJobLog, ActionRunDetails, ActionRunQuery, Client,
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
parse_action_inputs,
};
use crate::{
@@ -16,11 +17,14 @@ Usage: gotcha action workflow|run SUBCOMMAND
Workflow subcommands:
workflow list [OWNER/REPOSITORY]
List workflows
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY]
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…
Dispatch a workflow on a Git reference
Run subcommands:
run list [OWNER/REPOSITORY] List workflow runs
run list [OWNER/REPOSITORY] [--page N] [--limit N]
[--status STATUS] [--event EVENT] [--branch BRANCH]
[--actor LOGIN] [--sha SHA]
List workflow runs
run show ID [OWNER/REPOSITORY] Show a run and its jobs
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
@@ -29,13 +33,14 @@ Usage: gotcha action workflow SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
dispatch WORKFLOW REF [OWNER/REPOSITORY]";
dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…";
const RUN_HELP: &str = "\
Usage: gotcha action run SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
list [OWNER/REPOSITORY] [--page N] [--limit N] [--status STATUS]
[--event EVENT] [--branch BRANCH] [--actor LOGIN] [--sha SHA]
show ID [OWNER/REPOSITORY]
logs ID [OWNER/REPOSITORY]";
@@ -59,33 +64,24 @@ pub async fn run(
.await?,
);
}
[domain, group, action, workflow, reference]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
dispatch(client, &scope(selection, None)?, workflow, reference).await?;
}
[domain, group, action, workflow, reference, repository]
[domain, group, action, arguments @ ..]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
let arguments = dispatch_arguments(arguments, selection)?;
dispatch(
client,
&scope(selection, Some(repository))?,
workflow,
reference,
&arguments.repository,
&arguments.workflow,
&arguments.reference,
&arguments.inputs,
)
.await?;
}
[domain, group, action] if domain == "action" && group == "run" && action == "list" => {
print_runs(&client.action_runs(&scope(selection, None)?).await?);
}
[domain, group, action, repository]
[domain, group, action, arguments @ ..]
if domain == "action" && group == "run" && action == "list" =>
{
print_runs(
&client
.action_runs(&scope(selection, Some(repository))?)
.await?,
);
let (repository, query) = run_list_arguments(arguments, selection)?;
print_runs(&client.action_runs(&repository, &query).await?.items);
}
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
print_run(
@@ -161,14 +157,96 @@ async fn dispatch(
repository: &RepositoryScope,
workflow: &str,
reference: &str,
inputs: &std::collections::BTreeMap<String, String>,
) -> Result<(), Box<dyn Error>> {
client
.dispatch_action_workflow(repository, workflow, reference)
.dispatch_action_workflow(repository, workflow, reference, inputs)
.await?;
println!("Dispatched workflow {workflow} on {reference}.");
Ok(())
}
struct DispatchArguments {
repository: RepositoryScope,
workflow: String,
reference: String,
inputs: std::collections::BTreeMap<String, String>,
}
fn dispatch_arguments(
arguments: &[String],
selection: &Selection,
) -> Result<DispatchArguments, Box<dyn Error>> {
let [workflow, reference, remaining @ ..] = arguments else {
return Err("usage: gotcha action workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…".into());
};
let mut repository = None;
let mut inputs = Vec::new();
let mut index = 0;
while index < remaining.len() {
if remaining[index] == "--input" {
index += 1;
inputs.push(
remaining
.get(index)
.ok_or("--input requires KEY=VALUE")?
.clone(),
);
} else if remaining[index].starts_with('-') {
return Err(format!("unknown dispatch option: {}", remaining[index]).into());
} else if repository.replace(remaining[index].as_str()).is_some() {
return Err("only one OWNER/REPOSITORY may be supplied".into());
}
index += 1;
}
Ok(DispatchArguments {
repository: scope(selection, repository)?,
workflow: workflow.clone(),
reference: reference.clone(),
inputs: parse_action_inputs(&inputs)?,
})
}
fn run_list_arguments(
arguments: &[String],
selection: &Selection,
) -> Result<(RepositoryScope, ActionRunQuery), Box<dyn Error>> {
let mut repository = None;
let mut query = ActionRunQuery::default();
let mut index = 0;
while index < arguments.len() {
let argument = &arguments[index];
if argument.starts_with('-') {
index += 1;
let value = arguments
.get(index)
.ok_or_else(|| format!("{argument} requires a value"))?;
match argument.as_str() {
"--page" => query.page = positive_number(value, "page")?,
"--limit" => query.limit = positive_number(value, "limit")?,
"--status" => query.status = Some(value.clone()),
"--event" => query.event = Some(value.clone()),
"--branch" => query.branch = Some(value.clone()),
"--actor" => query.actor = Some(value.clone()),
"--sha" => query.head_sha = Some(value.clone()),
_ => return Err(format!("unknown run-list option: {argument}").into()),
}
} else if repository.replace(argument.as_str()).is_some() {
return Err("only one OWNER/REPOSITORY may be supplied".into());
}
index += 1;
}
Ok((scope(selection, repository)?, query))
}
fn positive_number(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
value
.parse::<i32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{name} must be a positive integer").into())
}
fn print_workflows(workflows: &[ActionWorkflow]) {
print_table(
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
@@ -299,4 +377,39 @@ mod tests {
.contains("logs ID")
);
}
#[test]
fn parses_dispatch_inputs_and_run_filters() {
let selection = Selection {
name: Some("gitea".into()),
url: "https://gitea.example.com".into(),
token: Some("secret".into()),
provider: gotcha_gitea::Provider::Gitea,
repository: Some(RepositoryScope::parse("hugo/Gotcha").unwrap()),
};
let dispatch = dispatch_arguments(
&[
"release.yml".into(),
"main".into(),
"--input".into(),
"channel=stable".into(),
],
&selection,
)
.unwrap();
assert_eq!(dispatch.inputs["channel"], "stable");
let (_, query) = run_list_arguments(
&[
"--page".into(),
"2".into(),
"--status".into(),
"in_progress".into(),
],
&selection,
)
.unwrap();
assert_eq!(query.page, 2);
assert_eq!(query.status.as_deref(), Some("in_progress"));
}
}

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 "
)
);
}

View File

@@ -5,8 +5,8 @@ use std::{
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use gotcha_gitea::{
ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, MilestoneDraft,
Provider, RepositoryId, Selection, models,
ActionRunQuery, ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery,
MilestoneDraft, Provider, RepositoryId, Selection, models, parse_action_inputs,
};
use crate::editor::{Editor, EditorAction, EditorEvent};
@@ -21,15 +21,17 @@ pub enum Tab {
Repositories,
Pulls,
Milestones,
Actions,
}
impl Tab {
pub const ALL: [Self; 5] = [
pub const ALL: [Self; 6] = [
Self::Home,
Self::Issues,
Self::Repositories,
Self::Pulls,
Self::Milestones,
Self::Actions,
];
pub const fn title(self) -> &'static str {
@@ -39,6 +41,7 @@ impl Tab {
Self::Repositories => "3 Repos",
Self::Pulls => "4 PRs",
Self::Milestones => "5 Milestones",
Self::Actions => "6 Actions",
}
}
}
@@ -48,6 +51,7 @@ pub enum RepositoryDestination {
Issues,
Commits,
Milestones,
Actions,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -66,6 +70,10 @@ pub enum ScreenKind {
Pull(RepositoryId, i64),
Milestones(RepositoryId),
Milestone(RepositoryId, i64),
ActionWorkflows(RepositoryId),
ActionRuns(RepositoryId),
ActionRun(RepositoryId, i64),
ActionLog(RepositoryId, i64, i64, String),
}
impl ScreenKind {
@@ -80,6 +88,9 @@ impl ScreenKind {
| Self::Files(_, _)
| Self::Pulls
| Self::Milestones(_)
| Self::ActionWorkflows(_)
| Self::ActionRuns(_)
| Self::ActionRun(_, _)
)
}
}
@@ -96,6 +107,10 @@ pub enum Target {
Content(RepositoryId, String, bool),
Comment(RepositoryId, i64, i64, String, bool),
Text(String, String),
ActionRuns(RepositoryId),
ActionWorkflow(RepositoryId, String, String),
ActionRun(RepositoryId, i64),
ActionJob(RepositoryId, i64, i64, String),
}
#[derive(Clone, Debug)]
@@ -354,6 +369,7 @@ impl App {
KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await,
KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await,
KeyCode::Char('5') => self.switch_tab(Tab::Milestones).await,
KeyCode::Char('6') => self.switch_tab(Tab::Actions).await,
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
KeyCode::Char('g') => self.screen.select(0),
@@ -552,6 +568,7 @@ impl App {
RepositoryDestination::Issues => ScreenKind::Issues(repository),
RepositoryDestination::Commits => ScreenKind::Commits(repository),
RepositoryDestination::Milestones => ScreenKind::Milestones(repository),
RepositoryDestination::Actions => ScreenKind::ActionWorkflows(repository),
};
self.open(kind).await;
}
@@ -579,6 +596,19 @@ impl App {
Target::Text(title, text) => {
self.open(ScreenKind::Text(title, text)).await;
}
Target::ActionRuns(repository) => {
self.open(ScreenKind::ActionRuns(repository)).await;
}
Target::ActionWorkflow(repository, workflow, reference) => {
self.editor = Some(Editor::action_dispatch(repository, workflow, reference));
}
Target::ActionRun(repository, run) => {
self.open(ScreenKind::ActionRun(repository, run)).await;
}
Target::ActionJob(repository, run, job, title) => {
self.open(ScreenKind::ActionLog(repository, run, job, title))
.await;
}
}
}
@@ -1042,6 +1072,13 @@ impl App {
Ok(())
}
}
EditorAction::ActionDispatch {
repository,
workflow,
} => {
self.save_action_dispatch(&repository, &workflow, &values)
.await
}
};
match result {
Ok(()) => {
@@ -1100,6 +1137,20 @@ impl App {
.map_err(|error| error.to_string())
}
async fn save_action_dispatch(
&self,
repository: &RepositoryId,
workflow: &str,
values: &[String],
) -> Result<(), String> {
let inputs =
parse_action_inputs(&[values[1].clone()]).map_err(|error| error.to_string())?;
self.client
.dispatch_action_workflow(repository, workflow, values[0].trim(), &inputs)
.await
.map_err(|error| error.to_string())
}
async fn save_server(
&mut self,
original_name: Option<&str>,
@@ -1168,6 +1219,12 @@ impl App {
ScreenKind::Milestone(repository, id) => {
self.load_milestone(repository, id, page).await
}
ScreenKind::ActionWorkflows(repository) => self.load_action_workflows(repository).await,
ScreenKind::ActionRuns(repository) => self.load_action_runs(repository, page).await,
ScreenKind::ActionRun(repository, run) => self.load_action_run(repository, run).await,
ScreenKind::ActionLog(repository, run, job, title) => {
self.load_action_log(repository, run, job, title).await
}
}
}
@@ -1214,7 +1271,7 @@ impl App {
screen.page = page;
screen.has_more = data.next_page.is_some();
screen.detail = format!(
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 15 switch panes · s servers · , preferences",
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 16 switch panes · s servers · , preferences",
self.activity_filter
);
screen.items = data
@@ -1341,6 +1398,7 @@ impl App {
RepositoryDestination::Issues => "Issue repositories",
RepositoryDestination::Commits => "Repositories",
RepositoryDestination::Milestones => "Milestone repositories",
RepositoryDestination::Actions => "Actions repositories",
},
);
screen.page = page;
@@ -1772,6 +1830,275 @@ impl App {
.collect();
Ok(screen)
}
async fn load_action_workflows(&self, repository: RepositoryId) -> Result<Screen, String> {
let (workflows, repository_data) = tokio::try_join!(
self.client.action_workflows(&repository),
self.client
.repository(&repository.owner, &repository.repository)
)
.map_err(err)?;
let default_branch = repository_data
.default_branch
.unwrap_or_else(|| "main".into());
let mut screen = Screen::new(
ScreenKind::ActionWorkflows(repository.clone()),
format!("{}/{} · Workflows", repository.owner, repository.repository),
);
screen.detail =
"Enter dispatches a workflow · Runs shows queued, running, and completed work".into();
screen.items.push(Item {
key: "runs".into(),
title: "Workflow runs".into(),
graph_lane: None,
decoration: ItemDecoration::None,
meta: "progress, jobs, steps, and logs".into(),
detail: "Open the repository's workflow-run history.".into(),
target: Target::ActionRuns(repository.clone()),
});
screen.items.extend(workflows.into_iter().map(|workflow| {
let id = workflow
.id
.clone()
.or_else(|| workflow.path.clone())
.unwrap_or_default();
Item {
key: format!("workflow-{id}"),
title: workflow.name.unwrap_or_else(|| "Unnamed workflow".into()),
graph_lane: None,
decoration: ItemDecoration::None,
meta: format!(
"{} · {}",
workflow.state.as_deref().unwrap_or("unknown"),
workflow.path.as_deref().unwrap_or_default()
),
detail: "Enter to choose a Git reference and workflow_dispatch inputs.".into(),
target: Target::ActionWorkflow(repository.clone(), id, default_branch.clone()),
}
}));
Ok(screen)
}
async fn load_action_runs(
&self,
repository: RepositoryId,
page: u32,
) -> Result<Screen, String> {
let result = self
.client
.action_runs(
&repository,
&ActionRunQuery {
page: page as i32,
..Default::default()
},
)
.await
.map_err(err)?;
let mut screen = Screen::new(
ScreenKind::ActionRuns(repository.clone()),
format!("{}/{} · Runs", repository.owner, repository.repository),
);
screen.page = page;
screen.has_more = result.has_more;
screen.detail =
"Auto-refresh preserves the selected run · Enter opens jobs and steps".into();
screen.items = result
.items
.into_iter()
.filter_map(|run| {
let id = run.id?;
let status = action_status(
run.status.as_deref().unwrap_or("unknown"),
run.conclusion.as_deref().unwrap_or_default(),
);
Some(Item {
key: id.to_string(),
title: format!(
"{} #{} {}",
status.0,
run.run_number.unwrap_or_default(),
run.display_title
.as_deref()
.or(run.path.as_deref())
.unwrap_or("Workflow run")
),
graph_lane: None,
decoration: ItemDecoration::None,
meta: format!(
"{} · {} · {}",
status.1,
run.event.as_deref().unwrap_or("unknown event"),
run.head_branch.as_deref().unwrap_or("unknown ref")
),
detail: format!(
"Started: {}\nCommit: {}",
run.started_at.as_deref().unwrap_or("not started"),
run.head_sha.as_deref().unwrap_or("unknown")
),
target: Target::ActionRun(repository.clone(), id),
})
})
.collect();
Ok(screen)
}
async fn load_action_run(&self, repository: RepositoryId, run: i64) -> Result<Screen, String> {
let details = self
.client
.action_run_details(&repository, run)
.await
.map_err(err)?;
let status = action_status(
details.run.status.as_deref().unwrap_or("unknown"),
details.run.conclusion.as_deref().unwrap_or_default(),
);
let mut screen = Screen::new(
ScreenKind::ActionRun(repository.clone(), run),
format!(
"Run #{} · {}",
details.run.run_number.unwrap_or_default(),
details
.run
.display_title
.as_deref()
.unwrap_or("Workflow run")
),
);
screen.detail = format!(
"{} {} \nEvent: {} \nReference: {} \nStarted: {} \nCompleted: {}\n\nAuto-refresh follows queued and running jobs.",
status.0,
status.1,
details.run.event.as_deref().unwrap_or("unknown"),
details.run.head_branch.as_deref().unwrap_or("unknown"),
details.run.started_at.as_deref().unwrap_or("not started"),
details
.run
.completed_at
.as_deref()
.unwrap_or("not completed")
);
screen.items = details
.jobs
.into_iter()
.map(|job| {
let status = action_status(
job.status.as_deref().unwrap_or("unknown"),
job.conclusion.as_deref().unwrap_or_default(),
);
let title = job.name.unwrap_or_else(|| "Job".into());
let detail = job
.steps
.as_deref()
.unwrap_or_default()
.iter()
.map(|step| {
let status = action_status(
step.status.as_deref().unwrap_or("unknown"),
step.conclusion.as_deref().unwrap_or_default(),
);
format!(
"{} {} · {}",
status.0,
step.name.as_deref().unwrap_or("Step"),
status.1
)
})
.collect::<Vec<_>>()
.join("\n");
Item {
key: job.id.unwrap_or_default().to_string(),
title: format!("{} {title}", status.0),
graph_lane: None,
decoration: ItemDecoration::None,
meta: format!(
"{} · {}",
status.1,
job.runner_name.as_deref().unwrap_or("no runner")
),
detail: if detail.is_empty() {
"No steps reported yet.".into()
} else {
detail
},
target: job.id.map_or(Target::None, |id| {
Target::ActionJob(repository.clone(), run, id, format!("{title} · Log"))
}),
}
})
.collect();
Ok(screen)
}
async fn load_action_log(
&self,
repository: RepositoryId,
run: i64,
job: i64,
title: String,
) -> Result<Screen, String> {
let details = self
.client
.action_job_details(&repository, run, job)
.await
.map_err(err)?;
let mut screen = Screen::new(
ScreenKind::ActionLog(repository, run, job, title.clone()),
title,
);
screen.detail = if details.groups.is_empty() {
"This job has no log output.".into()
} else {
"Select a setup, workflow step, or completion group to inspect its log output.".into()
};
screen.items = details
.groups
.into_iter()
.enumerate()
.map(|(index, group)| Item {
key: index.to_string(),
title: group.name,
graph_lane: None,
decoration: ItemDecoration::None,
meta: group.duration_seconds.map_or_else(
|| format!("{} log lines", group.text.lines().count()),
action_duration,
),
detail: group.text,
target: Target::None,
})
.collect();
Ok(screen)
}
}
fn action_status(status: &str, conclusion: &str) -> (&'static str, &'static str) {
match conclusion {
"success" => ("", "succeeded"),
"failure" | "timed_out" | "startup_failure" | "stale" => ("", "failed"),
"cancelled" => ("", "cancelled"),
"skipped" | "neutral" => ("", "skipped"),
_ => match status {
"queued" | "requested" => ("", "queued"),
"waiting" | "pending" | "blocked" => ("", "waiting"),
"in_progress" | "running" => ("", "in progress"),
"success" => ("", "succeeded"),
"failure" | "timed_out" | "startup_failure" | "stale" => ("", "failed"),
"cancelled" => ("", "cancelled"),
"skipped" | "neutral" => ("", "skipped"),
_ => ("?", "unknown"),
},
}
}
fn action_duration(seconds: u64) -> String {
if seconds >= 3_600 {
format!("{}h {}m", seconds / 3_600, seconds % 3_600 / 60)
} else if seconds >= 60 {
format!("{}m {}s", seconds / 60, seconds % 60)
} else {
format!("{seconds}s")
}
}
fn list_item_at(
@@ -1829,6 +2156,7 @@ fn root_kind(tab: Tab) -> ScreenKind {
Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits),
Tab::Pulls => ScreenKind::Pulls,
Tab::Milestones => ScreenKind::Repositories(RepositoryDestination::Milestones),
Tab::Actions => ScreenKind::Repositories(RepositoryDestination::Actions),
}
}
@@ -1837,6 +2165,7 @@ fn repository_pane(destination: RepositoryDestination) -> &'static str {
RepositoryDestination::Issues => "issues",
RepositoryDestination::Commits => "commits",
RepositoryDestination::Milestones => "milestones",
RepositoryDestination::Actions => "actions",
}
}

View File

@@ -23,6 +23,10 @@ pub enum EditorAction {
Settings,
IssueFilter,
PullFilter,
ActionDispatch {
repository: RepositoryId,
workflow: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -187,6 +191,20 @@ impl Editor {
)
}
pub fn action_dispatch(repository: RepositoryId, workflow: String, reference: String) -> Self {
Self::new(
"Dispatch workflow",
EditorAction::ActionDispatch {
repository,
workflow,
},
vec![
Field::new("Git reference", reference),
Field::new("Inputs (one KEY=VALUE per line)", "").multiline(),
],
)
}
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
let cursor = fields
.first()