Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- 🛡️ **Safety First**: Built-in safeguards against infinite loops
- 🔍 **Dry-run Mode**: Preview changes without committing
- 🚫 **Skip Control**: Label-based opt-out mechanism
- 📄 **Draft PR Control**: Optionally skip draft PRs
- 🍴 **Fork Friendly**: Handles forked PRs gracefully

## 🚀 Quick Start
Expand Down Expand Up @@ -70,6 +71,7 @@ jobs:
| `config_path` | Path to Felix configuration file | No | `.felixrc.json` |
| `dry_run` | Run in dry-run mode (comment instead of commit) | No | `false` |
| `skip_label` | PR label that skips Felix processing | No | `skip-felix` |
| `skip_draft_prs` | Skip processing draft pull requests | No | `false` |
| `allowed_bots` | Comma-separated list of bot names Felix should run against | No | `` |
| `paths` | Comma-separated list of paths to run fixers on | No | `.` |

Expand Down Expand Up @@ -176,9 +178,27 @@ Use your own npm scripts instead of built-in commands:
- Skips processing (cannot commit to forks with default token)
- Logs appropriate messages

### Draft PR Handling

By default, Felix processes draft PRs just like regular PRs. You can configure Felix to skip draft PRs entirely:

```yaml
- name: Run Fix-it Felix
uses: launchdarkly/fix-it-felix-action@v1
with:
skip_draft_prs: true
```

This is useful when you want to:

- Prevent commits on work-in-progress PRs
- Let developers refine their changes before auto-fixing
- Reduce action runs during development

### Skip Mechanisms

- **Label-based**: Add `skip-felix` label to PR
- **Draft PRs**: Set `skip_draft_prs: true` to skip draft PRs
- **Configuration**: Set `fixers: []` in `.felixrc.json`
- **Event-based**: Only runs on specific PR events

Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ inputs:
required: false
default: 'false'

skip_draft_prs:
description: 'Skip processing draft pull requests'
required: false
default: 'false'

