Victor Casado 33f2219307 fix: address second round of code-review findings
actions.js:
- Add assertValidRepo/assertValidIssueNumber guards at the top of all
  action handlers (applyClaim, applySync, applyValidate, applyPublish,
  applyReview, applyDecompose, applyUnblock) for fast-fail validation
- applyValidate: fix status transition — set 'validated' unconditionally
  when ok=true instead of preserving 'blocked' (was inconsistent with
  projectState becoming 'ready')

gh-api.js:
- runGh: preserve GITHUB_TOKEN by default; only delete when caller
  explicitly sets options.stripGithubToken=true (was deleting by
  default, breaking CI)

parsing.js:
- extractCoordinationState: throw SyntaxError on malformed JSON instead
  of silently returning null — lets callers distinguish bad JSON from
  absent marker
- normalizeBodyForComparison: fix regex to match JSON-quoted form
  "lastSyncAt": ... instead of bare lastSyncAt: ...

policy.js:
- loadPolicy: validate that parsed JSON is a plain object before
  spreading; coerce nested fields (labels, review, validation,
  branchModel, project, fieldNames) to objects before merging

state.js:
- assertIssueClaimable: block re-claim on status alone (not status AND
  owner) to prevent {status:'claimed', owner:null} bypass; use
  state.owner || 'unknown' in error message
- getCoordinationState: catch SyntaxError from extractCoordinationState,
  log warning to stderr, fall back to default state

tests/lib:
- Update malformed-JSON test to expect SyntaxError throw instead of null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:25:58 -04:00

102 lines
3.5 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_FILE = 'github-native-coordination.json';
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE);
const DEFAULT_SECTION_MARKER = 'ecc-coordination';
const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1';
const DEFAULT_LABELS = Object.freeze({
epic: 'epic',
available: 'coordination:available',
claimed: 'coordination:claimed',
ready: 'coordination:ready',
blocked: 'coordination:blocked',
validated: 'coordination:validated',
reviewRequested: 'coordination:review-requested',
reviewApproved: 'coordination:review-approved',
reviewChangesRequested: 'coordination:review-changes-requested',
published: 'coordination:published',
synced: 'coordination:synced',
});
const DEFAULT_POLICY = Object.freeze({
schemaVersion: DEFAULT_SCHEMA_VERSION,
sectionMarker: DEFAULT_SECTION_MARKER,
labels: DEFAULT_LABELS,
review: {
required: true,
defaultMode: 'required',
},
validation: {
required: true,
},
branchModel: {
epicOnly: true,
taskBranches: false,
},
project: {
enabled: false,
fieldNames: {
status: 'Status',
owner: 'Owner',
branch: 'Branch',
validation: 'Validation',
review: 'Review',
},
},
});
function loadPolicy(rootDir = process.cwd(), configPath = null) {
const resolvedPath = configPath
? path.resolve(configPath)
: path.join(rootDir, 'config', DEFAULT_CONFIG_FILE);
if (!fs.existsSync(resolvedPath)) {
return {
...DEFAULT_POLICY,
sourcePath: null,
};
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
} catch (error) {
throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Policy file ${resolvedPath} must contain a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
}
const labels = typeof parsed.labels === 'object' && parsed.labels !== null && !Array.isArray(parsed.labels) ? parsed.labels : {};
const review = typeof parsed.review === 'object' && parsed.review !== null && !Array.isArray(parsed.review) ? parsed.review : {};
const validation = typeof parsed.validation === 'object' && parsed.validation !== null && !Array.isArray(parsed.validation) ? parsed.validation : {};
const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {};
const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {};
const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {};
return {
...DEFAULT_POLICY,
...parsed,
labels: { ...DEFAULT_LABELS, ...labels },
review: { ...DEFAULT_POLICY.review, ...review },
validation: { ...DEFAULT_POLICY.validation, ...validation },
branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel },
project: {
...DEFAULT_POLICY.project,
...project,
fieldNames: { ...DEFAULT_POLICY.project.fieldNames, ...fieldNames },
},
sourcePath: resolvedPath,
};
}
module.exports = {
DEFAULT_CONFIG_FILE,
DEFAULT_CONFIG_PATH,
DEFAULT_LABELS,
DEFAULT_POLICY,
DEFAULT_SCHEMA_VERSION,
DEFAULT_SECTION_MARKER,
loadPolicy,
};