tbdflow 0.32.1

A CLI to streamline your Git workflow for Trunk-Based Development.
Documentation
# Non-Blocking Review (NBR) Workflow for Trunk-Based Development
#
# Inspired by Martin's "Trunktopus" pattern for server-side review management.
#
# This workflow provides:
# - Automatic issue creation for every push to main (audit trail)
# - Commit status gates (pending → success) for deploy blocking
# - Inline diffs for small changes (< 60 lines)
# - Multi-reviewer support via checklist
#
# To use this workflow:
# 1. Rename this file to `nbr-review.yml`
# 2. Configure your .tbdflow.yml:
#    review:
#      enabled: true
#      strategy: github-workflow
#      workflow: nbr-review.yml
#      default_reviewers:
#        - reviewer-username
#
# For teams NOT using tbdflow, you can trigger reviews in three ways:
#
# A) Tag-based trigger — add to the `on:` section:
#    push:
#      tags:
#        - 'review/*'
#
#   Examples:
#     git tag -a "review/abc1234" -m "Asking for review of abc1234"
#     or by using the lightweight approach: git tag "review/abc1234"
#     git push origin review/abc1234
#
# B) Conventional commit filter — add to the `create-review` job:
#    if: >-
#      github.event_name == 'workflow_dispatch' ||
#      contains(github.event.head_commit.message, 'feat:') ||
#      contains(github.event.head_commit.message, 'fix:') ||
#      contains(github.event.head_commit.message, 'refactor:') ||
#      contains(github.event.head_commit.message, 'perf:')
#
# C) Commit message marker — see the "Check if review needed" step below
#
# Option B (conventional commit job filter) is recommended (if you really don't want to use tbdflow :)).
# Most teams already use conventional commits, so it's zero friction,
# just commit as usual and reviews happen automatically for meaningful changes while skipping chore: and ci: commits.
#
# Option C is essentially a more flexible version of B (regex instead of contains()), so teams that want finer control will graduate to that.
#

name: Non-Blocking Review

on:
  # Triggered by tbdflow CLI
  workflow_dispatch:
    inputs:
      commit_sha:
        description: 'The full commit SHA to review'
        required: true
        type: string
      commit_message:
        description: 'The commit message'
        required: true
        type: string
      author:
        description: 'The commit author'
        required: true
        type: string
      reviewers:
        description: 'Comma-separated list of reviewers'
        required: false
        type: string
        default: ''

  # Also trigger on every push to main for full audit trail
  push:
    branches:
      - main
    # --- (A) Uncomment to also trigger on review tags ---
    #tags:
    #  - 'review/*'

  # Trigger when review issues are closed (updates commit status)
  issues:
    types: [closed]

# Prevent duplicate issues from rapid pushes
concurrency:
  group: nbr-review-${{ github.sha }}
  cancel-in-progress: false

