Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
00ed5d6
🐛 fix(qa): validate ICU patterns on locked segments at project creation
Ostico Apr 20, 2026
49ce566
refactor(qa): share ICU source detection logic across LQA flows
Copilot Apr 20, 2026
d283bed
docs(qa): add full phpdoc for shared ICU detector
Copilot Apr 20, 2026
ef79382
🔧 chore(phpstan): regenerate baseline for new @throws rules
Ostico Apr 20, 2026
8fe8839
📝 docs: add PR readiness checklist template and GitHub Actions validator
Ostico Apr 20, 2026
d513920
📝 docs(pr-template): simplify AI usage disclosure instructions
Ostico Apr 20, 2026
f9823a1
🔧 chore(jest): exclude .github/ from Jest test discovery
Ostico Apr 20, 2026
36d6e87
🔧 chore(phpstan): exclude APIDoc build artifacts from analysis
Ostico Apr 20, 2026
41e46fc
🔧 chore(phpstan): mark APIDoc.php exclude path as optional
Ostico Apr 20, 2026
78c6b89
Update .github/workflows/pr-readiness-check.yml
Ostico Apr 20, 2026
cd03503
👷 ci: add conventional-commit message enforcement workflow
Ostico Apr 20, 2026
b67d105
🐛 fix(ci): relax commit message check for Copilot and GitHub web edits
Ostico Apr 20, 2026
54ea153
Merge branch 'fix-icu-check-not-performed-on-blocked-segments' into c…
Ostico Apr 20, 2026
37931e4
Merge branch 'develop' into ci/pr-quality-gates
Ostico Apr 21, 2026
99a2be3
Merge remote-tracking branch 'origin/develop' into ci/pr-quality-gates
Ostico Apr 21, 2026
8c40af6
🔧 chore(phpstan): add @throws \Throwable to FastAnalysis transaction …
Ostico Apr 21, 2026
9f8cde0
👷 ci(test-guard): add test adequacy gate workflow
Ostico Apr 21, 2026
aaef9ff
👷 ci(test-guard): integrate test-guard into CI/CD pipeline
Ostico Apr 21, 2026
cb738f3
👷 ci(test-guard): bump ostico/test-guard to v1.0.3
Ostico Apr 21, 2026
1025eac
👷 ci(test-guard): bump ostico/test-guard to v1.0.5
Ostico Apr 21, 2026
7e832f5
👷 ci: add models:read permission to _ci-cd.yml
Ostico Apr 21, 2026
d749bd8
🐛 fix(ci): add models:read permission for test-guard AI analysis
Ostico Apr 21, 2026
fc04829
👷 ci(test-guard): switch to floating tag ostico/test-guard@v1
Ostico Apr 21, 2026
e24019a
👷 ci(permissions): replace statuses:write with checks:write in workflows
Ostico Apr 22, 2026
82e553d
Merge branch 'develop' into ci/pr-quality-gates
Ostico Apr 23, 2026
99c9db4
👷 ci(coverage): add JS coverage report to test-guard pipeline
Ostico Apr 23, 2026
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
66 changes: 66 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Summary

<!-- What does this PR do and why? Keep it brief. -->

## Type

<!-- Check one. -->

- [ ] `feat` — new user-facing feature
- [ ] `fix` — bug fix
- [ ] `refactor` — restructure without behavior change
- [ ] `chore` — build, deps, config, docs
- [ ] `perf` — performance improvement
- [ ] `test` — test coverage

## Changes

<!-- Key changes — what was modified and how.
One line per file or logical group. -->

| File | Change |
|------|--------|
| | |

## Migration Notes

<!-- Fill this section only when database migrations are involved.
Delete this section entirely if no migrations are needed.

All migrations MUST be placed in the migrations/ directory following
the naming convention: YYYYMMDDHHMMSS_description.php
extending AbstractMatecatMigration. -->

- [ ] Migration file added in `migrations/` directory
- [ ] Backward-compatible with current production schema
- [ ] NOT backward-compatible — breaking changes documented in Notes section
- [ ] Tested on a fresh database and on an existing one

## Testing

<!-- How did you verify this works? Check all that apply. -->

- [ ] `vendor/bin/phpunit --exclude-group=ExternalServices --no-coverage` passes
- [ ] `./vendor/bin/phpstan` passes (0 errors, with baseline)
- [ ] Manual testing performed (describe below)
- [ ] New tests added for changed behavior
- [ ] Regression tests added for bug fixes

<!-- Paste relevant test output or describe manual testing steps. -->

## AI Disclosure

<!-- If AI tools were used to write or review code in this PR, disclose it.
AI-assisted code is welcome, but you must understand everything you ship.
Unreviewed AI output will not be merged. -->

- [ ] No AI tools were used in this PR
- [ ] AI tools were used — details below

<!-- If AI was used, just name the agent/tool.
Example: "Claude Code (claude-opus-4-6)" -->

## Notes

<!-- Anything reviewers should know — breaking changes, follow-up work,
deployment steps, or open questions. Leave blank if none. -->
160 changes: 160 additions & 0 deletions .github/scripts/commit-message-check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use strict';

// ── Canonical emoji ↔ type map (from conventional-commit.prompt.md) ──

