Skip to content

ci: allow auto review via HOLON_AUTO_REVIEW variable #9

ci: allow auto review via HOLON_AUTO_REVIEW variable

ci: allow auto review via HOLON_AUTO_REVIEW variable #9

Workflow file for this run

name: Holon Solve (Reusable)

Check failure on line 1 in .github/workflows/holon-solve.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/holon-solve.yml

Invalid workflow file

(Line: 550, Col: 11): 'INPUT_AUTO_REVIEW' is already defined
on:
workflow_call:
inputs:
event_name:
description: 'Caller event name (optional; defaults to github.event_name)'
required: false
type: string
default: ''
event_action:
description: 'Caller event action (optional; defaults to github.event.action)'
required: false
type: string
default: ''
label_name:
description: 'Label name (optional; defaults to github.event.label.name)'
required: false
type: string
default: ''
assignee_login:
description: 'Assignee login (optional; defaults to github.event.assignee.login)'
required: false
type: string
default: ''
issue_number:
description: 'Issue or PR number'
required: false
type: number
default: 0
comment_id:
description: 'Comment ID for feedback reactions and replies'
required: false
type: number
default: 0
review_id:
description: 'Review ID (for PR review triggers and body fetch)'
required: false
type: number
default: 0
comment_body:
description: 'Comment body (for auto mode detection)'
required: false
type: string
default: ''
review_body:
description: 'Review body (for PR review events)'
required: false
type: string
default: ''
auto_review:
description: 'Automatically run review on PR events (uses github-review skill by default)'
required: false
type: boolean
default: false
mode:
description: 'Execution mode (solve, pr-fix, plan, review). Empty = auto-detect'
required: false
type: string
default: ''
skill:
description: 'Skill reference (e.g., github-solve). When provided, uses skill mode instead of mode-based prompts.'
required: false
type: string
default: ''
base:
description: 'Base branch for PR creation (empty = repository default branch)'
required: false
type: string
default: ''
agent:
description: 'Agent bundle reference'
required: false
type: string
default: ''
log_level:
description: 'Log level (debug, info, progress, minimal)'
required: false
type: string
default: 'progress'
assistant_output:
description: 'Assistant output mode (none, stream)'
required: false
type: string
default: 'none'
workspace:
description: 'Workspace path (default: .)'
required: false
type: string
default: '.'
input_dir:
description: 'Input directory path for artifact packaging (empty = temp dir, kept for debug)'
required: false
type: string
default: ''
output_dir:
description: 'Output directory path for artifact packaging (empty = temp dir, kept for debug)'
required: false
type: string
default: ''
version:
description: 'Holon version to download from releases'
required: false
type: string
default: 'latest'
build_from_source:
description: 'Build holon from source'
required: false
type: boolean
default: false
holon_repository:
description: 'Holon repository for building from source'
required: false
type: string
default: 'holon-run/holon'
runs_on:
description: >
Runner labels as a plain string (e.g., 'ubuntu-latest' or 'self-hosted')
or JSON array string (e.g., '["ubuntu-latest"]' or '["self-hosted","linux","x64"]').
Plain strings are automatically converted to arrays.
required: false
type: string
default: 'ubuntu-latest'
secrets:
anthropic_auth_token:
description: 'Anthropic Auth Token (legacy: anthropic_api_key also supported)'
required: true
anthropic_api_key:
description: 'Legacy alias for anthropic_auth_token (deprecated)'
holon_github_token:
description: 'GitHub Token (optional; defaults to github.token)'
required: false
anthropic_base_url:
description: 'Anthropic Base URL'
required: false
outputs:
result:
description: 'Execution result'
value: ${{ jobs.holon-solve.outputs.result }}
summary:
description: 'Execution summary path'
value: ${{ jobs.holon-solve.outputs.summary }}
jobs:
gate:
runs-on: ${{ (startsWith(inputs.runs_on, '[') && fromJSON(inputs.runs_on)) || inputs.runs_on || 'ubuntu-latest' }}
permissions:
contents: read
issues: read
pull-requests: read
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
reason: ${{ steps.gate.outputs.reason }}
goal_hint: ${{ steps.gate.outputs.goal_hint }}
is_pr: ${{ steps.gate.outputs.is_pr }}
issue_number: ${{ steps.gate.outputs.issue_number }}
steps:
- name: Gate execution
id: gate
run: |
set -euo pipefail
# Helper function to parse text and extract @holonbot mention with goal hint
# Args: text body
# Outputs: FOUND_MENTION (true/false), GOAL_HINT
parse_mention() {
local text="$1"
local found_mention='false'
local goal_hint=''
local in_code_block=0
local code_fence_type=''
local mention_line=-1
local line_num=0
# Process line by line
while IFS= read -r line || [ -n "$line" ]; do
line_num=$((line_num + 1))
# Track code block state (validate matching fence types)
# Allow up to 3 leading spaces (common in Markdown) and optional language info
if [[ "$line" =~ ^[[:space:]]{0,3}(\`\`\`|~~~) ]]; then
local fence_marker="${BASH_REMATCH[1]}"
if [ "$in_code_block" -eq 1 ]; then
# Only close code block if fence marker matches opener
if [ "$fence_marker" = "$code_fence_type" ]; then
in_code_block=0
code_fence_type=''
fi
else
# Open new code block and record fence marker type
in_code_block=1
code_fence_type="$fence_marker"
fi
continue
fi
# Skip if inside code block
if [ "$in_code_block" -eq 1 ]; then
continue
fi
# Ignore blockquotes (treat quoted content as non-actionable trigger text)
# Allow up to 3 leading spaces before '>' per Markdown
local trimmed_for_quote
trimmed_for_quote="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//')"
if [[ "$trimmed_for_quote" == \>* ]]; then
continue
fi
# Check for inline code blocks (ignore content between backticks)
# Remove inline code content before checking for mention
local cleaned_line="$line"
while [[ "$cleaned_line" =~ (.*)\`[^\`]*\`(.*) ]]; do
cleaned_line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
done
# Check for @holonbot mention with word boundary
# Pattern: @holonbot as a standalone token (not followed by letters/digits/_)
# This avoids matching @holonbot123 or similar.
if [[ "$cleaned_line" =~ (^|[^[:alnum:]_])@holonbot([^[:alnum:]_]|$) ]]; then
found_mention='true'
mention_line=$line_num
# Extract goal hint from the same line after @holonbot (even if mention isn't at line start)
local after_mention="${cleaned_line#*@holonbot}"
goal_hint="$(printf '%s' "$after_mention" | sed -e 's/^[[:space:][:punct:]]*//')"
# If no goal hint on mention line, we'll get it from next lines
break
fi
done <<< "$text"
# If we found a mention but no goal hint, look at subsequent lines
if [ "$found_mention" = 'true' ] && [ -z "$goal_hint" ]; then
local skip_to_mention=0
local in_code_block=0
local code_fence_type=''
local after_mention=false
while IFS= read -r line || [ -n "$line" ]; do
skip_to_mention=$((skip_to_mention + 1))
# Skip until we reach the mention line
if [ "$skip_to_mention" -lt "$mention_line" ]; then
continue
fi
# Start looking after the mention line
if [ "$skip_to_mention" -eq "$mention_line" ]; then
after_mention=true
continue
fi
# Track code block state for lines after mention (validate matching fence types)
if [[ "$line" =~ ^[[:space:]]{0,3}(\`\`\`|~~~) ]]; then
local fence_marker="${BASH_REMATCH[1]}"
if [ "$in_code_block" -eq 1 ]; then
# Only close code block if fence marker matches opener
if [ "$fence_marker" = "$code_fence_type" ]; then
in_code_block=0
code_fence_type=''
fi
else
# Open new code block and record fence marker type
in_code_block=1
code_fence_type="$fence_marker"
fi
continue
fi
# Skip code blocks
if [ "$in_code_block" -eq 1 ]; then
continue
fi
# Ignore blockquotes
local trimmed_for_quote
trimmed_for_quote="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//')"
if [[ "$trimmed_for_quote" == \>* ]]; then
continue
fi
# Skip inline code
local cleaned_line="$line"
while [[ "$cleaned_line" =~ (.*)\`[^\`]*\`(.*) ]]; do
cleaned_line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
done
# Get first non-empty line (trimmed)
local trimmed_line
trimmed_line="$(printf '%s' "$cleaned_line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -n "$trimmed_line" ]; then
goal_hint="$trimmed_line"
break
fi
done <<< "$text"
fi
printf '%s\n' "$found_mention"
printf '%s\n' "$goal_hint"
}
SHOULD_RUN='false'
REASON='Not eligible'
GOAL_HINT=''
EVENT_NAME="$INPUT_EVENT_NAME"
if [ -z "$EVENT_NAME" ]; then
EVENT_NAME="$EVENT_NAME_FALLBACK"
fi
EVENT_ACTION="$INPUT_EVENT_ACTION"
if [ -z "$EVENT_ACTION" ]; then
EVENT_ACTION="$EVENT_ACTION_FALLBACK"
fi
ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
if [ -z "$ISSUE_NUMBER" ] || [ "$ISSUE_NUMBER" = '0' ]; then
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if [ -z "$ISSUE_NUMBER" ]; then
ISSUE_NUMBER="$EVENT_PR_NUMBER"
fi
fi
if [ -z "$ISSUE_NUMBER" ] || [ "$ISSUE_NUMBER" = '0' ]; then
REASON='Unable to determine issue/PR number (provide inputs.issue_number)'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
echo "is_pr=false" >> "$GITHUB_OUTPUT"
echo "issue_number=0" >> "$GITHUB_OUTPUT"
exit 0
fi
LABEL_NAME="$INPUT_LABEL_NAME"
if [ -z "$LABEL_NAME" ]; then
LABEL_NAME="$EVENT_LABEL_NAME"
fi
ASSIGNEE_LOGIN="$INPUT_ASSIGNEE_LOGIN"
if [ -z "$ASSIGNEE_LOGIN" ]; then
ASSIGNEE_LOGIN="$EVENT_ASSIGNEE_LOGIN"
fi
COMMENT_BODY="$INPUT_COMMENT_BODY"
if [ -z "$COMMENT_BODY" ]; then
COMMENT_BODY="$EVENT_COMMENT_BODY"
fi
REVIEW_BODY="$INPUT_REVIEW_BODY"
if [ -z "$REVIEW_BODY" ]; then
REVIEW_BODY="$EVENT_REVIEW_BODY"
fi
COMMENT_ID="$INPUT_COMMENT_ID"
if [ -z "$COMMENT_ID" ]; then
COMMENT_ID="$EVENT_COMMENT_ID"
fi
if [ -z "$COMMENT_ID" ] || [ "$COMMENT_ID" = 'null' ]; then
COMMENT_ID='0'
fi
REVIEW_ID="$INPUT_REVIEW_ID"
if [ -z "$REVIEW_ID" ]; then
REVIEW_ID="$EVENT_REVIEW_ID"
fi
if [ -z "$REVIEW_ID" ] || [ "$REVIEW_ID" = 'null' ]; then
REVIEW_ID='0'
fi
# Skip PR review events by default to avoid per-inline triggers unless explicitly requested.
if { [ "$EVENT_NAME" = 'pull_request_review' ] || [ "$EVENT_NAME" = 'pull_request_review_comment' ]; } && \
[ -z "$INPUT_MODE" ] && [ -z "$INPUT_SKILL" ] && [ "$INPUT_AUTO_REVIEW" != 'true' ]; then
REASON='PR review events are ignored by default to avoid multiple inline triggers'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
echo "is_pr=$IS_PR" >> "$GITHUB_OUTPUT"
echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
exit 0
fi
# Determine if this issue number is a PR
IS_PR='false'
if [ -n "$EVENT_PR_NUMBER" ] || [ -n "$EVENT_ISSUE_PR_URL" ]; then
IS_PR='true'
else
if gh api --silent "repos/$REPO/pulls/$ISSUE_NUMBER" >/dev/null 2>&1; then
IS_PR='true'
fi
fi
# Determine which text to parse based on event type
PARSE_TEXT=''
if [ -n "$COMMENT_BODY" ]; then
PARSE_TEXT="$COMMENT_BODY"
elif [ -n "$REVIEW_BODY" ]; then
PARSE_TEXT="$REVIEW_BODY"
fi
# Avoid bot loops
if [ "$ACTOR" = 'holonbot[bot]' ]; then
REASON='Actor is holonbot[bot]'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
echo "is_pr=$IS_PR" >> "$GITHUB_OUTPUT"
echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
exit 0
fi
# Backward-compat: older callers may gate invocation themselves and pass only issue_number.
# Only auto-allow when no trigger context is provided (otherwise we can gate here).
if [ -z "$INPUT_EVENT_NAME" ] && [ "$EVENT_NAME" = 'workflow_call' ]; then
if [ "$COMMENT_ID" = '0' ] && [ "$REVIEW_ID" = '0' ] && [ -z "$LABEL_NAME" ] && [ -z "$ASSIGNEE_LOGIN" ]; then
SHOULD_RUN='true'
REASON='workflow_call without trigger context (assume caller gated)'
fi
fi
# Allow explicit mode input (manual/custom callers).
if [ "$SHOULD_RUN" != 'true' ] && [ -n "$INPUT_MODE" ]; then
SHOULD_RUN='true'
REASON='Explicit inputs.mode provided'
fi
# Comment triggers: prefer explicit comment_id to fetch body, which avoids passing raw body through workflow_call.
if [ "$SHOULD_RUN" != 'true' ] && [ "$COMMENT_ID" != '0' ]; then
if [ -z "$PARSE_TEXT" ]; then
PARSE_TEXT="$(gh api "repos/$REPO/issues/comments/$COMMENT_ID" --jq '.body' 2>/dev/null || true)"
if [ -z "$PARSE_TEXT" ] || [ "$PARSE_TEXT" = 'null' ]; then
PARSE_TEXT="$(gh api "repos/$REPO/pulls/comments/$COMMENT_ID" --jq '.body' 2>/dev/null || true)"
fi
if [ "$PARSE_TEXT" = 'null' ]; then
PARSE_TEXT=''
fi
fi
if [ -z "$PARSE_TEXT" ]; then
REASON='Comment body is empty or unavailable'
else
parse_result="$(parse_mention "$PARSE_TEXT")"
FOUND_MENTION="$(printf '%s' "$parse_result" | sed -n '1p')"
GOAL_HINT="$(printf '%s' "$parse_result" | sed -n '2p')"
if [ "$FOUND_MENTION" != 'true' ]; then
REASON='No @holonbot mention found (or only in code block)'
else
if [ "$IS_PR" = 'true' ]; then
IS_CROSS_REPO="$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER" --jq '.head.repo.full_name != .base.repo.full_name' 2>/dev/null || echo 'true')"
if [ "$IS_CROSS_REPO" = 'true' ]; then
REASON='PR is from a fork/cross-repo head; not supported'
fi
fi
if [ "$REASON" != 'PR is from a fork/cross-repo head; not supported' ]; then
PERMISSION="$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo '')"
if [ "$PERMISSION" != 'admin' ] && [ "$PERMISSION" != 'maintain' ] && [ "$PERMISSION" != 'write' ]; then
REASON="Insufficient permissions for $ACTOR: ${PERMISSION:-none}"
else
SHOULD_RUN='true'
REASON='Comment gates passed'
fi
fi
fi
fi
fi
# PR review triggers: prefer explicit review_id to fetch body.
if [ "$SHOULD_RUN" != 'true' ] && [ "$REVIEW_ID" != '0' ]; then
if [ -z "$PARSE_TEXT" ]; then
PARSE_TEXT="$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER/reviews/$REVIEW_ID" --jq '.body' 2>/dev/null || true)"
if [ "$PARSE_TEXT" = 'null' ]; then
PARSE_TEXT=''
fi
fi
if [ -z "$PARSE_TEXT" ]; then
REASON='Review body is empty or unavailable'
else
parse_result="$(parse_mention "$PARSE_TEXT")"
FOUND_MENTION="$(printf '%s' "$parse_result" | sed -n '1p')"
GOAL_HINT="$(printf '%s' "$parse_result" | sed -n '2p')"
if [ "$FOUND_MENTION" != 'true' ]; then
REASON='No @holonbot mention found in review body'
else
PERMISSION="$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo '')"
if [ "$PERMISSION" != 'admin' ] && [ "$PERMISSION" != 'maintain' ] && [ "$PERMISSION" != 'write' ]; then
REASON="Insufficient permissions for $ACTOR: ${PERMISSION:-none}"
else
SHOULD_RUN='true'
REASON='PR review gates passed'
fi
fi
fi
fi
# Auto-review: run on PRs when enabled (even without explicit trigger).
if [ "$SHOULD_RUN" != 'true' ] && [ "$INPUT_AUTO_REVIEW" = 'true' ] && [ "$IS_PR" = 'true' ]; then
SHOULD_RUN='true'
REASON='Auto-review enabled'
fi
# Label/assignment triggers rely on the event payload (label_name / assignee_login) to avoid false positives.
if [ "$SHOULD_RUN" != 'true' ] && [ "$LABEL_NAME" = 'holon' ]; then
SHOULD_RUN='true'
REASON='Labeled holon'
fi
if [ "$SHOULD_RUN" != 'true' ] && { [ "$ASSIGNEE_LOGIN" = 'holonbot[bot]' ] || [ "$ASSIGNEE_LOGIN" = 'holonbot' ]; }; then
SHOULD_RUN='true'
REASON='Assigned to holonbot[bot]'
fi
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
echo "is_pr=$IS_PR" >> "$GITHUB_OUTPUT"
echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
INPUT_EVENT_NAME: ${{ inputs.event_name }}
EVENT_NAME_FALLBACK: ${{ github.event_name }}
INPUT_EVENT_ACTION: ${{ inputs.event_action }}
EVENT_ACTION_FALLBACK: ${{ github.event.action }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }}
INPUT_LABEL_NAME: ${{ inputs.label_name }}
EVENT_LABEL_NAME: ${{ github.event.label.name }}
INPUT_ASSIGNEE_LOGIN: ${{ inputs.assignee_login }}
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
INPUT_COMMENT_ID: ${{ inputs.comment_id }}
EVENT_COMMENT_ID: ${{ github.event.comment.id }}
INPUT_COMMENT_BODY: ${{ inputs.comment_body }}
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
INPUT_REVIEW_ID: ${{ inputs.review_id }}
EVENT_REVIEW_ID: ${{ github.event.review.id }}
INPUT_REVIEW_BODY: ${{ inputs.review_body }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
INPUT_MODE: ${{ inputs.mode }}
INPUT_SKILL: ${{ inputs.skill }}
INPUT_AUTO_REVIEW: ${{ inputs.auto_review }}
INPUT_AUTO_REVIEW: ${{ inputs.auto_review }}
- name: Gate summary
if: always()
run: |
set -euo pipefail
if [ "${{ steps.gate.outputs.should_run }}" != 'true' ]; then
{
echo "### Holon skipped"
echo "- Reason: ${{ steps.gate.outputs.reason }}"
echo "- Event: ${{ inputs.event_name || github.event_name }} (${{ inputs.event_action || github.event.action }})"
echo "- Target: #${{ steps.gate.outputs.issue_number }}"
} >> "$GITHUB_STEP_SUMMARY"
fi
holon-solve:
needs: gate
if: needs.gate.outputs.should_run == 'true'
concurrency:
group: holon-${{ github.repository }}-${{ inputs.issue_number != 0 && inputs.issue_number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
runs-on: ${{ (startsWith(inputs.runs_on, '[') && fromJSON(inputs.runs_on)) || inputs.runs_on || 'ubuntu-latest' }}
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
outputs:
result: ${{ steps.result.outputs.result }}
summary: ${{ steps.result.outputs.summary }}
steps:
- name: Normalize context
id: ctx
run: |
set -euo pipefail
EVENT_NAME='${{ github.event_name }}'
ISSUE_NUMBER_FROM_GATE='${{ needs.gate.outputs.issue_number }}'
ISSUE_NUMBER_INPUT='${{ inputs.issue_number }}'
if [ -n "$ISSUE_NUMBER_FROM_GATE" ] && [ "$ISSUE_NUMBER_FROM_GATE" != '0' ]; then
ISSUE_NUMBER="$ISSUE_NUMBER_FROM_GATE"
elif [ -n "$ISSUE_NUMBER_INPUT" ] && [ "$ISSUE_NUMBER_INPUT" != '0' ]; then
ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
else
ISSUE_NUMBER='${{ github.event.issue.number }}'
if [ -z "$ISSUE_NUMBER" ]; then
ISSUE_NUMBER='${{ github.event.pull_request.number }}'
fi
fi
if [ -z "$ISSUE_NUMBER" ] || [ "$ISSUE_NUMBER" = '0' ]; then
echo "::error::Unable to determine issue/PR number. Provide inputs.issue_number." >&2
exit 1
fi
IS_PR_FROM_GATE='${{ needs.gate.outputs.is_pr }}'
IS_PR='false'
if [ "$IS_PR_FROM_GATE" = 'true' ]; then
IS_PR='true'
elif [ -n '${{ github.event.issue.pull_request.url }}' ] || [ -n '${{ github.event.pull_request.number }}' ]; then
IS_PR='true'
fi
COMMENT_BODY="$INPUT_COMMENT_BODY"
if [ -z "$COMMENT_BODY" ] && [ "$EVENT_NAME" = 'issue_comment' ]; then
COMMENT_BODY="$EVENT_COMMENT_BODY"
fi
REVIEW_BODY="$INPUT_REVIEW_BODY"
if [ -z "$REVIEW_BODY" ] && [ "$EVENT_NAME" = 'pull_request_review' ]; then
REVIEW_BODY="$EVENT_REVIEW_BODY"
fi
INPUT_DIR_INPUT='${{ inputs.input_dir }}'
if [ -n "$INPUT_DIR_INPUT" ]; then
INPUT_DIR="$INPUT_DIR_INPUT"
mkdir -p "$INPUT_DIR"
else
INPUT_DIR="$(mktemp -d -t holon-input-XXXXXX)"
fi
OUTPUT_DIR_INPUT='${{ inputs.output_dir }}'
if [ -n "$OUTPUT_DIR_INPUT" ]; then
OUTPUT_DIR="$OUTPUT_DIR_INPUT"
mkdir -p "$OUTPUT_DIR"
else
OUTPUT_DIR="$(mktemp -d -t holon-output-XXXXXX)"
fi
echo "INPUT_DIR=$INPUT_DIR" >> "$GITHUB_ENV"
echo "OUTPUT_DIR=$OUTPUT_DIR" >> "$GITHUB_ENV"
echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
echo "is_pr=$IS_PR" >> "$GITHUB_OUTPUT"
# Safely output comment body even if it contains HTML/markdown
# Use base64 encoding to handle arbitrary content
COMMENT_B64="$(printf '%s' "$COMMENT_BODY" | base64 -w 0)"
echo "comment_body_b64=$COMMENT_B64" >> "$GITHUB_OUTPUT"
# Safely output review body
REVIEW_B64="$(printf '%s' "$REVIEW_BODY" | base64 -w 0)"
echo "review_body_b64=$REVIEW_B64" >> "$GITHUB_OUTPUT"
echo "input_dir=$INPUT_DIR" >> "$GITHUB_OUTPUT"
echo "output_dir=$OUTPUT_DIR" >> "$GITHUB_OUTPUT"
env:
INPUT_COMMENT_BODY: ${{ inputs.comment_body }}
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
INPUT_REVIEW_BODY: ${{ inputs.review_body }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
- name: Gate execution
id: gate
run: |
set -euo pipefail
# Gate logic moved to jobs.gate; keep this step as a shim for downstream steps.
echo "should_run=${{ needs.gate.outputs.should_run }}" >> "$GITHUB_OUTPUT"
echo "reason=${{ needs.gate.outputs.reason }}" >> "$GITHUB_OUTPUT"
echo "goal_hint=${{ needs.gate.outputs.goal_hint }}" >> "$GITHUB_OUTPUT"
exit 0
# Helper function to parse text and extract @holonbot mention with goal hint
# Args: text body
# Outputs: FOUND_MENTION (true/false), GOAL_HINT
parse_mention() {
local text="$1"
local found_mention='false'
local goal_hint=''
local in_code_block=0
local code_fence_type=''
local mention_line=-1
local line_num=0
# Process line by line
while IFS= read -r line || [ -n "$line" ]; do
line_num=$((line_num + 1))
# Track code block state (validate matching fence types)
# Allow up to 3 leading spaces (common in Markdown) and optional language info
if [[ "$line" =~ ^[[:space:]]{0,3}(\`\`\`|~~~) ]]; then
local fence_marker="${BASH_REMATCH[1]}"
if [ "$in_code_block" -eq 1 ]; then
# Only close code block if fence marker matches opener
if [ "$fence_marker" = "$code_fence_type" ]; then
in_code_block=0
code_fence_type=''
fi
else
# Open new code block and record fence marker type
in_code_block=1
code_fence_type="$fence_marker"
fi
continue
fi
# Skip if inside code block
if [ "$in_code_block" -eq 1 ]; then
continue
fi
# Ignore blockquotes (treat quoted content as non-actionable trigger text)
# Allow up to 3 leading spaces before '>' per Markdown
local trimmed_for_quote
trimmed_for_quote="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//')"
if [[ "$trimmed_for_quote" == \>* ]]; then
continue
fi
# Check for inline code blocks (ignore content between backticks)
# Remove inline code content before checking for mention
local cleaned_line="$line"
while [[ "$cleaned_line" =~ (.*)\`[^\`]*\`(.*) ]]; do
cleaned_line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
done
# Check for @holonbot mention with word boundary
# Pattern: @holonbot as a standalone token (not followed by letters/digits/_)
# This avoids matching @holonbot123 or similar.
if [[ "$cleaned_line" =~ (^|[^[:alnum:]_])@holonbot([^[:alnum:]_]|$) ]]; then
found_mention='true'
mention_line=$line_num
# Extract goal hint from the same line after @holonbot (even if mention isn't at line start)
local after_mention="${cleaned_line#*@holonbot}"
goal_hint="$(printf '%s' "$after_mention" | sed -e 's/^[[:space:][:punct:]]*//')"
# If no goal hint on mention line, we'll get it from next lines
break
fi
done <<< "$text"
# If we found a mention but no goal hint, look at subsequent lines
if [ "$found_mention" = 'true' ] && [ -z "$goal_hint" ]; then
local skip_to_mention=0
local in_code_block=0
local code_fence_type=''
local after_mention=false
while IFS= read -r line || [ -n "$line" ]; do
skip_to_mention=$((skip_to_mention + 1))
# Skip until we reach the mention line
if [ "$skip_to_mention" -lt "$mention_line" ]; then
continue
fi
# Start looking after the mention line
if [ "$skip_to_mention" -eq "$mention_line" ]; then
after_mention=true
continue
fi
# Track code block state for lines after mention (validate matching fence types)
if [[ "$line" =~ ^[[:space:]]{0,3}(\`\`\`|~~~) ]]; then
local fence_marker="${BASH_REMATCH[1]}"
if [ "$in_code_block" -eq 1 ]; then
# Only close code block if fence marker matches opener
if [ "$fence_marker" = "$code_fence_type" ]; then
in_code_block=0
code_fence_type=''
fi
else
# Open new code block and record fence marker type
in_code_block=1
code_fence_type="$fence_marker"
fi
continue
fi
# Skip code blocks
if [ "$in_code_block" -eq 1 ]; then
continue
fi
# Ignore blockquotes
local trimmed_for_quote
trimmed_for_quote="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//')"
if [[ "$trimmed_for_quote" == \>* ]]; then
continue
fi
# Skip inline code
local cleaned_line="$line"
while [[ "$cleaned_line" =~ (.*)\`[^\`]*\`(.*) ]]; do
cleaned_line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
done
# Get first non-empty line (trimmed)
local trimmed_line
trimmed_line="$(printf '%s' "$cleaned_line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -n "$trimmed_line" ]; then
goal_hint="$trimmed_line"
break
fi
done <<< "$text"
fi
printf '%s\n' "$found_mention"
printf '%s\n' "$goal_hint"
}
SHOULD_RUN='false'
REASON='Not eligible'
GOAL_HINT=''
# Decode comment body from base64 (handles HTML/markdown safely)
if [ -n "${COMMENT_BODY_B64:-}" ]; then
if ! COMMENT_BODY="$(printf '%s' "$COMMENT_BODY_B64" | base64 -d 2>/dev/null)"; then
echo "Warning: failed to decode COMMENT_BODY_B64 as base64; proceeding with empty COMMENT_BODY" >&2
COMMENT_BODY=''
fi
else
COMMENT_BODY=''
fi
# Decode review body from base64
if [ -n "${REVIEW_BODY_B64:-}" ]; then
if ! REVIEW_BODY="$(printf '%s' "$REVIEW_BODY_B64" | base64 -d 2>/dev/null)"; then
echo "Warning: failed to decode REVIEW_BODY_B64 as base64; proceeding with empty REVIEW_BODY" >&2
REVIEW_BODY=''
fi
else
REVIEW_BODY=''
fi
# Determine which text to parse based on event type
PARSE_TEXT=''
if [ -n "$COMMENT_BODY" ]; then
PARSE_TEXT="$COMMENT_BODY"
elif [ -n "$REVIEW_BODY" ]; then
PARSE_TEXT="$REVIEW_BODY"
fi
# Avoid bot loops
if [ "$ACTOR" = 'holonbot[bot]' ]; then
REASON='Actor is holonbot[bot]'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
exit 0
fi
# Handle comment-based triggers (issue_comment, pull_request_review_comment)
if [ "$EVENT_NAME" = 'issue_comment' ] || [ "$EVENT_NAME" = 'pull_request_review_comment' ]; then
if [ "$EVENT_ACTION" = 'created' ] && [ -n "$PARSE_TEXT" ]; then
# Parse for @holonbot mention
parse_result="$(parse_mention "$PARSE_TEXT")"
FOUND_MENTION="$(printf '%s' "$parse_result" | sed -n '1p')"
GOAL_HINT="$(printf '%s' "$parse_result" | sed -n '2p')"
if [ "$FOUND_MENTION" = 'true' ]; then
echo "✅ Trigger path: comment command"
echo " Goal hint: ${GOAL_HINT:-<none>}"
else
REASON='No @holonbot mention found (or only in code block)'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$IS_PR" = 'true' ]; then
# Disallow fork PRs
IS_CROSS_REPO="$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER" --jq '.head.repo.full_name != .base.repo.full_name' 2>/dev/null || echo 'true')"
if [ "$IS_CROSS_REPO" = 'true' ]; then
REASON='PR is from a fork/cross-repo head; not supported'
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
# Require write/maintain/admin permissions
PERMISSION="$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo '')"
if [ "$PERMISSION" != 'admin' ] && [ "$PERMISSION" != 'maintain' ] && [ "$PERMISSION" != 'write' ]; then
REASON="Insufficient permissions for $ACTOR: ${PERMISSION:-none}"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
exit 0
fi
SHOULD_RUN='true'
REASON='Comment gates passed'
else
if [ "$EVENT_ACTION" != 'created' ]; then
REASON="Comment event not eligible: action=${EVENT_ACTION:-<none>}"
elif [ -z "$PARSE_TEXT" ]; then
REASON='Comment body is empty'
fi
fi
# Handle PR review events
elif [ "$EVENT_NAME" = 'pull_request_review' ]; then
if [ "$EVENT_ACTION" = 'submitted' ] || [ "$EVENT_ACTION" = 'edited' ]; then
# Parse for @holonbot mention in review body
if [ -n "$PARSE_TEXT" ]; then
parse_result="$(parse_mention "$PARSE_TEXT")"
FOUND_MENTION="$(printf '%s' "$parse_result" | sed -n '1p')"
GOAL_HINT="$(printf '%s' "$parse_result" | sed -n '2p')"
if [ "$FOUND_MENTION" = 'true' ]; then
echo "✅ Trigger path: PR review command"
echo " Goal hint: ${GOAL_HINT:-<none>}"
# Require write/maintain/admin permissions
PERMISSION="$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo '')"
if [ "$PERMISSION" != 'admin' ] && [ "$PERMISSION" != 'maintain' ] && [ "$PERMISSION" != 'write' ]; then
REASON="Insufficient permissions for $ACTOR: ${PERMISSION:-none}"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
exit 0
fi
SHOULD_RUN='true'
REASON='PR review gates passed'
else
REASON='No @holonbot mention found in review body'
fi
else
REASON='Review body is empty'
fi
else
REASON="PR review event not eligible: action=${EVENT_ACTION}"
fi
# Handle issue events (label, assign)
elif [ "$EVENT_NAME" = 'issues' ]; then
if [ "$EVENT_ACTION" = 'labeled' ] && [ "$LABEL_NAME" = 'holon' ]; then
SHOULD_RUN='true'
REASON='Issue labeled holon'
elif [ "$EVENT_ACTION" = 'assigned' ]; then
# Only trigger if assigned to holonbot[bot] specifically
ASSIGNEE_LOGIN="${ASSIGNEE_LOGIN:-}"
if [ "$ASSIGNEE_LOGIN" = 'holonbot[bot]' ] || [ "$ASSIGNEE_LOGIN" = 'holonbot' ]; then
SHOULD_RUN='true'
REASON='Issue assigned to holonbot[bot]'
else
REASON="Issue assigned to $ASSIGNEE_LOGIN (not holonbot[bot])"
fi
else
REASON='Issue event not eligible'
fi
# Handle PR events (label)
elif [ "$EVENT_NAME" = 'pull_request' ] && [ "$EVENT_ACTION" = 'labeled' ]; then
if [ "$LABEL_NAME" = 'holon' ]; then
SHOULD_RUN='true'
REASON='PR labeled holon'
else
REASON='PR label not eligible'
fi
# Default: allow (manual triggers, etc.)
else
SHOULD_RUN='true'
REASON='Default allow (manual trigger or workflow_call)'
fi
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "goal_hint=$GOAL_HINT" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.issue_number }}
IS_PR: ${{ steps.ctx.outputs.is_pr }}
COMMENT_BODY_B64: ${{ steps.ctx.outputs.comment_body_b64 }}
REVIEW_BODY_B64: ${{ steps.ctx.outputs.review_body_b64 }}
LABEL_NAME: ${{ github.event.label.name }}
ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
- name: Add initial reaction (eyes)
if: steps.gate.outputs.should_run == 'true' && inputs.comment_id != 0
run: |
set -euo pipefail
COMMENT_ID='${{ inputs.comment_id }}'
REPO='${{ github.repository }}'
# Add eyes reaction idempotently (ignore errors if already present)
gh api --silent -f content='eyes' "repos/$REPO/issues/comments/$COMMENT_ID/reactions" 2>/dev/null || true
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
- name: Resolve base branch
id: base
if: steps.gate.outputs.should_run == 'true'
run: |
set -euo pipefail
BASE="$INPUT_BASE"
if [ -z "$BASE" ]; then
BASE="$EVENT_DEFAULT_BRANCH"
fi
if [ -z "$BASE" ]; then
BASE="$(gh api "repos/$REPO" --jq '.default_branch')"
fi
if [ -z "$BASE" ] || [ "$BASE" = 'null' ]; then
echo "::warning::Unable to resolve repository default branch; falling back to 'main'" >&2
BASE='main'
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
REPO: ${{ github.repository }}
INPUT_BASE: ${{ inputs.base }}
EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- name: Checkout repository
if: steps.gate.outputs.should_run == 'true' && steps.ctx.outputs.is_pr != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history to avoid shallow clone issues
- name: Resolve PR checkout ref
id: pr-ref
if: steps.gate.outputs.should_run == 'true' && steps.ctx.outputs.is_pr == 'true'
run: |
set -euo pipefail
if [ -n "${EVENT_PR_HEAD_SHA:-}" ]; then
echo "ref=$EVENT_PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
exit 0
fi
REF="$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER" --jq '.head.sha')"
if [ -z "$REF" ] || [ "$REF" = 'null' ]; then
echo "::error::Unable to resolve PR head sha for $REPO#$ISSUE_NUMBER" >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ steps.ctx.outputs.issue_number }}
EVENT_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: Checkout repository (PR)
if: steps.gate.outputs.should_run == 'true' && steps.ctx.outputs.is_pr == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-ref.outputs.ref }}
fetch-depth: 0 # Fetch all history to avoid shallow clone issues
- name: Detect execution mode
id: detect
if: steps.gate.outputs.should_run == 'true'
run: |
set -euo pipefail
PROVIDED_MODE="${{ inputs.mode }}"
PROVIDED_SKILL="${{ inputs.skill }}"
AUTO_REVIEW='${{ inputs.auto_review }}'
IS_PR='${{ steps.ctx.outputs.is_pr }}'
# If auto_review is enabled for PRs, default the skill to github-review when none is provided.
if [ -z "$PROVIDED_SKILL" ] && [ "$AUTO_REVIEW" = 'true' ] && [ "$IS_PR" = 'true' ]; then
PROVIDED_SKILL='github-review'
echo "✅ Auto-review enabled → using skill: github-review"
fi
# If skill is provided, skip mode detection (skill mode takes precedence)
if [ -n "$PROVIDED_SKILL" ]; then
echo "mode=" >> "$GITHUB_OUTPUT"
echo "skill=$PROVIDED_SKILL" >> "$GITHUB_OUTPUT"
echo "✅ Skill mode enabled (skill: $PROVIDED_SKILL)"
exit 0
fi
# If mode is explicitly provided, use it
if [ -n "$PROVIDED_MODE" ]; then
echo "mode=$PROVIDED_MODE" >> "$GITHUB_OUTPUT"
echo "skill=" >> "$GITHUB_OUTPUT"
echo "✅ Using provided mode: $PROVIDED_MODE"
exit 0
fi
# Auto-detect mode based on ref type
if [ "$IS_PR" = 'true' ]; then
echo "mode=pr-fix" >> "$GITHUB_OUTPUT"
echo "skill=" >> "$GITHUB_OUTPUT"
echo "✅ Detected: PR → mode=pr-fix"
else
echo "mode=solve" >> "$GITHUB_OUTPUT"
echo "skill=" >> "$GITHUB_OUTPUT"
echo "✅ Detected: Issue → mode=solve"
fi
env:
GITHUB_TOKEN: ${{ secrets.holon_github_token || github.token }}
- name: Write input metadata
id: input-meta
if: always()
run: |
set -euo pipefail
INPUT_DIR='${{ steps.ctx.outputs.input_dir }}'
mkdir -p "$INPUT_DIR"
# Persist raw GitHub event payload for debugging/repro
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
cp "$GITHUB_EVENT_PATH" "$INPUT_DIR/event.json"
fi
EVENT_NAME='${{ github.event_name }}'
EVENT_ACTION='${{ github.event.action }}'
ACTOR='${{ github.actor }}'
REPO='${{ github.repository }}'
ISSUE_NUMBER='${{ steps.ctx.outputs.issue_number }}'
IS_PR='${{ steps.ctx.outputs.is_pr }}'
SHOULD_RUN="$GATE_SHOULD_RUN"
REASON="$GATE_REASON"
GOAL_HINT="$GATE_GOAL_HINT"
# Get comment_id from input (if available)
# Default to 0 if empty to ensure valid JSON for --argjson
COMMENT_ID='${{ inputs.comment_id }}'
if [ -z "$COMMENT_ID" ] || [ "$COMMENT_ID" = '' ]; then
COMMENT_ID='0'
fi
MODE=''
if [ "$SHOULD_RUN" = 'true' ]; then
MODE='${{ steps.detect.outputs.mode }}'
fi
jq -n \
--arg event_name "$EVENT_NAME" \
--arg event_action "$EVENT_ACTION" \
--arg actor "$ACTOR" \
--arg repository "$REPO" \
--arg issue_number "$ISSUE_NUMBER" \
--arg is_pr "$IS_PR" \
--arg should_run "$SHOULD_RUN" \
--arg reason "$REASON" \
--arg mode "$MODE" \
--arg goal_hint "$GOAL_HINT" \
--argjson comment_id "$COMMENT_ID" \
'{
event: {name: $event_name, action: $event_action, actor: $actor},
target: {repository: $repository, issue_number: ($issue_number | tonumber), is_pr: ($is_pr == "true")},
gate: {should_run: ($should_run == "true"), reason: $reason},
mode: $mode,
trigger: {goal_hint: $goal_hint, comment_id: $comment_id}
}' > "$INPUT_DIR/workflow.json"
env:
GATE_SHOULD_RUN: ${{ steps.gate.outputs.should_run }}
GATE_REASON: ${{ steps.gate.outputs.reason }}
GATE_GOAL_HINT: ${{ steps.gate.outputs.goal_hint }}
- name: Ensure Docker daemon
if: steps.gate.outputs.should_run == 'true' && runner.os == 'Linux'
run: |
if ! docker info >/dev/null 2>&1; then
sudo systemctl start docker || sudo service docker start
fi
docker info
- name: Run Holon
id: run
if: steps.gate.outputs.should_run == 'true'
uses: holon-run/holon@main
env:
HOLON_MODEL: ${{ vars.HOLON_MODEL || 'claude-sonnet-4-5-20250929' }}
with:
ref: "${{ github.repository }}#${{ steps.ctx.outputs.issue_number }}"
mode: ${{ steps.detect.outputs.mode }}
skill: ${{ steps.detect.outputs.skill }}
base: ${{ steps.base.outputs.base }}
agent: ${{ inputs.agent }}
anthropic_auth_token: ${{ secrets.anthropic_auth_token || secrets.anthropic_api_key }}
anthropic_base_url: ${{ secrets.anthropic_base_url || 'https://api.anthropic.com' }}
github_token: ${{ secrets.holon_github_token }}
log_level: ${{ inputs.log_level }}
assistant_output: ${{ inputs.assistant_output }}
workspace: ${{ inputs.workspace }}
input_dir: ${{ steps.ctx.outputs.input_dir }}
output_dir: ${{ steps.ctx.outputs.output_dir }}
version: ${{ inputs.version }}
build_from_source: ${{ inputs.build_from_source }}
holon_repository: ${{ inputs.holon_repository }}
- name: Post Summary
id: result
if: always()
run: |
OUTPUT_DIR='${{ steps.ctx.outputs.output_dir }}'
SHOULD_RUN="$GATE_SHOULD_RUN"
REASON="$GATE_REASON"
if [ "$SHOULD_RUN" != 'true' ]; then
{
echo "### Holon skipped"
echo "- Reason: $REASON"
echo "- Event: ${{ github.event_name }} (${{ github.event.action }})"
echo "- Target: #${{ steps.ctx.outputs.issue_number }}"
} >> "$GITHUB_STEP_SUMMARY"
fi
# Check for summary
if [ -f "$OUTPUT_DIR/summary.md" ]; then
cat "$OUTPUT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
echo "summary=$OUTPUT_DIR/summary.md" >> "$GITHUB_OUTPUT"
else
echo "summary=" >> "$GITHUB_OUTPUT"
fi
# Set result based on job status
if [ "$SHOULD_RUN" != 'true' ]; then
echo "result=skipped" >> "$GITHUB_OUTPUT"
else
echo "result=${{ steps.run.outcome }}" >> "$GITHUB_OUTPUT"
fi
env:
GATE_SHOULD_RUN: ${{ steps.gate.outputs.should_run }}
GATE_REASON: ${{ steps.gate.outputs.reason }}
- name: Post completion feedback
if: always() && inputs.comment_id != 0 && steps.gate.outputs.should_run == 'true'
run: |
set -euo pipefail
COMMENT_ID='${{ inputs.comment_id }}'
ISSUE_NUMBER='${{ steps.ctx.outputs.issue_number }}'
RESULT='${{ steps.result.outputs.result }}'
REPO='${{ github.repository }}'
RUN_URL='${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
# Determine reaction and message based on result
if [ "$RESULT" = 'success' ]; then
REACTION_CONTENT='white_check_mark'
STATUS_TEXT="Holon completed successfully."
else
REACTION_CONTENT='x'
STATUS_TEXT="Holon failed: $RESULT"
fi
# Add reaction idempotently (ignore errors if already present)
gh api --silent -f content="$REACTION_CONTENT" "repos/$REPO/issues/comments/$COMMENT_ID/reactions" 2>/dev/null || true
# Post reply comment with run URL and status
gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" \
-f body="$STATUS_TEXT
Run: $RUN_URL"
env:
GH_TOKEN: ${{ secrets.holon_github_token || github.token }}
- name: Bundle Holon Artifact (input + output)
id: bundle
if: always()
run: |
set -euo pipefail
INPUT_DIR='${{ steps.ctx.outputs.input_dir }}'
OUTPUT_DIR='${{ steps.ctx.outputs.output_dir }}'
ARTIFACT_DIR="$(mktemp -d -t holon-artifact-XXXXXX)"
mkdir -p "$ARTIFACT_DIR/input" "$ARTIFACT_DIR/output"
if [ -d "$INPUT_DIR" ]; then
cp -a "$INPUT_DIR/." "$ARTIFACT_DIR/input/" || true
fi
if [ -d "$OUTPUT_DIR" ]; then
cp -a "$OUTPUT_DIR/." "$ARTIFACT_DIR/output/" || true
fi
echo "artifact_dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
- name: Upload Holon Artifact (input + output)
if: always()
uses: actions/upload-artifact@v4
with:
name: holon-artifact-${{ steps.ctx.outputs.issue_number }}
path: ${{ steps.bundle.outputs.artifact_dir }}/
retention-days: 7