flagshark 1.0.0 โ 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/action/index.ts +155 -82
- package/dist/action.cjs +31832 -0
- package/dist/action.js +31136 -3205
- package/dist/cli.js +7 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -83,6 +83,8 @@ jobs:
|
|
|
83
83
|
with:
|
|
84
84
|
fetch-depth: 0 # Required for git blame age detection
|
|
85
85
|
- uses: FlagShark/flagshark@v1
|
|
86
|
+
env:
|
|
87
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
86
88
|
```
|
|
87
89
|
|
|
88
90
|
### Action Inputs
|
|
@@ -93,9 +95,23 @@ jobs:
|
|
|
93
95
|
| `threshold` | `6` | Staleness threshold in months |
|
|
94
96
|
| `fail-threshold` | `0` | Health score below which the check fails (0 = never fail) |
|
|
95
97
|
|
|
98
|
+
### Scan Modes
|
|
99
|
+
|
|
100
|
+
**`scan: changed`** (default) scans only files modified in the PR. Fast, focused on what you're changing.
|
|
101
|
+
|
|
102
|
+
**`scan: full`** scans the entire repository. Shows your full flag health score and finds stale flags everywhere, not just in changed files. Great for seeing the big picture:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
- uses: FlagShark/flagshark@v1
|
|
106
|
+
with:
|
|
107
|
+
scan: full
|
|
108
|
+
env:
|
|
109
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
110
|
+
```
|
|
111
|
+
|
|
96
112
|
### What the Action does
|
|
97
113
|
|
|
98
|
-
On every PR, FlagShark comments with a table of stale flags
|
|
114
|
+
On every PR, FlagShark comments with a table of stale flags:
|
|
99
115
|
|
|
100
116
|
> ### ๐ฆ FlagShark found 3 stale flags
|
|
101
117
|
>
|
|
@@ -154,22 +170,6 @@ A flag is marked stale if **any** of these signals fires:
|
|
|
154
170
|
|
|
155
171
|
FlagShark only checks files that actually import a flag SDK. A function called `isEnabled()` in a file that doesn't import LaunchDarkly/Unleash/etc. won't be flagged. This prevents false positives.
|
|
156
172
|
|
|
157
|
-
## Configuration
|
|
158
|
-
|
|
159
|
-
### `.flagsharkignore`
|
|
160
|
-
|
|
161
|
-
Exclude files or specific flags:
|
|
162
|
-
|
|
163
|
-
```
|
|
164
|
-
# Glob patterns for files
|
|
165
|
-
test/**
|
|
166
|
-
fixtures/**
|
|
167
|
-
|
|
168
|
-
# Specific flag names (prefix with flag:)
|
|
169
|
-
flag:PERMANENT_ADMIN_OVERRIDE
|
|
170
|
-
flag:MAINTENANCE_MODE
|
|
171
|
-
```
|
|
172
|
-
|
|
173
173
|
## License
|
|
174
174
|
|
|
175
175
|
MIT
|
package/action/index.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub Action entry point for FlagShark.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Architecture:
|
|
8
|
-
* action/index.ts (this file)
|
|
9
|
-
* โ
|
|
10
|
-
* โโ Runs the same scanner + staleness logic as the CLI
|
|
11
|
-
* โโ Posts a PR comment with markdown table
|
|
12
|
-
* โโ Sets a GitHub check status (pass/fail)
|
|
4
|
+
* Scans a repo for stale feature flags, posts a rich PR comment,
|
|
5
|
+
* writes a GitHub Actions job summary, and sets a status check.
|
|
13
6
|
*/
|
|
14
7
|
|
|
15
8
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
@@ -27,22 +20,22 @@ import type { StaleFlag } from '../src/staleness.js'
|
|
|
27
20
|
|
|
28
21
|
const COMMENT_MARKER = '<!-- flagshark-action -->'
|
|
29
22
|
const SKIP_DIRS = new Set([
|
|
30
|
-
'node_modules',
|
|
31
|
-
'
|
|
32
|
-
'.git',
|
|
33
|
-
'dist',
|
|
34
|
-
'build',
|
|
35
|
-
'coverage',
|
|
36
|
-
'__pycache__',
|
|
37
|
-
'.next',
|
|
38
|
-
'.turbo',
|
|
23
|
+
'node_modules', 'vendor', '.git', 'dist', 'build',
|
|
24
|
+
'coverage', '__pycache__', '.next', '.turbo',
|
|
39
25
|
])
|
|
40
26
|
|
|
27
|
+
// Logger that serializes objects properly instead of [object Object]
|
|
41
28
|
const logger = {
|
|
42
|
-
debug: (...args: unknown[]) => core.debug(args
|
|
43
|
-
info: (...args: unknown[]) => core.info(args
|
|
44
|
-
warn: (...args: unknown[]) => core.warning(args
|
|
45
|
-
error: (...args: unknown[]) => core.error(args
|
|
29
|
+
debug: (...args: unknown[]) => core.debug(formatLogArgs(args)),
|
|
30
|
+
info: (...args: unknown[]) => core.info(formatLogArgs(args)),
|
|
31
|
+
warn: (...args: unknown[]) => core.warning(formatLogArgs(args)),
|
|
32
|
+
error: (...args: unknown[]) => core.error(formatLogArgs(args)),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatLogArgs(args: unknown[]): string {
|
|
36
|
+
return args.map(a =>
|
|
37
|
+
typeof a === 'object' && a !== null ? JSON.stringify(a, null, 2) : String(a)
|
|
38
|
+
).join(' ')
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
async function run(): Promise<void> {
|
|
@@ -85,9 +78,7 @@ async function run(): Promise<void> {
|
|
|
85
78
|
for (const fp of filePaths) {
|
|
86
79
|
try {
|
|
87
80
|
const stat = statSync(fp)
|
|
88
|
-
if (stat.size > 5 * 1024 * 1024)
|
|
89
|
-
continue
|
|
90
|
-
}
|
|
81
|
+
if (stat.size > 5 * 1024 * 1024) continue
|
|
91
82
|
files.set(fp, readFileSync(fp, 'utf-8'))
|
|
92
83
|
} catch {
|
|
93
84
|
// Skip unreadable files
|
|
@@ -100,29 +91,62 @@ async function run(): Promise<void> {
|
|
|
100
91
|
const result = await analyzer.analyzeFiles(files)
|
|
101
92
|
const totalFlags = result.totalFlags.size
|
|
102
93
|
|
|
94
|
+
// Collect language stats
|
|
95
|
+
const langStats: Record<string, number> = {}
|
|
96
|
+
for (const [lang, count] of result.languages) {
|
|
97
|
+
langStats[lang] = count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Collect detected providers
|
|
101
|
+
const allFlags: FeatureFlag[] = []
|
|
102
|
+
for (const flags of result.totalFlags.values()) {
|
|
103
|
+
allFlags.push(...(flags as FeatureFlag[]))
|
|
104
|
+
}
|
|
105
|
+
const providers = [...new Set(
|
|
106
|
+
allFlags.map(f => f.provider).filter((p): p is string => p !== null && p !== undefined && p !== '')
|
|
107
|
+
)]
|
|
108
|
+
|
|
109
|
+
core.info(`Detection complete: ${totalFlags} unique flags across ${Object.keys(langStats).length} languages`)
|
|
110
|
+
|
|
103
111
|
// Run staleness analysis
|
|
104
112
|
const staleFlags = await analyzeStaleness(result.totalFlags as Map<string, FeatureFlag[]>, {
|
|
105
113
|
thresholdMonths: threshold,
|
|
106
114
|
repoRoot: process.cwd(),
|
|
107
115
|
})
|
|
108
116
|
|
|
109
|
-
// Use unique stale flag names (not occurrences) for health score โ matches CLI formula
|
|
110
117
|
const uniqueStaleNames = new Set(staleFlags.map((f) => f.name)).size
|
|
111
118
|
const healthScore =
|
|
112
119
|
totalFlags > 0 ? Math.round(((totalFlags - uniqueStaleNames) / totalFlags) * 100) : 100
|
|
113
|
-
|
|
114
120
|
const scanDuration = Date.now() - startTime
|
|
115
121
|
|
|
122
|
+
// Pretty log output
|
|
123
|
+
core.info('')
|
|
124
|
+
core.info('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ')
|
|
125
|
+
core.info('โ ๐ฆ FlagShark Scan Results โ')
|
|
126
|
+
core.info('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค')
|
|
127
|
+
core.info(`โ Files scanned: ${String(files.size).padStart(6)} โ`)
|
|
128
|
+
core.info(`โ Languages: ${String(Object.keys(langStats).length).padStart(6)} โ`)
|
|
129
|
+
core.info(`โ Flags detected: ${String(totalFlags).padStart(6)} โ`)
|
|
130
|
+
core.info(`โ Stale flags: ${String(uniqueStaleNames).padStart(6)} โ`)
|
|
131
|
+
core.info(`โ Health score: ${String(healthScore).padStart(3)}/100 โ`)
|
|
132
|
+
core.info(`โ Scan time: ${String(scanDuration).padStart(5)}ms โ`)
|
|
133
|
+
core.info('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ')
|
|
134
|
+
core.info('')
|
|
135
|
+
|
|
136
|
+
if (providers.length > 0) {
|
|
137
|
+
core.info(`Detected providers: ${providers.slice(0, 8).join(', ')}${providers.length > 8 ? ` (+${providers.length - 8} more)` : ''}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
// Set outputs
|
|
117
141
|
core.setOutput('health-score', healthScore.toString())
|
|
118
|
-
core.setOutput('stale-count',
|
|
142
|
+
core.setOutput('stale-count', uniqueStaleNames.toString())
|
|
119
143
|
core.setOutput('total-count', totalFlags.toString())
|
|
120
144
|
|
|
121
|
-
// Post PR comment
|
|
122
|
-
if (github.context.payload.pull_request &&
|
|
145
|
+
// Post PR comment
|
|
146
|
+
if (github.context.payload.pull_request && totalFlags > 0) {
|
|
123
147
|
const token = process.env.GITHUB_TOKEN || core.getInput('token')
|
|
124
148
|
if (token) {
|
|
125
|
-
await postComment(token, staleFlags, totalFlags, healthScore)
|
|
149
|
+
await postComment(token, staleFlags, totalFlags, healthScore, scanMode, langStats, providers, scanDuration)
|
|
126
150
|
}
|
|
127
151
|
}
|
|
128
152
|
|
|
@@ -130,19 +154,51 @@ async function run(): Promise<void> {
|
|
|
130
154
|
if (failThreshold > 0 && healthScore < failThreshold) {
|
|
131
155
|
core.setFailed(
|
|
132
156
|
`Flag health score ${healthScore}/100 is below threshold ${failThreshold}/100. ` +
|
|
133
|
-
|
|
157
|
+
`${uniqueStaleNames} stale flags found.`,
|
|
134
158
|
)
|
|
135
|
-
} else {
|
|
136
|
-
core.info(`Flag Health Score: ${healthScore}/100 (${staleFlags.length}/${totalFlags} stale)`)
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
// Summary
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
161
|
+
// Job summary (visible in Actions UI under "Summary" tab)
|
|
162
|
+
const healthEmoji = healthScore >= 90 ? '๐ข' : healthScore >= 70 ? '๐ก' : healthScore >= 40 ? '๐ ' : '๐ด'
|
|
163
|
+
|
|
164
|
+
core.summary.addHeading('๐ฆ FlagShark Scan Results', 2)
|
|
165
|
+
core.summary.addRaw(`\n${healthEmoji} **Health Score: ${healthScore}/100**\n\n`)
|
|
166
|
+
core.summary.addTable([
|
|
167
|
+
[{ data: 'Metric', header: true }, { data: 'Value', header: true }],
|
|
168
|
+
['Files scanned', files.size.toString()],
|
|
169
|
+
['Languages', Object.keys(langStats).join(', ') || 'none'],
|
|
170
|
+
['Total flags', totalFlags.toString()],
|
|
171
|
+
['Stale flags', uniqueStaleNames.toString()],
|
|
172
|
+
['Scan mode', scanMode],
|
|
173
|
+
['Scan time', `${scanDuration}ms`],
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
if (providers.length > 0) {
|
|
177
|
+
core.summary.addRaw(`\n**Detected providers:** ${providers.join(', ')}\n`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (uniqueStaleNames > 0) {
|
|
181
|
+
core.summary.addRaw('\n### Top stale flags\n\n')
|
|
182
|
+
core.summary.addTable([
|
|
183
|
+
[{ data: 'Flag', header: true }, { data: 'File', header: true }, { data: 'Age', header: true }, { data: 'Signal', header: true }],
|
|
184
|
+
...staleFlags.slice(0, 15).map(f => [
|
|
185
|
+
`\`${f.name}\``,
|
|
186
|
+
`${f.filePath}:${f.lineNumber}`,
|
|
187
|
+
f.age || 'unknown',
|
|
188
|
+
f.signals.map(s => s.description).join(', '),
|
|
189
|
+
]),
|
|
190
|
+
])
|
|
191
|
+
if (staleFlags.length > 15) {
|
|
192
|
+
core.summary.addRaw(`\n*... and ${staleFlags.length - 15} more stale flags*\n`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
core.summary.addRaw('\n---\n')
|
|
197
|
+
core.summary.addRaw('*Powered by [FlagShark](https://github.com/FlagShark/flagshark) โ find stale feature flags before they cause incidents*\n')
|
|
198
|
+
core.summary.addRaw('\n[Automate flag cleanup](https://flagshark.com) ยท [Open source CLI](https://github.com/FlagShark/flagshark) ยท [Report an issue](https://github.com/FlagShark/flagshark/issues)\n')
|
|
199
|
+
|
|
145
200
|
await core.summary.write()
|
|
201
|
+
|
|
146
202
|
} catch (error) {
|
|
147
203
|
if (error instanceof Error) {
|
|
148
204
|
core.setFailed(error.message)
|
|
@@ -157,86 +213,103 @@ async function postComment(
|
|
|
157
213
|
staleFlags: StaleFlag[],
|
|
158
214
|
totalFlags: number,
|
|
159
215
|
healthScore: number,
|
|
216
|
+
scanMode: string,
|
|
217
|
+
langStats: Record<string, number>,
|
|
218
|
+
providers: string[],
|
|
219
|
+
scanDuration: number,
|
|
160
220
|
): Promise<void> {
|
|
161
221
|
const octokit = github.getOctokit(token)
|
|
162
222
|
const { owner, repo } = github.context.repo
|
|
163
223
|
const prNumber = github.context.payload.pull_request!.number
|
|
164
224
|
|
|
165
|
-
const
|
|
166
|
-
const
|
|
225
|
+
const uniqueStaleCount = new Set(staleFlags.map((f) => f.name)).size
|
|
226
|
+
const modeLabel = scanMode === 'full' ? 'Full repo scan' : 'Changed files only'
|
|
227
|
+
const healthEmoji = healthScore >= 90 ? '๐ข' : healthScore >= 70 ? '๐ก' : healthScore >= 40 ? '๐ ' : '๐ด'
|
|
228
|
+
const langList = Object.entries(langStats).map(([l, c]) => `${l} (${c})`).join(', ')
|
|
229
|
+
const providerList = providers.length > 0
|
|
230
|
+
? providers.slice(0, 5).join(', ') + (providers.length > 5 ? ` +${providers.length - 5} more` : '')
|
|
231
|
+
: 'none detected'
|
|
167
232
|
|
|
168
233
|
let body = `${COMMENT_MARKER}\n`
|
|
169
|
-
body += `### ๐ฆ FlagShark found ${staleFlags.length} stale flag${staleFlags.length !== 1 ? 's' : ''}\n\n`
|
|
170
|
-
body += '| Flag | File | Added | Signal |\n'
|
|
171
|
-
body += '|------|------|-------|--------|\n'
|
|
172
234
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
body +=
|
|
235
|
+
// Header
|
|
236
|
+
if (uniqueStaleCount === 0) {
|
|
237
|
+
body += `## ๐ฆ FlagShark โ All flags healthy\n\n`
|
|
238
|
+
} else {
|
|
239
|
+
body += `## ๐ฆ FlagShark โ ${uniqueStaleCount} stale flag${uniqueStaleCount !== 1 ? 's' : ''} found\n\n`
|
|
176
240
|
}
|
|
177
241
|
|
|
178
|
-
|
|
179
|
-
|
|
242
|
+
// Health score badge
|
|
243
|
+
body += `${healthEmoji} **Health Score: ${healthScore}/100**\n\n`
|
|
244
|
+
|
|
245
|
+
// Stats row
|
|
246
|
+
body += `| Metric | Value |\n`
|
|
247
|
+
body += `|--------|-------|\n`
|
|
248
|
+
body += `| Flags detected | ${totalFlags} |\n`
|
|
249
|
+
body += `| Stale flags | ${uniqueStaleCount} |\n`
|
|
250
|
+
body += `| Languages | ${langList} |\n`
|
|
251
|
+
body += `| Providers | ${providerList} |\n`
|
|
252
|
+
body += `| Scan mode | ${modeLabel} |\n`
|
|
253
|
+
body += `| Scan time | ${scanDuration}ms |\n\n`
|
|
254
|
+
|
|
255
|
+
// Stale flags table
|
|
256
|
+
if (uniqueStaleCount > 0) {
|
|
257
|
+
body += `<details${uniqueStaleCount <= 5 ? ' open' : ''}>\n`
|
|
258
|
+
body += `<summary><strong>Stale flags (${uniqueStaleCount})</strong></summary>\n\n`
|
|
259
|
+
body += '| Flag | File | Age | Why it looks stale |\n'
|
|
260
|
+
body += '|------|------|-----|--------------------|\n'
|
|
261
|
+
|
|
262
|
+
const displayFlags = staleFlags.slice(0, 20)
|
|
263
|
+
for (const flag of displayFlags) {
|
|
264
|
+
const signals = flag.signals.map(s => s.description).join(', ')
|
|
265
|
+
const shortPath = flag.filePath.replace(/^\.\//, '')
|
|
266
|
+
body += `| \`${flag.name}\` | \`${shortPath}:${flag.lineNumber}\` | ${flag.age || 'unknown'} | ${signals} |\n`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (staleFlags.length > 20) {
|
|
270
|
+
body += `\n*... and ${staleFlags.length - 20} more. Run \`npx flagshark scan --verbose\` locally for the full list.*\n`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
body += '\n</details>\n\n'
|
|
180
274
|
}
|
|
181
275
|
|
|
182
|
-
|
|
183
|
-
body +=
|
|
184
|
-
|
|
276
|
+
// Footer with links
|
|
277
|
+
body += '---\n'
|
|
278
|
+
body += `*[FlagShark](https://github.com/FlagShark/flagshark) finds stale feature flags before they cause incidents*\n\n`
|
|
279
|
+
body += `[Automate flag cleanup](https://flagshark.com) ยท `
|
|
280
|
+
body += `[Install CLI](https://www.npmjs.com/package/flagshark) ยท `
|
|
281
|
+
body += `[Open source](https://github.com/FlagShark/flagshark)\n`
|
|
185
282
|
|
|
186
283
|
// Find existing comment to update
|
|
187
284
|
const { data: comments } = await octokit.rest.issues.listComments({
|
|
188
|
-
owner,
|
|
189
|
-
repo,
|
|
190
|
-
issue_number: prNumber,
|
|
191
|
-
per_page: 100,
|
|
285
|
+
owner, repo, issue_number: prNumber, per_page: 100,
|
|
192
286
|
})
|
|
193
287
|
|
|
194
288
|
const existing = comments.find((c) => c.body?.includes(COMMENT_MARKER))
|
|
195
289
|
|
|
196
290
|
if (existing) {
|
|
197
|
-
await octokit.rest.issues.updateComment({
|
|
198
|
-
owner,
|
|
199
|
-
repo,
|
|
200
|
-
comment_id: existing.id,
|
|
201
|
-
body,
|
|
202
|
-
})
|
|
291
|
+
await octokit.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
|
|
203
292
|
core.info('Updated existing FlagShark comment')
|
|
204
293
|
} else {
|
|
205
|
-
await octokit.rest.issues.createComment({
|
|
206
|
-
owner,
|
|
207
|
-
repo,
|
|
208
|
-
issue_number: prNumber,
|
|
209
|
-
body,
|
|
210
|
-
})
|
|
294
|
+
await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body })
|
|
211
295
|
core.info('Posted new FlagShark comment')
|
|
212
296
|
}
|
|
213
297
|
}
|
|
214
298
|
|
|
215
299
|
function walkDir(dir: string, supportedExts: Set<string>): string[] {
|
|
216
300
|
const results: string[] = []
|
|
217
|
-
|
|
218
301
|
try {
|
|
219
302
|
const entries = readdirSync(dir, { withFileTypes: true })
|
|
220
303
|
for (const entry of entries) {
|
|
221
|
-
if (SKIP_DIRS.has(entry.name))
|
|
222
|
-
continue
|
|
223
|
-
}
|
|
224
|
-
if (entry.name.startsWith('.')) {
|
|
225
|
-
continue
|
|
226
|
-
}
|
|
227
|
-
|
|
304
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue
|
|
228
305
|
const fullPath = join(dir, entry.name)
|
|
229
|
-
|
|
230
306
|
if (entry.isDirectory()) {
|
|
231
307
|
results.push(...walkDir(fullPath, supportedExts))
|
|
232
308
|
} else if (entry.isFile() && supportedExts.has(extname(entry.name))) {
|
|
233
309
|
results.push(fullPath)
|
|
234
310
|
}
|
|
235
311
|
}
|
|
236
|
-
} catch {
|
|
237
|
-
// Skip unreadable directories
|
|
238
|
-
}
|
|
239
|
-
|
|
312
|
+
} catch { /* skip unreadable dirs */ }
|
|
240
313
|
return results
|
|
241
314
|
}
|
|
242
315
|
|