name: 'Loki Issue to PR'
description: 'Hand Loki Mode a GitHub issue; it produces a patch and a PR body with a verifiable evidence receipt. Open the PR from a separate job that runs no agent.'
author: 'asklokesh'

branding:
  icon: 'git-pull-request'
  color: 'purple'

# Rule of Two: the issue text is untrusted, so the job that runs this action
# must not hold a write token. Run it with `permissions: contents: read,
# issues: read` and `actions/checkout` with `persist-credentials: false`, upload
# `outputs.patch`, and push and open the PR from a second job that runs no
# agent. docs/examples/loki-issue-to-pr.yml.example in asklokesh/loki-mode is the
# reference. Earlier versions pushed and opened the PR from the same step that
# ran the agent; this one does not.
#
# Token co-location within THIS job: the fetch step below holds GH_TOKEN and
# runs no agent; the agent step runs Loki 10 (`loki "<task>" --no-pr`) on the pre-fetched PRD text
# and is given no token (GH_TOKEN/GITHUB_TOKEN/GH_ENTERPRISE_TOKEN/
# GITHUB_ENTERPRISE_TOKEN all explicitly blanked, overriding anything the
# caller's `uses:` step set at a level this composite's steps would otherwise
# inherit). This narrows exposure to the same-step case (a prompt-injected
# agent invoking `gh`/git with the token directly) but does NOT provide
# process isolation: fetch and agent steps still share one runner VM and one
# Actions Runner.Worker process holding the job token in memory, reachable via
# /proc/<pid>/environ or ptrace with the passwordless sudo hosted runners
# grant (see the March 2025 tj-actions/changed-files compromise, which used
# exactly this). The real isolation boundary is the caller's job split: keep
# this job's permissions read-only (contents: read, issues: read) and push
# from a separate job/runner, as the reference workflow does.

inputs:
  issue:
    description: 'Issue reference. Defaults to the issue that triggered this workflow.'
    required: false
    default: ''
  provider:
    description: 'AI provider: claude, codex, cline, aider, or opencode'
    required: false
    default: 'claude'
  budget_limit:
    description: 'Max spend in USD before the run pauses. Breaching PAUSES; it never discards work.'
    required: false
    default: '10.00'
  max_iterations:
    description: 'Hard ceiling on RARV iterations.'
    required: false
    default: '20'
  version:
    description: 'loki-mode version to install. Pin this for reproducible runs.'
    required: false
    default: 'latest'
  attest:
    description: 'Opt-in. When true, sign a GitHub build attestation (Sigstore, DSSE) over receipt.json after the run. Needs id-token: write and attestations: write on the calling job, which breaks the read-only Rule of Two job; see docs/AGENT-CHANGE-RECEIPT.md for the split.'
    required: false
    default: 'false'

outputs:
  patch:
    description: 'Path to the patch Loki committed on top of the checked-out commit (empty file when nothing changed).'
    value: ${{ steps.run.outputs.patch }}
  pr_body:
    description: 'Path to a PR body carrying the evidence receipt.'
    value: ${{ steps.run.outputs.pr_body }}
  receipt:
    description: 'Path to the sealed receipt.json copied out of the run (empty when the run produced none).'
    value: ${{ steps.run.outputs.receipt }}
  pr_url:
    description: 'Always empty. This action no longer opens the pull request; open it from a job that runs no agent.'
    value: ''