outputs:
fixes_applied:
description: 'Whether any fixes were applied'
Expand Down
10 changes: 8 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30182,8 +30182,13 @@ class FixitFelix {
core.info('Not a pull request event');
return true;
}
// Skip if PR has skip label
const pr = this.context.payload.pull_request;
// Skip if draft PR and skipDraftPrs is enabled
if (this.inputs.skipDraftPrs && pr?.draft) {
core.info('PR is a draft and skip_draft_prs is enabled');
return true;
}
// Skip if PR has skip label
if (pr?.labels?.some((label) => label.name === this.inputs.skipLabel)) {
core.info(`PR has skip label: ${this.inputs.skipLabel}`);
return true;
Expand Down Expand Up @@ -31426,7 +31431,8 @@ async function run() {
allowedBots: core.getInput('allowed_bots'),
paths: core.getInput('paths'),
personalAccessToken: core.getInput('personal_access_token'),
debug: core.getBooleanInput('debug')
debug: core.getBooleanInput('debug'),
skipDraftPrs: core.getBooleanInput('skip_draft_prs')
};
const felix = new felix_1.FixitFelix(inputs, github.context);
const result = await felix.run();
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Configure Felix behavior through GitHub Action inputs:
| `config_path` | Path to Felix configuration file | `.felixrc.json` |
| `dry_run` | Run in dry-run mode (comment instead of commit) | `false` |
| `skip_label` | PR label that skips Felix processing | `skip-felix` |
| `skip_draft_prs` | Skip processing draft pull requests | `false` |
| `allowed_bots` | Comma-separated list of bot names Felix should run against | (empty) |

### Example
Expand Down Expand Up @@ -68,6 +69,7 @@ steps:
config_path: '.custom-felix.json'
dry_run: false
skip_label: 'no-autofix'
skip_draft_prs: true
allowed_bots: 'dependabot,renovate'
```

Expand Down
1 change: 1 addition & 0 deletions examples/advanced-usage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
commit_message: '🤖 Fix-it Felix: Automated code quality improvements'
config_path: '.felixrc.json'
skip_label: 'no-autofix'
skip_draft_prs: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
9 changes: 8 additions & 1 deletion src/felix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,16 @@
return true
}

// Skip if PR has skip label
const pr = this.context.payload.pull_request

// Skip if draft PR and skipDraftPrs is enabled
if (this.inputs.skipDraftPrs && pr?.draft) {
core.info('PR is a draft and skip_draft_prs is enabled')
return true
}

// Skip if PR has skip label
if (pr?.labels?.some((label: any) => label.name === this.inputs.skipLabel)) {

Check warning on line 147 in src/felix.ts

View workflow job for this annotation

GitHub Actions / dogfood

Unexpected any. Specify a different type
core.info(`PR has skip label: ${this.inputs.skipLabel}`)
return true
}
Expand Down Expand Up @@ -531,7 +538,7 @@
})

const changedFiles = files
.map((f: any) => f.filename)

Check warning on line 541 in src/felix.ts

View workflow job for this annotation

GitHub Actions / dogfood

Unexpected any. Specify a different type
.filter((file: string) => {
// Skip deleted files
try {
Expand Down Expand Up @@ -597,7 +604,7 @@
private filterFilesByFixer(
files: string[],
fixerName: string,
fixerConfig: any,

Check warning on line 607 in src/felix.ts

View workflow job for this annotation

GitHub Actions / dogfood

Unexpected any. Specify a different type
configuredPaths: string[]
): string[] {
// Get the extensions this fixer handles
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ async function run(): Promise<void> {
allowedBots: core.getInput('allowed_bots'),
paths: core.getInput('paths'),
personalAccessToken: core.getInput('personal_access_token'),
debug: core.getBooleanInput('debug')
debug: core.getBooleanInput('debug'),
skipDraftPrs: core.getBooleanInput('skip_draft_prs')
}

const felix = new FixitFelix(inputs, github.context)
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface FelixInputs {
paths: string
personalAccessToken?: string
debug: boolean
skipDraftPrs: boolean
}

export interface FelixConfig {
Expand Down
3 changes: 2 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ describe('ConfigManager', () => {
allowedBots: '',
paths: '',
personalAccessToken: '',
debug: false
debug: false,
skipDraftPrs: false
}

beforeEach(() => {
Expand Down
227 changes: 226 additions & 1 deletion tests/felix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
allowedBots: '',
paths: '',
personalAccessToken: '',
debug: false
debug: false,
skipDraftPrs: false
}

const mockContext = {
Expand Down Expand Up @@ -424,8 +425,8 @@
dryRun: true
}

let eslintCalled = false

Check failure on line 428 in tests/felix.test.ts

View workflow job for this annotation

GitHub Actions / dogfood

'eslintCalled' is assigned a value but never used

Check failure on line 428 in tests/felix.test.ts

View workflow job for this annotation

GitHub Actions / dogfood

'eslintCalled' is assigned a value but never used
let prettierCalled = false

Check failure on line 429 in tests/felix.test.ts

View workflow job for this annotation

GitHub Actions / dogfood

'prettierCalled' is assigned a value but never used

Check failure on line 429 in tests/felix.test.ts

View workflow job for this annotation

GitHub Actions / dogfood

'prettierCalled' is assigned a value but never used

mockExec.exec.mockImplementation((command, args, options) => {
if (args && args.includes('--pretty=format:%an')) {
Expand Down Expand Up @@ -591,4 +592,228 @@
process.env.GITHUB_TOKEN = originalEnv
})
})

describe('draft PR handling', () => {
it('should skip when PR is draft and skip_draft_prs is enabled', async () => {
const inputsWithSkipDraft = {
...defaultInputs,
skipDraftPrs: true
}

const draftContext = {
...mockContext,
payload: {
...mockContext.payload,
pull_request: {
...mockContext.payload.pull_request,
draft: true
}
}
}

const felix = new FixitFelix(inputsWithSkipDraft, draftContext)
const result = await felix.run()

expect(result.fixesApplied).toBe(false)
expect(result.changedFiles).toEqual([])
})

it('should proceed when PR is draft but skip_draft_prs is disabled', async () => {
const inputsWithoutSkipDraft = {
...defaultInputs,
skipDraftPrs: false
}

// Create a proper mock context with PR details
const draftPRContext = {
repo: { owner: 'test-owner', repo: 'test-repo' },
issue: { number: 456 },
eventName: 'pull_request',
payload: {
pull_request: {
number: 456,
draft: true,
base: {
ref: 'main',
repo: {
owner: { login: 'test-owner' },
name: 'test-repo',
full_name: 'test-owner/test-repo'
}
},
head: {
ref: 'feature',
repo: { full_name: 'test-owner/test-repo' }
}
}
}
} as any

// Mock core.getInput to provide a token
jest.spyOn(require('@actions/core'), 'getInput').mockImplementation((name: any) => {
if (name === 'personal_access_token') return 'mock-token'
return ''
})

// Mock process.env.GITHUB_TOKEN as fallback
const originalEnv = process.env.GITHUB_TOKEN
process.env.GITHUB_TOKEN = 'mock-github-token'

// Mock GitHub API
const mockOctokit = {
paginate: jest.fn().mockResolvedValue([{ filename: 'src/test.js' }]),
rest: {
pulls: {
listFiles: jest.fn()
}
}
}

jest.spyOn(require('@actions/github'), 'getOctokit').mockReturnValue(mockOctokit)

// Mock fs.existsSync to return true for test files
const mockFs = require('fs')
mockFs.existsSync.mockImplementation((path: string) => {
return path === 'src/test.js'
})

// Mock git commands for regular processing
mockExec.exec.mockImplementation((command, args, options) => {
if (args && args.includes('--pretty=format:%an')) {
options?.listeners?.stdout?.(Buffer.from('Regular User'))
} else if (args && args.includes('--pretty=format:%s')) {
options?.listeners?.stdout?.(Buffer.from('Regular commit'))
} else if (command === 'npx' && args && args.includes('--version')) {
// Mock prettier version check
options?.listeners?.stdout?.(Buffer.from('2.8.0'))
return Promise.resolve(0)
} else if (
command === 'npx' &&
args &&
args.includes('prettier') &&
args.includes('--write')
) {
// Mock prettier command
return Promise.resolve(0)
} else if (args && args.includes('--name-only') && !args.includes('origin/')) {
// Mock git diff for changed files after running fixer
options?.listeners?.stdout?.(Buffer.from('src/test.js'))
}
return Promise.resolve(0)
})

const felix = new FixitFelix(inputsWithoutSkipDraft, draftPRContext)
const result = await felix.run()

// Should proceed with fixes even though PR is draft
expect(result).toBeDefined()
expect(mockExec.exec).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['prettier', '--write']),
expect.any(Object)
)

// Restore environment
process.env.GITHUB_TOKEN = originalEnv
})

it('should proceed when PR is not draft regardless of skip_draft_prs setting', async () => {
const inputsWithSkipDraft = {
...defaultInputs,
skipDraftPrs: true
}

// Create a proper mock context with PR details for non-draft PR
const nonDraftPRContext = {
repo: { owner: 'test-owner', repo: 'test-repo' },
issue: { number: 456 },
eventName: 'pull_request',
payload: {
pull_request: {
number: 456,
draft: false,
base: {
ref: 'main',
repo: {
owner: { login: 'test-owner' },
name: 'test-repo',
full_name: 'test-owner/test-repo'
}
},
head: {
ref: 'feature',
repo: { full_name: 'test-owner/test-repo' }
}
}
}
} as any

// Mock core.getInput to provide a token
jest.spyOn(require('@actions/core'), 'getInput').mockImplementation((name: any) => {
if (name === 'personal_access_token') return 'mock-token'
return ''
})

// Mock process.env.GITHUB_TOKEN as fallback
const originalEnv = process.env.GITHUB_TOKEN
process.env.GITHUB_TOKEN = 'mock-github-token'

// Mock GitHub API
const mockOctokit = {
paginate: jest.fn().mockResolvedValue([{ filename: 'src/test.js' }]),
rest: {
pulls: {
listFiles: jest.fn()
}
}
}

jest.spyOn(require('@actions/github'), 'getOctokit').mockReturnValue(mockOctokit)

// Mock fs.existsSync to return true for test files
const mockFs = require('fs')
mockFs.existsSync.mockImplementation((path: string) => {
return path === 'src/test.js'
})

// Mock git commands for regular processing
mockExec.exec.mockImplementation((command, args, options) => {
if (args && args.includes('--pretty=format:%an')) {
options?.listeners?.stdout?.(Buffer.from('Regular User'))
} else if (args && args.includes('--pretty=format:%s')) {
options?.listeners?.stdout?.(Buffer.from('Regular commit'))
} else if (command === 'npx' && args && args.includes('--version')) {
// Mock prettier version check
options?.listeners?.stdout?.(Buffer.from('2.8.0'))
return Promise.resolve(0)
} else if (
command === 'npx' &&
args &&
args.includes('prettier') &&
args.includes('--write')
) {
// Mock prettier command
return Promise.resolve(0)
} else if (args && args.includes('--name-only') && !args.includes('origin/')) {
// Mock git diff for changed files after running fixer
options?.listeners?.stdout?.(Buffer.from('src/test.js'))
}
return Promise.resolve(0)
})

const felix = new FixitFelix(inputsWithSkipDraft, nonDraftPRContext)
const result = await felix.run()

// Should proceed with fixes when PR is not draft
expect(result).toBeDefined()
expect(mockExec.exec).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['prettier', '--write']),
expect.any(Object)
)

// Restore environment
process.env.GITHUB_TOKEN = originalEnv
})
})
})
Loading
Loading