Luther Autonomous Agent #759
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: Luther Autonomous Agent | |
| on: | |
| schedule: | |
| - cron: '0 */3 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| issue_number: | |
| description: 'Optional issue number to override the Luther queue.' | |
| required: false | |
| type: string | |
| force_debug: | |
| description: 'Set LLXPRT_DEBUG=llxprt:* for this run (overrides repo debug vars).' | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| defaults: | |
| run: | |
| shell: bash | |
| jobs: | |
| luther: | |
| name: Run Luther agent | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 360 | |
| env: | |
| MANUAL_ISSUE_INPUT: "${{ github.event_name == 'workflow_dispatch' && inputs.issue_number || '' }}" | |
| REPO: ${{ github.repository }} | |
| GH_TOKEN: ${{ github.token }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| OPENAI_API_KEY: ${{ secrets[vars.KEY_VAR_NAME] }} | |
| OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }} | |
| LLXPRT_DEFAULT_MODEL: ${{ vars.LLXPRT_DEFAULT_MODEL }} | |
| LLXPRT_DEFAULT_PROVIDER: ${{ vars.LLXPRT_DEFAULT_PROVIDER }} | |
| KEY_VAR_NAME: ${{ vars.KEY_VAR_NAME }} | |
| LUTHER_MAX_ATTEMPTS: 3 | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| LLXPRT_DEBUG: ${{ vars.DEBUG_NAMESPACES || 'llxprt:*' }} | |
| DEBUG_OUTPUT: stderr | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 | |
| - name: Select issue for Luther | |
| id: select_issue | |
| run: | | |
| set -euo pipefail | |
| sanitize_number() { | |
| local value="$1" | |
| value="${value//[[:space:]]/}" | |
| value="${value#\#}" | |
| printf '%s' "$value" | |
| } | |
| branch_exists() { | |
| local name="$1" | |
| git show-ref --verify --quiet "refs/heads/${name}" >/dev/null 2>&1 && return 0 | |
| git ls-remote --exit-code --heads origin "${name}" >/dev/null 2>&1 && return 0 | |
| return 1 | |
| } | |
| ensure_unique_branch() { | |
| local base="$1" | |
| local candidate="$base" | |
| if branch_exists "$candidate"; then | |
| # Append a UTC timestamp suffix until we find a unique name. | |
| while true; do | |
| local timestamp | |
| timestamp="$(date -u '+%Y%m%d%H%M%S')" | |
| candidate="${base}-${timestamp}" | |
| if ! branch_exists "$candidate"; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| fi | |
| printf '%s' "$candidate" | |
| } | |
| mkdir -p luther/state | |
| manual_input="$(sanitize_number "${MANUAL_ISSUE_INPUT:-}")" | |
| if [[ -n "$manual_input" ]]; then | |
| if [[ ! "$manual_input" =~ ^[0-9]+$ ]]; then | |
| echo "Manual issue override must be numeric. Received: ${manual_input}" >&2 | |
| exit 1 | |
| fi | |
| base_branch="luther-issue${manual_input}" | |
| branch="$(ensure_unique_branch "$base_branch")" | |
| { | |
| echo "issue_number=${manual_input}" | |
| echo "branch=${branch}" | |
| echo "selection_mode=manual" | |
| echo "pr_number=" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| repo_owner="${GITHUB_REPOSITORY%%/*}" | |
| remediation_json="$(gh pr list \ | |
| --state open \ | |
| --label 'luther remediate' \ | |
| --json number,updatedAt,headRefName,headRepositoryOwner,labels \ | |
| --limit 50)" | |
| remediation_pr="$( | |
| jq -r --arg owner "$repo_owner" ' | |
| [ | |
| .[] | | |
| select(.headRepositoryOwner.login == $owner) | | |
| select(((.labels // []) | map(.name)) | index("luther exhausted") | not) | |
| ] | |
| | sort_by(.updatedAt)[0].number // "" | |
| ' <<<"$remediation_json" | |
| )" | |
| if [[ -n "$remediation_pr" ]]; then | |
| pr_json_path="luther/state/remediation-pr.json" | |
| gh pr view "$remediation_pr" --json number,body,title,url,headRefName > "$pr_json_path" | |
| branch="$(jq -r '.headRefName // ""' "$pr_json_path")" | |
| if [[ -z "$branch" ]]; then | |
| echo "Unable to resolve branch for PR #${remediation_pr}; falling back to issue queue." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| issue_number="$(jq -r '.body // ""' "$pr_json_path" | grep -o '#[0-9]\+' | head -n1 | tr -d '#')" | |
| issue_number="$(sanitize_number "${issue_number}")" | |
| if [[ -z "$issue_number" ]]; then | |
| issue_number="${remediation_pr}" | |
| fi | |
| { | |
| echo "issue_number=${issue_number}" | |
| echo "branch=${branch}" | |
| echo "selection_mode=remediation" | |
| echo "pr_number=${remediation_pr}" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Selected PR #${remediation_pr} (branch ${branch}) for remediation." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| fi | |
| issues_json="$(gh issue list \ | |
| --state open \ | |
| --label 'OK for Luther' \ | |
| --json number,createdAt,assignees \ | |
| --limit 100 \ | |
| --search 'no:assignee sort:created-asc')" | |
| if [[ "$(jq 'length' <<<"$issues_json")" -eq 0 ]]; then | |
| { | |
| echo 'issue_number=' | |
| echo 'branch=' | |
| echo 'selection_mode=auto' | |
| echo 'pr_number=' | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| issue_number="$(jq -r 'sort_by(.createdAt)[0].number' <<<"$issues_json")" | |
| base_branch="luther-issue${issue_number}" | |
| branch="$(ensure_unique_branch "$base_branch")" | |
| { | |
| echo "issue_number=${issue_number}" | |
| echo "branch=${branch}" | |
| echo 'selection_mode=auto' | |
| echo 'pr_number=' | |
| } >> "$GITHUB_OUTPUT" | |
| - name: No eligible issues | |
| if: steps.select_issue.outputs.issue_number == '' | |
| run: | | |
| echo "No remediation PRs or open, unassigned issues labeled 'OK for Luther' were found." >> "$GITHUB_STEP_SUMMARY" | |
| - name: Ensure Luther labels exist | |
| if: steps.select_issue.outputs.issue_number != '' | |
| run: | | |
| set -euo pipefail | |
| while IFS='|' read -r label color description; do | |
| gh label create "$label" --color "$color" --description "$description" >/dev/null 2>&1 || true | |
| done <<'EOF' | |
| OK for Luther|bfd4f2|Eligible for Luther autonomous agent | |
| Luther working|0366d6|Issue currently processed by Luther | |
| Luther Done|0e8a16|Completed via Luther workflow | |
| luther-failed|cb2431|Latest Luther attempt failed | |
| luther remediate|d4c5f9|PR requires additional Luther remediation | |
| luther exhausted|b60205|Luther has exhausted automated remediation attempts | |
| EOF | |
| - name: Force debug logging for manual run | |
| if: github.event_name == 'workflow_dispatch' && inputs.force_debug == true | |
| run: | | |
| echo "LLXPRT_DEBUG=llxprt:*" >> "$GITHUB_ENV" | |
| echo "DEBUG_OUTPUT=stderr" >> "$GITHUB_ENV" | |
| - name: Fetch issue metadata | |
| id: issue_data | |
| if: steps.select_issue.outputs.issue_number != '' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p luther/state | |
| issue_json_path="luther/state/issue.json" | |
| gh issue view "$ISSUE_NUMBER" --json number,title,body,url,labels,assignees > "$issue_json_path" | |
| title="$(jq -r '.title' "$issue_json_path")" | |
| url="$(jq -r '.url' "$issue_json_path")" | |
| assignee_count="$(jq '.assignees | length' "$issue_json_path")" | |
| body_path="luther/state/issue-body.md" | |
| labels_value="$(jq -r '[.labels[].name] | join(", ")' "$issue_json_path")" | |
| is_doc_issue="$(jq -r 'if any(.labels[]?.name; . == "documentation") then "true" else "false" end' "$issue_json_path")" | |
| jq -r '.body // ""' "$issue_json_path" > "$body_path" | |
| { | |
| echo "title<<EOF" | |
| printf '%s\n' "$title" | |
| echo "EOF" | |
| echo "url=${url}" | |
| echo "assignee_count=${assignee_count}" | |
| echo "body_path=${body_path}" | |
| echo "json_path=${issue_json_path}" | |
| echo "labels=${labels_value}" | |
| echo "is_doc_issue=${is_doc_issue}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Fetch issue comments | |
| id: issue_comments | |
| if: steps.select_issue.outputs.issue_number != '' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| run: | | |
| set -euo pipefail | |
| issue_comments_path="luther/state/issue-comments.json" | |
| gh api "/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/comments" \ | |
| > "$issue_comments_path" || echo '[]' > "$issue_comments_path" | |
| echo "comments_path=${issue_comments_path}" >> "$GITHUB_OUTPUT" | |
| - name: Fetch remediation PR comments | |
| id: pr_comments | |
| if: steps.select_issue.outputs.pr_number != '' | |
| env: | |
| PR_NUMBER: ${{ steps.select_issue.outputs.pr_number }} | |
| run: | | |
| set -euo pipefail | |
| pr_comments_path="luther/state/pr-comments.json" | |
| gh api "/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ | |
| > "$pr_comments_path" || echo '[]' > "$pr_comments_path" | |
| echo "comments_path=${pr_comments_path}" >> "$GITHUB_OUTPUT" | |
| - name: Claim issue and update labels | |
| id: claim_issue | |
| if: steps.select_issue.outputs.issue_number != '' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| SELECTION_MODE: ${{ steps.select_issue.outputs.selection_mode }} | |
| ISSUE_JSON: ${{ steps.issue_data.outputs.json_path }} | |
| ISSUE_URL: ${{ steps.issue_data.outputs.url }} | |
| RUN_URL: ${{ env.RUN_URL }} | |
| PR_NUMBER: ${{ steps.select_issue.outputs.pr_number }} | |
| run: | | |
| set -euo pipefail | |
| assignee_count="$(jq '.assignees | length' "$ISSUE_JSON")" | |
| if [[ "${SELECTION_MODE}" == 'auto' && "$assignee_count" -gt 0 ]]; then | |
| echo "Issue #${ISSUE_NUMBER} now has an assignee; skipping." >> "$GITHUB_STEP_SUMMARY" | |
| echo "should_continue=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [[ "${SELECTION_MODE}" == 'auto' ]]; then | |
| if ! jq -e '.labels[]?.name | select(. == "OK for Luther")' "$ISSUE_JSON" >/dev/null; then | |
| echo "Issue #${ISSUE_NUMBER} no longer has the 'OK for Luther' label; assuming another run claimed it." >> "$GITHUB_STEP_SUMMARY" | |
| echo "should_continue=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| # Refresh labels to reflect active work | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "Luther Done" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "luther-failed" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "OK for Luther" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "Luther working" >/dev/null 2>&1 | |
| dispatch_note="scheduled queue" | |
| if [[ "${SELECTION_MODE}" == 'manual' ]]; then | |
| dispatch_note="manual override" | |
| elif [[ "${SELECTION_MODE}" == 'remediation' ]]; then | |
| dispatch_note="PR remediation" | |
| fi | |
| gh issue comment "$ISSUE_NUMBER" -F - <<EOF | |
| Luther is starting work on this issue (${dispatch_note}). Tracking branch: \`${ISSUE_BRANCH}\`. | |
| - Run: ${RUN_URL} | |
| - Selection: ${dispatch_note} | |
| $(if [[ "${SELECTION_MODE}" == 'remediation' && -n "${PR_NUMBER}" ]]; then echo "- Remediating PR #${PR_NUMBER}"; fi) | |
| I'll update this thread with results when the workflow finishes. | |
| EOF | |
| if [[ "${SELECTION_MODE}" == 'remediation' ]]; then | |
| pr_target="${PR_NUMBER:-}" | |
| if [[ -z "$pr_target" ]]; then | |
| pr_target="$(gh pr view "${ISSUE_BRANCH}" --json number -q .number 2>/dev/null || true)" | |
| fi | |
| if [[ -n "$pr_target" ]]; then | |
| gh pr edit "$pr_target" --remove-label "luther remediate" >/dev/null 2>&1 || true | |
| fi | |
| fi | |
| echo "should_continue=true" >> "$GITHUB_OUTPUT" | |
| - name: Configure git and working branch | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| env: | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| SELECTION_MODE: ${{ steps.select_issue.outputs.selection_mode }} | |
| PR_NUMBER: ${{ steps.select_issue.outputs.pr_number }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "Luther[bot]" | |
| git config user.email "[email protected]" | |
| if [[ "${SELECTION_MODE}" == 'remediation' ]]; then | |
| echo "Remediation mode: checking out existing branch ${ISSUE_BRANCH}" | |
| pr_number="${PR_NUMBER:-}" | |
| if [[ -n "$pr_number" ]]; then | |
| echo "Fetching PR #${pr_number} head into branch ${ISSUE_BRANCH}" | |
| git fetch origin "pull/${pr_number}/head:${ISSUE_BRANCH}" --depth=1 | |
| else | |
| echo "Fetching branch ${ISSUE_BRANCH} from origin" | |
| git fetch origin "${ISSUE_BRANCH}:${ISSUE_BRANCH}" --depth=1 | |
| fi | |
| git checkout "$ISSUE_BRANCH" | |
| # Ensure the branch tracks the remote properly for push | |
| git branch --set-upstream-to="origin/${ISSUE_BRANCH}" "${ISSUE_BRANCH}" || true | |
| echo "Current branch: $(git branch --show-current)" | |
| else | |
| git fetch origin main --depth=1 | |
| git checkout -B "$ISSUE_BRANCH" origin/main | |
| fi | |
| - name: Set up Node.js | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # ratchet:actions/[email protected] | |
| with: | |
| node-version: '24.x' | |
| cache: 'npm' | |
| - name: Install dependencies | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| run: npm ci | |
| - name: Fix rollup platform dependency | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| run: | | |
| if [ "${{ runner.os }}" == "Linux" ]; then | |
| npm install @rollup/rollup-linux-x64-gnu --no-save || true | |
| elif [ "${{ runner.os }}" == "Windows" ]; then | |
| npm install @rollup/rollup-win32-x64-msvc --no-save || true | |
| fi | |
| - name: Install LLxprt CLI nightly | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| run: npm install -g @vybestack/llxprt-code@nightly | |
| - name: Run Luther attempts | |
| id: run_attempts | |
| if: steps.claim_issue.outputs.should_continue == 'true' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| ISSUE_TITLE: ${{ steps.issue_data.outputs.title }} | |
| ISSUE_URL: ${{ steps.issue_data.outputs.url }} | |
| ISSUE_BODY_PATH: ${{ steps.issue_data.outputs.body_path }} | |
| SELECTION_MODE: ${{ steps.select_issue.outputs.selection_mode }} | |
| ISSUE_LABELS: ${{ steps.issue_data.outputs.labels }} | |
| IS_DOC_ISSUE: ${{ steps.issue_data.outputs.is_doc_issue }} | |
| ISSUE_COMMENTS_PATH: ${{ steps.issue_comments.outputs.comments_path }} | |
| PR_COMMENTS_PATH: ${{ steps.pr_comments.outputs.comments_path }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p luther/artifacts luther/state | |
| history_file="luther/state/attempt-history.md" | |
| truncate -s 0 "$history_file" | |
| results_file="luther/state/failure-summary.txt" | |
| truncate -s 0 "$results_file" | |
| format_comments() { | |
| local file="$1" | |
| if [[ -z "$file" || ! -s "$file" ]]; then | |
| return | |
| fi | |
| jq -r ' | |
| sort_by(.created_at) | | |
| reverse | | |
| .[:5] | | |
| reverse | | |
| map("* [" + ((.user.login // "unknown")) + " @ " + ((.created_at // "?")) + "] " + | |
| ((.body // "") | gsub("\r";"") | gsub("\n"; "\n "))) | | |
| .[] | |
| ' "$file" | |
| } | |
| issue_comments_text="$(format_comments "${ISSUE_COMMENTS_PATH:-}")" | |
| pr_comments_text="$(format_comments "${PR_COMMENTS_PATH:-}")" | |
| qa_names=("format" "lint" "typecheck" "test" "build") | |
| qa_commands=("npm run format" "npm run lint" "npm run typecheck" "npm run test" "npm run build") | |
| max_attempts="${LUTHER_MAX_ATTEMPTS:-3}" | |
| success_attempt="" | |
| if [[ ! -s "$ISSUE_BODY_PATH" ]]; then | |
| echo "_No issue body provided._" > "$ISSUE_BODY_PATH" | |
| fi | |
| for attempt in $(seq 1 "$max_attempts"); do | |
| attempt_dir="luther/artifacts/attempt-${attempt}" | |
| mkdir -p "$attempt_dir" | |
| prompt_file="${attempt_dir}/prompt.txt" | |
| { | |
| echo "You are Luther, an autonomous maintenance agent for the LLxprt Code repository." | |
| echo | |
| echo "Issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}" | |
| echo "URL: ${ISSUE_URL}" | |
| echo "Labels: ${ISSUE_LABELS:-<none>}" | |
| echo | |
| echo "Issue body:" | |
| echo "----------------------------------------" | |
| cat "$ISSUE_BODY_PATH" | |
| echo | |
| echo "----------------------------------------" | |
| echo | |
| echo "Recent issue comments:" | |
| if [[ -n "$issue_comments_text" ]]; then | |
| printf '%s\n' "$issue_comments_text" | |
| else | |
| echo "- (none)" | |
| fi | |
| if [[ -n "$pr_comments_text" ]]; then | |
| echo | |
| echo "Recent PR comments:" | |
| printf '%s\n' "$pr_comments_text" | |
| fi | |
| echo | |
| echo "Repository branch: ${ISSUE_BRANCH}" | |
| echo "Selection mode: ${SELECTION_MODE}" | |
| echo | |
| echo "Requirements:" | |
| if [[ "${IS_DOC_ISSUE}" == "true" ]]; then | |
| echo "- This issue is documentation-focused; you may update docs directly without writing new automated tests, but still describe any manual validation you perform." | |
| else | |
| echo "- Follow a strict test-first workflow: write or update tests before implementation." | |
| fi | |
| echo "- Limit work to resolving this issue; avoid touching unrelated areas." | |
| echo "- Leave git commits/pushes to the workflow; keep changes in the working tree." | |
| echo "- Reference the issue as #${ISSUE_NUMBER} inside any summaries you produce." | |
| echo "- CRITICAL: Do NOT modify any files in .github/workflows/ - GitHub will reject pushes that modify workflow files." | |
| echo "- CRITICAL: After making changes, you MUST run these QA commands FROM THE REPOSITORY ROOT:" | |
| echo " npm run format && npm run lint && npm run typecheck && npm run test && npm run build" | |
| echo "- CRITICAL: Verify that ALL commands complete successfully with exit code 0 before claiming success." | |
| echo "- CRITICAL: If any command fails, fix the issue and re-run ALL commands again." | |
| echo "- Do not use web-search or web-fetch tools; stay within the repository context." | |
| echo "- Do not spawn subagents (e.g., /task or named profiles like 'joethecoder'); complete the work yourself in this session." | |
| echo "- Do not rely on local-only profiles or secrets that are unavailable in CI; assume only the data in this repository is accessible." | |
| echo "- Produce a final response summarizing changes and the tests you ran." | |
| echo | |
| if [[ -s "$history_file" ]]; then | |
| echo "Previous attempts summary:" | |
| cat "$history_file" | |
| echo | |
| else | |
| echo "This is the first attempt for this run." | |
| echo | |
| fi | |
| echo "Focus on resolving the root cause, updating documentation/tests as needed, and ensuring the commands above pass." | |
| } > "$prompt_file" | |
| echo "Running LLxprt for attempt ${attempt}" | |
| llxprt_log="${attempt_dir}/llxprt.log" | |
| context_limit="${LLXPRT_CONTEXT_LIMIT:-200000}" | |
| set +e | |
| cat "$prompt_file" | llxprt \ | |
| --provider "${LLXPRT_DEFAULT_PROVIDER}" \ | |
| --model "${LLXPRT_DEFAULT_MODEL}" \ | |
| --yolo \ | |
| --key "${OPENAI_API_KEY}" \ | |
| --set modelparam.temperature=1 \ | |
| --set modelparam.max_tokens=10000 \ | |
| --set context-limit="${context_limit}" \ | |
| --set base-url="${OPENAI_BASE_URL}" \ | |
| --set shell-replacement=true 2>&1 | tee "$llxprt_log" | |
| llxprt_exit="${PIPESTATUS[1]}" | |
| set -e | |
| if [[ "$llxprt_exit" -ne 0 ]]; then | |
| echo "Attempt ${attempt}: LLxprt exited with status ${llxprt_exit}" | tee -a "$history_file" | |
| echo "Attempt ${attempt}: LLxprt exited with status ${llxprt_exit}" > "$results_file" | |
| { | |
| echo "---- LLxprt log (last 100 lines) ----" | |
| tail -n 100 "$llxprt_log" | |
| echo "--------------------------------------" | |
| } | tee -a "$history_file" >/dev/null | |
| fi | |
| qa_failure_command="" | |
| if [[ "$llxprt_exit" -eq 0 ]]; then | |
| for idx in "${!qa_commands[@]}"; do | |
| cmd="${qa_commands[$idx]}" | |
| name="${qa_names[$idx]}" | |
| log_file="${attempt_dir}/${name//[: ]/_}.log" | |
| echo "Attempt ${attempt}: running ${cmd} in ${GITHUB_WORKSPACE}" | |
| set +e | |
| bash -c "cd \"${GITHUB_WORKSPACE}\" && pwd && $cmd" >"$log_file" 2>&1 | |
| cmd_exit=$? | |
| set -e | |
| if [[ "$cmd_exit" -ne 0 ]]; then | |
| qa_failure_command="${cmd}" | |
| { | |
| echo "Attempt ${attempt}: ${cmd} failed with exit code ${cmd_exit}." | |
| echo "See ${log_file} for details." | |
| } | tee -a "$history_file" | |
| tail -n 100 "$log_file" > "$results_file" | |
| { | |
| echo "---- ${name} log (last 100 lines) ----" | |
| tail -n 100 "$log_file" | |
| echo "--------------------------------------" | |
| } | tee -a "$history_file" >/dev/null | |
| break | |
| fi | |
| done | |
| else | |
| echo "Attempt ${attempt}: skipped QA commands because LLxprt exited with ${llxprt_exit}." | tee -a "$history_file" >/dev/null | |
| fi | |
| if [[ "$llxprt_exit" -eq 0 && -z "$qa_failure_command" ]]; then | |
| success_attempt="${attempt}" | |
| { | |
| echo "Attempt ${attempt}: ✅ All QA commands passed." | |
| } | tee -a "$history_file" | |
| break | |
| fi | |
| if [[ "$attempt" -lt "$max_attempts" ]]; then | |
| echo "Attempt ${attempt} failed; preparing for retry." | tee -a "$history_file" >/dev/null | |
| fi | |
| done | |
| if [[ -n "$success_attempt" ]]; then | |
| { | |
| echo "result=success" | |
| echo "success_attempt=${success_attempt}" | |
| echo "attempts_exhausted=false" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "result=failure" | |
| echo "attempts_exhausted=true" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| if [[ -z "$success_attempt" && -s "$results_file" ]]; then | |
| { | |
| echo "failure_summary<<EOF" | |
| cat "$results_file" | |
| echo | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "history_path=${history_file}" >> "$GITHUB_OUTPUT" | |
| - name: Commit and push changes | |
| id: commit_push | |
| if: steps.run_attempts.outputs.result == 'success' | |
| env: | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| run: | | |
| set -euo pipefail | |
| if git diff --quiet && git diff --cached --quiet; then | |
| echo "No changes found to commit." >> "$GITHUB_STEP_SUMMARY" | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Prevent Luther from opening PRs that only change package-lock.json | |
| non_lock_changes="$(git status --porcelain | sed 's/^.. //' | grep -v '^package-lock\.json$' || true)" | |
| if [[ -z "$non_lock_changes" ]]; then | |
| { | |
| echo "Detected changes only in package-lock.json; skipping commit to avoid lockfile-only PR." | |
| echo "Re-run Luther after applying additional code changes manually." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Always discard package-lock.json changes from Luther commits | |
| if git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then | |
| git checkout -- package-lock.json | |
| fi | |
| git add -A | |
| git commit -m "Luther: address #${ISSUE_NUMBER}" | |
| git push --force-with-lease origin "$ISSUE_BRANCH" | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| - name: Open or update pull request | |
| id: pr | |
| if: steps.commit_push.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| ISSUE_TITLE: ${{ steps.issue_data.outputs.title }} | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| ISSUE_URL: ${{ steps.issue_data.outputs.url }} | |
| RUN_URL: ${{ env.RUN_URL }} | |
| SELECTION_MODE: ${{ steps.select_issue.outputs.selection_mode }} | |
| run: | | |
| set -euo pipefail | |
| echo "Selection mode: ${SELECTION_MODE}" | |
| echo "Branch name: ${ISSUE_BRANCH}" | |
| pr_title="Luther: ${ISSUE_TITLE}" | |
| pr_body=$(cat <<EOF | |
| ## Summary | |
| - Automated Luther run for #${ISSUE_NUMBER} | |
| - See workflow run: ${RUN_URL} | |
| Refs #${ISSUE_NUMBER} | |
| EOF | |
| ) | |
| pr_url="" | |
| pr_number="" | |
| echo "Checking if PR exists for branch ${ISSUE_BRANCH}..." | |
| if gh pr view "$ISSUE_BRANCH" >/dev/null 2>&1; then | |
| echo "✓ Found existing PR for branch ${ISSUE_BRANCH}" | |
| if [[ "${SELECTION_MODE}" != 'remediation' ]]; then | |
| echo "Updating PR title and body (non-remediation mode)" | |
| gh pr edit "$ISSUE_BRANCH" --title "$pr_title" --body "$pr_body" | |
| else | |
| echo "Skipping PR update (remediation mode - preserving original PR description)" | |
| fi | |
| pr_url="$(gh pr view "$ISSUE_BRANCH" --json url -q .url)" | |
| pr_number="$(gh pr view "$ISSUE_BRANCH" --json number -q .number)" | |
| echo "Found existing PR #${pr_number} for branch ${ISSUE_BRANCH}" | |
| else | |
| echo "✗ No existing PR found for branch ${ISSUE_BRANCH}, creating new one" | |
| pr_url="$(gh pr create --head "$ISSUE_BRANCH" --base main --title "$pr_title" --body "$pr_body")" | |
| pr_number="$(gh pr view "$ISSUE_BRANCH" --json number -q .number)" | |
| echo "Created new PR #${pr_number} for branch ${ISSUE_BRANCH}" | |
| fi | |
| echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" | |
| - name: Request CodeRabbit review | |
| if: steps.commit_push.outputs.has_changes == 'true' && steps.pr.outputs.pr_number != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GH_SECRET }} | |
| GITHUB_TOKEN: '' # Override job-level GITHUB_TOKEN to force gh to use GH_TOKEN | |
| PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | |
| run: | | |
| set -euo pipefail | |
| echo "Posting CodeRabbit review request to PR #${PR_NUMBER}" | |
| gh pr comment "${PR_NUMBER}" --body "@coderabbit review" | |
| - name: Label pull request status | |
| if: steps.commit_push.outputs.has_changes == 'true' | |
| env: | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| pr_number="$(gh pr view "$ISSUE_BRANCH" --json number -q .number 2>/dev/null || true)" | |
| if [[ -z "$pr_number" ]]; then | |
| exit 0 | |
| fi | |
| gh pr edit "$pr_number" --remove-label "luther remediate" >/dev/null 2>&1 || true | |
| gh pr edit "$pr_number" --remove-label "luther exhausted" >/dev/null 2>&1 || true | |
| gh pr edit "$pr_number" --add-label "Luther Done" >/dev/null 2>&1 || true | |
| - name: Update issue on success | |
| if: steps.run_attempts.outputs.result == 'success' && steps.commit_push.outputs.has_changes == 'true' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| PR_URL: ${{ steps.pr.outputs.pr_url }} | |
| RUN_URL: ${{ env.RUN_URL }} | |
| run: | | |
| set -euo pipefail | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "Luther working" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "luther-failed" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "Luther Done" >/dev/null 2>&1 || true | |
| summary="Luther completed successfully. QA logs are available from the workflow run." | |
| if [[ -n "${PR_URL}" ]]; then | |
| summary="${summary}\n\nPR: ${PR_URL}" | |
| fi | |
| gh issue comment "$ISSUE_NUMBER" -F - <<EOF | |
| ✅ Luther finished successfully. | |
| - Workflow: ${RUN_URL} | |
| - ${summary} | |
| EOF | |
| - name: No-op success handler | |
| if: steps.run_attempts.outputs.result == 'success' && steps.commit_push.outputs.has_changes != 'true' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| RUN_URL: ${{ env.RUN_URL }} | |
| run: | | |
| set -euo pipefail | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "Luther working" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "luther-failed" >/dev/null 2>&1 || true | |
| gh issue comment "$ISSUE_NUMBER" -F - <<EOF | |
| ⚠️ Luther completed all commands but no file changes were detected, so no PR was created. | |
| - Workflow: ${RUN_URL} | |
| - Next step: Investigate manually and relabel as needed. | |
| EOF | |
| - name: Update issue on failure | |
| if: steps.claim_issue.outputs.should_continue == 'true' && steps.run_attempts.outputs.result != 'success' | |
| env: | |
| ISSUE_NUMBER: ${{ steps.select_issue.outputs.issue_number }} | |
| RUN_URL: ${{ env.RUN_URL }} | |
| FAILURE_SUMMARY: ${{ steps.run_attempts.outputs.failure_summary }} | |
| run: | | |
| set -euo pipefail | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "Luther working" >/dev/null 2>&1 || true | |
| gh issue edit "$ISSUE_NUMBER" --add-label "luther-failed" >/dev/null 2>&1 || true | |
| gh issue comment "$ISSUE_NUMBER" -F - <<EOF | |
| ⚠️ Luther was unable to complete this issue after the allotted attempts. | |
| - Workflow: ${RUN_URL} | |
| - Failure summary: | |
| ${FAILURE_SUMMARY:-"(see artifacts for details)"} | |
| EOF | |
| - name: Update remediation labels on failure | |
| if: steps.claim_issue.outputs.should_continue == 'true' && steps.run_attempts.outputs.result != 'success' && steps.select_issue.outputs.selection_mode == 'remediation' | |
| env: | |
| ISSUE_BRANCH: ${{ steps.select_issue.outputs.branch }} | |
| PR_NUMBER: ${{ steps.select_issue.outputs.pr_number }} | |
| ATTEMPTS_EXHAUSTED: ${{ steps.run_attempts.outputs.attempts_exhausted }} | |
| run: | | |
| set -euo pipefail | |
| pr_target="${PR_NUMBER:-}" | |
| if [[ -z "$pr_target" ]]; then | |
| pr_target="$(gh pr view "${ISSUE_BRANCH}" --json number -q .number 2>/dev/null || true)" | |
| fi | |
| if [[ -z "$pr_target" ]]; then | |
| exit 0 | |
| fi | |
| if [[ "${ATTEMPTS_EXHAUSTED}" == 'true' ]]; then | |
| gh pr edit "$pr_target" --add-label "luther exhausted" >/dev/null 2>&1 || true | |
| else | |
| gh pr edit "$pr_target" --add-label "luther remediate" >/dev/null 2>&1 || true | |
| fi | |
| - name: Upload Luther artifacts | |
| if: always() && steps.claim_issue.outputs.should_continue == 'true' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4 | |
| with: | |
| name: luther-run-${{ github.run_id }} | |
| path: luther | |
| if-no-files-found: warn |