mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
Merge pull request #2236 from Victor-Casado/feat/github-native-coordination
feat: add github-native coordination (epic-* commands + scripts + tests). Command registry + catalog reconciled.
This commit is contained in:
commit
1c0c780452
@ -11,7 +11,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Harness-native ECC operator layer - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
"description": "Harness-native ECC operator layer - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
"url": "https://x.com/affaanmustafa"
|
"url": "https://x.com/affaanmustafa"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — Agent Instructions
|
# Everything Claude Code (ECC) — Agent Instructions
|
||||||
|
|
||||||
This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 85 commands, and automated hook workflows for software development.
|
This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 92 commands, and automated hook workflows for software development.
|
||||||
|
|
||||||
**Version:** 2.0.0
|
**Version:** 2.0.0
|
||||||
|
|
||||||
@ -153,7 +153,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
|||||||
```
|
```
|
||||||
agents/ — 67 specialized subagents
|
agents/ — 67 specialized subagents
|
||||||
skills/ — 269 workflow skills and domain knowledge
|
skills/ — 269 workflow skills and domain knowledge
|
||||||
commands/ — 85 slash commands
|
commands/ — 92 slash commands
|
||||||
hooks/ — Trigger-based automations
|
hooks/ — Trigger-based automations
|
||||||
rules/ — Always-follow guidelines (common + per-language)
|
rules/ — Always-follow guidelines (common + per-language)
|
||||||
scripts/ — Cross-platform Node.js utilities
|
scripts/ — Cross-platform Node.js utilities
|
||||||
|
|||||||
@ -428,7 +428,7 @@ If you stacked methods, clean up in this order:
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**That's it!** You now have access to 67 agents, 269 skills, and 85 legacy command shims.
|
**That's it!** You now have access to 67 agents, 269 skills, and 92 legacy command shims.
|
||||||
|
|
||||||
### Dashboard GUI
|
### Dashboard GUI
|
||||||
|
|
||||||
@ -1516,7 +1516,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
|||||||
| Feature | Claude Code | OpenCode | Status |
|
| Feature | Claude Code | OpenCode | Status |
|
||||||
|---------|---------------------|----------|--------|
|
|---------|---------------------|----------|--------|
|
||||||
| Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** |
|
| Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** |
|
||||||
| Commands | PASS: 85 commands | PASS: 35 commands | **Claude Code leads** |
|
| Commands | PASS: 92 commands | PASS: 35 commands | **Claude Code leads** |
|
||||||
| Skills | PASS: 269 skills | PASS: 37 skills | **Claude Code leads** |
|
| Skills | PASS: 269 skills | PASS: 37 skills | **Claude Code leads** |
|
||||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||||
@ -1677,7 +1677,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
|||||||
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot |
|
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot |
|
||||||
|---------|-----------------------|------------|-----------|----------|----------------|
|
|---------|-----------------------|------------|-----------|----------|----------------|
|
||||||
| **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
|
| **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
|
||||||
| **Commands** | 85 | Shared | Instruction-based | 35 | 5 prompts |
|
| **Commands** | 92 | Shared | Instruction-based | 35 | 5 prompts |
|
||||||
| **Skills** | 269 | Shared | 10 (native format) | 37 | Via instructions |
|
| **Skills** | 269 | Shared | 10 (native format) | 37 | Via instructions |
|
||||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
|
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
|
||||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |
|
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |
|
||||||
|
|||||||
@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成!** 你现在可以使用 67 个代理、269 个技能和 85 个命令。
|
**完成!** 你现在可以使用 67 个代理、269 个技能和 92 个命令。
|
||||||
|
|
||||||
### multi-* 命令需要额外配置
|
### multi-* 命令需要额外配置
|
||||||
|
|
||||||
|
|||||||
26
commands/epic-claim.md
Normal file
26
commands/epic-claim.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Claim an epic issue, stamp coordination state, and sync local ownership.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-claim
|
||||||
|
|
||||||
|
Claim one epic issue as the source of truth for a unit of work.
|
||||||
|
|
||||||
|
Use the coordination script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js claim <issue-number> --repo <owner/repo> --actor <login>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Loads the issue body and coordination block.
|
||||||
|
2. Marks the epic as claimed in GitHub issue state.
|
||||||
|
3. Updates labels and the local SQLite cache.
|
||||||
|
4. Appends an audit comment for the claim.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/orch-add-feature`
|
||||||
|
- `/orch-change-feature`
|
||||||
|
- `/prp-implement`
|
||||||
23
commands/epic-decompose.md
Normal file
23
commands/epic-decompose.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Break an epic into task children without creating task branches.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-decompose
|
||||||
|
|
||||||
|
Reconcile the task breakdown for one epic issue.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js decompose <issue-number> --repo <owner/repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Reads the epic issue body for task checklists and dependency references.
|
||||||
|
2. Stores the decomposition in the coordination block.
|
||||||
|
3. Leaves task branches out of the workflow.
|
||||||
|
4. Appends a concise audit comment.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/plan`
|
||||||
|
- `/prp-plan`
|
||||||
23
commands/epic-publish.md
Normal file
23
commands/epic-publish.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Publish a validated epic update back to the issue and local cache.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-publish
|
||||||
|
|
||||||
|
Publish a validated coordination update to GitHub.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js publish <issue-number> --repo <owner/repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Re-validates the epic before publishing.
|
||||||
|
2. Updates the coordination block in the issue body.
|
||||||
|
3. Appends a concise publish comment.
|
||||||
|
4. Records the final local snapshot.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/pr`
|
||||||
|
- `/prp-pr`
|
||||||
23
commands/epic-review.md
Normal file
23
commands/epic-review.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Mark epic review requested, approved, or changes requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-review
|
||||||
|
|
||||||
|
Coordinate review state for an epic issue.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js review <issue-number> --repo <owner/repo> --review approved
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Updates the review state in the coordination block.
|
||||||
|
2. Syncs review labels to GitHub.
|
||||||
|
3. Records the review outcome in an audit comment.
|
||||||
|
4. Keeps the local cache aligned with the issue body.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/review-pr`
|
||||||
|
- `/code-review`
|
||||||
23
commands/epic-sync.md
Normal file
23
commands/epic-sync.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Sync epic issue bodies, labels, and local coordination snapshots from GitHub.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-sync
|
||||||
|
|
||||||
|
Run a deterministic sync for epic issues.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js sync --repo <owner/repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Reads issue bodies as the canonical epic state.
|
||||||
|
2. Reconciles the coordination block with labels.
|
||||||
|
3. Writes a fresh local snapshot for each epic issue.
|
||||||
|
4. Keeps the SQLite cache aligned with GitHub.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/projects`
|
||||||
|
- `/work-items sync-github`
|
||||||
22
commands/epic-unblock.md
Normal file
22
commands/epic-unblock.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Sweep blocked epic issues and reopen anything whose dependencies are closed.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-unblock
|
||||||
|
|
||||||
|
Sweep blocked epics whose declared dependencies are complete.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js unblock --repo <owner/repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
1. Scans epic issues in the repository.
|
||||||
|
2. Checks each blocked epic's dependency list.
|
||||||
|
3. Moves fully unblocked epics to ready.
|
||||||
|
4. Updates labels, comments, and local snapshots.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/loop-status`
|
||||||
22
commands/epic-validate.md
Normal file
22
commands/epic-validate.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Validate epic readiness, dependencies, and coordination policy.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /epic-validate
|
||||||
|
|
||||||
|
Validate a single epic issue before publishing or review handoff.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/github-coordination.js validate <issue-number> --repo <owner/repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
What this checks:
|
||||||
|
|
||||||
|
1. Coordination state exists and is parseable.
|
||||||
|
2. Validation state is satisfied by policy.
|
||||||
|
3. Declared dependencies are closed.
|
||||||
|
4. The epic is ready for the next workflow stage.
|
||||||
|
|
||||||
|
Compatibility aliases:
|
||||||
|
|
||||||
|
- `/quality-gate`
|
||||||
38
config/github-native-coordination.json
Normal file
38
config/github-native-coordination.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "ecc.github.coordination.v1",
|
||||||
|
"sectionMarker": "ecc-coordination",
|
||||||
|
"labels": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"totalCommands": 85,
|
"totalCommands": 92,
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "aside",
|
"command": "aside",
|
||||||
@ -111,6 +111,72 @@
|
|||||||
],
|
],
|
||||||
"path": "commands/ecc-guide.md"
|
"path": "commands/ecc-guide.md"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-claim",
|
||||||
|
"description": "Claim an epic issue, stamp coordination state, and sync local ownership.",
|
||||||
|
"type": "review",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [
|
||||||
|
"orch-add-feature",
|
||||||
|
"orch-change-feature"
|
||||||
|
],
|
||||||
|
"path": "commands/epic-claim.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-decompose",
|
||||||
|
"description": "Break an epic into task children without creating task branches.",
|
||||||
|
"type": "review",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-decompose.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-publish",
|
||||||
|
"description": "Publish a validated epic update back to the issue and local cache.",
|
||||||
|
"type": "general",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-publish.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-review",
|
||||||
|
"description": "Mark epic review requested, approved, or changes requested.",
|
||||||
|
"type": "review",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-review.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-sync",
|
||||||
|
"description": "Sync epic issue bodies, labels, and local coordination snapshots from GitHub.",
|
||||||
|
"type": "general",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-sync.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-unblock",
|
||||||
|
"description": "Sweep blocked epic issues and reopen anything whose dependencies are closed.",
|
||||||
|
"type": "general",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-unblock.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "epic-validate",
|
||||||
|
"description": "Validate epic readiness, dependencies, and coordination policy.",
|
||||||
|
"type": "review",
|
||||||
|
"primaryAgents": [],
|
||||||
|
"allAgents": [],
|
||||||
|
"skills": [],
|
||||||
|
"path": "commands/epic-validate.md"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "evolve",
|
"command": "evolve",
|
||||||
"description": "Analyze instincts and suggest or generate evolved structures",
|
"description": "Analyze instincts and suggest or generate evolved structures",
|
||||||
@ -941,11 +1007,11 @@
|
|||||||
"statistics": {
|
"statistics": {
|
||||||
"byType": {
|
"byType": {
|
||||||
"build": 2,
|
"build": 2,
|
||||||
"general": 7,
|
"general": 10,
|
||||||
"orchestration": 11,
|
"orchestration": 11,
|
||||||
"planning": 2,
|
"planning": 2,
|
||||||
"refactoring": 1,
|
"refactoring": 1,
|
||||||
"review": 9,
|
"review": 13,
|
||||||
"testing": 53
|
"testing": 53
|
||||||
},
|
},
|
||||||
"topAgents": [
|
"topAgents": [
|
||||||
@ -995,6 +1061,14 @@
|
|||||||
"skill": "continuous-learning-v2",
|
"skill": "continuous-learning-v2",
|
||||||
"count": 6
|
"count": 6
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"skill": "orch-add-feature",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"skill": "orch-change-feature",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"skill": "tdd-workflow",
|
"skill": "tdd-workflow",
|
||||||
"count": 4
|
"count": 4
|
||||||
@ -1007,14 +1081,6 @@
|
|||||||
"skill": "flutter-dart-code-review",
|
"skill": "flutter-dart-code-review",
|
||||||
"count": 3
|
"count": 3
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"skill": "orch-add-feature",
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"skill": "orch-change-feature",
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"skill": "orch-fix-defect",
|
"skill": "orch-fix-defect",
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — 智能体指令
|
# Everything Claude Code (ECC) — 智能体指令
|
||||||
|
|
||||||
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、85 条命令以及自动化钩子工作流,用于软件开发。
|
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、92 条命令以及自动化钩子工作流,用于软件开发。
|
||||||
|
|
||||||
**版本:** 2.0.0
|
**版本:** 2.0.0
|
||||||
|
|
||||||
@ -148,7 +148,7 @@
|
|||||||
```
|
```
|
||||||
agents/ — 67 个专业子代理
|
agents/ — 67 个专业子代理
|
||||||
skills/ — 269 个工作流技能和领域知识
|
skills/ — 269 个工作流技能和领域知识
|
||||||
commands/ — 85 个斜杠命令
|
commands/ — 92 个斜杠命令
|
||||||
hooks/ — 基于触发的自动化
|
hooks/ — 基于触发的自动化
|
||||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||||
scripts/ — 跨平台 Node.js 实用工具
|
scripts/ — 跨平台 Node.js 实用工具
|
||||||
|
|||||||
@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**搞定!** 你现在可以使用 67 个智能体、269 项技能和 85 个命令了。
|
**搞定!** 你现在可以使用 67 个智能体、269 项技能和 92 个命令了。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@ -1141,7 +1141,7 @@ opencode
|
|||||||
| 功能特性 | Claude Code | OpenCode | 状态 |
|
| 功能特性 | Claude Code | OpenCode | 状态 |
|
||||||
|---------|---------------|----------|--------|
|
|---------|---------------|----------|--------|
|
||||||
| 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** |
|
| 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||||
| 命令 | PASS: 85 个 | PASS: 35 个 | **Claude Code 领先** |
|
| 命令 | PASS: 92 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||||
| 技能 | PASS: 269 项 | PASS: 37 项 | **Claude Code 领先** |
|
| 技能 | PASS: 269 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||||
@ -1249,7 +1249,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
|||||||
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||||
|---------|-----------------------|------------|-----------|----------|
|
|---------|-----------------------|------------|-----------|----------|
|
||||||
| **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
| **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||||
| **命令** | 85 | 共享 | 基于指令 | 35 |
|
| **命令** | 92 | 共享 | 基于指令 | 35 |
|
||||||
| **技能** | 269 | 共享 | 10 (原生格式) | 37 |
|
| **技能** | 269 | 共享 | 10 (原生格式) | 37 |
|
||||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||||
|
|||||||
196
scripts/github-coordination.js
Normal file
196
scripts/github-coordination.js
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const {
|
||||||
|
applyClaim,
|
||||||
|
applyDecompose,
|
||||||
|
applyPublish,
|
||||||
|
applyReview,
|
||||||
|
applySync,
|
||||||
|
applyUnblock,
|
||||||
|
applyValidate,
|
||||||
|
formatCollection,
|
||||||
|
formatSummary,
|
||||||
|
loadPolicy,
|
||||||
|
normalizeIssueNumber,
|
||||||
|
openStore,
|
||||||
|
} = require('./lib/github-coordination');
|
||||||
|
|
||||||
|
function usage(exitCode = 0) {
|
||||||
|
console.log([
|
||||||
|
'Usage: node scripts/github-coordination.js <command> [options]',
|
||||||
|
'',
|
||||||
|
'Commands:',
|
||||||
|
' claim <issue-number> Claim an epic issue and stamp coordination state',
|
||||||
|
' sync Sync epic issue bodies, labels, and local snapshots',
|
||||||
|
' validate <issue-number> Validate epic readiness and dependency status',
|
||||||
|
' publish <issue-number> Publish a validated epic update/comment',
|
||||||
|
' review <issue-number> Mark review requested/approved/blocked',
|
||||||
|
' unblock Sweep blocked epics whose dependencies are closed',
|
||||||
|
' decompose <issue-number> Reconcile epic task breakdown from issue body',
|
||||||
|
'',
|
||||||
|
'Options:',
|
||||||
|
' --repo <owner/repo> GitHub repository',
|
||||||
|
' --issue <number> Issue number for actions that target one issue',
|
||||||
|
' --actor <login> Claim owner / coordination actor',
|
||||||
|
' --branch <name> Epic branch name to stamp into the coordination body',
|
||||||
|
' --config <path> Optional coordination policy config',
|
||||||
|
' --db <path> SQLite state store path',
|
||||||
|
' --home <dir> Override home directory used by the state store',
|
||||||
|
' --limit <n> Limit issues scanned by sync/unblock',
|
||||||
|
' --dry-run Preview changes without modifying GitHub or state',
|
||||||
|
' --json Emit machine-readable JSON',
|
||||||
|
' --help, -h Show this help',
|
||||||
|
].join('\n'));
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue(args, index, flagName) {
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!value || value.startsWith('--')) {
|
||||||
|
throw new Error(`${flagName} requires a value`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean flags: map flag string → setter(parsed)
|
||||||
|
const BOOL_FLAGS = new Map([
|
||||||
|
['--help', p => { p.help = true; }],
|
||||||
|
['-h', p => { p.help = true; }],
|
||||||
|
['--json', p => { p.json = true; }],
|
||||||
|
['--dry-run', p => { p.dryRun = true; }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Value flags: map flag string → setter(parsed, value)
|
||||||
|
const VALUE_FLAGS = new Map([
|
||||||
|
['--repo', (p, v) => { p.repo = v; }],
|
||||||
|
['--actor', (p, v) => { p.actor = v; }],
|
||||||
|
['--branch', (p, v) => { p.branch = v; }],
|
||||||
|
['--config', (p, v) => { p.configPath = v; }],
|
||||||
|
['--db', (p, v) => { p.dbPath = v; }],
|
||||||
|
['--home', (p, v) => { p.homeDir = v; }],
|
||||||
|
['--validation', (p, v) => { p.validation = v; }],
|
||||||
|
['--review', (p, v) => { p.review = v; }],
|
||||||
|
['--status', (p, v) => { p.status = v; }],
|
||||||
|
['--project-state', (p, v) => { p.projectState = v; }],
|
||||||
|
['--issue', (p, v) => { p.issueNumber = normalizeIssueNumber(v); }],
|
||||||
|
['--limit', (p, v) => { p.limit = normalizeIssueNumber(v); }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = argv.slice(2);
|
||||||
|
const parsed = {
|
||||||
|
command: null, actor: null, branch: null, configPath: null,
|
||||||
|
dbPath: null, dryRun: false, help: false, homeDir: null,
|
||||||
|
issueNumber: null, json: false, limit: 100, repo: null,
|
||||||
|
validation: null, review: null, status: null, projectState: null,
|
||||||
|
positionals: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.length > 0 && !args[0].startsWith('-')) {
|
||||||
|
parsed.command = args.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (BOOL_FLAGS.has(arg)) {
|
||||||
|
BOOL_FLAGS.get(arg)(parsed);
|
||||||
|
} else if (VALUE_FLAGS.has(arg)) {
|
||||||
|
VALUE_FLAGS.get(arg)(parsed, readValue(args, i, arg));
|
||||||
|
i += 1;
|
||||||
|
} else if (!arg.startsWith('-')) {
|
||||||
|
parsed.positionals.push(arg);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.command) parsed.command = 'sync';
|
||||||
|
if (!parsed.issueNumber && parsed.positionals.length > 0) {
|
||||||
|
parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchCommand(options, ctx) {
|
||||||
|
const { store, policy, rootDir } = ctx;
|
||||||
|
const base = { configPath: options.configPath, dryRun: options.dryRun };
|
||||||
|
|
||||||
|
if (options.command === 'claim') {
|
||||||
|
if (!options.issueNumber) throw new Error('Missing issue number.');
|
||||||
|
return applyClaim(options.repo, options.issueNumber, {
|
||||||
|
...base, actor: options.actor, branch: options.branch, owner: options.actor,
|
||||||
|
projectState: options.projectState, review: options.review,
|
||||||
|
status: options.status, validation: options.validation,
|
||||||
|
}, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'sync') {
|
||||||
|
return applySync(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'validate') {
|
||||||
|
if (!options.issueNumber) throw new Error('Missing issue number.');
|
||||||
|
return applyValidate(options.repo, options.issueNumber, base, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'publish') {
|
||||||
|
if (!options.issueNumber) throw new Error('Missing issue number.');
|
||||||
|
return applyPublish(options.repo, options.issueNumber, base, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'review') {
|
||||||
|
if (!options.issueNumber) throw new Error('Missing issue number.');
|
||||||
|
return applyReview(options.repo, options.issueNumber, { ...base, review: options.review }, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'unblock') {
|
||||||
|
return applyUnblock(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
if (options.command === 'decompose') {
|
||||||
|
if (!options.issueNumber) throw new Error('Missing issue number.');
|
||||||
|
return applyDecompose(options.repo, options.issueNumber, base, { store, policy, rootDir });
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown command: ${options.command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutput(payload, options) {
|
||||||
|
if (options.json) {
|
||||||
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
} else if (options.command === 'sync' || options.command === 'unblock') {
|
||||||
|
process.stdout.write(formatCollection(payload));
|
||||||
|
} else {
|
||||||
|
process.stdout.write(formatSummary(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let store = null;
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv);
|
||||||
|
if (options.help) usage(0);
|
||||||
|
if (!options.repo) throw new Error('Missing --repo <owner/repo>.');
|
||||||
|
|
||||||
|
const policy = loadPolicy(process.cwd(), options.configPath);
|
||||||
|
store = await openStore({
|
||||||
|
dbPath: options.dbPath,
|
||||||
|
homeDir: options.homeDir || process.env.HOME || os.homedir(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = dispatchCommand(options, { store, policy, rootDir: process.cwd() });
|
||||||
|
formatOutput(payload, options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (store) store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
main,
|
||||||
|
parseArgs,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
57
scripts/lib/github-coordination.js
Normal file
57
scripts/lib/github-coordination.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const policy = require('./github-coordination/policy');
|
||||||
|
const parsing = require('./github-coordination/parsing');
|
||||||
|
const ghApi = require('./github-coordination/gh-api');
|
||||||
|
const state = require('./github-coordination/state');
|
||||||
|
const actions = require('./github-coordination/actions');
|
||||||
|
const store = require('./github-coordination/store');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_CONFIG_FILE: policy.DEFAULT_CONFIG_FILE,
|
||||||
|
DEFAULT_CONFIG_PATH: policy.DEFAULT_CONFIG_PATH,
|
||||||
|
DEFAULT_POLICY: policy.DEFAULT_POLICY,
|
||||||
|
DEFAULT_SCHEMA_VERSION: policy.DEFAULT_SCHEMA_VERSION,
|
||||||
|
loadPolicy: policy.loadPolicy,
|
||||||
|
|
||||||
|
extractCoordinationState: parsing.extractCoordinationState,
|
||||||
|
extractIssueReferences: parsing.extractIssueReferences,
|
||||||
|
extractTasks: parsing.extractTasks,
|
||||||
|
mergeIssueBody: parsing.mergeIssueBody,
|
||||||
|
renderCoordinationState: parsing.renderCoordinationState,
|
||||||
|
|
||||||
|
commentIssue: ghApi.commentIssue,
|
||||||
|
editIssue: ghApi.editIssue,
|
||||||
|
getIssue: ghApi.getIssue,
|
||||||
|
listIssues: ghApi.listIssues,
|
||||||
|
normalizeIssueNumber: ghApi.normalizeIssueNumber,
|
||||||
|
normalizeLabels: ghApi.normalizeLabels,
|
||||||
|
normalizeRepo: ghApi.normalizeRepo,
|
||||||
|
runGh: ghApi.runGh,
|
||||||
|
runGhJson: ghApi.runGhJson,
|
||||||
|
|
||||||
|
buildIssueComment: state.buildIssueComment,
|
||||||
|
buildIssueStateFromAction: state.buildIssueStateFromAction,
|
||||||
|
defaultCoordinationState: state.defaultCoordinationState,
|
||||||
|
desiredLabelsForState: state.desiredLabelsForState,
|
||||||
|
getCoordinationState: state.getCoordinationState,
|
||||||
|
mapStateToWorkItemStatus: state.mapStateToWorkItemStatus,
|
||||||
|
slugifySegment: state.slugifySegment,
|
||||||
|
summarizeStateForOutput: state.summarizeStateForOutput,
|
||||||
|
syncIssueLabels: state.syncIssueLabels,
|
||||||
|
verifyDependenciesClosed: state.verifyDependenciesClosed,
|
||||||
|
|
||||||
|
applyClaim: actions.applyClaim,
|
||||||
|
applyDecompose: actions.applyDecompose,
|
||||||
|
applyPublish: actions.applyPublish,
|
||||||
|
applyReview: actions.applyReview,
|
||||||
|
applySync: actions.applySync,
|
||||||
|
applyUnblock: actions.applyUnblock,
|
||||||
|
applyValidate: actions.applyValidate,
|
||||||
|
formatCollection: actions.formatCollection,
|
||||||
|
formatSummary: actions.formatSummary,
|
||||||
|
|
||||||
|
epicWorkItemId: store.epicWorkItemId,
|
||||||
|
openStore: store.openStore,
|
||||||
|
upsertCoordinationWorkItem: store.upsertCoordinationWorkItem,
|
||||||
|
};
|
||||||
385
scripts/lib/github-coordination/actions.js
Normal file
385
scripts/lib/github-coordination/actions.js
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { loadPolicy } = require('./policy');
|
||||||
|
const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing');
|
||||||
|
const { getIssue, listIssues, editIssue, commentIssue, normalizeLabels } = require('./gh-api');
|
||||||
|
const {
|
||||||
|
assertIssueClaimable,
|
||||||
|
buildIssueComment,
|
||||||
|
buildIssueStateFromAction,
|
||||||
|
desiredLabelsForState,
|
||||||
|
getCoordinationState,
|
||||||
|
summarizeStateForOutput,
|
||||||
|
syncIssueLabels,
|
||||||
|
verifyDependenciesClosed,
|
||||||
|
} = require('./state');
|
||||||
|
const { upsertCoordinationWorkItem } = require('./store');
|
||||||
|
const { extractIssueReferences, extractTasks } = require('./parsing');
|
||||||
|
|
||||||
|
function assertValidRepo(repo) {
|
||||||
|
if (typeof repo !== 'string' || !repo.trim()) {
|
||||||
|
throw new Error(`invalid repo: expected non-empty string, got ${JSON.stringify(repo)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidIssueNumber(issueNumber) {
|
||||||
|
if (!Number.isFinite(issueNumber) || issueNumber <= 0 || !Number.isInteger(issueNumber)) {
|
||||||
|
throw new Error(`invalid issueNumber: expected positive integer, got ${JSON.stringify(issueNumber)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function staleCoordinationLabels(issue, nextLabels, policy) {
|
||||||
|
const epicLabel = policy.labels && policy.labels.epic;
|
||||||
|
return normalizeLabels(issue.labels).filter(l =>
|
||||||
|
(l.startsWith('coordination:') || l === epicLabel) && !nextLabels.includes(l)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write
|
||||||
|
// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read
|
||||||
|
// an unclaimed issue, pass the check, and both succeed — resulting in a
|
||||||
|
// double-claim. A code-review finding suggested fixing this via
|
||||||
|
// context.store.acquireLock(repo, issueNumber), but that API does not exist in
|
||||||
|
// store.js; adding a call to it would throw at runtime. Left as-is until a
|
||||||
|
// locking primitive is available — callers should prevent races via external
|
||||||
|
// serialization (e.g. a serialized job queue or GitHub branch-protection rule).
|
||||||
|
function applyClaim(repo, issueNumber, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
assertValidIssueNumber(issueNumber);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const store = context.store || null;
|
||||||
|
const issue = getIssue(repo, issueNumber, options);
|
||||||
|
const currentState = getCoordinationState(issue, policy);
|
||||||
|
|
||||||
|
assertIssueClaimable(issue, currentState);
|
||||||
|
|
||||||
|
const nextState = buildIssueStateFromAction(issue, currentState, 'claim', {
|
||||||
|
owner: options.actor || options.owner || currentState.owner || issue.author?.login || null,
|
||||||
|
branch: options.branch || currentState.branch || null,
|
||||||
|
status: options.status || 'claimed',
|
||||||
|
validation: options.validation || currentState.validation || 'pending',
|
||||||
|
review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'),
|
||||||
|
projectState: options.projectState || 'in-progress',
|
||||||
|
}, policy);
|
||||||
|
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
if (!options.dryRun) {
|
||||||
|
editIssue(repo, issueNumber, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options);
|
||||||
|
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySync(repo, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const store = context.store || null;
|
||||||
|
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
|
||||||
|
const syncedAt = new Date().toISOString();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const currentState = getCoordinationState(issue, policy);
|
||||||
|
const nextState = buildIssueStateFromAction(issue, currentState, 'sync', {
|
||||||
|
status: currentState.status,
|
||||||
|
validation: currentState.validation,
|
||||||
|
review: currentState.review,
|
||||||
|
projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog',
|
||||||
|
}, policy);
|
||||||
|
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options);
|
||||||
|
|
||||||
|
let snapshot = null;
|
||||||
|
if (!options.dryRun) {
|
||||||
|
if (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) {
|
||||||
|
editIssue(repo, issue.number, { body }, options);
|
||||||
|
}
|
||||||
|
snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy });
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy),
|
||||||
|
syncedAt,
|
||||||
|
labelPlan,
|
||||||
|
snapshot: snapshot || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repo,
|
||||||
|
syncedAt,
|
||||||
|
count: results.length,
|
||||||
|
items: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
assertValidIssueNumber(issueNumber);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const issue = existingIssue || getIssue(repo, issueNumber, options);
|
||||||
|
const state = getCoordinationState(issue, policy);
|
||||||
|
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
|
||||||
|
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options);
|
||||||
|
const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number));
|
||||||
|
const validations = [];
|
||||||
|
|
||||||
|
if (missingDependencies.length > 0) {
|
||||||
|
validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') });
|
||||||
|
} else {
|
||||||
|
validations.push({ check: 'dependencies', ok: true, detail: 'closed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = validations.every(entry => entry.ok);
|
||||||
|
const nextState = buildIssueStateFromAction(issue, state, 'validate', {
|
||||||
|
status: ok ? 'validated' : state.status,
|
||||||
|
validation: ok ? 'passed' : 'failed',
|
||||||
|
projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog',
|
||||||
|
}, policy);
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
editIssue(repo, issueNumber, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy),
|
||||||
|
ok,
|
||||||
|
validations,
|
||||||
|
missingDependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPublish(repo, issueNumber, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
assertValidIssueNumber(issueNumber);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const issue = getIssue(repo, issueNumber, options);
|
||||||
|
const state = getCoordinationState(issue, policy);
|
||||||
|
const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue);
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.review && policy.review.required && state.review !== 'approved') {
|
||||||
|
throw new Error(`Issue #${issueNumber} cannot be published: review approval required (current: ${state.review})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = buildIssueStateFromAction(issue, state, 'publish', {
|
||||||
|
status: 'published',
|
||||||
|
validation: 'passed',
|
||||||
|
review: state.review === 'changes-requested' ? state.review : 'approved',
|
||||||
|
projectState: 'done',
|
||||||
|
}, policy);
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
editIssue(repo, issueNumber, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, {
|
||||||
|
validation: 'passed',
|
||||||
|
}), options);
|
||||||
|
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReview(repo, issueNumber, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
assertValidIssueNumber(issueNumber);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const issue = getIssue(repo, issueNumber, options);
|
||||||
|
const state = getCoordinationState(issue, policy);
|
||||||
|
const reviewState = options.review || 'approved';
|
||||||
|
const nextState = buildIssueStateFromAction(issue, state, 'review', {
|
||||||
|
status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked',
|
||||||
|
review: reviewState,
|
||||||
|
projectState: reviewState === 'approved' ? 'ready' : 'blocked',
|
||||||
|
}, policy);
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
editIssue(repo, issueNumber, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, {
|
||||||
|
review: reviewState,
|
||||||
|
}), options);
|
||||||
|
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDecompose(repo, issueNumber, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
assertValidIssueNumber(issueNumber);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const issue = getIssue(repo, issueNumber, options);
|
||||||
|
const state = getCoordinationState(issue, policy);
|
||||||
|
const tasks = extractTasks(issue.body);
|
||||||
|
const dependencies = extractIssueReferences(issue.body);
|
||||||
|
const nextState = buildIssueStateFromAction(issue, state, 'decompose', {
|
||||||
|
tasks,
|
||||||
|
dependencies,
|
||||||
|
status: tasks.some(task => !task.done) ? 'claimed' : state.status,
|
||||||
|
projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog',
|
||||||
|
}, policy);
|
||||||
|
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
editIssue(repo, issueNumber, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, {
|
||||||
|
taskCount: String(tasks.length),
|
||||||
|
dependencyCount: String(dependencies.length),
|
||||||
|
}), options);
|
||||||
|
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy),
|
||||||
|
tasks,
|
||||||
|
dependencyCount: dependencies.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUnblock(repo, options = {}, context = {}) {
|
||||||
|
assertValidRepo(repo);
|
||||||
|
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||||
|
const store = context.store || null;
|
||||||
|
const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 });
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const state = getCoordinationState(issue, policy);
|
||||||
|
if (state.status !== 'blocked') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
|
||||||
|
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues);
|
||||||
|
if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = buildIssueStateFromAction(issue, state, 'unblock', {
|
||||||
|
status: 'ready',
|
||||||
|
projectState: 'ready',
|
||||||
|
validation: state.validation === 'failed' ? 'pending' : state.validation,
|
||||||
|
}, policy);
|
||||||
|
const trackedIssue = {
|
||||||
|
...issue,
|
||||||
|
labels: desiredLabelsForState(nextState, policy),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const body = mergeIssueBody(issue, nextState, policy);
|
||||||
|
editIssue(repo, issue.number, {
|
||||||
|
body,
|
||||||
|
addLabels: trackedIssue.labels,
|
||||||
|
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||||
|
}, options);
|
||||||
|
commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, {
|
||||||
|
dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none',
|
||||||
|
}), options);
|
||||||
|
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy });
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repo,
|
||||||
|
count: results.length,
|
||||||
|
items: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummary(payload) {
|
||||||
|
const lines = [
|
||||||
|
`${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`,
|
||||||
|
`Repo: ${payload.repo}`,
|
||||||
|
`Status: ${payload.status}`,
|
||||||
|
`Owner: ${payload.owner || '(unassigned)'}`,
|
||||||
|
`Branch: ${payload.branch || '(none)'}`,
|
||||||
|
`Validation: ${payload.validation || 'pending'}`,
|
||||||
|
`Review: ${payload.review || 'not-requested'}`,
|
||||||
|
];
|
||||||
|
if (payload.tasks && payload.tasks.length > 0) {
|
||||||
|
lines.push(`Tasks: ${payload.tasks.length}`);
|
||||||
|
}
|
||||||
|
if (payload.dependencies && payload.dependencies.length > 0) {
|
||||||
|
lines.push(`Dependencies: ${payload.dependencies.join(', ')}`);
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCollection(payload) {
|
||||||
|
const lines = [
|
||||||
|
`Repo: ${payload.repo}`,
|
||||||
|
`Items: ${payload.count}`,
|
||||||
|
];
|
||||||
|
for (const item of payload.items || []) {
|
||||||
|
lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`);
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyClaim,
|
||||||
|
applyDecompose,
|
||||||
|
applyPublish,
|
||||||
|
applyReview,
|
||||||
|
applySync,
|
||||||
|
applyUnblock,
|
||||||
|
applyValidate,
|
||||||
|
formatCollection,
|
||||||
|
formatSummary,
|
||||||
|
};
|
||||||
175
scripts/lib/github-coordination/gh-api.js
Normal file
175
scripts/lib/github-coordination/gh-api.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
function normalizeRepo(repo) {
|
||||||
|
const parts = String(repo || '').split('/').filter(Boolean);
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`);
|
||||||
|
}
|
||||||
|
const [owner, name] = parts;
|
||||||
|
return { owner, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIssueNumber(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`Invalid issue number: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabelValue(label) {
|
||||||
|
if (typeof label === 'string') {
|
||||||
|
return label.trim();
|
||||||
|
}
|
||||||
|
if (label && typeof label === 'object') {
|
||||||
|
return String(label.name || label.label || '').trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabels(labels) {
|
||||||
|
return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env || process.env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real
|
||||||
|
// `gh` binary and command/commandArgs execute an arbitrary script via
|
||||||
|
// process.execPath. This variable MUST only be set in trusted, isolated test
|
||||||
|
// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in
|
||||||
|
// production — doing so allows arbitrary script execution under the caller's
|
||||||
|
// privileges.
|
||||||
|
function runGh(args, options = {}) {
|
||||||
|
const shimPath = process.env.ECC_GH_SHIM;
|
||||||
|
const command = shimPath ? process.execPath : 'gh';
|
||||||
|
const commandArgs = shimPath ? [shimPath, ...args] : args;
|
||||||
|
const env = { ...process.env };
|
||||||
|
|
||||||
|
if (options.stripGithubToken) {
|
||||||
|
delete env.GITHUB_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCommand(command, commandArgs, { cwd: options.cwd, env });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGhJson(args, options = {}) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(runGh(args, options) || 'null');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIssue(repo, issueNumber, options = {}) {
|
||||||
|
const { owner, name } = normalizeRepo(repo);
|
||||||
|
const json = runGhJson([
|
||||||
|
'issue',
|
||||||
|
'view',
|
||||||
|
String(issueNumber),
|
||||||
|
'--repo',
|
||||||
|
`${owner}/${name}`,
|
||||||
|
'--json',
|
||||||
|
'number,title,body,url,state,labels,author,updatedAt,assignees',
|
||||||
|
], options);
|
||||||
|
|
||||||
|
if (!json) {
|
||||||
|
throw new Error(`Unable to load issue #${issueNumber} from ${repo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listIssues(repo, options = {}) {
|
||||||
|
const { owner, name } = normalizeRepo(repo);
|
||||||
|
const limit = Number.isFinite(options.limit) ? options.limit : 100;
|
||||||
|
const state = options.state || 'all';
|
||||||
|
return runGhJson([
|
||||||
|
'issue',
|
||||||
|
'list',
|
||||||
|
'--repo',
|
||||||
|
`${owner}/${name}`,
|
||||||
|
'--state',
|
||||||
|
state,
|
||||||
|
'--limit',
|
||||||
|
String(limit),
|
||||||
|
'--json',
|
||||||
|
'number,title,body,url,state,labels,author,updatedAt,assignees',
|
||||||
|
], options) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function editIssue(repo, issueNumber, options = {}) {
|
||||||
|
const { owner, name } = normalizeRepo(repo);
|
||||||
|
const args = [
|
||||||
|
'issue',
|
||||||
|
'edit',
|
||||||
|
String(issueNumber),
|
||||||
|
'--repo',
|
||||||
|
`${owner}/${name}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
args.push('--body', options.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of options.addLabels || []) {
|
||||||
|
args.push('--add-label', label);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of options.removeLabels || []) {
|
||||||
|
args.push('--remove-label', label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.title) {
|
||||||
|
args.push('--title', options.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.assignee) {
|
||||||
|
args.push('--add-assignee', options.assignee);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runGh(args, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentIssue(repo, issueNumber, body, options = {}) {
|
||||||
|
const { owner, name } = normalizeRepo(repo);
|
||||||
|
return runGh([
|
||||||
|
'issue',
|
||||||
|
'comment',
|
||||||
|
String(issueNumber),
|
||||||
|
'--repo',
|
||||||
|
`${owner}/${name}`,
|
||||||
|
'--body',
|
||||||
|
body,
|
||||||
|
], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
commentIssue,
|
||||||
|
editIssue,
|
||||||
|
getIssue,
|
||||||
|
listIssues,
|
||||||
|
normalizeIssueNumber,
|
||||||
|
normalizeLabels,
|
||||||
|
normalizeRepo,
|
||||||
|
runGh,
|
||||||
|
runGhJson,
|
||||||
|
};
|
||||||
143
scripts/lib/github-coordination/parsing.js
Normal file
143
scripts/lib/github-coordination/parsing.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy');
|
||||||
|
|
||||||
|
function escapeRegExp(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBodyForComparison(body) {
|
||||||
|
return (body || '').replace(/"lastSyncAt"\s*:\s*[^,\}\n]+/g, '"lastSyncAt": NORMALIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCoordinationState(body, policy = DEFAULT_POLICY) {
|
||||||
|
const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
|
||||||
|
const regex = new RegExp(
|
||||||
|
`<!--\\s*${marker}:start\\s*-->\\s*` +
|
||||||
|
'```json\\s*([\\s\\S]*?)\\s*```' +
|
||||||
|
`\\s*<!--\\s*${marker}:end\\s*-->`,
|
||||||
|
'm'
|
||||||
|
);
|
||||||
|
const match = String(body || '').match(regex);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1]);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
throw new SyntaxError(
|
||||||
|
`Malformed coordination JSON in body: ${error.message} — raw: ${match[1].slice(0, 120)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIssueReferences(text) {
|
||||||
|
const refs = new Set();
|
||||||
|
const source = String(text || '');
|
||||||
|
for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) {
|
||||||
|
refs.add(Number.parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTasks(body) {
|
||||||
|
const lines = String(body || '').split(/\r?\n/);
|
||||||
|
const tasks = [];
|
||||||
|
let inTasks = false;
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) {
|
||||||
|
inTasks = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTasks && /^#{2,3}\s+\S/.test(line)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (inTasks) {
|
||||||
|
const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i);
|
||||||
|
if (taskMatch) {
|
||||||
|
tasks.push({
|
||||||
|
title: taskMatch[2].trim(),
|
||||||
|
done: taskMatch[1].toLowerCase() === 'x',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringList(value) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.split(',')
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCoordinationState(state, policy = DEFAULT_POLICY) {
|
||||||
|
const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER;
|
||||||
|
const payload = {
|
||||||
|
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||||
|
kind: state.kind || 'epic',
|
||||||
|
status: state.status || 'available',
|
||||||
|
owner: state.owner || null,
|
||||||
|
branch: state.branch || null,
|
||||||
|
validation: state.validation || 'pending',
|
||||||
|
review: state.review || 'not-requested',
|
||||||
|
project: state.project || { state: 'backlog', fields: {} },
|
||||||
|
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
|
||||||
|
tasks: Array.isArray(state.tasks) ? state.tasks : [],
|
||||||
|
labels: Array.isArray(state.labels) ? state.labels : [],
|
||||||
|
lastAction: state.lastAction || 'sync',
|
||||||
|
lastActionAt: state.lastActionAt || new Date().toISOString(),
|
||||||
|
lastSyncAt: state.lastSyncAt || new Date().toISOString(),
|
||||||
|
notes: state.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<!-- ${marker}:start -->`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(payload, null, 2),
|
||||||
|
'```',
|
||||||
|
`<!-- ${marker}:end -->`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) {
|
||||||
|
const body = String(issue.body || '');
|
||||||
|
const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
|
||||||
|
const rendered = renderCoordinationState(nextState, policy);
|
||||||
|
const regex = new RegExp(
|
||||||
|
`\\n?<!--\\s*${markerEscaped}:start\\s*-->[\\s\\S]*?<!--\\s*${markerEscaped}:end\\s*-->\\n?`,
|
||||||
|
'm'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (regex.test(body)) {
|
||||||
|
return body.replace(regex, `\n${rendered}\n`).trim() + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = body.trimEnd();
|
||||||
|
if (!trimmed) {
|
||||||
|
return `${rendered}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${trimmed}\n\n${rendered}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
escapeRegExp,
|
||||||
|
extractCoordinationState,
|
||||||
|
extractIssueReferences,
|
||||||
|
extractTasks,
|
||||||
|
mergeIssueBody,
|
||||||
|
normalizeBodyForComparison,
|
||||||
|
parseStringList,
|
||||||
|
renderCoordinationState,
|
||||||
|
};
|
||||||
101
scripts/lib/github-coordination/policy.js
Normal file
101
scripts/lib/github-coordination/policy.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
'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,
|
||||||
|
};
|
||||||
252
scripts/lib/github-coordination/state.js
Normal file
252
scripts/lib/github-coordination/state.js
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy');
|
||||||
|
const { extractIssueReferences, extractTasks } = require('./parsing');
|
||||||
|
const { normalizeLabels, listIssues, editIssue } = require('./gh-api');
|
||||||
|
|
||||||
|
function slugifySegment(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCoordinationState(issue, policy = DEFAULT_POLICY) {
|
||||||
|
return {
|
||||||
|
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||||
|
kind: 'epic',
|
||||||
|
status: 'available',
|
||||||
|
owner: issue && issue.author && issue.author.login ? issue.author.login : null,
|
||||||
|
branch: null,
|
||||||
|
validation: 'pending',
|
||||||
|
review: 'not-requested',
|
||||||
|
project: {
|
||||||
|
state: 'backlog',
|
||||||
|
fields: {},
|
||||||
|
},
|
||||||
|
dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''),
|
||||||
|
tasks: extractTasks(issue && issue.body ? issue.body : ''),
|
||||||
|
labels: normalizeLabels(issue && issue.labels),
|
||||||
|
lastAction: 'sync',
|
||||||
|
lastActionAt: new Date().toISOString(),
|
||||||
|
lastSyncAt: new Date().toISOString(),
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoordinationState(issue, policy = DEFAULT_POLICY) {
|
||||||
|
const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order
|
||||||
|
let existing;
|
||||||
|
try {
|
||||||
|
existing = extractCoordinationState(issue && issue.body, policy);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`[github-coordination] Warning: ${error.message} (issue #${issue && issue.number})\n`);
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
...defaultCoordinationState(issue, policy),
|
||||||
|
...existing,
|
||||||
|
project: {
|
||||||
|
...defaultCoordinationState(issue, policy).project,
|
||||||
|
...(existing.project || {}),
|
||||||
|
},
|
||||||
|
tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''),
|
||||||
|
dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''),
|
||||||
|
labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return defaultCoordinationState(issue, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const next = {
|
||||||
|
...currentState,
|
||||||
|
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||||
|
kind: 'epic',
|
||||||
|
lastAction: action,
|
||||||
|
lastActionAt: now,
|
||||||
|
lastSyncAt: now,
|
||||||
|
labels: normalizeLabels(issue.labels),
|
||||||
|
dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body),
|
||||||
|
tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.owner !== undefined) next.owner = options.owner;
|
||||||
|
if (options.branch !== undefined) next.branch = options.branch;
|
||||||
|
if (options.validation !== undefined) next.validation = options.validation;
|
||||||
|
if (options.review !== undefined) next.review = options.review;
|
||||||
|
if (options.status !== undefined) next.status = options.status;
|
||||||
|
if (options.projectState !== undefined) {
|
||||||
|
next.project = { ...(next.project || {}), state: options.projectState };
|
||||||
|
}
|
||||||
|
if (options.notes !== undefined) next.notes = options.notes;
|
||||||
|
if (options.tasks !== undefined) next.tasks = options.tasks;
|
||||||
|
if (options.dependencies !== undefined) next.dependencies = options.dependencies;
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function desiredLabelsForState(state, policy = DEFAULT_POLICY) {
|
||||||
|
const labels = [];
|
||||||
|
const known = policy.labels || DEFAULT_POLICY.labels;
|
||||||
|
|
||||||
|
labels.push(known.epic);
|
||||||
|
labels.push(known.synced);
|
||||||
|
|
||||||
|
if (state.status === 'available') labels.push(known.available);
|
||||||
|
if (state.status === 'claimed') labels.push(known.claimed);
|
||||||
|
if (state.status === 'ready') labels.push(known.ready);
|
||||||
|
if (state.status === 'blocked') labels.push(known.blocked);
|
||||||
|
if (state.validation === 'passed') labels.push(known.validated);
|
||||||
|
if (state.review === 'requested') labels.push(known.reviewRequested);
|
||||||
|
if (state.review === 'approved') labels.push(known.reviewApproved);
|
||||||
|
if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested);
|
||||||
|
if (state.status === 'published') labels.push(known.published);
|
||||||
|
|
||||||
|
return Array.from(new Set(labels.filter(Boolean))).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) {
|
||||||
|
const desired = new Set(desiredLabelsForState(state, policy));
|
||||||
|
const current = new Set(normalizeLabels(issue.labels));
|
||||||
|
const addLabels = Array.from(desired).filter(label => !current.has(label));
|
||||||
|
const removeLabels = Array.from(current).filter(label => {
|
||||||
|
if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !desired.has(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) {
|
||||||
|
return { addLabels, removeLabels };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addLabels.length > 0 || removeLabels.length > 0) {
|
||||||
|
editIssue(repo, issue.number, { ...options, addLabels, removeLabels });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { addLabels, removeLabels };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIssueByNumber(issues, issueNumber) {
|
||||||
|
return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssueComment(action, repo, issueNumber, state, extra = {}) {
|
||||||
|
const summary = [
|
||||||
|
`ECC coordination ${action}`,
|
||||||
|
`Repo: ${repo}`,
|
||||||
|
`Issue: #${issueNumber}`,
|
||||||
|
`Status: ${state.status}`,
|
||||||
|
`Owner: ${state.owner || '(unassigned)'}`,
|
||||||
|
`Branch: ${state.branch || '(none)'}`,
|
||||||
|
`Validation: ${state.validation || 'pending'}`,
|
||||||
|
`Review: ${state.review || 'not-requested'}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(extra)) {
|
||||||
|
summary.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.push('', 'This comment is part of the append-only coordination audit trail.');
|
||||||
|
return summary.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToWorkItemStatus(state) {
|
||||||
|
switch (state) {
|
||||||
|
case 'blocked':
|
||||||
|
return 'blocked';
|
||||||
|
case 'published':
|
||||||
|
return 'done';
|
||||||
|
case 'validated':
|
||||||
|
case 'reviewing':
|
||||||
|
case 'claimed':
|
||||||
|
case 'ready':
|
||||||
|
return 'in-progress';
|
||||||
|
case 'changes-requested':
|
||||||
|
return 'needs-review';
|
||||||
|
case 'available':
|
||||||
|
default:
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProjectProjection(state, policy = DEFAULT_POLICY) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(policy.project && policy.project.enabled),
|
||||||
|
state: state.project && state.project.state ? state.project.state : 'backlog',
|
||||||
|
fields: {
|
||||||
|
...(state.project && state.project.fields ? state.project.fields : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) {
|
||||||
|
return {
|
||||||
|
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||||
|
repo,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
issueUrl: issue.url || null,
|
||||||
|
issueTitle: issue.title,
|
||||||
|
action,
|
||||||
|
status: state.status,
|
||||||
|
owner: state.owner || null,
|
||||||
|
branch: state.branch || null,
|
||||||
|
validation: state.validation || 'pending',
|
||||||
|
review: state.review || 'not-requested',
|
||||||
|
project: summarizeProjectProjection(state, policy),
|
||||||
|
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
|
||||||
|
tasks: Array.isArray(state.tasks) ? state.tasks : [],
|
||||||
|
labels: normalizeLabels(issue.labels),
|
||||||
|
workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`,
|
||||||
|
lastActionAt: state.lastActionAt || null,
|
||||||
|
lastSyncAt: state.lastSyncAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIssueClaimable(issue, state) {
|
||||||
|
if (String(issue.state || '').toLowerCase() !== 'open') {
|
||||||
|
throw new Error(`Issue #${issue.number} is not open`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'claimed') {
|
||||||
|
throw new Error(`Issue #${issue.number} is already claimed by ${state.owner || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) {
|
||||||
|
if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 });
|
||||||
|
const closed = [];
|
||||||
|
for (const dependencyNumber of dependencyNumbers) {
|
||||||
|
const issue = findIssueByNumber(issueList, dependencyNumber);
|
||||||
|
if (!issue) {
|
||||||
|
process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`);
|
||||||
|
} else if (String(issue.state || '').toLowerCase() === 'closed') {
|
||||||
|
closed.push(dependencyNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assertIssueClaimable,
|
||||||
|
buildIssueComment,
|
||||||
|
buildIssueStateFromAction,
|
||||||
|
defaultCoordinationState,
|
||||||
|
desiredLabelsForState,
|
||||||
|
findIssueByNumber,
|
||||||
|
getCoordinationState,
|
||||||
|
mapStateToWorkItemStatus,
|
||||||
|
slugifySegment,
|
||||||
|
summarizeProjectProjection,
|
||||||
|
summarizeStateForOutput,
|
||||||
|
syncIssueLabels,
|
||||||
|
verifyDependenciesClosed,
|
||||||
|
};
|
||||||
65
scripts/lib/github-coordination/store.js
Normal file
65
scripts/lib/github-coordination/store.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const { createStateStore } = require('../state-store');
|
||||||
|
const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('./policy');
|
||||||
|
const { normalizeLabels } = require('./gh-api');
|
||||||
|
const { slugifySegment, mapStateToWorkItemStatus, summarizeProjectProjection } = require('./state');
|
||||||
|
|
||||||
|
function epicWorkItemId(repo, issueNumber) {
|
||||||
|
return `github-${slugifySegment(repo)}-epic-${issueNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) {
|
||||||
|
if (!store) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const metadata = {
|
||||||
|
schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||||
|
repo,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
issueUrl: issue.url || null,
|
||||||
|
issueTitle: issue.title || null,
|
||||||
|
labels: normalizeLabels(issue.labels),
|
||||||
|
coordination: state,
|
||||||
|
projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY),
|
||||||
|
action,
|
||||||
|
actionAt: now,
|
||||||
|
syncedBy: 'ecc-github-coordination',
|
||||||
|
};
|
||||||
|
|
||||||
|
return store.upsertWorkItem({
|
||||||
|
id: epicWorkItemId(repo, issue.number),
|
||||||
|
source: 'github-epic',
|
||||||
|
sourceId: String(issue.number),
|
||||||
|
title: `Epic #${issue.number}: ${issue.title}`,
|
||||||
|
status: mapStateToWorkItemStatus(state.status),
|
||||||
|
priority: state.status === 'blocked' ? 'high' : 'normal',
|
||||||
|
url: issue.url || null,
|
||||||
|
owner: state.owner || (issue.author && issue.author.login) || null,
|
||||||
|
repoRoot: options.repoRoot || process.cwd(),
|
||||||
|
sessionId: options.sessionId || null,
|
||||||
|
metadata,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openStore(options = {}) {
|
||||||
|
if (options.dbPath === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStateStore({
|
||||||
|
dbPath: options.dbPath,
|
||||||
|
homeDir: options.homeDir || process.env.HOME || os.homedir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
epicWorkItemId,
|
||||||
|
openStore,
|
||||||
|
upsertCoordinationWorkItem,
|
||||||
|
};
|
||||||
@ -165,6 +165,74 @@ function printWorkItems(section) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeGithubCoordination(workItems) {
|
||||||
|
const epicItems = workItems.items.filter(item => item.source === 'github-epic');
|
||||||
|
const summary = {
|
||||||
|
totalCount: epicItems.length,
|
||||||
|
availableCount: 0,
|
||||||
|
claimedCount: 0,
|
||||||
|
readyCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
validatedCount: 0,
|
||||||
|
publishedCount: 0,
|
||||||
|
recent: epicItems.slice(0, 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of epicItems) {
|
||||||
|
const state = item.metadata && item.metadata.coordination ? item.metadata.coordination.status : item.status;
|
||||||
|
switch (state) {
|
||||||
|
case 'available':
|
||||||
|
summary.availableCount += 1;
|
||||||
|
break;
|
||||||
|
case 'claimed':
|
||||||
|
summary.claimedCount += 1;
|
||||||
|
break;
|
||||||
|
case 'ready':
|
||||||
|
summary.readyCount += 1;
|
||||||
|
break;
|
||||||
|
case 'blocked':
|
||||||
|
summary.blockedCount += 1;
|
||||||
|
break;
|
||||||
|
case 'validated':
|
||||||
|
summary.validatedCount += 1;
|
||||||
|
break;
|
||||||
|
case 'published':
|
||||||
|
summary.publishedCount += 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
summary.availableCount += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printGithubCoordination(section) {
|
||||||
|
console.log(`GitHub epic coordination: ${section.totalCount} tracked`);
|
||||||
|
if (section.totalCount === 0) {
|
||||||
|
console.log(' - none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Available: ${section.availableCount}`);
|
||||||
|
console.log(` Claimed: ${section.claimedCount}`);
|
||||||
|
console.log(` Ready: ${section.readyCount}`);
|
||||||
|
console.log(` Blocked: ${section.blockedCount}`);
|
||||||
|
console.log(` Validated: ${section.validatedCount}`);
|
||||||
|
console.log(` Published: ${section.publishedCount}`);
|
||||||
|
|
||||||
|
for (const item of section.recent) {
|
||||||
|
console.log(` - ${item.source}/${item.sourceId || item.id} ${item.status}: ${item.title}`);
|
||||||
|
if (item.metadata && item.metadata.coordination) {
|
||||||
|
const coordination = item.metadata.coordination;
|
||||||
|
console.log(` Epic status: ${coordination.status}`);
|
||||||
|
console.log(` Owner: ${coordination.owner || '(unassigned)'}`);
|
||||||
|
console.log(` Branch: ${coordination.branch || '(none)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function printReadiness(section) {
|
function printReadiness(section) {
|
||||||
console.log(`Readiness: ${section.status}`);
|
console.log(`Readiness: ${section.status}`);
|
||||||
console.log(` Attention items: ${section.attentionCount}`);
|
console.log(` Attention items: ${section.attentionCount}`);
|
||||||
@ -188,6 +256,10 @@ function printHuman(payload) {
|
|||||||
console.log();
|
console.log();
|
||||||
printGovernance(payload.governance);
|
printGovernance(payload.governance);
|
||||||
console.log();
|
console.log();
|
||||||
|
if (payload.githubCoordination) {
|
||||||
|
printGithubCoordination(payload.githubCoordination);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
printWorkItems(payload.workItems);
|
printWorkItems(payload.workItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,6 +390,35 @@ function renderMarkdown(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.githubCoordination) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'## GitHub Epic Coordination',
|
||||||
|
'',
|
||||||
|
`Tracked: ${payload.githubCoordination.totalCount}`,
|
||||||
|
`Available: ${payload.githubCoordination.availableCount}`,
|
||||||
|
`Claimed: ${payload.githubCoordination.claimedCount}`,
|
||||||
|
`Ready: ${payload.githubCoordination.readyCount}`,
|
||||||
|
`Blocked: ${payload.githubCoordination.blockedCount}`,
|
||||||
|
`Validated: ${payload.githubCoordination.validatedCount}`,
|
||||||
|
`Published: ${payload.githubCoordination.publishedCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload.githubCoordination.recent.length === 0) {
|
||||||
|
lines.push('', '- none');
|
||||||
|
} else {
|
||||||
|
lines.push('', 'Recent epics:');
|
||||||
|
for (const item of payload.githubCoordination.recent) {
|
||||||
|
lines.push(`- ${formatCode(item.source)} ${formatCode(item.sourceId || item.id)} ${item.status}: ${item.title}`);
|
||||||
|
if (item.metadata && item.metadata.coordination) {
|
||||||
|
lines.push(` - Epic status: ${item.metadata.coordination.status}`);
|
||||||
|
lines.push(` - Owner: ${item.metadata.coordination.owner || '(unassigned)'}`);
|
||||||
|
lines.push(` - Branch: ${item.metadata.coordination.branch || '(none)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,6 +451,7 @@ async function main() {
|
|||||||
workItemLimit: options.limit,
|
workItemLimit: options.limit,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
|
||||||
|
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
const output = `${JSON.stringify(payload, null, 2)}\n`;
|
const output = `${JSON.stringify(payload, null, 2)}\n`;
|
||||||
|
|||||||
307
tests/lib/github-coordination.test.js
Normal file
307
tests/lib/github-coordination.test.js
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const {
|
||||||
|
normalizeRepo,
|
||||||
|
extractCoordinationState,
|
||||||
|
buildIssueStateFromAction,
|
||||||
|
desiredLabelsForState,
|
||||||
|
extractTasks,
|
||||||
|
renderCoordinationState,
|
||||||
|
DEFAULT_POLICY,
|
||||||
|
} = require('../../scripts/lib/github-coordination');
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGroup(group, descriptors, counters) {
|
||||||
|
for (const { name, fn } of descriptors.filter(d => d.group === group)) {
|
||||||
|
if (await test(name, fn)) counters.passed += 1;
|
||||||
|
else counters.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESCRIPTORS = [
|
||||||
|
// normalizeRepo
|
||||||
|
{
|
||||||
|
group: 'normalizeRepo',
|
||||||
|
name: 'normalizeRepo returns { owner, name } for "owner/repo"',
|
||||||
|
fn: () => {
|
||||||
|
const result = normalizeRepo('acme/my-repo');
|
||||||
|
assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'normalizeRepo',
|
||||||
|
name: 'normalizeRepo throws on "owner/repo/extra"',
|
||||||
|
fn: () => {
|
||||||
|
assert.throws(() => normalizeRepo('owner/repo/extra'), /Invalid repo format/);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'normalizeRepo',
|
||||||
|
name: 'normalizeRepo throws on bare string with no slash',
|
||||||
|
fn: () => {
|
||||||
|
assert.throws(() => normalizeRepo('justowner'), /Invalid repo format/);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'normalizeRepo',
|
||||||
|
name: 'normalizeRepo throws on empty string',
|
||||||
|
fn: () => {
|
||||||
|
assert.throws(() => normalizeRepo(''), /Invalid repo format/);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'normalizeRepo',
|
||||||
|
name: 'normalizeRepo throws on whitespace-only string',
|
||||||
|
fn: () => {
|
||||||
|
assert.throws(() => normalizeRepo(' '), /Invalid repo format/);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// extractCoordinationState
|
||||||
|
{
|
||||||
|
group: 'extractCoordinationState',
|
||||||
|
name: 'extractCoordinationState returns null for body with no coordination section',
|
||||||
|
fn: () => {
|
||||||
|
const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'extractCoordinationState',
|
||||||
|
name: 'extractCoordinationState returns parsed state from a proper coordination JSON block',
|
||||||
|
fn: () => {
|
||||||
|
const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' };
|
||||||
|
const body = [
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].join('\n');
|
||||||
|
const result = extractCoordinationState(body);
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.strictEqual(result.status, 'available');
|
||||||
|
assert.strictEqual(result.kind, 'epic');
|
||||||
|
assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'extractCoordinationState',
|
||||||
|
name: 'extractCoordinationState throws SyntaxError when JSON block is malformed',
|
||||||
|
fn: () => {
|
||||||
|
const body = [
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```json',
|
||||||
|
'{ not valid json }',
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].join('\n');
|
||||||
|
assert.throws(() => extractCoordinationState(body), SyntaxError);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// buildIssueStateFromAction
|
||||||
|
{
|
||||||
|
group: 'buildIssueStateFromAction',
|
||||||
|
name: 'buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt',
|
||||||
|
fn: () => {
|
||||||
|
const issue = { number: 1, body: '', labels: [] };
|
||||||
|
const currentState = {
|
||||||
|
schemaVersion: DEFAULT_POLICY.schemaVersion,
|
||||||
|
status: 'available', owner: null, branch: null,
|
||||||
|
validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
|
||||||
|
};
|
||||||
|
const before = new Date();
|
||||||
|
const result = buildIssueStateFromAction(issue, currentState, 'claim', {
|
||||||
|
owner: 'alice', branch: 'feat/my-branch', status: 'claimed',
|
||||||
|
});
|
||||||
|
const after = new Date();
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 'claimed');
|
||||||
|
assert.strictEqual(result.owner, 'alice');
|
||||||
|
assert.strictEqual(result.branch, 'feat/my-branch');
|
||||||
|
assert.strictEqual(result.lastAction, 'claim');
|
||||||
|
assert.ok(result.lastActionAt);
|
||||||
|
const actionAt = new Date(result.lastActionAt);
|
||||||
|
assert.ok(actionAt >= before && actionAt <= after);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'buildIssueStateFromAction',
|
||||||
|
name: 'buildIssueStateFromAction with "unblock" action preserves owner from existing state',
|
||||||
|
fn: () => {
|
||||||
|
const issue = { number: 2, body: '', labels: [] };
|
||||||
|
const currentState = {
|
||||||
|
schemaVersion: DEFAULT_POLICY.schemaVersion,
|
||||||
|
status: 'blocked', owner: 'bob', branch: 'feat/blocked-branch',
|
||||||
|
validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
|
||||||
|
};
|
||||||
|
const result = buildIssueStateFromAction(issue, currentState, 'unblock', { status: 'ready' });
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 'ready');
|
||||||
|
assert.strictEqual(result.owner, 'bob');
|
||||||
|
assert.strictEqual(result.branch, 'feat/blocked-branch');
|
||||||
|
assert.strictEqual(result.lastAction, 'unblock');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'buildIssueStateFromAction',
|
||||||
|
name: 'buildIssueStateFromAction with "validate" action sets status "validated" and validation "passed"',
|
||||||
|
fn: () => {
|
||||||
|
const issue = { number: 3, body: '', labels: [] };
|
||||||
|
const currentState = {
|
||||||
|
schemaVersion: DEFAULT_POLICY.schemaVersion,
|
||||||
|
status: 'claimed', owner: 'carol', branch: 'feat/new',
|
||||||
|
validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
|
||||||
|
};
|
||||||
|
const result = buildIssueStateFromAction(issue, currentState, 'validate', {
|
||||||
|
status: 'validated', validation: 'passed',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 'validated');
|
||||||
|
assert.strictEqual(result.validation, 'passed');
|
||||||
|
assert.strictEqual(result.lastAction, 'validate');
|
||||||
|
assert.strictEqual(result.owner, 'carol');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// desiredLabelsForState
|
||||||
|
{
|
||||||
|
group: 'desiredLabelsForState',
|
||||||
|
name: 'desiredLabelsForState for status "available" includes "coordination:available"',
|
||||||
|
fn: () => {
|
||||||
|
const labels = desiredLabelsForState({ status: 'available' });
|
||||||
|
assert.ok(Array.isArray(labels));
|
||||||
|
assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'desiredLabelsForState',
|
||||||
|
name: 'desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"',
|
||||||
|
fn: () => {
|
||||||
|
const labels = desiredLabelsForState({ status: 'claimed' });
|
||||||
|
assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`);
|
||||||
|
assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'desiredLabelsForState',
|
||||||
|
name: 'desiredLabelsForState for status "blocked" includes "coordination:blocked"',
|
||||||
|
fn: () => {
|
||||||
|
const labels = desiredLabelsForState({ status: 'blocked' });
|
||||||
|
assert.ok(labels.includes('coordination:blocked'), `Expected coordination:blocked in [${labels.join(', ')}]`);
|
||||||
|
assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'desiredLabelsForState',
|
||||||
|
name: 'desiredLabelsForState for status "ready" includes "coordination:ready"',
|
||||||
|
fn: () => {
|
||||||
|
const labels = desiredLabelsForState({ status: 'ready' });
|
||||||
|
assert.ok(labels.includes('coordination:ready'), `Expected coordination:ready in [${labels.join(', ')}]`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// extractTasks
|
||||||
|
{
|
||||||
|
group: 'extractTasks',
|
||||||
|
name: 'extractTasks returns empty array when body has no Tasks section',
|
||||||
|
fn: () => {
|
||||||
|
const tasks = extractTasks('Some issue without any task list.');
|
||||||
|
assert.deepStrictEqual(tasks, []);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'extractTasks',
|
||||||
|
name: 'extractTasks parses completed and open checkboxes under ## Tasks heading',
|
||||||
|
fn: () => {
|
||||||
|
const body = ['## Tasks', '- [x] Done task', '- [ ] Open task', '- [x] Another done task'].join('\n');
|
||||||
|
const tasks = extractTasks(body);
|
||||||
|
const completed = tasks.filter(t => t.done);
|
||||||
|
const open = tasks.filter(t => !t.done);
|
||||||
|
assert.strictEqual(tasks.length, 3);
|
||||||
|
assert.strictEqual(completed.length, 2);
|
||||||
|
assert.strictEqual(open.length, 1);
|
||||||
|
assert.strictEqual(open[0].title, 'Open task');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'extractTasks',
|
||||||
|
name: 'extractTasks stops parsing at next heading after task section',
|
||||||
|
fn: () => {
|
||||||
|
const body = ['## Tasks', '- [x] First task', '## Notes', '- [ ] This is not a task'].join('\n');
|
||||||
|
const tasks = extractTasks(body);
|
||||||
|
assert.strictEqual(tasks.length, 1);
|
||||||
|
assert.strictEqual(tasks[0].title, 'First task');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// renderCoordinationState
|
||||||
|
{
|
||||||
|
group: 'renderCoordinationState',
|
||||||
|
name: 'renderCoordinationState returns a string containing the section marker',
|
||||||
|
fn: () => {
|
||||||
|
const state = {
|
||||||
|
schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available',
|
||||||
|
owner: null, branch: null, validation: 'pending', review: 'not-requested',
|
||||||
|
project: { state: 'backlog', fields: {} }, dependencies: [], tasks: [], labels: [],
|
||||||
|
lastAction: 'sync', lastActionAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null,
|
||||||
|
};
|
||||||
|
const rendered = renderCoordinationState(state);
|
||||||
|
assert.ok(typeof rendered === 'string');
|
||||||
|
assert.ok(rendered.includes('<!-- ecc-coordination:start -->'), 'Missing start marker');
|
||||||
|
assert.ok(rendered.includes('<!-- ecc-coordination:end -->'), 'Missing end marker');
|
||||||
|
assert.ok(rendered.includes('```json'), 'Missing json code fence');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'renderCoordinationState',
|
||||||
|
name: 'renderCoordinationState output round-trips through extractCoordinationState',
|
||||||
|
fn: () => {
|
||||||
|
const state = {
|
||||||
|
schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'claimed',
|
||||||
|
owner: 'carol', branch: 'feat/my-feature', validation: 'pending', review: 'requested',
|
||||||
|
project: { state: 'in-progress', fields: {} }, dependencies: [5, 6],
|
||||||
|
tasks: [{ title: 'Write tests', done: false }], labels: ['coordination:claimed'],
|
||||||
|
lastAction: 'claim', lastActionAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
lastSyncAt: '2026-01-01T00:00:00.000Z', notes: null,
|
||||||
|
};
|
||||||
|
const rendered = renderCoordinationState(state);
|
||||||
|
const extracted = extractCoordinationState(rendered);
|
||||||
|
assert.ok(extracted !== null);
|
||||||
|
assert.strictEqual(extracted.status, 'claimed');
|
||||||
|
assert.strictEqual(extracted.owner, 'carol');
|
||||||
|
assert.deepStrictEqual(extracted.dependencies, [5, 6]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log('\n=== Testing github-coordination ===\n');
|
||||||
|
|
||||||
|
const counters = { passed: 0, failed: 0 };
|
||||||
|
const groups = [...new Set(DESCRIPTORS.map(d => d.group))];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
await runGroup(group, DESCRIPTORS, counters);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${counters.passed}, Failed: ${counters.failed}`);
|
||||||
|
process.exit(counters.failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
238
tests/scripts/github-coordination.test.js
Normal file
238
tests/scripts/github-coordination.test.js
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/github-coordination.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFileSync, spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const { createStateStore } = require('../../scripts/lib/state-store');
|
||||||
|
|
||||||
|
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'github-coordination.js');
|
||||||
|
|
||||||
|
function createTempDir(prefix) {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(dirPath) {
|
||||||
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGhShim(rootDir, responses) {
|
||||||
|
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||||
|
const logPath = path.join(rootDir, 'gh-calls.jsonl');
|
||||||
|
fs.writeFileSync(shimPath, `
|
||||||
|
const fs = require('fs');
|
||||||
|
const responses = ${JSON.stringify(responses)};
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const key = args.join(' ');
|
||||||
|
const logPath = process.env.ECC_GH_SHIM_LOG;
|
||||||
|
if (logPath) {
|
||||||
|
fs.appendFileSync(logPath, JSON.stringify({ args }, null, 0) + '\\n');
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||||
|
process.stdout.write(JSON.stringify(responses[key]));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (args[0] === 'issue' && (args[1] === 'edit' || args[1] === 'comment')) {
|
||||||
|
process.stdout.write('{}');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error('Unexpected gh args: ' + key);
|
||||||
|
process.exit(3);
|
||||||
|
`);
|
||||||
|
return { shimPath, logPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args = [], options = {}) {
|
||||||
|
return spawnSync('node', [SCRIPT, ...args], {
|
||||||
|
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.env || {}),
|
||||||
|
},
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(stdout) {
|
||||||
|
return JSON.parse(stdout.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
process.stdout.write(` PASS ${name}\n`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
process.stdout.write(` FAIL ${name}\n`);
|
||||||
|
process.stdout.write(` Error: ${error.message}\n`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStore(dbPath) {
|
||||||
|
const store = await createStateStore({ dbPath });
|
||||||
|
try {
|
||||||
|
return store.listWorkItems({ limit: 20 });
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
process.stdout.write('\n=== Testing github-coordination.js ===\n\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
if (await test('claims an epic issue, updates GitHub state, and caches a work item', async () => {
|
||||||
|
const rootDir = createTempDir('github-coordination-claim-');
|
||||||
|
const dbPath = path.join(rootDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const epicBody = [
|
||||||
|
'# Ship GitHub-native coordination',
|
||||||
|
'',
|
||||||
|
'We want deterministic epic state.',
|
||||||
|
'',
|
||||||
|
'## Tasks',
|
||||||
|
'- [ ] Claim the epic',
|
||||||
|
'- [ ] Validate the epic',
|
||||||
|
].join('\n');
|
||||||
|
const issueView = {
|
||||||
|
number: 12,
|
||||||
|
title: 'Ship GitHub-native coordination',
|
||||||
|
body: epicBody,
|
||||||
|
url: 'https://github.com/affaan-m/ECC/issues/12',
|
||||||
|
state: 'OPEN',
|
||||||
|
labels: [{ name: 'epic' }],
|
||||||
|
author: { login: 'maintainer' },
|
||||||
|
updatedAt: '2026-06-01T12:00:00Z',
|
||||||
|
};
|
||||||
|
const shim = writeGhShim(rootDir, {
|
||||||
|
'issue view 12 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': issueView,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = run(['claim', '12', '--repo', 'affaan-m/ECC', '--actor', 'codex', '--db', dbPath, '--json'], {
|
||||||
|
cwd: rootDir,
|
||||||
|
env: {
|
||||||
|
ECC_GH_SHIM: shim.shimPath,
|
||||||
|
ECC_GH_SHIM_LOG: shim.logPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
const payload = parseJson(result.stdout);
|
||||||
|
assert.strictEqual(payload.status, 'claimed');
|
||||||
|
assert.strictEqual(payload.owner, 'codex');
|
||||||
|
assert.strictEqual(payload.project.state, 'in-progress');
|
||||||
|
|
||||||
|
const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
|
||||||
|
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
|
||||||
|
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
|
||||||
|
|
||||||
|
const stored = await readStore(dbPath);
|
||||||
|
const epicItem = stored.items.find(item => item.source === 'github-epic');
|
||||||
|
assert.ok(epicItem, 'expected github epic work item');
|
||||||
|
assert.strictEqual(epicItem.status, 'in-progress');
|
||||||
|
assert.strictEqual(epicItem.metadata.coordination.status, 'claimed');
|
||||||
|
assert.strictEqual(epicItem.metadata.coordination.owner, 'codex');
|
||||||
|
} finally {
|
||||||
|
cleanup(rootDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await test('unblocks an epic when dependencies are closed', async () => {
|
||||||
|
const rootDir = createTempDir('github-coordination-unblock-');
|
||||||
|
const dbPath = path.join(rootDir, 'state.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blockedBody = [
|
||||||
|
'# Release readiness',
|
||||||
|
'',
|
||||||
|
'Dependencies: #2',
|
||||||
|
'',
|
||||||
|
'<!-- ecc-coordination:start -->',
|
||||||
|
'```json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 'ecc.github.coordination.v1',
|
||||||
|
kind: 'epic',
|
||||||
|
status: 'blocked',
|
||||||
|
owner: 'codex',
|
||||||
|
branch: 'feat/release-readiness',
|
||||||
|
validation: 'pending',
|
||||||
|
review: 'requested',
|
||||||
|
project: { state: 'blocked', fields: {} },
|
||||||
|
dependencies: [2],
|
||||||
|
tasks: [{ title: 'Check release checklist', done: false }],
|
||||||
|
labels: ['epic', 'coordination:blocked'],
|
||||||
|
lastAction: 'claim',
|
||||||
|
lastActionAt: '2026-06-01T13:00:00Z',
|
||||||
|
lastSyncAt: '2026-06-01T13:00:00Z',
|
||||||
|
notes: null,
|
||||||
|
}, null, 2),
|
||||||
|
'```',
|
||||||
|
'<!-- ecc-coordination:end -->',
|
||||||
|
].join('\n');
|
||||||
|
const openIssue = {
|
||||||
|
number: 1,
|
||||||
|
title: 'Release readiness',
|
||||||
|
body: blockedBody,
|
||||||
|
url: 'https://github.com/affaan-m/ECC/issues/1',
|
||||||
|
state: 'OPEN',
|
||||||
|
labels: [{ name: 'epic' }, { name: 'coordination:blocked' }],
|
||||||
|
author: { login: 'codex' },
|
||||||
|
updatedAt: '2026-06-01T13:00:00Z',
|
||||||
|
};
|
||||||
|
const closedDependency = {
|
||||||
|
number: 2,
|
||||||
|
title: 'Release prerequisite',
|
||||||
|
body: '# Release prerequisite',
|
||||||
|
url: 'https://github.com/affaan-m/ECC/issues/2',
|
||||||
|
state: 'CLOSED',
|
||||||
|
labels: [{ name: 'blocked-by-release' }],
|
||||||
|
author: { login: 'maintainer' },
|
||||||
|
updatedAt: '2026-06-01T10:00:00Z',
|
||||||
|
};
|
||||||
|
const shim = writeGhShim(rootDir, {
|
||||||
|
'issue list --repo affaan-m/ECC --state all --limit 100 --json number,title,body,url,state,labels,author,updatedAt,assignees': [openIssue, closedDependency],
|
||||||
|
'issue view 1 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': openIssue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = run(['unblock', '--repo', 'affaan-m/ECC', '--db', dbPath, '--json'], {
|
||||||
|
cwd: rootDir,
|
||||||
|
env: {
|
||||||
|
ECC_GH_SHIM: shim.shimPath,
|
||||||
|
ECC_GH_SHIM_LOG: shim.logPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
const payload = parseJson(result.stdout);
|
||||||
|
assert.strictEqual(payload.count, 1);
|
||||||
|
assert.strictEqual(payload.items[0].status, 'ready');
|
||||||
|
|
||||||
|
const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
|
||||||
|
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
|
||||||
|
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
|
||||||
|
|
||||||
|
const stored = await readStore(dbPath);
|
||||||
|
const epicItem = stored.items.find(item => item.source === 'github-epic');
|
||||||
|
assert.ok(epicItem, 'expected github epic work item');
|
||||||
|
assert.strictEqual(epicItem.metadata.coordination.status, 'ready');
|
||||||
|
} finally {
|
||||||
|
cleanup(rootDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user