diff --git a/.github/workflows/ai-breaking-change.yml b/.github/workflows/ai-breaking-change.yml deleted file mode 100644 index f795671..0000000 --- a/.github/workflows/ai-breaking-change.yml +++ /dev/null @@ -1,193 +0,0 @@ -name: Detect breaking changes - -on: - workflow_call: - inputs: - prompt-file: - required: true - type: string - description: Path to the breaking-change prompt YAML in the caller repo - file-patterns: - required: true - type: string - description: Newline-separated glob patterns for API surface files - label: - required: false - type: string - default: breaking - description: Label to apply when breaking changes are detected - diff-max-bytes: - required: false - type: number - default: 100000 - description: Maximum bytes of diff to include in the prompt - -jobs: - infer: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - models: read - outputs: - skip: ${{ steps.filter.outputs.skip }} - breaking: ${{ steps.parse.outputs.breaking }} - items: ${{ steps.parse.outputs.items }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Filter diff - id: filter - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FILE_PATTERNS: ${{ inputs.file-patterns }} - PR: ${{ github.event.pull_request.number }} - run: | - gh pr diff "$PR" > /tmp/full.diff - - python3 << 'FILTER' - import os, re, fnmatch - diff = open('/tmp/full.diff').read() - patterns = [p.strip() for p in os.environ['FILE_PATTERNS'].splitlines() if p.strip()] - sections = re.split(r'(?=^diff --git)', diff, flags=re.MULTILINE) - with open('/tmp/api.diff', 'w') as out: - for s in sections: - m = re.match(r'diff --git a/(\S+)', s) - if m: - path = m.group(1) - if any(fnmatch.fnmatch(path, p) for p in patterns): - out.write(s) - FILTER - - if [ ! -s /tmp/api.diff ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - - name: Build prompt - if: steps.filter.outputs.skip != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROMPT_FILE: ${{ inputs.prompt-file }} - DIFF_MAX_BYTES: ${{ inputs.diff-max-bytes }} - PR: ${{ github.event.pull_request.number }} - run: | - TITLE=$(gh pr view "$PR" --json title --jq .title) - - { - printf 'PR #%s: %s\n' "$PR" "$TITLE" - echo "" - echo "Diff of API surface files:" - head -c "$DIFF_MAX_BYTES" /tmp/api.diff - } > /tmp/user-message.txt - - python3 << 'SPLICE' - import os - prompt_file = os.environ['PROMPT_FILE'] - with open(prompt_file) as f: - lines = f.readlines() - with open('/tmp/user-message.txt') as f: - user_msg = f.read() - - insert_at = len(lines) - for i, line in enumerate(lines): - if i == 0: - continue - if line.strip() and not line[0].isspace(): - insert_at = i - break - - entry = [' - role: user\n', ' content: |\n'] - for ln in user_msg.splitlines(): - entry.append(' ' + ln + '\n') - - lines[insert_at:insert_at] = entry - with open('/tmp/prompt.yml', 'w') as f: - f.writelines(lines) - - try: - import yaml - doc = yaml.safe_load(open('/tmp/prompt.yml')) - assert doc['messages'][-1]['role'] == 'user', 'prompt splice failed' - except ImportError: - pass - SPLICE - - - name: Detect breaking changes - if: steps.filter.outputs.skip != 'true' - id: detect - uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 - with: - prompt-file: /tmp/prompt.yml - - - name: Parse response - if: steps.filter.outputs.skip != 'true' - id: parse - env: - RESPONSE_FILE: ${{ steps.detect.outputs.response-file }} - run: | - if [ -z "$RESPONSE_FILE" ] || [ ! -f "$RESPONSE_FILE" ]; then - echo "::warning::Model response file is missing; skipping." - exit 0 - fi - if ! jq empty "$RESPONSE_FILE" 2>/dev/null; then - echo "::warning::Model response is not valid JSON; skipping." - { - echo "## Breaking change detection failed" - echo "Model returned invalid JSON. Breaking label was **not** applied." - if [ -s "$RESPONSE_FILE" ]; then - echo '```' - cat "$RESPONSE_FILE" - echo '```' - fi - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - BREAKING=$(jq -r '.breaking' "$RESPONSE_FILE") - echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" - # Pass items as compact JSON for safe cross-job transfer - ITEMS=$(jq -c '.items' "$RESPONSE_FILE") - echo "items=$ITEMS" >> "$GITHUB_OUTPUT" - - apply: - runs-on: ubuntu-latest - needs: infer - if: needs.infer.outputs.skip != 'true' && needs.infer.outputs.breaking != '' - permissions: - contents: read - pull-requests: write - issues: write - steps: - - name: Apply breaking label and comment - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BREAKING: ${{ needs.infer.outputs.breaking }} - ITEMS_JSON: ${{ needs.infer.outputs.items }} - LABEL: ${{ inputs.label }} - PR: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - if [ "$BREAKING" = "true" ]; then - gh label create "$LABEL" --color "B60205" --repo "$REPO" 2>/dev/null || true - gh pr edit "$PR" --repo "$REPO" --add-label "$LABEL" - - { - echo "⚠️ **Potential breaking changes detected:**" - echo "" - printf '%s' "$ITEMS_JSON" | jq -r '.[]' | sed 's/^/- /' - echo "" - echo "_Review carefully before merging. Consider a major version bump._" - } > /tmp/breaking-comment.md - - EXISTING=$(gh pr view "$PR" --repo "$REPO" --json comments --jq '.comments[] | select(.body | startswith("⚠️ **Potential breaking")) | .id' | head -1) - if [ -n "$EXISTING" ]; then - gh api graphql -f query="mutation { updateIssueComment(input: {id: \"$EXISTING\", body: $(jq -Rs . /tmp/breaking-comment.md)}) { issueComment { id } } }" - else - gh pr comment "$PR" --repo "$REPO" --body-file /tmp/breaking-comment.md - fi - else - gh pr edit "$PR" --repo "$REPO" --remove-label "$LABEL" 2>/dev/null || true - fi diff --git a/.github/workflows/ai-classify-pr.yml b/.github/workflows/ai-classify-pr.yml deleted file mode 100644 index 8739d01..0000000 --- a/.github/workflows/ai-classify-pr.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Classify PR - -on: - workflow_call: - inputs: - prompt-file: - required: true - type: string - description: Path to the classify prompt YAML in the caller repo - labels: - required: true - type: string - description: Comma-separated valid labels (e.g. "bug,enhancement,documentation") - diff-max-bytes: - required: false - type: number - default: 100000 - description: Maximum bytes of diff to include in the prompt - -jobs: - infer: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - models: read - outputs: - label: ${{ steps.validate.outputs.label }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Build prompt - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROMPT_FILE: ${{ inputs.prompt-file }} - DIFF_MAX_BYTES: ${{ inputs.diff-max-bytes }} - PR: ${{ github.event.pull_request.number }} - run: | - gh pr diff "$PR" > /tmp/pr.diff - gh pr view "$PR" --json title --jq .title > /tmp/pr-title.txt - gh pr view "$PR" --json body --jq '.body // ""' > /tmp/pr-body.txt - - { - printf 'PR #%s: %s\n' "$PR" "$(cat /tmp/pr-title.txt)" - echo "" - cat /tmp/pr-body.txt - echo "" - echo "Diff (truncated):" - head -c "$DIFF_MAX_BYTES" /tmp/pr.diff - } > /tmp/user-message.txt - - python3 << 'SPLICE' - import os - prompt_file = os.environ['PROMPT_FILE'] - with open(prompt_file) as f: - lines = f.readlines() - with open('/tmp/user-message.txt') as f: - user_msg = f.read() - - insert_at = len(lines) - for i, line in enumerate(lines): - if i == 0: - continue - if line.strip() and not line[0].isspace(): - insert_at = i - break - - entry = [' - role: user\n', ' content: |\n'] - for ln in user_msg.splitlines(): - entry.append(' ' + ln + '\n') - - lines[insert_at:insert_at] = entry - with open('/tmp/prompt.yml', 'w') as f: - f.writelines(lines) - - try: - import yaml - doc = yaml.safe_load(open('/tmp/prompt.yml')) - assert doc['messages'][-1]['role'] == 'user', 'prompt splice failed' - except ImportError: - pass - SPLICE - - - name: Classify - id: classify - uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 - with: - prompt-file: /tmp/prompt.yml - - - name: Validate label - id: validate - env: - LABELS: ${{ inputs.labels }} - RESPONSE_FILE: ${{ steps.classify.outputs.response-file }} - run: | - if [ -z "$RESPONSE_FILE" ] || [ ! -f "$RESPONSE_FILE" ]; then - echo "::warning::Model response file is missing; skipping classification." - exit 0 - fi - # Handle both JSON object ({"label":"bug"}) and plain text responses - RAW=$(cat "$RESPONSE_FILE") - LABEL=$(printf '%s' "$RAW" | jq -r '.label // empty' 2>/dev/null || true) - if [ -z "$LABEL" ]; then - LABEL=$(printf '%s' "$RAW" | tr -d '[:space:]') - fi - LABEL=$(printf '%s' "$LABEL" | tr '[:upper:]' '[:lower:]') - IFS=',' read -ra ALLOWED <<< "$LABELS" - for L in "${ALLOWED[@]}"; do - if [ "$LABEL" = "$L" ]; then - echo "label=$LABEL" >> "$GITHUB_OUTPUT" - exit 0 - fi - done - echo "::warning::Unexpected label '$LABEL' not in allowed list; skipping." - - apply: - runs-on: ubuntu-latest - needs: infer - if: needs.infer.outputs.label != '' - permissions: - contents: read - pull-requests: write - issues: write - steps: - - name: Apply label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - LABEL: ${{ needs.infer.outputs.label }} - LABELS: ${{ inputs.labels }} - PR: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - CURRENT=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') - IFS=',' read -ra ALL_LABELS <<< "$LABELS" - for L in "${ALL_LABELS[@]}"; do - if [ "$L" != "$LABEL" ] && echo "$CURRENT" | grep -qx "$L"; then - gh pr edit "$PR" --repo "$REPO" --remove-label "$L" 2>/dev/null || true - fi - done - if ! echo "$CURRENT" | grep -qx "$LABEL"; then - gh pr edit "$PR" --repo "$REPO" --add-label "$LABEL" 2>/dev/null || true - fi