Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ib25c8bc022433f1dff87e9f6aeff4a726a6a6964
265 lines
7.5 KiB
TypeScript
265 lines
7.5 KiB
TypeScript
import type {
|
|
AnalysisResult,
|
|
EngineBackend,
|
|
QualityBackendConfig,
|
|
WebhookEvent,
|
|
} from '../types.js';
|
|
|
|
// Conventional commit prefixes
|
|
const CONVENTIONAL_COMMIT =
|
|
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s/i;
|
|
|
|
const WIP_PATTERN = /\b(wip|work.in.progress|do.not.merge|don't.merge|draft)\b/i;
|
|
const BREAKING_PATTERN = /\b(breaking.change|BREAKING)\b/i;
|
|
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP)\b/;
|
|
|
|
export class QualityBackend implements EngineBackend {
|
|
name = 'quality';
|
|
|
|
constructor(private config: QualityBackendConfig) {}
|
|
|
|
async analyze(event: WebhookEvent): Promise<AnalysisResult> {
|
|
const body = event.body.trim();
|
|
const title = event.title.trim();
|
|
const signals: { name: string; positive: boolean; weight: number }[] = [];
|
|
|
|
// --- Title analysis ---
|
|
|
|
if (title.length < 10) {
|
|
signals.push({ name: 'very short title', positive: false, weight: 1.2 });
|
|
} else if (title.length > 200) {
|
|
signals.push({
|
|
name: 'excessively long title',
|
|
positive: false,
|
|
weight: 0.5,
|
|
});
|
|
}
|
|
|
|
if (CONVENTIONAL_COMMIT.test(title)) {
|
|
signals.push({
|
|
name: 'conventional commit format',
|
|
positive: true,
|
|
weight: 1,
|
|
});
|
|
}
|
|
|
|
if (WIP_PATTERN.test(title) || WIP_PATTERN.test(body)) {
|
|
signals.push({
|
|
name: 'marked as work-in-progress',
|
|
positive: false,
|
|
weight: 1.5,
|
|
});
|
|
}
|
|
|
|
// --- Body analysis ---
|
|
|
|
if (body.length === 0) {
|
|
signals.push({ name: 'empty description', positive: false, weight: 2 });
|
|
} else if (body.length < this.config.minBodyLength) {
|
|
signals.push({
|
|
name: `short description (${body.length} chars)`,
|
|
positive: false,
|
|
weight: 1.2,
|
|
});
|
|
} else if (body.length >= this.config.minBodyLength) {
|
|
signals.push({ name: 'adequate description', positive: true, weight: 1 });
|
|
if (body.length > 300) {
|
|
signals.push({
|
|
name: 'thorough description',
|
|
positive: true,
|
|
weight: 0.5,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (/```[\s\S]*?```/.test(body)) {
|
|
signals.push({ name: 'has code blocks', positive: true, weight: 0.7 });
|
|
}
|
|
|
|
if (/^#{1,6}\s/m.test(body)) {
|
|
signals.push({
|
|
name: 'has section headers',
|
|
positive: true,
|
|
weight: 0.8,
|
|
});
|
|
}
|
|
|
|
// Checklists
|
|
const checklistItems = body.match(/^[\s]*-\s*\[[ x]\]/gm);
|
|
if (checklistItems) {
|
|
const checked = checklistItems.filter((i) => /\[x\]/i.test(i)).length;
|
|
const total = checklistItems.length;
|
|
if (total > 0 && checked === total) {
|
|
signals.push({
|
|
name: `checklist complete (${total}/${total})`,
|
|
positive: true,
|
|
weight: 1,
|
|
});
|
|
} else if (total > 0) {
|
|
signals.push({
|
|
name: `checklist incomplete (${checked}/${total})`,
|
|
positive: false,
|
|
weight: 0.8,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Breaking changes
|
|
if (BREAKING_PATTERN.test(title) || BREAKING_PATTERN.test(body)) {
|
|
// Not inherently positive or negative, but we flag it for visibility.
|
|
// If there's a description of the breaking change, it's better.
|
|
if (body.length > 100 && BREAKING_PATTERN.test(body)) {
|
|
signals.push({
|
|
name: 'breaking change documented',
|
|
positive: true,
|
|
weight: 0.8,
|
|
});
|
|
} else {
|
|
signals.push({
|
|
name: 'breaking change mentioned but not detailed',
|
|
positive: false,
|
|
weight: 0.8,
|
|
});
|
|
}
|
|
}
|
|
|
|
// TODOs/FIXMEs in description suggest unfinished work
|
|
const todoMatches = body.match(TODO_PATTERN);
|
|
if (todoMatches) {
|
|
signals.push({
|
|
name: `unfinished markers in description (${todoMatches.length})`,
|
|
positive: false,
|
|
weight: 0.6,
|
|
});
|
|
}
|
|
|
|
// --- Type-specific signals ---
|
|
|
|
if (event.type === 'issue') {
|
|
if (/\b(steps?\s+to\s+reproduce|reproduction|repro\s+steps?)\b/i.test(body)) {
|
|
signals.push({
|
|
name: 'has reproduction steps',
|
|
positive: true,
|
|
weight: 1.3,
|
|
});
|
|
}
|
|
|
|
if (/\b(expected|actual)\s+(behavior|behaviour|result|output)\b/i.test(body)) {
|
|
signals.push({
|
|
name: 'has expected/actual behavior',
|
|
positive: true,
|
|
weight: 1.2,
|
|
});
|
|
}
|
|
|
|
if (
|
|
/\b(version|environment|os|platform|browser|node|python|java|rust|go)\s*[:\d]/i.test(body)
|
|
) {
|
|
signals.push({
|
|
name: 'has environment details',
|
|
positive: true,
|
|
weight: 1,
|
|
});
|
|
}
|
|
|
|
if (/\b(stack\s*trace|traceback|error|exception|panic)\b/i.test(body)) {
|
|
signals.push({
|
|
name: 'includes error output',
|
|
positive: true,
|
|
weight: 0.8,
|
|
});
|
|
}
|
|
|
|
// Template usage detection (common issue template markers)
|
|
if (/\b(describe the bug|feature request|is your feature request related to)\b/i.test(body)) {
|
|
signals.push({
|
|
name: 'uses issue template',
|
|
positive: true,
|
|
weight: 0.6,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (event.type === 'pull_request') {
|
|
if (/\b(fix(es)?|clos(es|ing)|resolv(es|ing))\s+#\d+/i.test(body)) {
|
|
signals.push({ name: 'links to issue', positive: true, weight: 1.3 });
|
|
}
|
|
|
|
if (/\b(test\s*(plan|strategy|coverage)|how\s+to\s+test|testing|tested\s+by)\b/i.test(body)) {
|
|
signals.push({ name: 'has test plan', positive: true, weight: 1.2 });
|
|
}
|
|
|
|
// Migration or upgrade guide
|
|
if (/\b(migration|upgrade|breaking).*(guide|instruction|step)/i.test(body)) {
|
|
signals.push({
|
|
name: 'has migration guide',
|
|
positive: true,
|
|
weight: 1,
|
|
});
|
|
}
|
|
|
|
// Before/after comparison
|
|
if (/\b(before|after)\b/i.test(body) && /\b(before|after)\b/gi.test(body)) {
|
|
const beforeAfter = body.match(/\b(before|after)\b/gi);
|
|
if (beforeAfter && beforeAfter.length >= 2) {
|
|
signals.push({
|
|
name: 'has before/after comparison',
|
|
positive: true,
|
|
weight: 0.7,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shared: references to other issues/PRs
|
|
const refs = body.match(/#\d+/g);
|
|
if (refs && refs.length > 0) {
|
|
signals.push({
|
|
name: `references ${refs.length} issue(s)/PR(s)`,
|
|
positive: true,
|
|
weight: 0.6,
|
|
});
|
|
}
|
|
|
|
// Screenshots or images
|
|
if (/!\[.*\]\(.*\)/.test(body) || /<img\s/i.test(body)) {
|
|
signals.push({
|
|
name: 'has images/screenshots',
|
|
positive: true,
|
|
weight: 0.8,
|
|
});
|
|
}
|
|
|
|
// --- Weighted scoring ---
|
|
|
|
if (signals.length === 0) {
|
|
return {
|
|
impact: 'neutral',
|
|
confidence: 0.1,
|
|
reasoning: 'No quality signals detected.',
|
|
};
|
|
}
|
|
|
|
const positiveWeight = signals.filter((s) => s.positive).reduce((s, x) => s + x.weight, 0);
|
|
const negativeWeight = signals.filter((s) => !s.positive).reduce((s, x) => s + x.weight, 0);
|
|
|
|
let impact: AnalysisResult['impact'];
|
|
if (positiveWeight > negativeWeight * 1.2) {
|
|
impact = 'positive';
|
|
} else if (negativeWeight > positiveWeight * 1.2) {
|
|
impact = 'negative';
|
|
} else {
|
|
impact = 'neutral';
|
|
}
|
|
|
|
const totalWeight = positiveWeight + negativeWeight;
|
|
const confidence = Math.min(
|
|
1,
|
|
(Math.abs(positiveWeight - negativeWeight) / Math.max(totalWeight, 1)) * 0.5 + 0.2
|
|
);
|
|
|
|
const reasoning = `Quality: ${signals.map((s) => `${s.positive ? '+' : '-'} ${s.name}`).join(', ')}.`;
|
|
|
|
return { impact, confidence, reasoning };
|
|
}
|
|
}
|