ktworkflow-mcp/src/tools/napcatAutomation.ts

359 lines
12 KiB
TypeScript

import type {
NapcatAuditClassificationInput,
NapcatAuditClassificationResult,
NapcatAuditCommand,
NapcatAutomationReasonCode,
NapcatUpstreamAuditResult,
} from './napcatAutomation.types.js';
export const napcatUpstreamHotZonePatterns = [
'packages/napcat-core/**login*',
'packages/napcat-core/**qrcode*',
'packages/napcat-shell/**',
'packages/napcat-framework/**',
'packages/napcat-webui-backend/**QQLogin*',
'packages/napcat-webui-backend/**Data*',
'packages/napcat-webui-backend/**auth*',
'packages/napcat-webui-frontend/**',
'packages/napcat-adapter/**',
'packages/napcat-onebot/**',
'packages/napcat-vite/**',
'package.json',
'pnpm-lock.yaml',
'tsconfig*.json',
'vite*.ts',
] as const;
const hotZoneMatchers: Array<{ pattern: string; regex: RegExp }> = [
{
pattern: 'packages/napcat-core/**login*',
regex: /^packages\/napcat-core\/.*login.*$/i,
},
{
pattern: 'packages/napcat-core/**qrcode*',
regex: /^packages\/napcat-core\/.*qrcode.*$/i,
},
{
pattern: 'packages/napcat-shell/**',
regex: /^packages\/napcat-shell(?:\/|$)/i,
},
{
pattern: 'packages/napcat-framework/**',
regex: /^packages\/napcat-framework(?:\/|$)/i,
},
{
pattern: 'packages/napcat-webui-backend/**QQLogin*',
regex: /^packages\/napcat-webui-backend\/.*qqlogin.*$/i,
},
{
pattern: 'packages/napcat-webui-backend/**Data*',
regex: /^packages\/napcat-webui-backend\/.*data.*$/i,
},
{
pattern: 'packages/napcat-webui-backend/**auth*',
regex: /^packages\/napcat-webui-backend\/.*auth.*$/i,
},
{
pattern: 'packages/napcat-webui-frontend/**',
regex: /^packages\/napcat-webui-frontend(?:\/|$)/i,
},
{
pattern: 'packages/napcat-adapter/**',
regex: /^packages\/napcat-adapter(?:\/|$)/i,
},
{
pattern: 'packages/napcat-onebot/**',
regex: /^packages\/napcat-onebot(?:\/|$)/i,
},
{
pattern: 'packages/napcat-vite/**',
regex: /^packages\/napcat-vite(?:\/|$)/i,
},
{ pattern: 'package.json', regex: /^package\.json$/i },
{ pattern: 'pnpm-lock.yaml', regex: /^pnpm-lock\.yaml$/i },
{ pattern: 'tsconfig*.json', regex: /(?:^|\/)tsconfig[^/]*\.json$/i },
{ pattern: 'vite*.ts', regex: /(?:^|\/)vite[^/]*\.ts$/i },
];
/**
* Checks whether Git audit metadata contains a repo-relative path that the classifier can trust.
* @param value - Raw path string from fork/upstream Git diff collectors; empty entries are allowed because collectors may emit blank lines.
* @returns True when a non-empty path is absolute, UNC-based, or contains a parent-directory segment.
*/
function hasInvalidAuditPath(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (/^[a-zA-Z]:/.test(trimmed)) return true;
if (trimmed.startsWith('\\\\') || trimmed.startsWith('//')) return true;
if (trimmed.startsWith('\\') || trimmed.startsWith('/')) return true;
const normalized = trimmed
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/+/g, '/');
return normalized.split('/').includes('..');
}
/**
* Checks both file lists for invalid non repo-relative metadata.
* @param input - Collector result carrying fork patch and upstream changed file paths; both arrays must contain repo-relative Git paths when non-empty.
* @returns True when any path entry would make deterministic hot-zone and overlap classification untrustworthy.
*/
function hasInvalidAuditMetadata(
input: NapcatAuditClassificationInput,
): boolean {
for (const file of input.forkPatchFiles) {
if (hasInvalidAuditPath(file)) {
return true;
}
}
for (const file of input.upstreamChangedFiles) {
if (hasInvalidAuditPath(file)) {
return true;
}
}
return false;
}
/**
* Normalizes Git path output into a stable comparison key for audit classifiers.
* @param value - File path from Git diff/status collectors; may contain Windows separators, leading `./`, or surrounding whitespace, but must represent a repo-relative path.
* @returns A lowercase POSIX-style path key used only for deterministic classification.
*/
function normalizeAuditPath(value: string): string {
return value
.trim()
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/+/g, '/')
.toLowerCase();
}
/**
* Normalizes and de-duplicates path lists while preserving first-seen order.
* @param files - File paths reported by fork patch and upstream release diff collectors; empty or whitespace-only entries are ignored.
* @returns Stable repo-relative path keys for hot-zone and overlap checks.
*/
function normalizeAuditPathList(files: string[]): string[] {
const normalized: string[] = [];
const seen = new Set<string>();
for (const file of files) {
const pathKey = normalizeAuditPath(file);
if (!pathKey || seen.has(pathKey)) continue;
seen.add(pathKey);
normalized.push(pathKey);
}
return normalized;
}
/**
* Checks whether a normalized path falls inside the NapCat upstream manual-review hot zones.
* @param pathKey - Lowercase POSIX path from `normalizeAuditPathList`; must be repo-relative and not an absolute filesystem path.
* @returns True when the path is covered by one of the fixed hot-zone patterns from the release plan.
*/
function isNapcatHotZonePath(pathKey: string): boolean {
for (const matcher of hotZoneMatchers) {
if (matcher.regex.test(pathKey)) {
return true;
}
}
return false;
}
/**
* Collects upstream hot-zone file hits in deterministic first-seen order.
* @param upstreamChangedFiles - Upstream release diff files from the last accepted base to the candidate release; values must be repo-relative Git paths.
* @returns The subset of upstream paths that require manual human review.
*/
function collectHotZoneFiles(upstreamChangedFiles: string[]): string[] {
const hotZoneFiles: string[] = [];
for (const pathKey of normalizeAuditPathList(upstreamChangedFiles)) {
if (isNapcatHotZonePath(pathKey)) {
hotZoneFiles.push(pathKey);
}
}
return hotZoneFiles;
}
/**
* Collects exact file overlaps between KT fork patches and upstream release changes.
* @param forkPatchFiles - Fork-only patch files collected from the KT fork comparison range; values must be repo-relative Git paths.
* @param upstreamChangedFiles - Upstream release diff files collected from the upstream comparison range; values must be repo-relative Git paths.
* @returns Stable exact path overlaps that require human review before candidate creation.
*/
function collectOverlapFiles(
forkPatchFiles: string[],
upstreamChangedFiles: string[],
): string[] {
const upstreamChangedPathSet = new Set(
normalizeAuditPathList(upstreamChangedFiles),
);
const overlapFiles: string[] = [];
for (const pathKey of normalizeAuditPathList(forkPatchFiles)) {
if (upstreamChangedPathSet.has(pathKey)) {
overlapFiles.push(pathKey);
}
}
return overlapFiles;
}
/**
* Builds the public classification summary without leaking long file lists into callers.
* @param reasonCodes - Reason codes selected by the classifier; blocked and manual-review states carry operational meaning for the next pipeline gate.
* @param hotZoneCount - Number of upstream files matching fixed NapCat hot-zone patterns.
* @param overlapCount - Number of exact files changed by both the KT fork patch range and upstream release range.
* @returns A short deterministic summary suitable for MCP responses and JSON artifacts.
*/
function buildClassificationSummary(
reasonCodes: NapcatAutomationReasonCode[],
hotZoneCount: number,
overlapCount: number,
): string {
if (reasonCodes.includes('DRY_MERGE_CONFLICT')) {
return 'Dry merge conflict detected; block automation until a human resolves the upstream sync.';
}
if (
reasonCodes.includes('HOT_ZONE_CHANGED') ||
reasonCodes.includes('FORK_PATCH_OVERLAP')
) {
return `Manual review required: hotZoneFiles=${hotZoneCount}, forkPatchOverlaps=${overlapCount}.`;
}
return 'Safe candidate: no dry-merge conflict, hot-zone change, or fork patch overlap was detected.';
}
/**
* Classifies a NapCat upstream sync audit from deterministic collector metadata.
* @param input - Collector result from dry-merge probing plus fork/upstream Git diff file lists; file arrays must contain repo-relative paths from the NapCatQQ fork.
* @returns Classification, reason codes, recommended action, and summary for the next release-pipeline gate.
*/
export function classifyNapcatUpstreamAudit(
input: NapcatAuditClassificationInput,
): NapcatAuditClassificationResult {
if (hasInvalidAuditMetadata(input)) {
return {
classification: 'blocked',
reasonCodes: ['METADATA_UNAVAILABLE'],
recommendedAction: 'block',
summary:
'Invalid audit metadata: fork and upstream file paths must be repo-relative Git paths without absolute roots, UNC prefixes, or parent-directory segments.',
};
}
if (input.dryMergeConflict) {
return {
classification: 'blocked',
reasonCodes: ['DRY_MERGE_CONFLICT'],
recommendedAction: 'block',
summary: buildClassificationSummary(['DRY_MERGE_CONFLICT'], 0, 0),
};
}
const hotZoneFiles = collectHotZoneFiles(input.upstreamChangedFiles);
const overlapFiles = collectOverlapFiles(
input.forkPatchFiles,
input.upstreamChangedFiles,
);
const reasonCodes: NapcatAutomationReasonCode[] = [];
if (hotZoneFiles.length > 0) {
reasonCodes.push('HOT_ZONE_CHANGED');
}
if (overlapFiles.length > 0) {
reasonCodes.push('FORK_PATCH_OVERLAP');
}
if (reasonCodes.length > 0) {
return {
classification: 'manual-review',
reasonCodes,
recommendedAction: 'request-human-review',
summary: buildClassificationSummary(
reasonCodes,
hotZoneFiles.length,
overlapFiles.length,
),
};
}
return {
classification: 'safe-candidate',
reasonCodes: ['NO_UPSTREAM_CHANGE'],
recommendedAction: 'create-candidate-branch',
summary: buildClassificationSummary(['NO_UPSTREAM_CHANGE'], 0, 0),
};
}
/**
* Builds a read-only upstream audit plan for NapCatQQ release sync automation.
* @param input - Same deterministic classifier input supplied by the dry-run collector; values are embedded only in the returned classification, never executed.
* @returns Dry-run commands and artifact paths for a future collector, with no Git merge/push, Docker, Kubernetes, or release mutations.
*/
export function buildNapcatUpstreamAudit(
input: NapcatAuditClassificationInput,
): NapcatUpstreamAuditResult {
const classification = classifyNapcatUpstreamAudit(input);
const commands: NapcatAuditCommand[] = [
{
command: 'git status --short --branch',
description: 'Confirm the fork worktree state before reading audit ranges.',
readOnly: true,
},
{
command: 'git fetch --dry-run upstream --tags --prune',
description:
'Probe upstream release ref availability without updating local refs.',
readOnly: true,
},
{
command: 'git fetch --dry-run origin --prune',
description:
'Probe fork ref availability without updating remote-tracking refs.',
readOnly: true,
},
{
command:
'git diff --name-only "${LAST_ACCEPTED_UPSTREAM_BASE:-origin/main}..${UPSTREAM_RELEASE_REF:-upstream/main}"',
description: 'List upstream release files changed since the last accepted base.',
readOnly: true,
},
{
command:
'git diff --name-only "${LAST_ACCEPTED_UPSTREAM_BASE:-origin/main}..HEAD"',
description: 'List KT fork patch files for deterministic overlap checks.',
readOnly: true,
},
{
command:
'git merge-tree "${FORK_BRANCH:-HEAD}" "${UPSTREAM_RELEASE_REF:-upstream/main}"',
description: 'Probe the dry merge shape without updating the worktree or refs.',
readOnly: true,
},
];
return {
...classification,
artifacts: [
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/context.md',
type: 'markdown',
},
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/classification.json',
type: 'json',
},
{
path: '.kt-workspace/test-artifacts/napcat-upstream-audit/files.json',
type: 'json',
},
],
commands,
execute: false,
hotZonePatterns: [...napcatUpstreamHotZonePatterns],
};
}