diff --git a/plugins/magpie-release-management/.claude-plugin/plugin.json b/plugins/magpie-release-management/.claude-plugin/plugin.json index 38334e9e8..c30ef9552 100644 --- a/plugins/magpie-release-management/.claude-plugin/plugin.json +++ b/plugins/magpie-release-management/.claude-plugin/plugin.json @@ -9,5 +9,6 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", + "types": "./hooks/types.d.ts", "skills": "./skills" } diff --git a/plugins/magpie-release-management/.codex-plugin/plugin.json b/plugins/magpie-release-management/.codex-plugin/plugin.json new file mode 100644 index 000000000..7685413b3 --- /dev/null +++ b/plugins/magpie-release-management/.codex-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "magpie-release-management", + "description": "Apache Magpie \u2014 ASF release lifecycle: plan, RC cut/sign, vote, tally, promote, announce, archive, audit.", + "version": "0.9.0.dev202611041445", + "author": { + "name": "Apache Magpie", + "url": "https://magpie.apache.org/" + }, + "homepage": "https://magpie.apache.org/", + "repository": "https://github.com/apache/magpie", + "license": "Apache-2.0", + "skills": "./skills", + "hooks": "./hooks/codex-hooks.json" +} diff --git a/plugins/magpie-release-management/hooks/codex-hooks.json b/plugins/magpie-release-management/hooks/codex-hooks.json new file mode 100644 index 000000000..b0fac4109 --- /dev/null +++ b/plugins/magpie-release-management/hooks/codex-hooks.json @@ -0,0 +1,4 @@ +{ + "description": "Apache Magpie \u2014 no Codex hooks. hooks.json holds Claude Code mods only.", + "hooks": {} +} diff --git a/plugins/magpie-release-management/hooks/hooks.json b/plugins/magpie-release-management/hooks/hooks.json new file mode 100644 index 000000000..45c17b870 --- /dev/null +++ b/plugins/magpie-release-management/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://code.claude.com/schemas/hooks.json", + "description": "Apache Magpie — Claude Code mod for live verification checklist in verify-rc.", + "modules": [ + "./verify-rc-pane.ts" + ] +} diff --git a/plugins/magpie-release-management/hooks/types.d.ts b/plugins/magpie-release-management/hooks/types.d.ts new file mode 100644 index 000000000..334e170b1 --- /dev/null +++ b/plugins/magpie-release-management/hooks/types.d.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Self-contained ambient declaration required by strict plugin validation. +// Companion TypeScript runtime definitions live in types.ts. Both type declarations +// must be kept in sync when modifying the state shape. + +declare module 'claude-code' { + interface PluginState { + 'magpie-release-management': { + verifyRcState: { + rcTag: string; + version: string; + stagingUrl?: string | null; + postTo?: string | null; + overallStatus: 'running' | 'passed' | 'passed-with-warnings' | 'failed'; + currentStepId?: string | null; + steps: Array<{ + id: string; + label: string; + status: 'pending' | 'running' | 'passed' | 'failed' | 'warning' | 'skipped'; + detail?: string | null; + durationMs?: number | null; + }>; + startedAt: number; + completedAt?: number | null; + } | null; + }; + } +} diff --git a/plugins/magpie-release-management/hooks/types.ts b/plugins/magpie-release-management/hooks/types.ts new file mode 100644 index 000000000..bb48370b3 --- /dev/null +++ b/plugins/magpie-release-management/hooks/types.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Companion ambient declaration lives in types.d.ts for Claude Code mod plugin +// validation. Any property added to VerifyRcState or VerifyRcStep must be +// kept in sync across both files. + +export type VerifyStepId = + | 'preflight' + | 'inventory' + | 'signatures' + | 'checksums' + | 'rat-license-headers' + | 'notice-license' + | 'binary-exclusion' + | 'jvm-artefacts' + | 'nexus-staging' + | 'source-tree-integrity' + | 'version-consistency' + | 'reproducibility' + | 'verdict'; + +export type StepStatus = 'pending' | 'running' | 'passed' | 'failed' | 'warning' | 'skipped'; + +export type OverallVerifyStatus = 'running' | 'passed' | 'passed-with-warnings' | 'failed'; + +export interface VerifyRcStep { + id: VerifyStepId; + label: string; + status: StepStatus; + detail?: string | null; + durationMs?: number | null; +} + +export interface VerifyRcState { + rcTag: string; + version: string; + stagingUrl?: string | null; + postTo?: string | null; + overallStatus: OverallVerifyStatus; + currentStepId?: VerifyStepId | null; + steps: VerifyRcStep[]; + startedAt: number; + completedAt?: number | null; +} + +/** + * Returns the canonical 13 verification steps in exact SKILL.md execution order, + * all initialized in 'pending' status. + */ +export function createDefaultVerifySteps(): VerifyRcStep[] { + return [ + { id: 'preflight', label: 'Pre-flight check & config resolution', status: 'pending' }, + { id: 'inventory', label: 'Staged artefacts inventory', status: 'pending' }, + { id: 'signatures', label: 'GPG signatures (KEYS verification)', status: 'pending' }, + { id: 'checksums', label: 'SHA512 & digest checksums', status: 'pending' }, + { id: 'rat-license-headers', label: 'Apache RAT license headers', status: 'pending' }, + { id: 'notice-license', label: 'NOTICE and LICENSE integrity', status: 'pending' }, + { id: 'binary-exclusion', label: 'Prohibited binaries scan', status: 'pending' }, + { id: 'jvm-artefacts', label: 'JVM artefact checks', status: 'pending' }, + { id: 'nexus-staging', label: 'Nexus staging repository', status: 'pending' }, + { id: 'source-tree-integrity', label: 'Source integrity & symlinks', status: 'pending' }, + { id: 'version-consistency', label: 'Version manifest consistency', status: 'pending' }, + { id: 'reproducibility', label: 'Reproducible build verification', status: 'pending' }, + { id: 'verdict', label: 'Final verification verdict', status: 'pending' }, + ]; +} diff --git a/plugins/magpie-release-management/hooks/verify-rc-pane.test.ts b/plugins/magpie-release-management/hooks/verify-rc-pane.test.ts new file mode 100644 index 000000000..9b0ad5dda --- /dev/null +++ b/plugins/magpie-release-management/hooks/verify-rc-pane.test.ts @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, test } from 'claude-code/testing'; + +import { createDefaultVerifySteps } from './types.ts'; +import type { + StepStatus, + VerifyRcState, + VerifyRcStep, +} from './types.ts'; + +import { + calculateProgressSummary, + formatStepStatusBadge, + getVerifyRcState, + renderVerifyRcPane, + register, + sanitizeText, + setVerifyRcState, + truncateText, + verifyRcState, +} from './verify-rc-pane.ts'; + +function assert(condition: any, message?: string): void { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + +function assertStrictEqual(actual: any, expected: any, message?: string): void { + if (actual !== expected) { + throw new Error( + message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}` + ); + } +} + +function assertDeepStrictEqual(actual: any, expected: any, message?: string): void { + const a = JSON.stringify(actual); + const b = JSON.stringify(expected); + if (a !== b) { + throw new Error(message || `Expected ${b} but got ${a}`); + } +} + +function createMockEngine(overrides: any = {}): any { + const store = new Map(); + let invalidated = false; + return { + state: { + get: (atom: any) => { + return { isSet: store.has(atom.key), value: store.has(atom.key) ? store.get(atom.key) : null }; + }, + set: (atom: any, wrapped: any) => { + const prev = store.has(atom.key) ? store.get(atom.key) : null; + const nextVal = + typeof wrapped === 'function' + ? wrapped(prev) + : wrapped && typeof wrapped === 'object' && 'value' in wrapped + ? wrapped.value + : wrapped; + store.set(atom.key, nextVal); + return { isSet: true, value: nextVal }; + }, + _store: store, + }, + ui: { + invalidate: () => { + invalidated = true; + }, + resolve: () => ({ + Box: (props: any) => ({ type: 'Box', ...props }), + Text: (props: any) => ({ type: 'Text', ...props }), + }), + get wasInvalidated() { + return invalidated; + }, + }, + ...overrides, + }; +} + +describe('magpie-release-management verify-rc checklist mod (PR-1)', () => { + describe('normalization and formatters', () => { + test('sanitizeText strips ANSI CSI, OSC sequences, and control characters', () => { + assertStrictEqual(sanitizeText('plain text'), 'plain text'); + assertStrictEqual(sanitizeText('\x1b[31mRed Alert\x1b[0m'), 'Red Alert'); + assertStrictEqual(sanitizeText('\x1b]0;Window Title\x07Clean Text'), 'Clean Text'); + assertStrictEqual(sanitizeText('Line 1\x00\x08Text\x1f'), 'Line 1Text'); + assertStrictEqual(sanitizeText('\x1b[?25lHidden cursor'), 'Hidden cursor'); + assertStrictEqual(sanitizeText('failure\n[PASS] another check'), 'failure [PASS] another check'); + assertStrictEqual(sanitizeText(null), ''); + assertStrictEqual(sanitizeText(undefined), ''); + assertStrictEqual(sanitizeText(12345), ''); + }); + + test('truncateText handles short and long strings with ellipsis', () => { + assertStrictEqual(truncateText('short', 10), 'short'); + assertStrictEqual(truncateText('exactlength', 11), 'exactlength'); + assertStrictEqual(truncateText('a very long text that must be truncated', 15), 'a very long te\u2026'); + assertStrictEqual(truncateText('', 10), ''); + }); + + test('formatStepStatusBadge maps all 6 StepStatus values correctly', () => { + const passed = formatStepStatusBadge('passed'); + assertStrictEqual(passed.label, 'PASS'); + assertStrictEqual(passed.color, 'success'); + assertStrictEqual(passed.symbol, '[\u2713]'); + assertStrictEqual(passed.bold, true); + + const failed = formatStepStatusBadge('failed'); + assertStrictEqual(failed.label, 'FAIL'); + assertStrictEqual(failed.color, 'error'); + assertStrictEqual(failed.symbol, '[\u2717]'); + assertStrictEqual(failed.bold, true); + + const warning = formatStepStatusBadge('warning'); + assertStrictEqual(warning.label, 'WARN'); + assertStrictEqual(warning.color, 'warning'); + assertStrictEqual(warning.symbol, '[!]'); + assertStrictEqual(warning.bold, true); + + const running = formatStepStatusBadge('running'); + assertStrictEqual(running.label, 'RUNNING'); + assertStrictEqual(running.color, 'info'); + assertStrictEqual(running.symbol, '[*]'); + assertStrictEqual(running.bold, true); + + const skipped = formatStepStatusBadge('skipped'); + assertStrictEqual(skipped.label, 'SKIP'); + assertStrictEqual(skipped.color, 'dim'); + assertStrictEqual(skipped.symbol, '[-]'); + assertStrictEqual(skipped.bold, false); + + const pending = formatStepStatusBadge('pending'); + assertStrictEqual(pending.label, 'PENDING'); + assertStrictEqual(pending.color, 'dim'); + assertStrictEqual(pending.symbol, '[ ]'); + assertStrictEqual(pending.bold, false); + }); + + test('calculateProgressSummary aggregates step metrics accurately', () => { + const steps: VerifyRcStep[] = [ + { id: 'preflight', label: 'Preflight', status: 'passed' }, + { id: 'inventory', label: 'Inventory', status: 'passed' }, + { id: 'signatures', label: 'Signatures', status: 'failed', detail: 'Key missing' }, + { id: 'checksums', label: 'Checksums', status: 'warning', detail: 'Deprecated md5' }, + { id: 'rat-license-headers', label: 'RAT', status: 'running' }, + { id: 'notice-license', label: 'Notice', status: 'skipped' }, + { id: 'binary-exclusion', label: 'Binaries', status: 'pending' }, + ]; + + const summary = calculateProgressSummary(steps); + assertStrictEqual(summary.total, 7); + assertStrictEqual(summary.passed, 2); + assertStrictEqual(summary.failed, 1); + assertStrictEqual(summary.warning, 1); + assertStrictEqual(summary.running, 1); + assertStrictEqual(summary.skipped, 1); + assertStrictEqual(summary.pending, 1); + assertStrictEqual(summary.completed, 5); // passed + failed + warning + skipped + }); + }); + + describe('createDefaultVerifySteps canonical sequence', () => { + test('returns all 13 canonical steps matching verify-rc/SKILL.md in exact order', () => { + const steps = createDefaultVerifySteps(); + assertStrictEqual(steps.length, 13); + + const expectedIds = [ + 'preflight', + 'inventory', + 'signatures', + 'checksums', + 'rat-license-headers', + 'notice-license', + 'binary-exclusion', + 'jvm-artefacts', + 'nexus-staging', + 'source-tree-integrity', + 'version-consistency', + 'reproducibility', + 'verdict', + ]; + + for (let i = 0; i < expectedIds.length; i += 1) { + assertStrictEqual(steps[i].id, expectedIds[i]); + assertStrictEqual(steps[i].status, 'pending'); + assert(typeof steps[i].label === 'string' && steps[i].label.length > 0); + } + }); + }); + + describe('state store management and accessors', () => { + test('getVerifyRcState and setVerifyRcState round-trip successfully', async () => { + const $ = createMockEngine(); + const initial = await getVerifyRcState($); + assertStrictEqual(initial, null); + + const sampleState: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + stagingUrl: 'https://dist.apache.org/repos/dist/dev/foo/2.11.0-rc1', + overallStatus: 'running', + currentStepId: 'signatures', + steps: createDefaultVerifySteps(), + startedAt: Date.now(), + }; + + await setVerifyRcState($, sampleState); + assertStrictEqual($.ui.wasInvalidated, true); + + const retrieved = await getVerifyRcState($); + assertDeepStrictEqual(retrieved, sampleState); + + await setVerifyRcState($, null); + const afterReset = await getVerifyRcState($); + assertStrictEqual(afterReset, null); + }); + + test('getVerifyRcState fails open when store throws exception', async () => { + const failing$ = { + state: { + get: () => { + throw new Error('Database disk error'); + }, + }, + }; + const result = await getVerifyRcState(failing$); + assertStrictEqual(result, null); + }); + }); + + describe('renderVerifyRcPane UI component', () => { + test('returns null when state is null or steps empty', () => { + const $ = createMockEngine(); + assertStrictEqual(renderVerifyRcPane($, { component: 'Pane' }, null), null); + + const emptyStepsState: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'running', + steps: [], + startedAt: Date.now(), + }; + assertStrictEqual(renderVerifyRcPane($, { component: 'Pane' }, emptyStepsState), null); + }); + + test('returns null when $.ui.resolve fails or Box/Text are missing', () => { + const broken$ = { + ui: { + resolve: () => { + throw new Error('UI resolve failed'); + }, + }, + }; + const state: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'running', + steps: createDefaultVerifySteps(), + startedAt: Date.now(), + }; + assertStrictEqual(renderVerifyRcPane(broken$, { component: 'Pane' }, state), null); + }); + + test('renders structured Box tree with header, step rows, and footer', () => { + const $ = createMockEngine(); + const steps = createDefaultVerifySteps(); + steps[0].status = 'passed'; + steps[1].status = 'passed'; + steps[2].status = 'running'; + + const state: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'running', + currentStepId: 'signatures', + steps, + startedAt: Date.now(), + }; + + const pane = renderVerifyRcPane($, { component: 'Pane' }, state); + assert(pane !== null); + assertStrictEqual(pane.type, 'Box'); + assertStrictEqual(pane.flexDirection, 'column'); + assertStrictEqual(pane.children.length, 3); // Header box, Steps box, Footer box + + const headerBox = pane.children[0]; + assertStrictEqual(headerBox.type, 'Box'); + const headerTitle = headerBox.children[0]; + assert(headerTitle.children[0].includes('2.11.0-rc1')); + + const stepsBox = pane.children[1]; + assertStrictEqual(stepsBox.type, 'Box'); + assertStrictEqual(stepsBox.children.length, 13); + + // Verify row 0 (preflight) has PASS badge + const row0 = stepsBox.children[0]; + assertStrictEqual(row0.children[0].children[0], '[\u2713] '); + assertStrictEqual(row0.children[2].children[0], '[PASS]'); + + // Verify row 2 (signatures) has RUNNING badge + const row2 = stepsBox.children[2]; + assertStrictEqual(row2.children[0].children[0], '[*] '); + assertStrictEqual(row2.children[2].children[0], '[RUNNING]'); + + const footerBox = pane.children[2]; + assert(footerBox.children[0].children[0].includes('signatures')); + }); + + test('displays inline details for failed or warning steps while keeping passed rows single-line', () => { + const $ = createMockEngine(); + const steps = createDefaultVerifySteps(); + steps[2].status = 'failed'; + steps[2].detail = 'Key 0x1234ABCD not found in KEYS file'; + steps[3].status = 'warning'; + steps[3].detail = 'Deprecated md5 digest file staged'; + steps[0].status = 'passed'; + steps[0].detail = 'This should not appear in row'; + + const state: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'failed', + steps, + startedAt: Date.now(), + }; + + const pane = renderVerifyRcPane($, { component: 'Pane' }, state); + const stepsBox = pane.children[1]; + + // Row 0 (passed): children length is 3 (no detail) + const passedRow = stepsBox.children[0]; + assertStrictEqual(passedRow.children.length, 3); + + // Row 2 (failed): children length is 4 (includes detail) + const failedRow = stepsBox.children[2]; + assertStrictEqual(failedRow.children.length, 4); + assert(failedRow.children[3].children[0].includes('0x1234ABCD')); + + // Row 3 (warning): children length is 4 (includes detail) + const warningRow = stepsBox.children[3]; + assertStrictEqual(warningRow.children.length, 4); + assert(warningRow.children[3].children[0].includes('Deprecated md5')); + }); + + test('renders appropriate final verdict footer text and colors', () => { + const $ = createMockEngine(); + const steps = createDefaultVerifySteps(); + + const passedState: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'passed', + steps, + startedAt: Date.now(), + }; + const passedPane = renderVerifyRcPane($, { component: 'Pane' }, passedState); + assert(passedPane.children[2].children[0].children[0].includes('PASS')); + + const warnState: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'passed-with-warnings', + steps, + startedAt: Date.now(), + }; + const warnPane = renderVerifyRcPane($, { component: 'Pane' }, warnState); + assert(warnPane.children[2].children[0].children[0].includes('PASS-WITH-WARNINGS')); + + const failedState: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'failed', + steps, + startedAt: Date.now(), + }; + const failedPane = renderVerifyRcPane($, { component: 'Pane' }, failedState); + assert(failedPane.children[2].children[0].children[0].includes('FAIL')); + }); + + test('renders exact visual text format matching Pane layout specification', () => { + const $ = createMockEngine(); + const steps = createDefaultVerifySteps(); + steps[0].status = 'passed'; + steps[1].status = 'passed'; + steps[2].status = 'running'; + + const state: VerifyRcState = { + rcTag: '3.0.0-rc1', + version: '3.0.0', + overallStatus: 'running', + currentStepId: 'signatures', + steps, + startedAt: 1700000000000, + }; + + const pane = renderVerifyRcPane($, { component: 'Pane' }, state); + assert(pane !== null); + + // Helper to format Box/Text hierarchy for visual inspection + const lines: string[] = []; + lines.push('---------------- [ LIVE PANE RENDERING PREVIEW ] ----------------'); + lines.push(`${pane.children[0].children[0].children[0]}`); + lines.push(`${pane.children[0].children[1].children[0]}`); + lines.push(''); + for (const row of pane.children[1].children) { + const rowText = row.children.map((c: any) => c.children[0]).join(''); + lines.push(rowText); + } + lines.push(''); + lines.push(`${pane.children[2].children[0].children[0]}`); + lines.push('------------------------------------------------------------------'); + console.log('\n' + lines.join('\n')); + + // Also preview failure state + const failSteps = createDefaultVerifySteps(); + failSteps[0].status = 'passed'; + failSteps[1].status = 'passed'; + failSteps[2].status = 'failed'; + failSteps[2].detail = 'Key 0xABCD1234 not found in staged KEYS file from dist.apache.org'; + failSteps[3].status = 'warning'; + failSteps[3].detail = 'Deprecated md5 digest file staged alongside sha512'; + const failState: VerifyRcState = { + rcTag: '3.0.0-rc1', + version: '3.0.0', + overallStatus: 'failed', + steps: failSteps, + startedAt: 1700000000000, + completedAt: 1700000015000, + }; + const failPane = renderVerifyRcPane($, { component: 'Pane' }, failState); + const failLines: string[] = []; + failLines.push('-------------- [ HARD BLOCKER FAILURE PANE PREVIEW ] -------------'); + failLines.push(`${failPane.children[0].children[0].children[0]}`); + failLines.push(`${failPane.children[0].children[1].children[0]}`); + failLines.push(''); + for (const row of failPane.children[1].children) { + const rowText = row.children.map((c: any) => c.children[0]).join(''); + failLines.push(rowText); + } + failLines.push(''); + failLines.push(`${failPane.children[2].children[0].children[0]}`); + failLines.push('------------------------------------------------------------------'); + console.log('\n' + failLines.join('\n')); + + // Verify Header + const headerBox = pane.children[0]; + assertStrictEqual(headerBox.children[0].children[0], 'RC Verification: 3.0.0-rc1'); + assertStrictEqual(headerBox.children[0].bold, true); + assertStrictEqual( + headerBox.children[1].children[0], + 'Progress: 2/13 completed \u00B7 Status: RUNNING' + ); + assertStrictEqual(headerBox.children[1].color, 'info'); + + // Verify Step Rows + const stepsBox = pane.children[1]; + assertStrictEqual(stepsBox.children.length, 13); + + // Preflight (passed) + const row0 = stepsBox.children[0]; + assertStrictEqual(row0.children[0].children[0], '[\u2713] '); + assertStrictEqual(row0.children[0].color, 'success'); + assertStrictEqual(row0.children[1].children[0], 'Pre-flight check & config resolution '); + assertStrictEqual(row0.children[2].children[0], '[PASS]'); + assertStrictEqual(row0.children.length, 3); // Single-line row + + // Inventory (passed) + const row1 = stepsBox.children[1]; + assertStrictEqual(row1.children[0].children[0], '[\u2713] '); + assertStrictEqual(row1.children[2].children[0], '[PASS]'); + + // Signatures (running) + const row2 = stepsBox.children[2]; + assertStrictEqual(row2.children[0].children[0], '[*] '); + assertStrictEqual(row2.children[0].color, 'info'); + assertStrictEqual(row2.children[1].children[0], 'GPG signatures (KEYS verification) '); + assertStrictEqual(row2.children[1].bold, true); + assertStrictEqual(row2.children[2].children[0], '[RUNNING]'); + + // Checksums (pending) + const row3 = stepsBox.children[3]; + assertStrictEqual(row3.children[0].children[0], '[ ] '); + assertStrictEqual(row3.children[0].color, 'dim'); + assertStrictEqual(row3.children[1].children[0], 'SHA512 & digest checksums '); + assertStrictEqual(row3.children[2].children[0], '[PENDING]'); + + // Verify Footer + const footerBox = pane.children[2]; + assertStrictEqual( + footerBox.children[0].children[0], + 'Current: GPG signatures (KEYS verification)...' + ); + assertStrictEqual(footerBox.children[0].color, 'info'); + }); + + test('strips ANSI escape sequences and truncates overflow in rendered output', () => { + const $ = createMockEngine(); + const steps = createDefaultVerifySteps(); + steps[2].status = 'failed'; + steps[2].detail = + '\x1b[31mKey 0xABCD1234 not found in staged KEYS file from dist.apache.org repository\x1b[0m'; + + const state: VerifyRcState = { + rcTag: '\x1b[32m2.11.0-rc1\x1b[0m', + version: '2.11.0', + overallStatus: 'failed', + steps, + startedAt: Date.now(), + }; + + const pane = renderVerifyRcPane($, { component: 'Pane' }, state); + const headerBox = pane.children[0]; + // Title must be sanitized + assertStrictEqual(headerBox.children[0].children[0], 'RC Verification: 2.11.0-rc1'); + + // Failed row detail must be sanitized and truncated to 60 chars + const stepsBox = pane.children[1]; + const failedRow = stepsBox.children[2]; + assertStrictEqual(failedRow.children.length, 4); + const detailText = failedRow.children[3].children[0]; + assert(!detailText.includes('\x1b')); + assert(detailText.endsWith('\u2026')); + // Prefix is " \u2014 " (3 chars) + truncated text (60 chars) = 63 chars + assertStrictEqual(detailText.length, 63); + }); + }); + + describe('hook registration', () => { + test('register attaches session.start and ui.render (Pane)', async () => { + const registeredHooks = new Map(); + const on = (event: string, handler: Function) => { + registeredHooks.set(event, handler); + }; + + register(on); + assertStrictEqual(registeredHooks.has('session.start'), true); + assertStrictEqual(registeredHooks.has('ui.render'), true); + + const $ = createMockEngine(); + let nextCalled = false; + const next = (e: any) => { + nextCalled = true; + return 'next-result'; + }; + + // session.start resets state to null + await setVerifyRcState($, { + rcTag: 'old-rc', + version: 'old', + overallStatus: 'running', + steps: createDefaultVerifySteps(), + startedAt: Date.now(), + }); + const sessionHandler = registeredHooks.get('session.start')!; + const sessionResult = await sessionHandler($, { type: 'start' }, next); + assertStrictEqual(nextCalled, true); + assertStrictEqual(sessionResult, 'next-result'); + const stateAfterStart = await getVerifyRcState($); + assertStrictEqual(stateAfterStart, null); + + // ui.render passes through when component is not Pane + nextCalled = false; + const uiHandler = registeredHooks.get('ui.render')!; + const otherComponentResult = await uiHandler($, { component: 'AbovePrompt' }, next); + assertStrictEqual(nextCalled, true); + assertStrictEqual(otherComponentResult, 'next-result'); + + // ui.render returns Pane when component is Pane and state populated + nextCalled = false; + const steps = createDefaultVerifySteps(); + await setVerifyRcState($, { + rcTag: '2.11.0-rc1', + version: '2.11.0', + overallStatus: 'running', + steps, + startedAt: Date.now(), + }); + const paneResult = await uiHandler($, { component: 'Pane' }, next); + assertStrictEqual(nextCalled, false); + assert(paneResult !== null && paneResult.type === 'Box'); + }); + }); + + describe('ambient PluginState type conformity', () => { + test('VerifyRcState matches ambient PluginState magpie-release-management interface shape', () => { + const state: VerifyRcState = { + rcTag: '2.11.0-rc1', + version: '2.11.0', + stagingUrl: 'https://dist.apache.org', + postTo: 'https://github.com/apache/foo/issues/1', + overallStatus: 'running', + currentStepId: 'signatures', + steps: [ + { + id: 'signatures', + label: 'Signatures', + status: 'passed', + detail: 'All keys verified', + durationMs: 120, + }, + ], + startedAt: Date.now(), + completedAt: Date.now() + 5000, + }; + + // Verify all required fields exist and match expected types + assertStrictEqual(typeof state.rcTag, 'string'); + assertStrictEqual(typeof state.version, 'string'); + assertStrictEqual(typeof state.overallStatus, 'string'); + assertStrictEqual(typeof state.startedAt, 'number'); + assert(Array.isArray(state.steps)); + assertStrictEqual(state.steps[0].id, 'signatures'); + assertStrictEqual(state.steps[0].status, 'passed'); + }); + }); +}); diff --git a/plugins/magpie-release-management/hooks/verify-rc-pane.ts b/plugins/magpie-release-management/hooks/verify-rc-pane.ts new file mode 100644 index 000000000..757910d1b --- /dev/null +++ b/plugins/magpie-release-management/hooks/verify-rc-pane.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { atom, read, update } from 'claude-code'; +import type { + VerifyRcState, + VerifyRcStep, + StepStatus, + OverallVerifyStatus, +} from './types.ts'; + +export const verifyRcState = atom( + { plugin: 'magpie-release-management', key: 'verifyRcState' }, + null as VerifyRcState | null +); + +/** + * Retrieves the current verifyRcState from the session state store. + */ +export async function getVerifyRcState($: any): Promise { + try { + return (await read($, verifyRcState)) ?? null; + } catch { + return null; + } +} + +/** + * Sets the verifyRcState in the session state store and invalidates the UI. + */ +export async function setVerifyRcState($: any, state: VerifyRcState | null): Promise { + try { + await update($, verifyRcState, () => state); + try { + $.ui.invalidate('ui.render'); + } catch { + // UI invalidation optional in non-interactive / headless contexts + } + } catch { + // Fail-open + } +} + +/** + * Strips ANSI CSI and OSC sequences and C0/C1 control characters, and folds line + * breaks to a space so that one detail cannot draw a second checklist row. + */ +export function sanitizeText(raw: unknown): string { + if (typeof raw !== 'string') { + return ''; + } + return raw + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-Z\\-_]/g, '') + .replace(/[\r\n\t]+/g, ' ') + .replace(/[\x00-\x1f\x7f-\x9f]/g, '') + .trim(); +} + +/** + * Maps step statuses to terminal display badges, symbols, colors, and emphasis. + */ +export function formatStepStatusBadge(status: StepStatus): { + symbol: string; + label: string; + color: string; + bold: boolean; +} { + switch (status) { + case 'passed': + return { symbol: '[\u2713]', label: 'PASS', color: 'success', bold: true }; + case 'failed': + return { symbol: '[\u2717]', label: 'FAIL', color: 'error', bold: true }; + case 'warning': + return { symbol: '[!]', label: 'WARN', color: 'warning', bold: true }; + case 'running': + return { symbol: '[*]', label: 'RUNNING', color: 'info', bold: true }; + case 'skipped': + return { symbol: '[-]', label: 'SKIP', color: 'dim', bold: false }; + case 'pending': + default: + return { symbol: '[ ]', label: 'PENDING', color: 'dim', bold: false }; + } +} + +export interface ProgressSummary { + total: number; + completed: number; + passed: number; + failed: number; + warning: number; + running: number; + pending: number; + skipped: number; +} + +/** + * Aggregates progress metrics across the checklist steps. + */ +export function calculateProgressSummary(steps: VerifyRcStep[]): ProgressSummary { + let passed = 0; + let failed = 0; + let warning = 0; + let running = 0; + let pending = 0; + let skipped = 0; + + for (const step of steps) { + switch (step.status) { + case 'passed': + passed += 1; + break; + case 'failed': + failed += 1; + break; + case 'warning': + warning += 1; + break; + case 'running': + running += 1; + break; + case 'skipped': + skipped += 1; + break; + case 'pending': + default: + pending += 1; + break; + } + } + + const completed = passed + failed + warning + skipped; + return { + total: steps.length, + completed, + passed, + failed, + warning, + running, + pending, + skipped, + }; +} + +/** + * Truncates text cleanly with an ellipsis when exceeding maxLen. + */ +export function truncateText(text: string, maxLen: number): string { + if (!text || text.length <= maxLen) { + return text; + } + return text.slice(0, maxLen - 1) + '\u2026'; +} + +/** + * Resolves color for overall verification status. + */ +function getOverallStatusColor(status: OverallVerifyStatus): string { + switch (status) { + case 'passed': + return 'success'; + case 'passed-with-warnings': + return 'warning'; + case 'failed': + return 'error'; + case 'running': + default: + return 'info'; + } +} + +/** + * Renders the Release Candidate verification checklist Pane. + * + * Designed with a compact vertical budget: + * - Clean single-line rows for passed or pending steps + * - Inline details rendered on failures or warnings + * - Returns null when state is unpopulated or UI components are unavailable + */ +export function renderVerifyRcPane($: any, e: any, state: VerifyRcState | null): any { + if (!state || !state.steps || state.steps.length === 0) { + return null; + } + + let Box: any; + let Text: any; + try { + const resolved = $.ui.resolve(e); + Box = resolved.Box; + Text = resolved.Text; + } catch { + return null; + } + + if (!Box || !Text) { + return null; + } + + const summary = calculateProgressSummary(state.steps); + const statusColor = getOverallStatusColor(state.overallStatus); + const cleanTag = sanitizeText(state.rcTag) || 'Release Candidate'; + + const headerChildren = [ + Text({ + children: [`RC Verification: ${cleanTag}`], + bold: true, + }), + Text({ + children: [ + `Progress: ${summary.completed}/${summary.total} completed \u00B7 Status: ${state.overallStatus.toUpperCase()}`, + ], + color: statusColor, + bold: true, + }), + ]; + + const stepRows: any[] = []; + for (const step of state.steps) { + const badgeSpec = formatStepStatusBadge(step.status); + const cleanLabel = sanitizeText(step.label); + const isSpecialState = step.status === 'failed' || step.status === 'warning'; + + const rowChildren = [ + Text({ + children: [`${badgeSpec.symbol} `], + color: badgeSpec.color, + bold: badgeSpec.bold, + }), + Text({ + children: [`${cleanLabel} `], + bold: step.status === 'running' || step.status === 'failed', + }), + Text({ + children: [`[${badgeSpec.label}]`], + color: badgeSpec.color, + bold: badgeSpec.bold, + }), + ]; + + if (isSpecialState && step.detail) { + const cleanDetail = truncateText(sanitizeText(step.detail), 60); + rowChildren.push( + Text({ + children: [` \u2014 ${cleanDetail}`], + color: badgeSpec.color, + }) + ); + } + + stepRows.push( + Box({ + children: rowChildren, + }) + ); + } + + const footerText = + state.overallStatus === 'running' + ? state.currentStepId + ? `Current: ${sanitizeText(state.steps.find((s) => s.id === state.currentStepId)?.label || 'Running check')}...` + : 'Running mechanical release checks...' + : state.overallStatus === 'passed' + ? 'Verdict: PASS (All mechanical checks passed; voter obligation remains)' + : state.overallStatus === 'passed-with-warnings' + ? 'Verdict: PASS-WITH-WARNINGS (Review warnings prior to vote draft)' + : 'Verdict: FAIL (Hard blocker encountered; do not release candidate)'; + + const footerBox = Box({ + paddingTop: 1, + children: [ + Text({ + children: [footerText], + color: statusColor, + bold: state.overallStatus !== 'running', + }), + ], + }); + + return Box({ + flexDirection: 'column', + children: [ + Box({ + flexDirection: 'column', + paddingBottom: 1, + children: headerChildren, + }), + Box({ + flexDirection: 'column', + children: stepRows, + }), + footerBox, + ], + }); +} + +/** + * Registers the magpie-release-management verify-rc checklist mod hooks. + * + * 1. session.start - Initializes or resets the verifyRcState atom to null + * 2. ui.render (component=Pane) - Renders the verification checklist pane + */ +export function register(hooks: any): void { + hooks('session.start', async ($: any, e: any, next: any) => { + try { + await setVerifyRcState($, null); + } catch { + // Fail-open + } + return next(e); + }); + + hooks('ui.render', async ($: any, e: any, next: any) => { + if (e.component === 'Pane') { + try { + const state = await getVerifyRcState($); + const pane = renderVerifyRcPane($, e, state); + if (pane) { + return pane; + } + } catch { + // Fail-open to next render handler + } + } + return next(e); + }); +} diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index 5375af0a6..5a681ef14 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -129,6 +129,10 @@ "description": "Apache Magpie — no Codex hooks. hooks.json holds Claude Code mods only.", "hooks": {}, }, + "magpie-release-management": { + "description": "Apache Magpie — no Codex hooks. hooks.json holds Claude Code mods only.", + "hooks": {}, + }, "magpie-security": { "description": "Apache Magpie — no Codex hooks. hooks.json holds Claude Code mods only.", "hooks": {},