Skip to content

Commit d42041c

Browse files
AlexMaderaclaude
andauthored
ci: implement enterprise-grade PR quality gates and security scanning (#266)
* ci: implement enterprise-grade PR quality gates and security scanning * ci: implement enterprise-grade PR quality gates and security scanning * fix:pr comments and improve code * fix: improve commit linting and code quality * Removed the dependency-review job (i added it) * fix: address CodeRabbit review comments - Expand scope pattern to allow uppercase, underscores, slashes, dots - Add concurrency control to cancel duplicate security scan runs - Add explanatory comment for Bandit CLI flags - Remove dependency-review job (requires repo settings) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update commit lint examples with expanded scope patterns Show slashes and dots in scope examples to demonstrate the newly allowed characters (api/users, package.json) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove feature request issue template Feature requests are directed to GitHub Discussions via the issue template config.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address security vulnerabilities in service orchestrator - Fix port parsing crash on malformed docker-compose entries - Fix shell injection risk by using shlex.split() with shell=False Prevents crashes when docker-compose.yml contains environment variables in port mappings (e.g., '${PORT}:8080') and eliminates shell injection vulnerabilities in subprocess execution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a3f8754 commit d42041c

16 files changed

Lines changed: 855 additions & 35 deletions

File tree

.github/dependabot.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
version: 2
2+
updates:
3+
# Python dependencies
4+
- package-ecosystem: pip
5+
directory: /apps/backend
6+
schedule:
7+
interval: weekly
8+
open-pull-requests-limit: 5
9+
labels:
10+
- dependencies
11+
- python
12+
commit-message:
13+
prefix: "chore(deps)"
14+
15+
# npm dependencies
16+
- package-ecosystem: npm
17+
directory: /apps/frontend
18+
schedule:
19+
interval: weekly
20+
open-pull-requests-limit: 5
21+
labels:
22+
- dependencies
23+
- javascript
24+
commit-message:
25+
prefix: "chore(deps)"
26+
27+
# GitHub Actions
28+
- package-ecosystem: github-actions
29+
directory: /
30+
schedule:
31+
interval: weekly
32+
open-pull-requests-limit: 5
33+
labels:
34+
- dependencies
35+
- ci
36+
commit-message:
37+
prefix: "ci(deps)"

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
PYTHONPATH: ${{ github.workspace }}/apps/backend
5151
run: |
5252
source .venv/bin/activate
53-
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
53+
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=30
5454
5555
- name: Upload coverage reports
5656
if: matrix.python-version == '3.12'
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Auto Label
1+
name: Issue Auto Label
22

33
on:
44
issues:

.github/workflows/lint.yml

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,3 @@ jobs:
2828
- name: Run ruff format check
2929
run: ruff format apps/backend/ --check --diff
3030

31-
# TypeScript/React linting
32-
frontend:
33-
runs-on: ubuntu-latest
34-
steps:
35-
- name: Checkout
36-
uses: actions/checkout@v4
37-
38-
- name: Setup Node.js
39-
uses: actions/setup-node@v4
40-
with:
41-
node-version: '24'
42-
43-
- name: Install dependencies
44-
working-directory: apps/frontend
45-
run: npm ci --ignore-scripts
46-
47-
- name: Run ESLint
48-
working-directory: apps/frontend
49-
run: npm run lint
50-
51-
- name: Run TypeScript check
52-
working-directory: apps/frontend
53-
run: npm run typecheck
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
name: PR Auto Label
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
# Cancel in-progress runs for the same PR
8+
concurrency:
9+
group: pr-auto-label-${{ github.event.pull_request.number }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
16+
jobs:
17+
label:
18+
name: Auto Label PR
19+
runs-on: ubuntu-latest
20+
# Don't run on fork PRs (they can't write labels)
21+
if: github.event.pull_request.head.repo.full_name == github.repository
22+
timeout-minutes: 5
23+
steps:
24+
- name: Auto-label PR
25+
uses: actions/github-script@v7
26+
with:
27+
retries: 3
28+
retry-exempt-status-codes: 400,401,403,404,422
29+
script: |
30+
const { owner, repo } = context.repo;
31+
const pr = context.payload.pull_request;
32+
const prNumber = pr.number;
33+
const title = pr.title;
34+
35+
console.log(`::group::PR #${prNumber} - Auto-labeling`);
36+
console.log(`Title: ${title}`);
37+
38+
const labelsToAdd = new Set();
39+
const labelsToRemove = new Set();
40+
41+
// ═══════════════════════════════════════════════════════════════
42+
// TYPE LABELS (from PR title - Conventional Commits)
43+
// ═══════════════════════════════════════════════════════════════
44+
const typeMap = {
45+
'feat': 'feature',
46+
'fix': 'bug',
47+
'docs': 'documentation',
48+
'refactor': 'refactor',
49+
'test': 'test',
50+
'ci': 'ci',
51+
'chore': 'chore',
52+
'perf': 'performance',
53+
'style': 'style',
54+
'build': 'build'
55+
};
56+
57+
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
58+
if (typeMatch) {
59+
const type = typeMatch[1].toLowerCase();
60+
const isBreaking = typeMatch[3] === '!';
61+
62+
if (typeMap[type]) {
63+
labelsToAdd.add(typeMap[type]);
64+
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
65+
}
66+
67+
if (isBreaking) {
68+
labelsToAdd.add('breaking-change');
69+
console.log(` ⚠️ Breaking change detected`);
70+
}
71+
} else {
72+
console.log(` ⚠️ No conventional commit prefix found in title`);
73+
}
74+
75+
// ═══════════════════════════════════════════════════════════════
76+
// AREA LABELS (from changed files)
77+
// ═══════════════════════════════════════════════════════════════
78+
let files = [];
79+
try {
80+
const { data } = await github.rest.pulls.listFiles({
81+
owner,
82+
repo,
83+
pull_number: prNumber,
84+
per_page: 100
85+
});
86+
files = data;
87+
} catch (e) {
88+
console.log(` ⚠️ Could not fetch files: ${e.message}`);
89+
}
90+
91+
const areas = {
92+
frontend: false,
93+
backend: false,
94+
ci: false,
95+
docs: false,
96+
tests: false
97+
};
98+
99+
for (const file of files) {
100+
const path = file.filename;
101+
if (path.startsWith('apps/frontend/')) areas.frontend = true;
102+
if (path.startsWith('apps/backend/')) areas.backend = true;
103+
if (path.startsWith('.github/')) areas.ci = true;
104+
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
105+
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
106+
}
107+
108+
// Determine area label (mutually exclusive)
109+
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
110+
111+
if (areas.frontend && areas.backend) {
112+
labelsToAdd.add('area/fullstack');
113+
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
114+
console.log(` 📁 Area: fullstack (${files.length} files)`);
115+
} else if (areas.frontend) {
116+
labelsToAdd.add('area/frontend');
117+
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
118+
console.log(` 📁 Area: frontend (${files.length} files)`);
119+
} else if (areas.backend) {
120+
labelsToAdd.add('area/backend');
121+
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
122+
console.log(` 📁 Area: backend (${files.length} files)`);
123+
} else if (areas.ci) {
124+
labelsToAdd.add('area/ci');
125+
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
126+
console.log(` 📁 Area: ci (${files.length} files)`);
127+
}
128+
129+
// ═══════════════════════════════════════════════════════════════
130+
// SIZE LABELS (from lines changed)
131+
// ═══════════════════════════════════════════════════════════════
132+
const additions = pr.additions || 0;
133+
const deletions = pr.deletions || 0;
134+
const totalLines = additions + deletions;
135+
136+
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
137+
let sizeLabel;
138+
139+
if (totalLines < 10) sizeLabel = 'size/XS';
140+
else if (totalLines < 100) sizeLabel = 'size/S';
141+
else if (totalLines < 500) sizeLabel = 'size/M';
142+
else if (totalLines < 1000) sizeLabel = 'size/L';
143+
else sizeLabel = 'size/XL';
144+
145+
labelsToAdd.add(sizeLabel);
146+
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
147+
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
148+
149+
console.log('::endgroup::');
150+
151+
// ═══════════════════════════════════════════════════════════════
152+
// APPLY LABELS
153+
// ═══════════════════════════════════════════════════════════════
154+
console.log(`::group::Applying labels`);
155+
156+
// Remove old labels (in parallel)
157+
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
158+
if (removeArray.length > 0) {
159+
const removePromises = removeArray.map(async (label) => {
160+
try {
161+
await github.rest.issues.removeLabel({
162+
owner,
163+
repo,
164+
issue_number: prNumber,
165+
name: label
166+
});
167+
console.log(` ✓ Removed: ${label}`);
168+
} catch (e) {
169+
if (e.status !== 404) {
170+
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
171+
}
172+
}
173+
});
174+
await Promise.all(removePromises);
175+
}
176+
177+
// Add new labels
178+
const addArray = [...labelsToAdd];
179+
if (addArray.length > 0) {
180+
try {
181+
await github.rest.issues.addLabels({
182+
owner,
183+
repo,
184+
issue_number: prNumber,
185+
labels: addArray
186+
});
187+
console.log(` ✓ Added: ${addArray.join(', ')}`);
188+
} catch (e) {
189+
// Some labels might not exist
190+
if (e.status === 404) {
191+
core.warning(`Some labels do not exist. Please create them in repository settings.`);
192+
// Try adding one by one
193+
for (const label of addArray) {
194+
try {
195+
await github.rest.issues.addLabels({
196+
owner,
197+
repo,
198+
issue_number: prNumber,
199+
labels: [label]
200+
});
201+
} catch (e2) {
202+
console.log(` ⚠ Label '${label}' does not exist`);
203+
}
204+
}
205+
} else {
206+
throw e;
207+
}
208+
}
209+
}
210+
211+
console.log('::endgroup::');
212+
213+
// Summary
214+
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
215+
216+
// Write job summary
217+
core.summary
218+
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
219+
.addTable([
220+
[{data: 'Category', header: true}, {data: 'Label', header: true}],
221+
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
222+
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
223+
['Size', sizeLabel]
224+
])
225+
.addRaw(`\n**Files changed:** ${files.length}\n`)
226+
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
227+
await core.summary.write();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: PR Status Check
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
# Cancel in-progress runs for the same PR
8+
concurrency:
9+
group: pr-status-${{ github.event.pull_request.number }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
pull-requests: write
14+
15+
jobs:
16+
mark-checking:
17+
name: Set Checking Status
18+
runs-on: ubuntu-latest
19+
# Don't run on fork PRs (they can't write labels)
20+
if: github.event.pull_request.head.repo.full_name == github.repository
21+
timeout-minutes: 5
22+
steps:
23+
- name: Update PR status label
24+
uses: actions/github-script@v7
25+
with:
26+
retries: 3
27+
retry-exempt-status-codes: 400,401,403,404,422
28+
script: |
29+
const { owner, repo } = context.repo;
30+
const prNumber = context.payload.pull_request.number;
31+
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
32+
33+
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
34+
35+
// Remove old status labels (parallel for speed)
36+
const removePromises = statusLabels.map(async (label) => {
37+
try {
38+
await github.rest.issues.removeLabel({
39+
owner,
40+
repo,
41+
issue_number: prNumber,
42+
name: label
43+
});
44+
console.log(` ✓ Removed: ${label}`);
45+
} catch (e) {
46+
if (e.status !== 404) {
47+
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
48+
}
49+
}
50+
});
51+
52+
await Promise.all(removePromises);
53+
54+
// Add checking label
55+
try {
56+
await github.rest.issues.addLabels({
57+
owner,
58+
repo,
59+
issue_number: prNumber,
60+
labels: ['🔄 Checking']
61+
});
62+
console.log(` ✓ Added: 🔄 Checking`);
63+
} catch (e) {
64+
// Label might not exist - create helpful error
65+
if (e.status === 404) {
66+
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
67+
}
68+
throw e;
69+
}
70+
71+
console.log('::endgroup::');
72+
console.log(`✅ PR #${prNumber} marked as checking`);

0 commit comments

Comments
 (0)