const EMOJI_TYPE_MAP = {
'\u{1F3D7}\uFE0F': 'build', // 🏗️
'\u{1F527}': 'chore', // 🔧
'\u{1F477}': 'ci', // 👷
'\u{1F4DD}': 'docs', // 📝
'\u2728': 'feat', // ✨
'\u{1F41B}': 'fix', // 🐛
'\u26A1\uFE0F': 'perf', // ⚡️
'\u267B\uFE0F': 'refactor', // ♻️
'\u23EA\uFE0F': 'revert', // ⏪️
'\u{1F484}': 'style', // 💄
'\u2705': 'test', // ✅
'\u{1F310}': 'i18n', // 🌐
};

const TYPE_EMOJI_MAP = Object.fromEntries(
Object.entries(EMOJI_TYPE_MAP).map(([emoji, type]) => [type, emoji]),
);

const VALID_TYPES = Object.values(EMOJI_TYPE_MAP);

const MAX_SUBJECT_LENGTH = 100;

// ── Helpers ──────────────────────────────────────────────────────────

/**
* Strip Unicode variation selector 16 (U+FE0F) for comparison.
* Some editors/terminals include it, others don't.
*/
function stripVS16(str) {
return str.replace(/\uFE0F/g, '');
}

/**
* Find the conventional type for a given emoji.
* Matches with or without the trailing VS16 character.
*/
function findTypeForEmoji(emoji) {
const normalized = stripVS16(emoji);
for (const [key, type] of Object.entries(EMOJI_TYPE_MAP)) {
if (stripVS16(key) === normalized) {
return type;
}
}
return null;
}

function isAutoGenerated(subject) {
return /^Merge /.test(subject);
}

function isGitHubWebEdit(subject) {
return /^(Update|Create|Delete|Rename) .+/.test(subject);
}
Comment on lines +56 to +58
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isGitHubWebEdit() is used to skip validation in relaxed mode, but the current pattern (/^(Update|Create|Delete|Rename) .+/) will also skip non-auto-generated subjects that happen to start with those verbs (e.g. "Update docs"), weakening the hygiene gate. Tighten the detection to match GitHub’s default web editor messages more precisely (e.g. require a file/path-like token, or match the exact templates GitHub uses).

Copilot uses AI. Check for mistakes.

// ── Core validation ──────────────────────────────────────────────────

function validateCommitMessage(message, {requireEmoji = true} = {}) {
const errors = [];

if (!message || typeof message !== 'string') {
return {valid: false, errors: ['Empty or invalid commit message'], skipped: false};
}

const subject = message.split('\n')[0].trim();

if (!subject) {
return {valid: false, errors: ['Empty subject line'], skipped: false};
}

if (isAutoGenerated(subject)) {
return {valid: true, errors: [], skipped: true};
}

if (!requireEmoji && isGitHubWebEdit(subject)) {
return {valid: true, errors: [], skipped: true};
}
Comment thread
Ostico marked this conversation as resolved.

if (subject.length > MAX_SUBJECT_LENGTH) {
errors.push(
`Subject exceeds ${MAX_SUBJECT_LENGTH} characters (${subject.length})`,
);
}

const firstSpace = subject.indexOf(' ');
if (firstSpace === -1) {
errors.push(
requireEmoji
? 'Subject must start with an emoji followed by a space'
: 'Subject must contain a space',
);
return {valid: false, errors, skipped: false};
}

const firstToken = subject.slice(0, firstSpace);
const rest = subject.slice(firstSpace + 1);

const emojiType = findTypeForEmoji(firstToken);
const hasEmoji = emojiType !== null;

if (requireEmoji && !hasEmoji) {
const validList = Object.entries(EMOJI_TYPE_MAP)
.map(([e, t]) => `${e} ${t}`)
.join(', ');
errors.push(`Unknown emoji "${firstToken}". Valid: ${validList}`);
}

const textToParse = hasEmoji ? rest : subject;
const formatMatch = textToParse.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
if (!formatMatch) {
errors.push(
hasEmoji
? 'Format after emoji must be: <type>[(<scope>)][!]: <description>'
: 'Format must be: <type>[(<scope>)][!]: <description>',
);
return {valid: false, errors, skipped: false};
}

const [, type, , , description] = formatMatch;

if (!VALID_TYPES.includes(type)) {
errors.push(
`Invalid type "${type}". Valid: ${VALID_TYPES.join(', ')}`,
);
}

if (hasEmoji && emojiType && VALID_TYPES.includes(type) && emojiType !== type) {
const correctEmoji = TYPE_EMOJI_MAP[type] || '?';
errors.push(
`Emoji ${firstToken} is for "${emojiType}", not "${type}". ` +
`Use ${correctEmoji} for "${type}"`,
);
}

if (description && /^[A-Z]/.test(description)) {
errors.push('Description must start with a lowercase letter');
}

if (description && description.endsWith('.')) {
errors.push('Description must not end with a period');
}

return {valid: errors.length === 0, errors, skipped: false};
}

module.exports = {
EMOJI_TYPE_MAP,
TYPE_EMOJI_MAP,
VALID_TYPES,
MAX_SUBJECT_LENGTH,
validateCommitMessage,
findTypeForEmoji,
isAutoGenerated,
isGitHubWebEdit,
stripVS16,
};
Loading
Loading