runs:
  using: 'composite'
  steps:
    - name: Refuse a checkout that left a push token behind
      shell: bash
      run: |
        # actions/checkout persists the job token in git config unless told
        # not to. The agent could push with it, whatever this action does.
        if git config --get-regexp '^http\..*\.extraheader$' >/dev/null 2>&1; then
          echo "::error::actions/checkout left a token in git config that the agent could push with. Set 'persist-credentials: false' on the checkout step of this job."
          exit 1
        fi

    - name: Install Loki Mode
      shell: bash
      run: npm install -g loki-mode@${{ inputs.version }}

    - name: Resolve the issue reference
      id: ref
      shell: bash
      run: |
        # Prefer the explicit input; otherwise use the triggering issue. Failing
        # CLOSED here is deliberate: a run with no issue would otherwise start
        # from the repository's own state and produce an unrelated change.
        REF="${{ inputs.issue }}"
        if [ -z "$REF" ]; then
          NUM="${{ github.event.issue.number }}"
          if [ -z "$NUM" ]; then
            echo "::error::No issue to work on. Pass the 'issue' input, or trigger this action from an issue event."
            exit 1
          fi
          REF="${{ github.repository }}#${NUM}"
        fi
        echo "ref=$REF" >> "$GITHUB_OUTPUT"
        echo "Resolved issue reference: $REF"

    - name: Fetch the issue (holds the token, runs no agent)
      id: fetch
      shell: bash
      env:
        # Used only to read the issue in this step. Never exported to the
        # agent step below.
        GH_TOKEN: ${{ github.token }}
        ISSUE_REF: ${{ steps.ref.outputs.ref }}
      run: |
        set -euo pipefail

        case "$ISSUE_REF" in
          */*'#'*) : ;;
          *) echo "::error::Malformed issue reference: $ISSUE_REF (expected owner/repo#number)"; exit 1 ;;
        esac
        REPO="${ISSUE_REF%%#*}"
        NUMBER="${ISSUE_REF##*#}"
        case "$NUMBER" in
          ''|*[!0-9]*) echo "::error::Issue number must be digits, got '$NUMBER' from '$ISSUE_REF'"; exit 1 ;;
        esac

        # Same fields as fetch_github_issue() in autonomy/issue-providers.sh:
        # number,title,body,labels,author,createdAt,url. No comments -- the
        # PRD the agent works from carries the same content the CLI's own
        # issue-mode path would have handed it, nothing wider.
        ISSUE_JSON="$(gh issue view "$NUMBER" --repo "$REPO" \
          --json number,title,body,labels,author,createdAt,url)"

        OUT="$RUNNER_TEMP/loki-out"
        mkdir -p "$OUT"
        PRD_FILE="$OUT/prd-issue-${NUMBER}.md"

        REPO="$REPO" ISSUE_JSON="$ISSUE_JSON" PRD_FILE="$PRD_FILE" python3 -c "
        import json, os

        data = json.loads(os.environ['ISSUE_JSON'])
        repo = os.environ['REPO']
        title = data.get('title', '')
        body = data.get('body') or ''
        labels = ', '.join(l['name'] for l in data.get('labels', []))
        author = (data.get('author') or {}).get('login', '')
        url = data.get('url', '')
        created_at = data.get('createdAt', '')
        number = data.get('number', '')

        lines = [
            '# PRD: ' + title,
            '',
            '**Source:** GitHub Issue [#' + str(number) + '](' + url + ')',
            '**Author:** @' + author,
            '**Created:** ' + created_at,
            '**Repository:** ' + repo,
        ]
        if labels:
            lines.append('**Labels:** ' + labels)
        lines += [
            '',
            '---',
            '',
            '## Overview',
            '',
            body,
            '',
            '---',
            '',
            '## Acceptance Criteria',
            '',
            'Based on the issue description, implement the following:',
            '',
            '1. Address all requirements specified in the issue body above',
            '2. Ensure backward compatibility (unless explicitly breaking changes are requested)',
            '3. Add appropriate tests for new functionality',
            '4. Update documentation as needed',
            '',
            '---',
            '',
            '## References',
            '',
            '- Original Issue: ' + url,
            '',
        ]
        with open(os.environ['PRD_FILE'], 'w') as f:
            f.write(chr(10).join(lines))
        "
        echo "prd=$PRD_FILE" >> "$GITHUB_OUTPUT"
        echo "issue_number=$NUMBER" >> "$GITHUB_OUTPUT"

    - name: Resolve the issue to a patch (no token in this step's environment)
      id: run
      shell: bash
      env:
        LOKI_PROVIDER: ${{ inputs.provider }}
        LOKI_BUDGET_LIMIT: ${{ inputs.budget_limit }}
        LOKI_MAX_ITERATIONS: ${{ inputs.max_iterations }}  # not read by engine10
        LOKI_E10_CAP_S: '2400'
        # The agent step never pushes or opens a PR (Rule of Two).
        LOKI_DELEGATE_PR: '0'
        ISSUE_REF: ${{ steps.ref.outputs.ref }}
        PRD_FILE: ${{ steps.fetch.outputs.prd }}
        # Explicitly blanked, not merely omitted: this overrides any value the
        # caller's `uses:` step set, which a composite action's steps would
        # otherwise inherit. These are the same four run.sh withholds
        # (_loki_withhold_github_tokens).
        GH_TOKEN: ''
        GITHUB_TOKEN: ''
        GH_ENTERPRISE_TOKEN: ''
        GITHUB_ENTERPRISE_TOKEN: ''
      run: |
        set -uo pipefail

        # git identity: Loki commits the session's work on its own branch.
        git config user.name  "${GITHUB_ACTOR:-loki-mode}"
        git config user.email "${GITHUB_ACTOR:-loki-mode}@users.noreply.github.com"

        START_SHA="$(git rev-parse HEAD)"
        RC=0
        # Loki 10 engine, routed explicitly: the task is prefixed so it is always
        # multi-word and never starts with "-" (bin/loki sends a one-word or
        # flag-first argument to the legacy CLI or a subcommand, and a body like
        # "status" would otherwise run that subcommand). The task goes first and
        # --no-pr after it, because a flag-first call also stays on legacy.
        # --no-pr: engine10 ignores LOKI_DELEGATE_PR; this step holds no token.
        # The run commits on loki/<runId> in this checkout, so HEAD has the change.
        TASK="Resolve ${ISSUE_REF}. Task spec follows. $(cat "$PRD_FILE")"
        loki "$TASK" --no-pr || RC=$?

        OUT="$RUNNER_TEMP/loki-out"
        mkdir -p "$OUT"
        git diff --binary "$START_SHA" HEAD -- . ':(exclude).loki' > "$OUT/loki.patch"
        # PR body: the Loki 10 receipt this run sealed under .loki/runs/e10-*/.
        # Rendered from the run's own receipt.md; the digest comes from receipt.json.
        RUN_DIR="$(ls -d .loki/runs/e10-* 2>/dev/null | sort | tail -n 1)"
        {
          echo "Resolves ${ISSUE_REF}."
          echo ""
          if [ -n "$RUN_DIR" ] && [ -f "$RUN_DIR/receipt.md" ]; then
            head -c 20000 "$RUN_DIR/receipt.md"
            cp "$RUN_DIR/receipt.json" "$OUT/receipt.json" 2>/dev/null || true
            SHA="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("receipt_sha256",""))' "$RUN_DIR/receipt.json" 2>/dev/null || true)"
            echo ""
            echo "Receipt digest (sha256): ${SHA:-unavailable}"
            echo "Check it: download the loki-patch artifact of this run and run \`loki verify receipt.json\`."
          else
            echo "Evidence Receipt: unavailable for this run (no receipt under .loki/runs/)."
          fi
        } > "$OUT/pr-body.md"
        echo "patch=$OUT/loki.patch" >> "$GITHUB_OUTPUT"
        echo "pr_body=$OUT/pr-body.md" >> "$GITHUB_OUTPUT"
        if [ -f "$OUT/receipt.json" ]; then echo "receipt=$OUT/receipt.json" >> "$GITHUB_OUTPUT"; fi

        echo "::warning::This action does not open the pull request (Rule of Two). Upload outputs.patch and open the PR from a job that holds the write token and runs no agent; see docs/examples/loki-issue-to-pr.yml.example in asklokesh/loki-mode."
        if [ -n "$RUN_DIR" ]; then
          echo "Loki 10 receipt under $RUN_DIR/"
        fi

        exit "$RC"

    # Opt-in (attest: true). Needs `id-token: write` and `attestations: write` on the
    # calling job. The subject is the receipt.json file itself, so
    # `gh attestation verify receipt.json --repo OWNER/REPO` matches its sha256.
    - name: Attest the receipt
      if: ${{ inputs.attest == 'true' && steps.run.outputs.receipt != '' && !cancelled() }}
      uses: actions/attest-build-provenance@v2
      with:
        subject-path: ${{ steps.run.outputs.receipt }}
