From 35fa2264b27490ddf15ed08ded7a9a67290f61b8 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:52:31 +0700 Subject: [PATCH 01/22] test(automation): define DEV readiness behavior --- automation/dev-readiness.test.mjs | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 automation/dev-readiness.test.mjs diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs new file mode 100644 index 0000000..d5c7162 --- /dev/null +++ b/automation/dev-readiness.test.mjs @@ -0,0 +1,87 @@ +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('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('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'); +}); From 36f2ab3a1a4392f2adce92eaaafa9ea2ff496b6b Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:52:51 +0700 Subject: [PATCH 02/22] feat(automation): add pure DEV readiness evaluator --- automation/dev-readiness.mjs | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 automation/dev-readiness.mjs diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs new file mode 100644 index 0000000..7d03c43 --- /dev/null +++ b/automation/dev-readiness.mjs @@ -0,0 +1,110 @@ +const INTEGRATION_BRANCHES = Object.freeze({ + 'ChipIn-one/chipin-frontend': 'dev', + 'ChipIn-one/chipin-backend': 'develop', + 'ChipIn-one/chipin-knowledge-base': 'main', +}); + +const COMPLETE_SUBISSUE_STATES = 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.'); + } + if (input.blockers.some((blocker) => blocker.state !== 'closed')) { + return result('NOT_READY', 'At least one required blocker is still open.'); + } + return null; +} + +function classifyDelivery(input) { + if (input.deliveryClass === 'code' || input.deliveryClass === 'non-code') { + 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.'); + } + const complete = input.requiredItems.every( + (item) => item.kind === 'subissue' && COMPLETE_SUBISSUE_STATES.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.'); + } + const allIntegrated = input.requiredItems.every( + (item) => item.kind === 'pr' && 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 input = { + requiredItems: [], + blockers: [], + metadataReadable: true, + projectReadable: true, + isCompositeParent: false, + ...rawInput, + }; + + 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.'); + } + + 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 (input.isCompositeParent) { + readiness = blocked ?? evaluateComposite(input); + } else { + readiness = blocked ?? evaluateCodeDelivery(input); + } + + if (input.currentStatus === 'DEV' && readiness.state !== 'READY_FOR_DEV') { + return result('INCONSISTENT', `Current status is DEV but readiness recomputation is ${readiness.state}.`); + } + + return readiness; +} + +export { INTEGRATION_BRANCHES }; From f0bd092a2f5d067068f38f62fc54b21b0e6b091e Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:53:22 +0700 Subject: [PATCH 03/22] test(automation): add DEV readiness dry-run scenarios --- automation/scenarios.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 automation/scenarios.json diff --git a/automation/scenarios.json b/automation/scenarios.json new file mode 100644 index 0000000..f97a03d --- /dev/null +++ b/automation/scenarios.json @@ -0,0 +1,22 @@ +[ + {"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"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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":"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":"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"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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":[]}}, + {"name":"metadata unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":false,"projectReadable":true}}, + {"name":"Project status unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":false}}, + {"name":"ambiguous Task fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Task","metadataReadable":true,"projectReadable":true}}, + {"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}}, + {"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"}]}}, + {"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"}]}}, + {"name":"reopened blocker after DEV is inconsistent","expected":"INCONSISTENT","input":{"repository":"ChipIn-one/chipin-backend","currentStatus":"DEV","workKind":"Bug","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"develop"}],"blockers":[{"state":"open"}]}}, + {"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"}]}} +] From c292f2368a429e67b322b75ee8a029e3c063d175 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:53:34 +0700 Subject: [PATCH 04/22] test(automation): execute dry-run scenario matrix --- automation/scenarios.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 automation/scenarios.test.mjs 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); + }); +} From 81fc3515bb17f325ccabfce78916c903ae1024c4 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:53:51 +0700 Subject: [PATCH 05/22] docs(automation): document DEV readiness dry-run --- automation/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 automation/README.md diff --git a/automation/README.md b/automation/README.md new file mode 100644 index 0000000..b93f5c7 --- /dev/null +++ b/automation/README.md @@ -0,0 +1,30 @@ +# DEV readiness dry-run + +This directory contains the pure, read-only decision logic for the future ChipIn Project `→ DEV` automation. + +It does not call GitHub APIs and cannot mutate issues, Projects, fields, statuses, PRs, or relationships. The manual workflow only checks the evaluator against fixtures. + +## States + +- `READY_FOR_DEV` — deterministic delivery requirements are integrated. +- `NOT_READY` — readable state shows required work is incomplete. +- `BLOCKED_UNKNOWN` — required structured state is missing, unreadable, ambiguous, or unsupported. +- `INCONSISTENT` — an item already at `DEV` no longer satisfies DEV readiness and needs human review. + +## Integration branches + +- frontend: `dev` +- backend: `develop` +- knowledge base: `main` + +Deployment and informational `References` never gate `DEV`. `PROD`, `Done`, and parent closure are outside this evaluator and remain manual in v1. + +## 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. + +## Run + +```sh +node --test automation/*.test.mjs +``` From 25af9196c3966f294645e8361067b39a810d169e Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:54:02 +0700 Subject: [PATCH 06/22] ci(automation): add manual read-only DEV dry-run --- .github/workflows/dev-readiness-dry-run.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/dev-readiness-dry-run.yml diff --git a/.github/workflows/dev-readiness-dry-run.yml b/.github/workflows/dev-readiness-dry-run.yml new file mode 100644 index 0000000..fc7fd14 --- /dev/null +++ b/.github/workflows/dev-readiness-dry-run.yml @@ -0,0 +1,21 @@ +name: DEV readiness dry-run + +on: + 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 From 3dedaf4d35103c7ed6b600b62d6ca9e2e6672b58 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:55:49 +0700 Subject: [PATCH 07/22] test(automation): require explicit readable state --- automation/dev-readiness.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index d5c7162..54db915 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -85,3 +85,13 @@ test('ambiguous Task fails closed', () => { }); 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'); +}); From 5028ac225e73289ae9537a5edc746d7383d2d72f Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 20:56:11 +0700 Subject: [PATCH 08/22] fix(automation): fail closed when readability is unspecified --- automation/dev-readiness.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index 7d03c43..db47935 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -72,8 +72,8 @@ export function evaluateDevReadiness(rawInput) { const input = { requiredItems: [], blockers: [], - metadataReadable: true, - projectReadable: true, + metadataReadable: false, + projectReadable: false, isCompositeParent: false, ...rawInput, }; From 99945e626fde356da26282cb9460289054275ba9 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:05:39 +0700 Subject: [PATCH 09/22] test(automation): cover terminal statuses and blocker readiness --- automation/dev-readiness.test.mjs | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index 54db915..d552255 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -52,6 +52,16 @@ test('open blocker prevents DEV readiness', () => { 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('unreadable structured metadata fails closed', () => { const result = evaluateDevReadiness({ ...base, metadataReadable: false }); assert.equal(result.state, 'BLOCKED_UNKNOWN'); @@ -67,6 +77,36 @@ test('reopened required work after DEV is inconsistent', () => { 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, @@ -86,6 +126,16 @@ test('ambiguous Task fails closed', () => { 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('missing readability flags fail closed', () => { const result = evaluateDevReadiness({ repository: 'ChipIn-one/chipin-frontend', From fa9ed7e7066b846998aff60415e793f8090205ff Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:05:50 +0700 Subject: [PATCH 10/22] ci(automation): run DEV policy tests on pull requests --- .github/workflows/dev-readiness-dry-run.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-readiness-dry-run.yml b/.github/workflows/dev-readiness-dry-run.yml index fc7fd14..7013a6e 100644 --- a/.github/workflows/dev-readiness-dry-run.yml +++ b/.github/workflows/dev-readiness-dry-run.yml @@ -1,6 +1,10 @@ -name: DEV readiness dry-run +name: DEV readiness policy tests on: + pull_request: + paths: + - 'automation/**' + - '.github/workflows/dev-readiness-dry-run.yml' workflow_dispatch: permissions: From 20b9cbf9cc70abaecc97670fb945f620d5e88f13 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:07:14 +0700 Subject: [PATCH 11/22] fix(automation): make DEV readiness monotonic and status-aware --- automation/dev-readiness.mjs | 54 +++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index db47935..b8de419 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -4,7 +4,16 @@ const INTEGRATION_BRANCHES = Object.freeze({ 'ChipIn-one/chipin-knowledge-base': 'main', }); -const COMPLETE_SUBISSUE_STATES = new Set(['DEV', 'PROD', 'Done']); +const KNOWN_PROJECT_STATUSES = new Set([ + 'Backlog', + 'Todo', + 'In Progress', + 'DEV', + 'PROD', + 'Done', +]); + +const COMPLETE_DEPENDENCY_STATUSES = new Set(['DEV', 'PROD', 'Done']); function result(state, reason) { return { state, reason }; @@ -18,9 +27,26 @@ function blockerState(input) { if (hasUnreadableRelation(input.blockers)) { return result('BLOCKED_UNKNOWN', 'A blocking relationship is unreadable.'); } - if (input.blockers.some((blocker) => blocker.state !== 'closed')) { - return result('NOT_READY', 'At least one required blocker is still open.'); + + for (const blocker of input.blockers) { + 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; } @@ -42,7 +68,7 @@ function evaluateComposite(input) { return result('BLOCKED_UNKNOWN', 'A required sub-issue is unreadable.'); } const complete = input.requiredItems.every( - (item) => item.kind === 'subissue' && COMPLETE_SUBISSUE_STATES.has(item.state), + (item) => item.kind === 'subissue' && COMPLETE_DEPENDENCY_STATUSES.has(item.state), ); return complete ? result('READY_FOR_DEV', 'All required sub-issues are complete for DEV roll-up.') @@ -86,6 +112,10 @@ export function evaluateDevReadiness(rawInput) { return result('BLOCKED_UNKNOWN', 'Required structured GitHub state is missing.'); } + if (!KNOWN_PROJECT_STATUSES.has(input.currentStatus)) { + return result('BLOCKED_UNKNOWN', 'Project status is unknown.'); + } + const blocked = blockerState(input); const deliveryClass = classifyDelivery(input); let readiness; @@ -100,11 +130,21 @@ export function evaluateDevReadiness(rawInput) { readiness = blocked ?? evaluateCodeDelivery(input); } - if (input.currentStatus === 'DEV' && readiness.state !== 'READY_FOR_DEV') { - return result('INCONSISTENT', `Current status is DEV but readiness recomputation is ${readiness.state}.`); + 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 }; +export { INTEGRATION_BRANCHES, KNOWN_PROJECT_STATUSES }; From c35de05f6ff300fa64cc79e381510c34ebcf08d9 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:07:51 +0700 Subject: [PATCH 12/22] test(automation): expand DEV readiness safety fixtures --- automation/scenarios.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/automation/scenarios.json b/automation/scenarios.json index f97a03d..54ffe52 100644 --- a/automation/scenarios.json +++ b/automation/scenarios.json @@ -6,17 +6,22 @@ {"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"}]}}, {"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"}]}}, {"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"}]}}, {"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"}]}}, {"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"}]}}, {"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":[]}}, {"name":"metadata unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":false,"projectReadable":true}}, {"name":"Project status unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":false}}, + {"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"}]}}, {"name":"ambiguous Task fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Task","metadataReadable":true,"projectReadable":true}}, {"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}}, {"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"}]}}, {"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"}]}}, - {"name":"reopened blocker after DEV is inconsistent","expected":"INCONSISTENT","input":{"repository":"ChipIn-one/chipin-backend","currentStatus":"DEV","workKind":"Bug","metadataReadable":true,"projectReadable":true,"requiredItems":[{"kind":"pr","state":"merged","baseBranch":"develop"}],"blockers":[{"state":"open"}]}}, + {"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"}]}}, + {"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"}]}}, + {"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"}]}}, {"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"}]}} ] From c0e8d96a875bb51326dc8711a89ae338ee27f85d Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:09:23 +0700 Subject: [PATCH 13/22] docs(automation): clarify policy evaluator scope and safety --- automation/README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/automation/README.md b/automation/README.md index b93f5c7..2197153 100644 --- a/automation/README.md +++ b/automation/README.md @@ -1,15 +1,15 @@ -# DEV readiness dry-run +# DEV readiness policy evaluator This directory contains the pure, read-only decision logic for the future ChipIn Project `→ DEV` automation. -It does not call GitHub APIs and cannot mutate issues, Projects, fields, statuses, PRs, or relationships. The manual workflow only checks the evaluator against fixtures. +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. -- `NOT_READY` — readable state shows required work is incomplete. +- `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` no longer satisfies DEV readiness and needs human review. +- `INCONSISTENT` — an item already at `DEV` or `PROD` no longer satisfies DEV readiness and needs human review. ## Integration branches @@ -17,13 +17,17 @@ It does not call GitHub APIs and cannot mutate issues, Projects, fields, statuse - backend: `develop` - knowledge base: `main` -Deployment and informational `References` never gate `DEV`. `PROD`, `Done`, and parent closure are outside this evaluator and remain manual in v1. +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. -## Run +## 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 From 89314fd51765b5e7e4969daeba5eb2c2e5a8b130 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:15:30 +0700 Subject: [PATCH 14/22] test(automation): cover Codex fail-closed findings --- automation/dev-readiness.test.mjs | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index d552255..292fdc5 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -136,6 +136,46 @@ test('unknown Project status fails closed', () => { 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('missing readability flags fail closed', () => { const result = evaluateDevReadiness({ repository: 'ChipIn-one/chipin-frontend', From f691bf0d7b82083fde53a96efe3d6361cbac081a Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:16:15 +0700 Subject: [PATCH 15/22] fix(automation): fail closed on unsupported and nullable state --- automation/dev-readiness.mjs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index b8de419..ac44a68 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -13,13 +13,15 @@ const KNOWN_PROJECT_STATUSES = new Set([ 'Done', ]); +const KNOWN_WORK_KINDS = new Set(['Task', 'Feature', 'Bug']); +const KNOWN_DELIVERY_CLASSES = new Set(['code', 'non-code']); const COMPLETE_DEPENDENCY_STATUSES = new Set(['DEV', 'PROD', 'Done']); function result(state, reason) { return { state, reason }; } -function hasUnreadableRelation(items = []) { +function hasUnreadableRelation(items) { return items.some((item) => item?.readable === false || !item?.state); } @@ -51,7 +53,7 @@ function blockerState(input) { } function classifyDelivery(input) { - if (input.deliveryClass === 'code' || input.deliveryClass === 'non-code') { + if (input.deliveryClass !== undefined) { return input.deliveryClass; } if (input.workKind === 'Bug' || input.workKind === 'Feature') { @@ -112,10 +114,25 @@ export function evaluateDevReadiness(rawInput) { return result('BLOCKED_UNKNOWN', 'Required structured GitHub state is missing.'); } + 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; @@ -147,4 +164,8 @@ export function evaluateDevReadiness(rawInput) { return readiness; } -export { INTEGRATION_BRANCHES, KNOWN_PROJECT_STATUSES }; +export { + INTEGRATION_BRANCHES, + KNOWN_PROJECT_STATUSES, + KNOWN_WORK_KINDS, +}; From a27b04704d7f8223365922254d8b284110e2598a Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:20:15 +0700 Subject: [PATCH 16/22] test(automation): require explicit blocker relationship read --- automation/dev-readiness.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index 292fdc5..0c1084a 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -176,6 +176,18 @@ test('null blockers fail closed instead of throwing', () => { 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('missing readability flags fail closed', () => { const result = evaluateDevReadiness({ repository: 'ChipIn-one/chipin-frontend', From 85d5c93a760f25a0a015c8dfbbf76ee9612fd404 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:21:56 +0700 Subject: [PATCH 17/22] test(automation): make successful blocker reads explicit --- automation/scenarios.json | 150 +++++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 25 deletions(-) diff --git a/automation/scenarios.json b/automation/scenarios.json index 54ffe52..63e6168 100644 --- a/automation/scenarios.json +++ b/automation/scenarios.json @@ -1,27 +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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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":[]}}, - {"name":"metadata unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":false,"projectReadable":true}}, - {"name":"Project status unavailable fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Feature","metadataReadable":true,"projectReadable":false}}, - {"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"}]}}, - {"name":"ambiguous Task fails closed","expected":"BLOCKED_UNKNOWN","input":{"repository":"ChipIn-one/chipin-frontend","currentStatus":"In Progress","workKind":"Task","metadataReadable":true,"projectReadable":true}}, - {"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}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}}, - {"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"}]}} + { + "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":[]} + } ] From 13df201fb31c3aaea112c0bced96cd382d87be8f Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:22:25 +0700 Subject: [PATCH 18/22] fix(automation): require explicit blocker read --- automation/dev-readiness.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index ac44a68..5baf4d2 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -97,13 +97,15 @@ function evaluateCodeDelivery(input) { } export function evaluateDevReadiness(rawInput) { + const source = rawInput ?? {}; + const blockersRead = Object.hasOwn(source, 'blockers'); const input = { requiredItems: [], blockers: [], metadataReadable: false, projectReadable: false, isCompositeParent: false, - ...rawInput, + ...source, }; if (!input.metadataReadable || !input.projectReadable) { @@ -114,6 +116,10 @@ export function evaluateDevReadiness(rawInput) { 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.'); } From 50d210465799c758da42eb7684d36006dfa041be Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:44:46 +0700 Subject: [PATCH 19/22] test(automation): cover unsupported blocker state --- automation/dev-readiness.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index 0c1084a..96bf0b6 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -62,6 +62,16 @@ test('open blocker already at DEV no longer blocks integration readiness', () => 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'); From cf824fa15bc3f60dd8a5e1c656277cd581c0ea3a Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 21:46:13 +0700 Subject: [PATCH 20/22] fix(automation): reject unsupported blocker state --- automation/dev-readiness.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index 5baf4d2..20def1e 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -15,6 +15,7 @@ const KNOWN_PROJECT_STATUSES = new Set([ 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 COMPLETE_DEPENDENCY_STATUSES = new Set(['DEV', 'PROD', 'Done']); function result(state, reason) { @@ -31,6 +32,10 @@ function blockerState(input) { } 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; } From 0fe1745f5f1946c978a71e29ae7955050ca9755d Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 22:00:36 +0700 Subject: [PATCH 21/22] test(automation): cover required-item read failures --- automation/dev-readiness.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/automation/dev-readiness.test.mjs b/automation/dev-readiness.test.mjs index 96bf0b6..67a9390 100644 --- a/automation/dev-readiness.test.mjs +++ b/automation/dev-readiness.test.mjs @@ -198,6 +198,27 @@ test('omitted blockers fail closed instead of assuming an empty successful read' 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', From 7c15141a3302a9eebd1ed7521a3edd4307b42370 Mon Sep 17 00:00:00 2001 From: Syllik Date: Thu, 10 Sep 2026 22:02:07 +0700 Subject: [PATCH 22/22] fix(automation): fail closed on required relationship reads --- automation/dev-readiness.mjs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/automation/dev-readiness.mjs b/automation/dev-readiness.mjs index 20def1e..cdce4a7 100644 --- a/automation/dev-readiness.mjs +++ b/automation/dev-readiness.mjs @@ -16,6 +16,7 @@ const KNOWN_PROJECT_STATUSES = new Set([ 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) { @@ -74,8 +75,15 @@ function evaluateComposite(input) { 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) => item.kind === 'subissue' && COMPLETE_DEPENDENCY_STATUSES.has(item.state), + (item) => COMPLETE_DEPENDENCY_STATUSES.has(item.state), ); return complete ? result('READY_FOR_DEV', 'All required sub-issues are complete for DEV roll-up.') @@ -93,8 +101,20 @@ function evaluateCodeDelivery(input) { 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.kind === 'pr' && item.state === 'merged' && item.baseBranch === expectedBranch, + (item) => item.state === 'merged' && item.baseBranch === expectedBranch, ); return allIntegrated ? result('READY_FOR_DEV', `All required PRs are merged to ${expectedBranch}.`) @@ -104,6 +124,7 @@ function evaluateCodeDelivery(input) { export function evaluateDevReadiness(rawInput) { const source = rawInput ?? {}; const blockersRead = Object.hasOwn(source, 'blockers'); + const requiredItemsRead = Object.hasOwn(source, 'requiredItems'); const input = { requiredItems: [], blockers: [], @@ -152,6 +173,8 @@ export function evaluateDevReadiness(rawInput) { 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 {