feat(core/gh): auto-resolve gh_run_wait defaults

This commit is contained in:
James Peter
2026-01-02 10:39:58 +10:00
parent fcb3afb055
commit a865bee5ec
3 changed files with 261 additions and 62 deletions
+140 -48
View File
@@ -8886,6 +8886,7 @@ async fn handle_gh_run_wait(
) -> ResponseInputItem {
use serde::Deserialize;
use serde_json::Value;
use std::path::Path;
#[derive(Deserialize, Clone)]
struct Params {
@@ -8935,6 +8936,55 @@ async fn handle_gh_run_wait(
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn run_git(cwd: &Path, args: &[&str]) -> Option<String> {
let output = tokio::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
async fn detect_branch(cwd: &Path) -> String {
if let Some(branch) = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await {
if branch != "HEAD" {
return branch;
}
}
if let Some(symref) = run_git(cwd, &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]).await {
if let Some((_, name)) = symref.rsplit_once('/') {
if !name.is_empty() {
return name.to_string();
}
}
}
if let Some(show) = run_git(cwd, &["remote", "show", "origin"]).await {
for line in show.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("HEAD branch:") {
let name = rest.trim();
if !name.is_empty() {
return name.to_string();
}
}
}
}
"main".to_string()
}
let mut params_for_event = serde_json::from_str::<Value>(&arguments).ok();
let parsed: Params = match serde_json::from_str(&arguments) {
Ok(p) => p,
@@ -8949,6 +8999,8 @@ async fn handle_gh_run_wait(
}
};
let cwd = sess.cwd.clone();
execute_custom_tool(
sess,
ctx,
@@ -8969,69 +9021,108 @@ async fn handle_gh_run_wait(
.map(|s| s.to_string());
let mut resolved_run_id = run_id;
if resolved_run_id.is_none() {
let workflow = match parsed.workflow.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
Some(name) => name.to_string(),
None => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "gh_run_wait requires run_id or workflow".to_string(),
success: Some(false),
},
};
}
};
let branch = parsed
.branch
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or("main")
.to_string();
let mut resolved_workflow = parsed
.workflow
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let branch = match parsed
.branch
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
Some(value) => value.to_string(),
None => detect_branch(&cwd).await,
};
let json = match run_gh(&[
"run",
"list",
"--workflow",
&workflow,
"--branch",
&branch,
"--limit",
"1",
"--json",
"databaseId,displayTitle,workflowName,headBranch,status,conclusion",
],
repo.as_deref(),
)
.await
{
Ok(out) => out,
Err(err) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: err,
success: Some(false),
},
};
}
if resolved_run_id.is_none() {
let (json, branch_label) = if let Some(workflow) = resolved_workflow.as_ref() {
let json = match run_gh(&[
"run",
"list",
"--workflow",
workflow,
"--branch",
&branch,
"--limit",
"1",
"--json",
"databaseId,displayTitle,workflowName,headBranch,status,conclusion",
],
repo.as_deref(),
)
.await
{
Ok(out) => out,
Err(err) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: err,
success: Some(false),
},
};
}
};
(json, branch.clone())
} else {
let json = match run_gh(&[
"run",
"list",
"--branch",
&branch,
"--limit",
"1",
"--json",
"databaseId,displayTitle,workflowName,headBranch,status,conclusion",
],
repo.as_deref(),
)
.await
{
Ok(out) => out,
Err(err) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: err,
success: Some(false),
},
};
}
};
(json, branch.clone())
};
let runs: Vec<Value> = serde_json::from_str(&json).unwrap_or_default();
let run = runs.into_iter().next();
let run_id = run
.as_ref()
.and_then(|item| item.get("databaseId").cloned())
.and_then(|val| match val {
Value::Number(num) => num.as_u64().map(|v| v.to_string()),
Value::String(s) => Some(s),
_ => None,
});
if resolved_workflow.is_none() {
resolved_workflow = run
.as_ref()
.and_then(|item| item.get("workflowName"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
}
if run_id.is_none() {
let detail = if let Some(workflow) = resolved_workflow.as_ref() {
format!("workflow '{workflow}' on {branch_label}")
} else {
format!("branch {branch_label}")
};
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("No runs found for workflow '{workflow}' on {branch}"),
content: format!("No runs found for {detail}"),
success: Some(false),
},
};
@@ -9125,9 +9216,10 @@ async fn handle_gh_run_wait(
"run_id": run_id,
"status": status,
"conclusion": if conclusion.is_empty() { None::<String> } else { Some(conclusion.clone()) },
"workflow": workflow_name,
"workflow": workflow_name.or_else(|| resolved_workflow.clone()),
"title": display_title,
"url": html_url,
"branch": branch,
"jobs": {
"total": total_jobs,
"completed": completed_jobs,
+3 -2
View File
@@ -870,7 +870,8 @@ pub fn create_gh_run_wait_tool() -> OpenAiTool {
"branch".to_string(),
JsonSchema::String {
description: Some(
"Branch to filter when selecting latest run (default: main).".to_string(),
"Branch to filter when selecting latest run (default: current branch, falling back to main)."
.to_string(),
),
allowed_values: None,
},
@@ -883,7 +884,7 @@ pub fn create_gh_run_wait_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "gh_run_wait".to_string(),
description: "Wait for a GitHub Actions run to finish, using gh run view polling."
description: "Wait for a GitHub Actions run to finish, using gh run view polling. If run_id is omitted, selects the latest run for the workflow/branch; if both are omitted, selects the latest run on the current branch."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
+118 -12
View File
@@ -4,6 +4,7 @@
# Usage examples:
# scripts/wait-for-gh-run.sh --run 17901972778
# scripts/wait-for-gh-run.sh --workflow Release --branch main
# scripts/wait-for-gh-run.sh # picks latest run on current branch
#
# Dependencies: gh (GitHub CLI), jq.
@@ -16,12 +17,13 @@ Usage: wait-for-gh-run.sh [OPTIONS]
Options:
-r, --run ID Run ID to monitor.
-w, --workflow NAME Workflow name or filename to pick the latest run.
-b, --branch BRANCH Branch to filter when selecting a run (default: main).
-b, --branch BRANCH Branch to filter when selecting a run (default: current branch).
-i, --interval SECONDS Polling interval in seconds (default: 8).
-L, --failure-logs Print logs for any job that does not finish successfully.
-h, --help Show this help message.
Either --run or --workflow must be supplied.
If neither --run nor --workflow is provided, the latest run on the current
branch is selected automatically.
EOF
}
@@ -34,9 +36,10 @@ require_binary() {
RUN_ID=""
WORKFLOW=""
BRANCH="main"
BRANCH=""
INTERVAL="8"
PRINT_FAILURE_LOGS=false
AUTO_SELECTED_RUN=false
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -72,15 +75,38 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "$RUN_ID" && -z "$WORKFLOW" ]]; then
echo "error: either --run or --workflow must be specified" >&2
usage >&2
exit 1
fi
require_binary gh
require_binary jq
default_branch() {
local branch=""
if command -v git >/dev/null 2>&1; then
if branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); then
if [[ -n "$branch" && "$branch" != "HEAD" ]]; then
echo "$branch"
return 0
fi
fi
if branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null); then
branch="${branch#origin/}"
if [[ -n "$branch" ]]; then
echo "$branch"
return 0
fi
fi
if branch=$(git remote show origin 2>/dev/null | awk '/HEAD branch/ {print $NF}'); then
if [[ -n "$branch" ]]; then
echo "$branch"
return 0
fi
fi
fi
echo "main"
}
select_latest_run() {
local workflow="$1"
local branch="$2"
@@ -98,8 +124,49 @@ select_latest_run() {
jq -r '.[0].databaseId' <<<"$json"
}
select_latest_run_any() {
local branch="$1"
local json
if ! json=$(gh run list --branch "$branch" --limit 1 --json databaseId,workflowName,displayTitle,headBranch 2>/dev/null); then
echo "error: failed to list runs on branch '$branch'" >&2
exit 1
fi
if [[ $(jq 'length' <<<"$json") -eq 0 ]]; then
echo "error: no runs found on branch '$branch'" >&2
exit 1
fi
WORKFLOW=$(jq -r '.[0].workflowName // ""' <<<"$json")
jq -r '.[0].databaseId' <<<"$json"
}
format_duration() {
local total="$1"
local hours=$((total / 3600))
local minutes=$(((total % 3600) / 60))
local seconds=$((total % 60))
if [[ $hours -gt 0 ]]; then
printf '%dh%02dm%02ds' "$hours" "$minutes" "$seconds"
elif [[ $minutes -gt 0 ]]; then
printf '%dm%02ds' "$minutes" "$seconds"
else
printf '%ds' "$seconds"
fi
}
if [[ -z "$BRANCH" ]]; then
BRANCH=$(default_branch)
fi
if [[ -z "$RUN_ID" ]]; then
RUN_ID=$(select_latest_run "$WORKFLOW" "$BRANCH")
if [[ -n "$WORKFLOW" ]]; then
RUN_ID=$(select_latest_run "$WORKFLOW" "$BRANCH")
AUTO_SELECTED_RUN=true
else
RUN_ID=$(select_latest_run_any "$BRANCH")
AUTO_SELECTED_RUN=true
fi
fi
if [[ -z "$RUN_ID" ]]; then
@@ -108,9 +175,19 @@ if [[ -z "$RUN_ID" ]]; then
fi
echo "Waiting for GitHub Actions run $RUN_ID..." >&2
if [[ "$AUTO_SELECTED_RUN" == true ]]; then
if [[ -z "$WORKFLOW" ]]; then
echo "Auto-selected latest run on branch '$BRANCH'." >&2
else
echo "Auto-selected latest '$WORKFLOW' run on branch '$BRANCH'." >&2
fi
elif [[ -n "$WORKFLOW" ]]; then
echo "Using workflow '$WORKFLOW' on branch '$BRANCH'." >&2
fi
last_status=""
last_jobs_snapshot=""
last_progress_snapshot=""
while true; do
json=""
@@ -143,6 +220,17 @@ while true; do
last_jobs_snapshot="$jobs_snapshot"
fi
total_jobs=$(jq -r '.jobs | length' <<<"$json")
completed_jobs=$(jq -r '[.jobs[]? | select(.status == "completed")] | length' <<<"$json")
in_progress_jobs=$(jq -r '[.jobs[]? | select(.status == "in_progress")] | length' <<<"$json")
queued_jobs=$(jq -r '[.jobs[]? | select(.status == "queued")] | length' <<<"$json")
progress_snapshot="$completed_jobs/$total_jobs/$in_progress_jobs/$queued_jobs"
if [[ "$status" != "completed" && "$total_jobs" != "0" && "$progress_snapshot" != "$last_progress_snapshot" ]]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') progress: $completed_jobs/$total_jobs completed ($in_progress_jobs in_progress, $queued_jobs queued)" >&2
last_progress_snapshot="$progress_snapshot"
fi
failing_jobs=$(jq -c '
.jobs[]? | select(
.status == "completed" and (.conclusion // "") != "" and
@@ -177,8 +265,22 @@ while true; do
fi
if [[ "$status" == "completed" ]]; then
started_at=$(jq -r '.startedAt // ""' <<<"$json")
updated_at=$(jq -r '.updatedAt // ""' <<<"$json")
duration=""
if [[ -n "$started_at" && -n "$updated_at" ]]; then
start_epoch=$(date -d "$started_at" +%s 2>/dev/null || true)
end_epoch=$(date -d "$updated_at" +%s 2>/dev/null || true)
if [[ -n "$start_epoch" && -n "$end_epoch" && "$end_epoch" -ge "$start_epoch" ]]; then
duration=$(format_duration $((end_epoch - start_epoch)))
fi
fi
if [[ "$conclusion" == "success" ]]; then
echo "Run $RUN_ID succeeded." >&2
if [[ -n "$duration" ]]; then
echo "Run $RUN_ID succeeded in $duration." >&2
else
echo "Run $RUN_ID succeeded." >&2
fi
exit 0
else
if [[ "$PRINT_FAILURE_LOGS" == true ]]; then
@@ -193,7 +295,11 @@ while true; do
echo "--- End logs for job: $job_name ---" >&2
done
fi
echo "Run $RUN_ID finished with conclusion '$conclusion'." >&2
if [[ -n "$duration" ]]; then
echo "Run $RUN_ID finished with conclusion '$conclusion' in $duration." >&2
else
echo "Run $RUN_ID finished with conclusion '$conclusion'." >&2
fi
exit 1
fi
fi