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

@@ -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()