diff --git a/.DS_Store b/.DS_Store index 5008ddfc..061ecb8d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/skills/nathan /SKILL.md b/.claude/skills/nathan /SKILL.md new file mode 100644 index 00000000..a90e6c45 --- /dev/null +++ b/.claude/skills/nathan /SKILL.md @@ -0,0 +1,106 @@ +--- +name: nathan +description: Triggers the Nathan AI review workflow by posting "+Nathan" on the open GitHub PR for the current branch. Works for both direct contributors and fork contributors. Requires an open PR on the current branch and the GitHub CLI (gh) to be authenticated with write access or higher on the upstream repository. +chain-role: isolated +invocation: user +allowed-tools: Bash(git branch:*) Bash(gh repo view:*) Bash(gh api:*) Bash(gh pr list:*) Bash(gh pr comment:*) +--- + +# Nathan + +Trigger the Nathan AI review by posting `+Nathan` on the open GitHub PR for the current branch. Works transparently whether you are working on the main repo or a fork. + +## Instructions + +### Step 1: Resolve current branch +Run `git branch --show-current`. + +If output is empty (detached HEAD), stop: "Cannot determine the current branch — the repository is in detached HEAD state. Check out a branch first." + +### Step 2: Detect fork and resolve target repo +Run: +``` +gh repo view --json isFork,parent +``` + +- If `isFork` is `false`: the target repo is the current repo. Use `` as the head filter. +- If `isFork` is `true`: the target repo is `parent.nameWithOwner` (the upstream). Get the authenticated user's login to build the head filter: + ``` + gh api user --jq '.login' + ``` + Use `:` as the head filter and `` as the repo. + +### Step 3: Find open PR + +**Not a fork:** +``` +gh pr list --head "" --state open --json number,title,url --limit 1 +``` + +**Fork:** +``` +gh pr list --repo "" --head ":" --state open --json number,title,url --limit 1 +``` + +- If result is `[]`, stop: "No open PR found for branch ''. Please open a PR on GitHub before triggering a review." +- If command fails, report the error and stop. + +Parse `number`, `title`, and `url` from the result. + +### Step 4: Post +Nathan comment +Use the PR URL so the comment always targets the correct repo regardless of fork status: +```bash +gh pr comment "" --body '+Nathan' +``` + +### Step 5: Confirm +``` +✅ Nathan review triggered on PR # () +<url> +The Nathan Gate workflow will begin shortly. +``` + +## Examples + +### Example 1: Direct contributor (not a fork) +User says: `/Nathan` +Actions: +1. Gets branch → `feature/add-docs` +2. Repo is not a fork +3. Finds open PR → #23 "Add documentation" +4. Posts `+Nathan` +Result: "✅ Nathan review triggered on PR #23 (Add documentation)\nhttps://github.com/..." + +### Example 2: Fork contributor +User says: `/Nathan` +Actions: +1. Gets branch → `fix/typo` +2. Repo is a fork of `upstream-org/repo`; user login is `contributor` +3. Searches upstream for PR with head `contributor:fix/typo` → #47 "Fix typo in README" +4. Posts `+Nathan` using the PR URL +Result: "✅ Nathan review triggered on PR #47 (Fix typo in README)\nhttps://github.com/upstream-org/repo/pull/47" + +### Example 3: No open PR (edge case) +User says: `/Nathan` +Actions: +1. Gets branch → `old-branch` +2. No open PR found +Result: "No open PR found for branch 'old-branch'. Please open a PR on GitHub before triggering a review." + +## Troubleshooting + +### Error: `gh: command not found` +Cause: GitHub CLI not installed or not in PATH. +Solution: Install from https://cli.github.com and run `gh auth login`. + +### Error: HTTP 401 / auth failure +Cause: GitHub CLI is not authenticated. +Solution: Run `gh auth login` and follow the prompts. + +### Error: No open PR found +Cause: PR does not exist for this branch, or is closed/merged. +Solution: Open a PR on GitHub first, or switch to a branch with an active PR. + +### Error: Nathan review does not start +Cause: Your account does not have write access or higher on the upstream repository. +Solution: Verify your permissions — the Nathan Gate workflow requires at least write access to dispatch. diff --git a/.github/workflows/nathan-commit-gate.yml b/.github/workflows/nathan-commit-gate.yml deleted file mode 100644 index 2f631d05..00000000 --- a/.github/workflows/nathan-commit-gate.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Nathan Commit Gate - -on: - pull_request: - types: [synchronize] - -permissions: - contents: read - pull-requests: read - actions: write - -jobs: - dispatch-nathan: - # Do not allow fork PRs to auto-dispatch the secret-bearing workflow. - if: ${{ github.event.pull_request != null && github.event.pull_request.head.repo.fork == false }} - runs-on: ubuntu-latest - steps: - - name: Validate dispatcher permissions - id: perm - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - ACTOR: ${{ github.actor }} - shell: bash - run: | - set -euo pipefail - - permission="$(gh api -H "Accept: application/vnd.github+json" \ - "repos/${REPO}/collaborators/${ACTOR}/permission" \ - --jq '.permission // ""' 2>/dev/null || true)" - - case "${permission}" in - admin|maintain|write) - echo "allowed=true" >> "$GITHUB_OUTPUT" - echo "✅ ${ACTOR} has ${permission} permission." - ;; - *) - echo "allowed=false" >> "$GITHUB_OUTPUT" - echo "⏭️ ${ACTOR} does not have sufficient permission (permission='${permission:-none}'); skipping dispatch." - ;; - esac - - - name: Check latest commit message (+Nathan) - id: gate - if: ${{ steps.perm.outputs.allowed == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - shell: bash - run: | - set -euo pipefail - - commits_json="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/commits" | jq -s 'add')" - sha="$(printf '%s' "$commits_json" | jq -r '.[-1].sha // ""')" - msg="$(printf '%s' "$commits_json" | jq -r '.[-1].commit.message // ""')" - - shopt -s nocasematch - if [[ "$msg" == *"+nathan"* ]]; then - echo "triggered=true" >> "$GITHUB_OUTPUT" - else - echo "triggered=false" >> "$GITHUB_OUTPUT" - fi - shopt -u nocasematch - - first_line="$(printf '%s\n' "$msg" | head -n1 | tr -d '\r')" - echo "Latest PR commit: ${sha:-unknown} — ${first_line:-<no message>}" - - - name: Dispatch Nathan workflow - if: ${{ steps.perm.outputs.allowed == 'true' && steps.gate.outputs.triggered == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - DISPATCH_REF: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - - if [[ -z "${HEAD_REF:-}" ]]; then - echo "Missing PR head ref; cannot dispatch." - exit 1 - fi - if [[ -z "${DISPATCH_REF:-}" ]]; then - echo "Missing repository default branch; cannot dispatch." - exit 1 - fi - - echo "Dispatching trigger-n8n-workflow.yml for PR #${PR_NUMBER} (workflow ref=${DISPATCH_REF}, PR ref=${HEAD_REF})" - dispatch_output="" - if dispatch_output="$( - gh api -X POST "repos/${REPO}/actions/workflows/trigger-n8n-workflow.yml/dispatches" \ - -f ref="${DISPATCH_REF}" \ - -f inputs[pr_number]="${PR_NUMBER}" 2>&1 - )"; then - exit 0 - fi - - rc=$? - echo "$dispatch_output" - if [[ "$dispatch_output" == *"Failed to run workflow dispatch"* && "$dispatch_output" == *"\"status\":500"* ]]; then - echo "Transient GitHub 500 while dispatching workflow; retrying once..." - sleep 2 - if dispatch_output="$( - gh api -X POST "repos/${REPO}/actions/workflows/trigger-n8n-workflow.yml/dispatches" \ - -f ref="${DISPATCH_REF}" \ - -f inputs[pr_number]="${PR_NUMBER}" 2>&1 - )"; then - exit 0 - fi - echo "$dispatch_output" - fi - - exit $rc diff --git a/.github/workflows/nathan-gate.yml b/.github/workflows/nathan-gate.yml new file mode 100644 index 00000000..5f657c67 --- /dev/null +++ b/.github/workflows/nathan-gate.yml @@ -0,0 +1,207 @@ +name: Nathan Gate + +on: + pull_request: + types: [synchronize] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + actions: write + +jobs: + dispatch-nathan: + # For pull_request events: skip fork PRs to avoid exposing secrets in fork-controlled code. + # For issue_comment events: only proceed if the comment is on an open PR (not a plain issue). + # issue_comment always runs in the base-repo context regardless of fork status, so + # the maintainer permission check is the primary security gate for that event type. + if: ${{ (github.event_name == 'pull_request' && github.event.pull_request != null && github.event.pull_request.head.repo.fork == false) || (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && github.event.issue.state == 'open') }} + runs-on: ubuntu-latest + # One active run per PR at a time. If a run is in progress and a new trigger arrives, + # the new one queues rather than stacking — at most 1 running + 1 pending per PR. + concurrency: + group: nathan-pr-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + steps: + - name: Validate dispatcher permissions (maintainers only) + id: perm + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + shell: bash + run: | + set -euo pipefail + + permission="$(gh api -H "Accept: application/vnd.github+json" \ + "repos/${REPO}/collaborators/${ACTOR}/permission" \ + --jq '.permission // ""' 2>/dev/null || true)" + + case "${permission}" in + admin|maintain|write) + echo "allowed=true" >> "$GITHUB_OUTPUT" + echo "permission=${permission}" >> "$GITHUB_OUTPUT" + echo "✅ ${ACTOR} has ${permission} permission." + ;; + *) + echo "allowed=false" >> "$GITHUB_OUTPUT" + echo "permission=${permission}" >> "$GITHUB_OUTPUT" + echo "⏭️ ${ACTOR} is not a maintainer (permission='${permission:-none}'); skipping dispatch." + ;; + esac + + - name: Cooldown check — prevent rapid re-triggers + id: cooldown + if: ${{ steps.perm.outputs.allowed == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event.pull_request.number }} + CURRENT_COMMENT_ID: ${{ github.event.comment.id }} + COOLDOWN_MINUTES: '2' + shell: bash + run: | + set -euo pipefail + + if [[ -z "${PR_NUMBER:-}" || ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "blocked=false" >> "$GITHUB_OUTPUT" + echo "⚠️ No valid PR number for cooldown check; skipping." + exit 0 + fi + + cutoff="$(date -u -d "${COOLDOWN_MINUTES} minutes ago" +%Y-%m-%dT%H:%M:%SZ)" + + # Fetch only comments created within the cooldown window (since= is server-side filtering). + # Exclude the current triggering comment so a fresh +Nathan is never self-blocked. + recent="$(gh api \ + "repos/${REPO}/issues/${PR_NUMBER}/comments?since=${cutoff}&per_page=100" \ + --jq "[.[] | select(.body | ascii_downcase | contains(\"+nathan\")) | select(.id | tostring != \"${CURRENT_COMMENT_ID:-}\")] | length" \ + 2>/dev/null || echo "0")" + + if [[ "${recent}" -gt 0 ]]; then + echo "blocked=true" >> "$GITHUB_OUTPUT" + echo "⏭️ Nathan was already triggered within the last ${COOLDOWN_MINUTES} minutes; skipping to prevent duplicate reviews." + else + echo "blocked=false" >> "$GITHUB_OUTPUT" + echo "✅ No recent Nathan trigger found; proceeding." + fi + + - name: Check latest commit message (+Nathan) — commit push trigger + id: gate_commit + if: ${{ steps.perm.outputs.allowed == 'true' && steps.cooldown.outputs.blocked != 'true' && github.event_name == 'pull_request' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + set -euo pipefail + + commits_json="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/commits" | jq -s 'add')" + sha="$(printf '%s' "$commits_json" | jq -r '.[-1].sha // ""')" + msg="$(printf '%s' "$commits_json" | jq -r '.[-1].commit.message // ""')" + + shopt -s nocasematch + if [[ "$msg" == *"+nathan"* ]]; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + fi + shopt -u nocasematch + + first_line="$(printf '%s\n' "$msg" | head -n1 | tr -d '\r')" + echo "Latest PR commit: ${sha:-unknown} — ${first_line:-<no message>}" + + - name: Check PR comment body (+Nathan) — comment trigger + id: gate_comment + if: ${{ steps.perm.outputs.allowed == 'true' && steps.cooldown.outputs.blocked != 'true' && github.event_name == 'issue_comment' }} + env: + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_ID: ${{ github.event.comment.id }} + ACTOR: ${{ github.actor }} + shell: bash + run: | + set -euo pipefail + + body="$(printf '%s' "${COMMENT_BODY:-}")" + + # Reject oversized comment bodies to prevent resource exhaustion + if [[ ${#body} -gt 65536 ]]; then + echo "⏭️ Comment body exceeds size limit; skipping." + echo "triggered=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check only for the trigger phrase — comment content is never executed + shopt -s nocasematch + if [[ "$body" == *"+nathan"* ]]; then + echo "triggered=true" >> "$GITHUB_OUTPUT" + echo "✅ Found +Nathan trigger phrase in comment #${COMMENT_ID} by ${ACTOR}." + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + echo "⏭️ Comment #${COMMENT_ID} does not contain '+Nathan'; skipping dispatch." + fi + shopt -u nocasematch + + - name: Dispatch Nathan workflow + if: ${{ steps.perm.outputs.allowed == 'true' && steps.cooldown.outputs.blocked != 'true' && (steps.gate_commit.outputs.triggered == 'true' || steps.gate_comment.outputs.triggered == 'true') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event.pull_request.number }} + HEAD_REF: ${{ github.event_name == 'issue_comment' && '' || github.event.pull_request.head.ref }} + DISPATCH_REF: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + shell: bash + run: | + set -euo pipefail + + if [[ -z "${PR_NUMBER:-}" ]]; then + echo "Missing PR number; cannot dispatch." + exit 1 + fi + if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number '${PR_NUMBER}'; cannot dispatch." + exit 1 + fi + if [[ -z "${DISPATCH_REF:-}" ]]; then + echo "Missing repository default branch; cannot dispatch." + exit 1 + fi + + REF_INFO="${HEAD_REF:-<comment trigger>}" + TRIGGER_SOURCE="commit" + if [[ "${EVENT_NAME}" == "issue_comment" ]]; then + TRIGGER_SOURCE="comment" + fi + echo "Dispatching trigger-n8n-workflow.yml for PR #${PR_NUMBER} (event=${EVENT_NAME:-unknown}, trigger_source=${TRIGGER_SOURCE}, workflow ref=${DISPATCH_REF}, PR ref=${REF_INFO})" + + dispatch_output="" + if dispatch_output="$( + gh api -X POST "repos/${REPO}/actions/workflows/trigger-n8n-workflow.yml/dispatches" \ + -f ref="${DISPATCH_REF}" \ + -f inputs[pr_number]="${PR_NUMBER}" \ + -f inputs[trigger_source]="${TRIGGER_SOURCE}" 2>&1 + )"; then + exit 0 + fi + + rc=$? + echo "$dispatch_output" + if [[ "$dispatch_output" == *"Failed to run workflow dispatch"* && "$dispatch_output" == *"\"status\":500"* ]]; then + echo "Transient GitHub 500 while dispatching workflow; retrying once..." + sleep 2 + if dispatch_output="$( + gh api -X POST "repos/${REPO}/actions/workflows/trigger-n8n-workflow.yml/dispatches" \ + -f ref="${DISPATCH_REF}" \ + -f inputs[pr_number]="${PR_NUMBER}" \ + -f inputs[trigger_source]="${TRIGGER_SOURCE}" 2>&1 + )"; then + exit 0 + fi + echo "$dispatch_output" + fi + + exit $rc diff --git a/.github/workflows/trigger-n8n-workflow.yml b/.github/workflows/trigger-n8n-workflow.yml index bd4c8a6b..af92d6f0 100644 --- a/.github/workflows/trigger-n8n-workflow.yml +++ b/.github/workflows/trigger-n8n-workflow.yml @@ -4,13 +4,17 @@ on: workflow_dispatch: inputs: pr_number: - description: PR number to process + description: PR number to process (maintainer-triggered runs) required: false type: string pr_selector: description: PR URL or branch name (used when pr_number is not provided) required: false type: string + trigger_source: + description: Source of the trigger — 'comment' or 'commit' (set by Nathan Gate; leave blank for manual runs) + required: false + type: string permissions: contents: read @@ -47,6 +51,7 @@ jobs: PR_NUMBER: ${{ github.event.inputs.pr_number }} PR_SELECTOR: ${{ github.event.inputs.pr_selector }} IS_MANUAL_RUN: ${{ github.event_name == 'workflow_dispatch' && github.actor != 'github-actions[bot]' }} + TRIGGER_SOURCE: ${{ github.event.inputs.trigger_source }} N8N_ROSE_LANGUAGES: ${{ vars.N8N_ROSE_LANGUAGES }} steps: - name: Validate dispatcher permissions @@ -67,7 +72,7 @@ jobs: echo "✅ ${ACTOR} has ${PERMISSION} permission." ;; *) - echo "❌ ${ACTOR} does not have sufficient permission (permission='${PERMISSION}')." + echo "❌ ${ACTOR} is not a maintainer (permission='${PERMISSION}')." exit 1 ;; esac @@ -165,7 +170,7 @@ jobs: triggered="false" fi shopt -u nocasematch - if [[ "${IS_MANUAL_RUN}" == "true" ]]; then + if [[ "${IS_MANUAL_RUN}" == "true" || "${TRIGGER_SOURCE:-}" == "comment" ]]; then triggered="true" fi if [[ "$triggered" == "true" ]]; then @@ -359,7 +364,7 @@ jobs: fi if [[ ${IS_MANUAL_RUN} == true ]]; then - echo "ℹ️ run for PR #${PR_NUMBER}" + echo "ℹ️ Maintainer-triggered run for PR #${PR_NUMBER}" fi - name: Fetch PR metadata