jobs:
  create-review:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
    # --- (B) Uncomment to only review conventional commit types ---
    # if: >-
    #   github.event_name == 'workflow_dispatch' ||
    #   contains(github.event.head_commit.message, 'feat:') ||
    #   contains(github.event.head_commit.message, 'fix:') ||
    #   contains(github.event.head_commit.message, 'refactor:') ||
    #   contains(github.event.head_commit.message, 'perf:')
    permissions:
      issues: write
      statuses: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need parent commit for diff

      # --- (C) Uncomment to filter by commit message marker [review] or
      #     conventional commit prefixes. Gate subsequent steps with:
      #     if: steps.check-review.outputs.needs_review == 'true'
      #
      # - name: Check if review needed
      #   id: check-review
      #   run: |
      #     MSG=$(git log -1 --format=%s)
      #     if [[ "$MSG" =~ ^(feat|fix|refactor|perf|docs)\(?.*\)?:.* ]]; then
      #       echo "needs_review=true" >> $GITHUB_OUTPUT
      #     elif [[ "$MSG" =~ \[review\] ]]; then
      #       echo "needs_review=true" >> $GITHUB_OUTPUT
      #     else
      #       echo "needs_review=false" >> $GITHUB_OUTPUT
      #     fi

      - name: Get commit info
        id: commit-info
        env:
          EVENT_NAME: ${{ github.event_name }}
          INPUT_SHA: ${{ inputs.commit_sha }}
          INPUT_MESSAGE: ${{ inputs.commit_message }}
          INPUT_AUTHOR: ${{ inputs.author }}
          INPUT_REVIEWERS: ${{ inputs.reviewers }}
          PUSH_SHA: ${{ github.sha }}
        run: |
          if [ "$EVENT_NAME" == "workflow_dispatch" ]; then
            echo "sha=$INPUT_SHA" >> $GITHUB_OUTPUT
            # Use heredoc for message to safely handle special characters
            EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
            echo "message<<$EOF" >> $GITHUB_OUTPUT
            echo "$INPUT_MESSAGE" >> $GITHUB_OUTPUT
            echo "$EOF" >> $GITHUB_OUTPUT
            echo "author=$INPUT_AUTHOR" >> $GITHUB_OUTPUT
            echo "reviewers=$INPUT_REVIEWERS" >> $GITHUB_OUTPUT
          else
            echo "sha=$PUSH_SHA" >> $GITHUB_OUTPUT
            EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
            echo "message<<$EOF" >> $GITHUB_OUTPUT
            git log -1 --format=%s >> $GITHUB_OUTPUT
            echo "$EOF" >> $GITHUB_OUTPUT
            echo "author=$(git log -1 --format=%an)" >> $GITHUB_OUTPUT
            echo "reviewers=" >> $GITHUB_OUTPUT
          fi

      - name: Check for existing review issue
        id: existing
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          COMMIT_SHA: ${{ steps.commit-info.outputs.sha }}
        run: |
          SHORT_SHA="${COMMIT_SHA:0:7}"

          EXISTING=$(gh issue list --state open --label "review-pending" --search "$SHORT_SHA in:title" --json number --jq '.[0].number // empty')

          if [ -n "$EXISTING" ]; then
            echo "exists=true" >> $GITHUB_OUTPUT
            echo "issue_number=$EXISTING" >> $GITHUB_OUTPUT
            echo "Review issue #$EXISTING already exists for $SHORT_SHA"
          else
            echo "exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Generate inline diff (for small changes)
        if: steps.existing.outputs.exists != 'true'
        id: diff
        env:
          COMMIT_SHA: ${{ steps.commit-info.outputs.sha }}
        run: |
          DIFF=$(git show --no-color --stat --patch --no-prefix "$COMMIT_SHA" | tail -n +2)
          DIFF_LINES=$(echo "$DIFF" | wc -l)

          if [ "$DIFF_LINES" -le 60 ]; then
            echo "include_diff=true" >> $GITHUB_OUTPUT
            echo "diff_lines=$DIFF_LINES" >> $GITHUB_OUTPUT
            # Escape for GitHub Actions
            DIFF_ESCAPED=$(echo "$DIFF" | sed 's/`/\\`/g')
            EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
            echo "diff<<$EOF" >> $GITHUB_OUTPUT
            echo "$DIFF" >> $GITHUB_OUTPUT
            echo "$EOF" >> $GITHUB_OUTPUT
          else
            echo "include_diff=false" >> $GITHUB_OUTPUT
            echo "diff_lines=$DIFF_LINES" >> $GITHUB_OUTPUT
          fi

      - name: Ensure labels exist
        if: steps.existing.outputs.exists != 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh label create "review-pending" --color "1D76DB" --description "Review awaiting attention" 2>/dev/null || true
          gh label create "review-accepted" --color "0E8A16" --description "Review approved" 2>/dev/null || true
          gh label create "review-concern" --color "E4E669" --description "Concern raised - needs attention" 2>/dev/null || true
          gh label create "review-dismissed" --color "6E7681" --description "Review dismissed" 2>/dev/null || true

      - name: Create review issue
        if: steps.existing.outputs.exists != 'true'
        id: create-issue
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          COMMIT_SHA: ${{ steps.commit-info.outputs.sha }}
          COMMIT_MESSAGE: ${{ steps.commit-info.outputs.message }}
          COMMIT_AUTHOR: ${{ steps.commit-info.outputs.author }}
          COMMIT_REVIEWERS: ${{ steps.commit-info.outputs.reviewers }}
          INCLUDE_DIFF: ${{ steps.diff.outputs.include_diff }}
          DIFF_LINES: ${{ steps.diff.outputs.diff_lines }}
          DIFF_CONTENT: ${{ steps.diff.outputs.diff }}
          REPO_FULL_NAME: ${{ github.repository }}
        run: |
          SHORT_SHA="${COMMIT_SHA:0:7}"
          REPO_URL="https://github.com/$REPO_FULL_NAME"

          # Build diff section
          if [ "$INCLUDE_DIFF" == "true" ]; then
            DIFF_SECTION="

          <details>
          <summary>📝 Inline diff (${DIFF_LINES} lines)</summary>

          \`\`\`diff
          ${DIFF_CONTENT}
          \`\`\`

          </details>"
          else
            DIFF_SECTION="

          > 📄 Large change (${DIFF_LINES} lines) - [view full diff]($REPO_URL/commit/$COMMIT_SHA)"
          fi

          # Build reviewer checklist
          REVIEWER_CHECKLIST=""
          if [ -n "$COMMIT_REVIEWERS" ]; then
            IFS=',' read -ra REVIEWER_ARRAY <<< "$COMMIT_REVIEWERS"
            for reviewer in "${REVIEWER_ARRAY[@]}"; do
              reviewer=$(echo "$reviewer" | xargs)  # trim whitespace
              if [ -n "$reviewer" ]; then
                REVIEWER_CHECKLIST="$REVIEWER_CHECKLIST
          - [ ] @$reviewer"
              fi
            done
          fi

          BODY="## Non-Blocking Review Request

          **Commit:** [\`$SHORT_SHA\`]($REPO_URL/commit/$COMMIT_SHA)
          **Author:** $COMMIT_AUTHOR
          **Message:** $COMMIT_MESSAGE
          $DIFF_SECTION

          ---

          > In Trunk-Based Development, this code is already in the trunk.
          > Your goal is **Course Correction** and **Knowledge Sharing**, not gatekeeping.

          ### 🔍 What to Look For

          | Focus | Question |
          |-------|----------|
          | **Design & Intent** | Does the implementation align with our architectural patterns? |
          | **Logic & Edge Cases** | Are there logical flaws or unhappy paths that tests might miss? |
          | **Readability** | Are names descriptive? (Code as Documentation) |
          | **Simplification** | Can this be done with less code or lower complexity? |

          ### 💬 How to Comment

          - **Questions > Commands**: _\"Could we use the existing helper here?\"_ instead of _\"Change this.\"_
          - **Praise**: If you see something clever or clean, say so! NBR boosts team morale.
          - **Nitpicking**: Label minor style issues as \`(nit)\` so the author knows they're optional.

          ### ✅ Reviewer Checklist
          $REVIEWER_CHECKLIST

          ### When to Close

          - **Approve & Close**: Code is safe and understandable.
          - **Comment & Close**: Non-critical improvements noted.
          - **Keep Open**: Only for critical bugs or security flaws requiring immediate fix-forward.

          ---

          To approve via CLI: \`tbdflow review --approve $SHORT_SHA\`"

          # Create the issue
          ISSUE_URL=$(gh issue create \
            --title "[Review] $COMMIT_MESSAGE ($SHORT_SHA)" \
            --body "$BODY" \
            --label "review-pending")

          ISSUE_NUM=$(echo "$ISSUE_URL" | grep -oE '[0-9]+$')
          echo "issue_number=$ISSUE_NUM" >> $GITHUB_OUTPUT
          echo "✅ Created review issue #$ISSUE_NUM: $ISSUE_URL"

          # Set pending commit status with link to the review issue
          gh api "repos/$REPO_FULL_NAME/statuses/$COMMIT_SHA" \
            -f state='pending' \
            -f context='peer-review' \
            -f description='Awaiting non-blocking review' \
            -f target_url="$ISSUE_URL"
          echo "✅ Commit status set to pending"

  # Update commit status when review issue is closed.
  # Matches any review label to ensure commit status is always updated.
  on-review-closed:
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'issues' &&
      github.event.action == 'closed' &&
      (contains(github.event.issue.labels.*.name, 'review-pending') ||
       contains(github.event.issue.labels.*.name, 'review-accepted') ||
       contains(github.event.issue.labels.*.name, 'review-dismissed') ||
       contains(github.event.issue.labels.*.name, 'invalid'))
    permissions:
      statuses: write
      issues: write  # needs write to swap labels on close

    steps:
      - name: Extract commit SHA and mark as reviewed
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_BODY: ${{ github.event.issue.body }}
          ISSUE_TITLE: ${{ github.event.issue.title }}
          ISSUE_URL: ${{ github.event.issue.html_url }}
          ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }}
          REPO_FULL_NAME: ${{ github.repository }}
        run: |
          # Extract commit SHA from issue body (looks for /commit/SHA pattern)
          FULL_SHA=$(echo "$ISSUE_BODY" | grep -oE 'github\.com/.*/commit/[a-f0-9]+' | grep -oE '[a-f0-9]{40}' | head -n1)

          if [ -z "$FULL_SHA" ]; then
            # Try short SHA from title
            SHORT_SHA=$(echo "$ISSUE_TITLE" | grep -oE '\([a-f0-9]{7}\)' | tr -d '()')
            if [ -n "$SHORT_SHA" ]; then
              # Get full SHA from short
              FULL_SHA=$(gh api "repos/$REPO_FULL_NAME/commits/$SHORT_SHA" --jq '.sha' 2>/dev/null || echo "")
            fi
          fi

          if [ -n "$FULL_SHA" ]; then
            ISSUE_NUM=$(echo "$ISSUE_URL" | grep -oE '[0-9]+$')

            # Swap labels only if no terminal label is already set
            # (tbdflow CLI or a human may have already set review-accepted or review-dismissed)
            HAS_ACCEPTED=$(echo "$ISSUE_LABELS" | grep -c "review-accepted" || true)
            HAS_DISMISSED=$(echo "$ISSUE_LABELS" | grep -c "review-dismissed" || true)
            IS_INVALID=$(echo "$ISSUE_LABELS" | grep -c "invalid" || true)

            if [ "$HAS_ACCEPTED" -eq 0 ] && [ "$HAS_DISMISSED" -eq 0 ] && [ "$IS_INVALID" -eq 0 ]; then
              gh issue edit "$ISSUE_NUM" --repo "$REPO_FULL_NAME" --remove-label "review-pending" 2>/dev/null || true
              gh issue edit "$ISSUE_NUM" --repo "$REPO_FULL_NAME" --add-label "review-accepted" 2>/dev/null || true
            fi

            # Set commit status based on review outcome
            if [ "$HAS_DISMISSED" -gt 0 ]; then
              STATUS_DESC="Review dismissed"
            elif [ "$IS_INVALID" -gt 0 ]; then
              STATUS_DESC="Invalid review"
            else
              STATUS_DESC="Non-blocking review completed ✅"
            fi

            gh api "repos/$REPO_FULL_NAME/statuses/$FULL_SHA" \
              -f state='success' \
              -f context='peer-review' \
              -f description="$STATUS_DESC" \
              -f target_url="$ISSUE_URL"
            echo "✅ Commit $FULL_SHA marked as reviewed"
          else
            echo "⚠️ Could not extract commit SHA from issue"
          fi