ci: allow auto review via HOLON_AUTO_REVIEW variable #9
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Holon Solve (Reusable) | ||
| 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 | ||