Add first-class Actions management (#63)
This commit is contained in:
@@ -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 · 1–5 switch panes · s servers · , preferences",
|
||||
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–6 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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user