diff --git a/.github/workflows/dev-readiness-dry-run.yml b/.github/workflows/dev-readiness-dry-run.yml new file mode 100644 index 0000000..7013a6e --- /dev/null +++ b/.github/workflows/dev-readiness-dry-run.yml @@ -0,0 +1,25 @@ +name: DEV readiness policy tests + +on: + pull_request: + paths: + - 'automation/**' + - '.github/workflows/dev-readiness-dry-run.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + evaluate-fixtures: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run DEV readiness tests + run: node --test automation/*.test.mjs diff --git a/automation/README.md b/automation/README.md new file mode 100644 index 0000000..2197153 --- /dev/null +++ b/automation/README.md @@ -0,0 +1,34 @@ +# DEV readiness policy evaluator + +This directory contains the pure, read-only decision logic for the future ChipIn Project `→ DEV` automation. + +This PR is the policy layer only. It does not call GitHub APIs and cannot mutate issues, Projects, fields, statuses, PRs, or relationships. A later read-only adapter will feed real GitHub issue/PR/Project state into this evaluator before any live mutation is considered. + +## States + +- `READY_FOR_DEV` — deterministic delivery requirements are integrated and the current Project status is still before `DEV`. +- `NOT_READY` — no DEV transition should happen now; required work may be incomplete, the task may be manual/non-code, or the item may already be at a terminal/integrated status. +- `BLOCKED_UNKNOWN` — required structured state is missing, unreadable, ambiguous, or unsupported. +- `INCONSISTENT` — an item already at `DEV` or `PROD` no longer satisfies DEV readiness and needs human review. + +## Integration branches + +- frontend: `dev` +- backend: `develop` +- knowledge base: `main` + +A blocking issue is considered satisfied for DEV readiness when the blocker itself is closed or its readable Project status is `DEV`, `PROD`, or `Done`. An unresolved/open blocker without an integrated Project status still blocks. + +Deployment and informational `References` never gate `DEV`. `PROD`, `Done`, and parent closure remain manual in v1, and the evaluator never proposes a transition back from those statuses. + +## Why not Boardly + +Boardly was reviewed before implementing this evaluator. It provides GitHub Projects v2 dry-run and sub-issue gating, but its built-in model does not express ChipIn's required Development-linked PR integration checks against repository-specific integration branches or the post-DEV inconsistency rule. Reusing it would require a larger customization layer than this isolated pure evaluator. + +## Tests + +The GitHub Actions workflow runs on pull requests that touch `automation/**` or the workflow itself, and can also be started manually. It has only `contents: read` permission. + +```sh +node --test automation/*.test.mjs +``` diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs new file mode 100644 index 0000000..cdce4a7 --- /dev/null +++ b/automation/dev-readiness.mjs @@ -0,0 +1,205 @@ +const INTEGRATION_BRANCHES = Object.freeze({ + 'ChipIn-one/chipin-frontend': 'dev', + 'ChipIn-one/chipin-backend': 'develop', + 'ChipIn-one/chipin-knowledge-base': 'main', +}); + +const KNOWN_PROJECT_STATUSES = new Set([ + 'Backlog', + 'Todo', + 'In Progress', + 'DEV', + 'PROD', + 'Done', +]); + +const KNOWN_WORK_KINDS = new Set(['Task', 'Feature', 'Bug']); +const KNOWN_DELIVERY_CLASSES = new Set(['code', 'non-code']); +const KNOWN_BLOCKER_STATES = new Set(['open', 'closed']); +const KNOWN_PR_STATES = new Set(['open', 'closed', 'merged']); +const COMPLETE_DEPENDENCY_STATUSES = new Set(['DEV', 'PROD', 'Done']); + +function result(state, reason) { + return { state, reason }; +} + +function hasUnreadableRelation(items) { + return items.some((item) => item?.readable === false || !item?.state); +} + +function blockerState(input) { + if (hasUnreadableRelation(input.blockers)) { + return result('BLOCKED_UNKNOWN', 'A blocking relationship is unreadable.'); + } + + for (const blocker of input.blockers) { + if (!KNOWN_BLOCKER_STATES.has(blocker.state)) { + return result('BLOCKED_UNKNOWN', 'A blocker has an unknown issue state.'); + } + + if (blocker.state === 'closed') { + continue; + } + + if ( + blocker.projectStatus !== undefined + && !KNOWN_PROJECT_STATUSES.has(blocker.projectStatus) + ) { + return result('BLOCKED_UNKNOWN', 'A blocker has an unknown Project status.'); + } + + if (COMPLETE_DEPENDENCY_STATUSES.has(blocker.projectStatus)) { + continue; + } + + return result('NOT_READY', 'At least one required blocker is not integrated yet.'); + } + + return null; +} + +function classifyDelivery(input) { + if (input.deliveryClass !== undefined) { + return input.deliveryClass; + } + if (input.workKind === 'Bug' || input.workKind === 'Feature') { + return 'code'; + } + return null; +} + +function evaluateComposite(input) { + if (!input.requiredItems.length) { + return result('BLOCKED_UNKNOWN', 'Composite parent has no readable required sub-issues.'); + } + if (hasUnreadableRelation(input.requiredItems)) { + return result('BLOCKED_UNKNOWN', 'A required sub-issue is unreadable.'); + } + + for (const item of input.requiredItems) { + if (item.kind !== 'subissue' || !KNOWN_PROJECT_STATUSES.has(item.state)) { + return result('BLOCKED_UNKNOWN', 'A required sub-issue has unsupported structured state.'); + } + } + + const complete = input.requiredItems.every( + (item) => COMPLETE_DEPENDENCY_STATUSES.has(item.state), + ); + return complete + ? result('READY_FOR_DEV', 'All required sub-issues are complete for DEV roll-up.') + : result('NOT_READY', 'At least one required sub-issue is not complete.'); +} + +function evaluateCodeDelivery(input) { + const expectedBranch = INTEGRATION_BRANCHES[input.repository]; + if (!expectedBranch) { + return result('BLOCKED_UNKNOWN', 'Repository has no configured integration branch.'); + } + if (!input.requiredItems.length) { + return result('NOT_READY', 'No required implementation PR is recorded.'); + } + if (hasUnreadableRelation(input.requiredItems)) { + return result('BLOCKED_UNKNOWN', 'A required implementation relationship is unreadable.'); + } + + for (const item of input.requiredItems) { + if ( + item.kind !== 'pr' + || !KNOWN_PR_STATES.has(item.state) + || typeof item.baseBranch !== 'string' + || item.baseBranch.length === 0 + ) { + return result('BLOCKED_UNKNOWN', 'A required implementation PR has incomplete or unsupported structured state.'); + } + } + + const allIntegrated = input.requiredItems.every( + (item) => item.state === 'merged' && item.baseBranch === expectedBranch, + ); + return allIntegrated + ? result('READY_FOR_DEV', `All required PRs are merged to ${expectedBranch}.`) + : result('NOT_READY', `At least one required PR is not merged to ${expectedBranch}.`); +} + +export function evaluateDevReadiness(rawInput) { + const source = rawInput ?? {}; + const blockersRead = Object.hasOwn(source, 'blockers'); + const requiredItemsRead = Object.hasOwn(source, 'requiredItems'); + const input = { + requiredItems: [], + blockers: [], + metadataReadable: false, + projectReadable: false, + isCompositeParent: false, + ...source, + }; + + if (!input.metadataReadable || !input.projectReadable) { + return result('BLOCKED_UNKNOWN', 'Required structured GitHub state is unreadable.'); + } + + if (!input.repository || !input.currentStatus || !input.workKind) { + return result('BLOCKED_UNKNOWN', 'Required structured GitHub state is missing.'); + } + + if (!blockersRead) { + return result('BLOCKED_UNKNOWN', 'Blocking relationships were not read explicitly.'); + } + + if (!Array.isArray(input.requiredItems) || !Array.isArray(input.blockers)) { + return result('BLOCKED_UNKNOWN', 'Required relationship collections are unreadable.'); + } + + if (!KNOWN_PROJECT_STATUSES.has(input.currentStatus)) { + return result('BLOCKED_UNKNOWN', 'Project status is unknown.'); + } + + if (!KNOWN_WORK_KINDS.has(input.workKind)) { + return result('BLOCKED_UNKNOWN', 'Work kind is unknown.'); + } + + if ( + input.deliveryClass !== undefined + && !KNOWN_DELIVERY_CLASSES.has(input.deliveryClass) + ) { + return result('BLOCKED_UNKNOWN', 'Delivery class is unknown.'); + } + + const blocked = blockerState(input); + const deliveryClass = classifyDelivery(input); + let readiness; + + if (!deliveryClass) { + readiness = result('BLOCKED_UNKNOWN', 'Task delivery class is ambiguous.'); + } else if (deliveryClass === 'non-code') { + readiness = result('NOT_READY', 'Standalone non-code work terminates at Done manually, not DEV.'); + } else if (!requiredItemsRead) { + readiness = result('BLOCKED_UNKNOWN', 'Required implementation relationships were not read explicitly.'); + } else if (input.isCompositeParent) { + readiness = blocked ?? evaluateComposite(input); + } else { + readiness = blocked ?? evaluateCodeDelivery(input); + } + + if (input.currentStatus === 'DEV' || input.currentStatus === 'PROD') { + if (readiness.state !== 'READY_FOR_DEV') { + return result( + 'INCONSISTENT', + `Current status is ${input.currentStatus} but readiness recomputation is ${readiness.state}.`, + ); + } + return result('NOT_READY', `Current status is already ${input.currentStatus}; no DEV transition is allowed.`); + } + + if (input.currentStatus === 'Done') { + return result('NOT_READY', 'Current status is Done; DEV automation must not change a manual terminal state.'); + } + + return readiness; +} + +export { + INTEGRATION_BRANCHES, + KNOWN_PROJECT_STATUSES, + KNOWN_WORK_KINDS, +}; diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs new file mode 100644 index 0000000..67a9390 --- /dev/null +++ b/automation/dev-readiness.test.mjs @@ -0,0 +1,230 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { evaluateDevReadiness } from './dev-readiness.mjs'; + +const base = { + currentStatus: 'In Progress', + workKind: 'Feature', + requiredItems: [], + blockers: [], + metadataReadable: true, + projectReadable: true, +}; + +test('standalone frontend delivery is ready when its required PR is merged to dev', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'READY_FOR_DEV'); +}); + +test('backend delivery is not ready when required PR targets the wrong branch', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-backend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'main' }], + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('composite parent waits for any incomplete required sub-issue', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-knowledge-base', + isCompositeParent: true, + requiredItems: [ + { kind: 'subissue', state: 'DEV' }, + { kind: 'subissue', state: 'In Progress' }, + ], + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('open blocker prevents DEV readiness', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + blockers: [{ state: 'open' }], + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('open blocker already at DEV no longer blocks integration readiness', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + blockers: [{ state: 'open', projectStatus: 'DEV' }], + }); + assert.equal(result.state, 'READY_FOR_DEV'); +}); + +test('unsupported blocker state fails closed even when Project status is DEV', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + blockers: [{ state: 'archived', projectStatus: 'DEV' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('unreadable structured metadata fails closed', () => { + const result = evaluateDevReadiness({ ...base, metadataReadable: false }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('reopened required work after DEV is inconsistent', () => { + const result = evaluateDevReadiness({ + ...base, + currentStatus: 'DEV', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'open', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'INCONSISTENT'); +}); + +test('reopened required work after PROD is inconsistent', () => { + const result = evaluateDevReadiness({ + ...base, + currentStatus: 'PROD', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'open', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'INCONSISTENT'); +}); + +test('already DEV delivery does not propose another DEV transition', () => { + const result = evaluateDevReadiness({ + ...base, + currentStatus: 'DEV', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('PROD delivery never proposes a DEV transition', () => { + const result = evaluateDevReadiness({ + ...base, + currentStatus: 'PROD', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('standalone non-code task never auto-transitions to DEV', () => { + const result = evaluateDevReadiness({ + ...base, + workKind: 'Task', + deliveryClass: 'non-code', + repository: 'ChipIn-one/chipin-knowledge-base', + }); + assert.equal(result.state, 'NOT_READY'); +}); + +test('ambiguous Task fails closed', () => { + const result = evaluateDevReadiness({ + ...base, + workKind: 'Task', + repository: 'ChipIn-one/chipin-frontend', + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('unknown Project status fails closed', () => { + const result = evaluateDevReadiness({ + ...base, + currentStatus: 'Unknown status', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('unknown work kind fails closed even with explicit delivery class', () => { + const result = evaluateDevReadiness({ + ...base, + workKind: 'Incident', + deliveryClass: 'code', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('unsupported delivery class fails closed', () => { + const result = evaluateDevReadiness({ + ...base, + deliveryClass: 'mixed', + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('null required items fail closed instead of throwing', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: null, + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('null blockers fail closed instead of throwing', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + blockers: null, + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('omitted blockers fail closed instead of assuming an empty successful read', () => { + const result = evaluateDevReadiness({ + repository: 'ChipIn-one/chipin-frontend', + currentStatus: 'In Progress', + workKind: 'Feature', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + metadataReadable: true, + projectReadable: true, + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('omitted required items fail closed instead of assuming an empty successful read', () => { + const result = evaluateDevReadiness({ + repository: 'ChipIn-one/chipin-frontend', + currentStatus: 'In Progress', + workKind: 'Feature', + blockers: [], + metadataReadable: true, + projectReadable: true, + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('required merged PR without base branch fails closed', () => { + const result = evaluateDevReadiness({ + ...base, + repository: 'ChipIn-one/chipin-frontend', + requiredItems: [{ kind: 'pr', state: 'merged' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); + +test('missing readability flags fail closed', () => { + const result = evaluateDevReadiness({ + repository: 'ChipIn-one/chipin-frontend', + currentStatus: 'In Progress', + workKind: 'Feature', + requiredItems: [{ kind: 'pr', state: 'merged', baseBranch: 'dev' }], + }); + assert.equal(result.state, 'BLOCKED_UNKNOWN'); +}); diff --git a/automation/scenarios.json b/automation/scenarios.json new file mode 100644 index 0000000..63e6168 --- /dev/null +++ b/automation/scenarios.json @@ -0,0 +1,127 @@ +[ + { + "name":"frontend PR merged to dev", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"backend PR merged to develop", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-backend","currentStatus":"In Progress","workKind":"Bug","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"develop"}],"blockers":[]} + }, + { + "name":"KB PR merged to main", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"main"}],"blockers":[]} + }, + { + "name":"required PR still open", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"open","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"required PR merged to wrong branch", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-backend","currentStatus":"In Progress","workKind":"Bug","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"main"}],"blockers":[]} + }, + { + "name":"required PR closed unmerged", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"closed","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"open blocker prevents readiness", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[{"state":"open"}]} + }, + { + "name":"open blocker already at DEV is resolved for integration", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[{"state":"open","projectStatus":"DEV"}]} + }, + { + "name":"unreadable blocker fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[{"state":"open","readable":false}]} + }, + { + "name":"unknown blocker Project status fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[{"state":"open","projectStatus":"UNKNOWN"}]} + }, + { + "name":"composite parent waits for partial subissues", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"isCompositeParent":true,"requiredItems":[{"kind":"subissue","state":"DEV"},{"kind":"subissue","state":"In Progress"}],"blockers":[]} + }, + { + "name":"composite parent ready when required subissues are DEV", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"isCompositeParent":true,"requiredItems":[{"kind":"subissue","state":"DEV"},{"kind":"subissue","state":"DEV"}],"blockers":[]} + }, + { + "name":"composite parent accepts completed non-code subissue", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"isCompositeParent":true,"requiredItems":[{"kind":"subissue","state":"DEV"},{"kind":"subissue","state":"Done"}],"blockers":[]} + }, + { + "name":"composite parent without required subissues fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"isCompositeParent":true,"requiredItems":[],"blockers":[]} + }, + { + "name":"metadata unavailable fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":false,"projectReadable":true,"blockers":[]} + }, + { + "name":"Project status unavailable fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":false,"blockers":[]} + }, + { + "name":"unknown Project status fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"Unknown status","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"ambiguous Task fails closed", + "expected":"BLOCKED_UNKNOWN", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Task","metadataReadable":true,"projectReadable":true,"blockers":[]} + }, + { + "name":"non-code Task is manual Done only", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-knowledge-base","currentStatus":"In Progress","workKind":"Task","deliveryClass":"non-code","metadataReadable":true,"projectReadable":true,"blockers":[]} + }, + { + "name":"explicit code Task can reach DEV", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Task","deliveryClass":"code","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"reopened required PR after DEV is inconsistent", + "expected":"INCONSISTENT", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"DEV","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"open","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"reopened required PR after PROD is inconsistent", + "expected":"INCONSISTENT", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"PROD","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"open","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"already DEV never proposes another DEV transition", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"DEV","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"PROD never regresses to DEV", + "expected":"NOT_READY", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"PROD","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"blockers":[]} + }, + { + "name":"deployment and References do not gate DEV", + "expected":"READY_FOR_DEV", + "input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"dev"}],"deploymentState":"not_deployed","references":[{"state":"open"}],"blockers":[]} + } +] diff --git a/automation/scenarios.test.mjs b/automation/scenarios.test.mjs new file mode 100644 index 0000000..87def26 --- /dev/null +++ b/automation/scenarios.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { evaluateDevReadiness } from './dev-readiness.mjs'; + +const scenarios = JSON.parse( + await readFile(new URL('./scenarios.json', import.meta.url), 'utf8'), +); + +for (const scenario of scenarios) { + test(`dry-run: ${scenario.name}`, () => { + const result = evaluateDevReadiness(scenario.input); + assert.equal(result.state, scenario.expected, result.reason); + }); +}