[Spec] websocket server #34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# .github/workflows/auto-scaffold-from-issue.yml | |
name: Auto scaffold spec from issue | |
on: | |
issues: | |
types: [labeled] | |
permissions: | |
contents: write | |
pull-requests: write | |
issues: write | |
jobs: | |
scaffold: | |
if: > | |
github.event.issue.state == 'open' && | |
( | |
contains(github.event.issue.labels.*.name, 'spec:proposal') || | |
(github.event.action == 'labeled' && github.event.label.name == 'spec:proposal') | |
) | |
runs-on: ubuntu-latest | |
steps: | |
- name: Check out repo | |
uses: actions/checkout@v4 | |
- name: Extract fields from Issue Form (robust, multiline) | |
id: fields | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const body = context.payload.issue.body || ""; | |
// Escape for regex | |
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
// Capture a block under a heading or bold label (multiline): | |
// Matches "**Label**\n<block>" or "### Label\n<block>" | |
// until next "**" / "###" or end. | |
function block(label){ | |
const re = new RegExp( | |
`(?:^|\\n)\\s*(?:\\*\\*|###)\\s*${esc(label)}[^\\n]*\\n` + | |
`([\\s\\S]*?)` + | |
`(?=\\n\\s*(?:\\*\\*|###)\\s*|$)`, | |
'i' | |
); | |
const m = body.match(re); | |
return m ? m[1].trim() : ""; | |
} | |
// First non-empty line of a block (single-line field) | |
function oneLine(label){ | |
const b = block(label); | |
const s = (b.split(/\r?\n/).find(Boolean) || "").trim(); | |
return s; | |
} | |
// Parse checkboxes (GitHub renders "- [x] Label") | |
function checked(){ | |
const out = []; | |
const re = /- \[[xX]\] (.+)/g; | |
let m; | |
while ((m = re.exec(body))) out.push(m[1].trim()); | |
return out; | |
} | |
const epic = oneLine("Jira Epic ID"); | |
const title = oneLine("Spec title") || context.payload.issue.title.replace(/^\[Spec\]\s*/i,'').trim(); | |
const family = oneLine("Spec family (folder key)") || oneLine("Spec family"); | |
const version = oneLine("Version (vN)") || oneLine("Version"); | |
const owner = oneLine("Spec owner (GitHub handle)") || oneLine("Spec owner"); | |
const summary = block("1–2 paragraph summary") || block("Summary"); | |
const scope = block("Scope (in/out)") || block("Scope"); | |
const risks = block("Risks & assumptions") || block("Risks"); | |
const links = block("Related links") || block("Links"); | |
const artifacts = checked(); | |
if (!family || !version) { | |
core.setFailed("Missing 'Spec family' or 'Version' from the Issue Form."); | |
return; | |
} | |
if (!epic) { | |
core.setFailed("Missing 'Jira Epic ID' in the Issue Form."); | |
return; | |
} | |
if (!/^MXOP-\d{4}$/.test(epic)) { | |
core.setFailed(`Epic '${epic}' is invalid. Expected 'MXOP-1234'.`); | |
return; | |
} | |
core.setOutput("epic", epic); | |
core.setOutput("title", title); | |
core.setOutput("family", family); | |
core.setOutput("version", version); | |
core.setOutput("owner", owner); | |
core.setOutput("summary", summary); | |
core.setOutput("scope", scope); | |
core.setOutput("risks", risks); | |
core.setOutput("links", links); | |
core.setOutput("artifacts", JSON.stringify(artifacts)); | |
core.setOutput("issue_number", String(context.payload.issue.number)); | |
- name: Compute env vars | |
run: | | |
set -euo pipefail | |
echo "TITLE=${{ steps.fields.outputs.title }}" >> $GITHUB_ENV | |
echo "FAMILY=${{ steps.fields.outputs.family }}" >> $GITHUB_ENV | |
echo "VERSION=${{ steps.fields.outputs.version }}" >> $GITHUB_ENV | |
echo "OWNER=${{ steps.fields.outputs.owner }}" >> $GITHUB_ENV | |
echo "ISSUE_NUMBER=${{ steps.fields.outputs.issue_number }}" >> $GITHUB_ENV | |
echo "TODAY=$(date +%F)" >> $GITHUB_ENV | |
echo "STAGE=dev" >> $GITHUB_ENV | |
echo "SPEC_VERSION=1.0.0" >> $GITHUB_ENV | |
# ----- multiline envs ----- | |
{ | |
echo 'SUMMARY<<EOF'; echo "${{ steps.fields.outputs.summary }}"; echo 'EOF' | |
echo 'SCOPE<<EOF'; echo "${{ steps.fields.outputs.scope }}"; echo 'EOF' | |
echo 'RISKS<<EOF'; echo "${{ steps.fields.outputs.risks }}"; echo 'EOF' | |
echo 'LINKS<<EOF'; echo "${{ steps.fields.outputs.links }}"; echo 'EOF' | |
} >> $GITHUB_ENV | |
# JSON array of checked options | |
{ | |
echo 'ARTIFACTS_JSON<<EOF' | |
echo '${{ steps.fields.outputs.artifacts }}' | |
echo 'EOF' | |
} >> $GITHUB_ENV | |
# ----- sanitize folder/branch parts ----- | |
SAFE_FAMILY=$(printf '%s' "${{ steps.fields.outputs.family }}" \ | |
| tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-\n' | tr -d '\r\n') | |
SAFE_VERSION=$(printf '%s' "${{ steps.fields.outputs.version }}" \ | |
| tr -cd 'A-Za-z0-9-\n' | tr -d '\r\n') | |
# Epic must be available *now* (not only via $GITHUB_ENV) | |
EPIC_RAW="${{ steps.fields.outputs.epic }}" | |
EPIC=$(printf '%s' "$EPIC_RAW" | tr -d '\r\n ') | |
# Validate Epic and guard against empties | |
if ! printf '%s' "$EPIC" | grep -Eq '^MXOP-[0-9]{4}$'; then | |
echo "::error::Invalid Jira Epic ID '$EPIC_RAW' (expected MXOP-1234)" | |
exit 1 | |
fi | |
[ -n "$SAFE_FAMILY" ] || { echo "::error::Parsed family is empty"; exit 1; } | |
[ -n "$SAFE_VERSION" ] || { echo "::error::Parsed version is empty"; exit 1; } | |
# One branch per Epic | |
BRANCH="spec/${EPIC}" | |
DIR="_specs/${SAFE_FAMILY}/${SAFE_VERSION}" | |
# Export for later steps | |
{ | |
echo "EPIC=$EPIC" | |
echo "BRANCH=$BRANCH" | |
echo "DIR=$DIR" | |
} >> "$GITHUB_ENV" | |
echo "Using BRANCH='$BRANCH' and DIR='$DIR'" | |
- name: Verify jq is available | |
run: jq --version | |
- name: Derive human-readable artifacts list (optional) | |
run: | | |
echo "ARTIFACTS_LIST=$(echo "$ARTIFACTS_JSON" | jq -r 'if type=="array" and length>0 then join(", ") else "-" end')" >> $GITHUB_ENV | |
# 🔐 Guard: if branch exists, comment and stop | |
- name: "Guard: handle existing Epic branch / PR (comment & stop)" | |
id: guard | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const owner = context.repo.owner; | |
const repo = context.repo.repo; | |
const epic = process.env.EPIC; | |
const branch = `spec/${epic}`; | |
let branchExists = false; | |
try { | |
await github.rest.git.getRef({ owner, repo, ref: `heads/${branch}` }); | |
branchExists = true; | |
} catch {} | |
if (!branchExists) { core.setOutput('ok', 'true'); return; } | |
const prs = await github.paginate(github.rest.pulls.list, { | |
owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 100 | |
}); | |
let body = `❌ **Scaffold aborted** for **${epic}**.\n\nA branch \`${branch}\` already exists`; | |
if (prs.length) { | |
body += ` (open PR: ${prs[0].html_url}).\n\n**What to do:**\n` + | |
`1. Continue work in that PR, or\n` + | |
`2. Back up changes → delete the PR and branch → re-raise the proposal.`; | |
} else { | |
body += `.\n\n**What to do:**\n` + | |
`1. Back up any changes from \`${branch}\` (clone or **Code → Download ZIP**).\n` + | |
`2. Delete the branch in GitHub (**Repo → Branches → Delete**).\n` + | |
`3. Re-raise the proposal (label this issue \`spec:proposal\` again).`; | |
} | |
body += `\n\n_Note: The scaffold only runs when the Epic branch does **not** exist._`; | |
await github.rest.issues.createComment({ | |
owner, repo, issue_number: Number(process.env.ISSUE_NUMBER), body | |
}); | |
core.setOutput('ok', 'false'); | |
- name: Enforce Epic uniqueness on default branch | |
if: steps.guard.outputs.ok == 'true' | |
run: | | |
set -e | |
if git grep -R --fixed-strings --line-number "epic: ${EPIC}" -- "_specs/" >/dev/null 2>&1; then | |
echo "::error::A spec with epic '${EPIC}' already exists on the default branch." | |
exit 1 | |
fi | |
- name: Scaffold spec folder | |
if: steps.guard.outputs.ok == 'true' | |
run: | | |
set -eux | |
: "${DIR:?DIR is not set. Check the 'Compute env vars' step exported DIR to \$GITHUB_ENV}" | |
mkdir -p "$DIR/assets/diagrams" "$DIR/assets/images" "$DIR/assets/attachments" | |
# Resolve which pages were checked (from $ARTIFACTS_JSON JSON array) | |
HLD=false; LLD=false; API=false; UX=false; DM=false; ROPS=false; QA=false | |
has () { echo "$ARTIFACTS_JSON" | jq -e --arg x "$1" 'type=="array" and any(.[]?; . == $x)' >/dev/null 2>&1; } | |
has 'HLD' && HLD=true | |
has 'LLD' && LLD=true | |
has 'API Spec' && API=true | |
has 'API' && API=true | |
has 'UX' && UX=true | |
has 'Data Model' && DM=true | |
has 'Rollout & Ops' && ROPS=true | |
has 'QA / Test Plan' && QA=true | |
# index.md — use epic | |
cat > "$DIR/index.md" <<'EOF' | |
--- | |
layout: spec | |
epic: __EPIC__ | |
family: __FAMILY__ | |
version: __VERSION__ | |
title: __TITLE__ | |
status: in-progress | |
stage: __STAGE__ | |
spec_version: "__SPEC_VERSION__" | |
owner: "__OWNER__" | |
team: "" | |
created_at: __TODAY__ | |
updated_at: | |
tags: [] | |
changelog: | |
- date: __TODAY__ | |
text: "Initial scaffold from issue #__ISSUE__" | |
nav_title: "Overview" | |
nav_order: 0 | |
--- | |
## Summary | |
__SUMMARY__ | |
## Scope | |
__SCOPE__ | |
## Risks & assumptions | |
__RISKS__ | |
## Related Docs | |
EOF | |
# Replace placeholders (escape sed metachars) | |
esc () { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; } | |
sed -i \ | |
-e "s|__EPIC__|$(esc "$EPIC")|g" \ | |
-e "s|__FAMILY__|$(esc "$FAMILY")|g" \ | |
-e "s|__VERSION__|$(esc "$VERSION")|g" \ | |
-e "s|__TITLE__|$(esc "$TITLE")|g" \ | |
-e "s|__STAGE__|$(esc "$STAGE")|g" \ | |
-e "s|__SPEC_VERSION__|$(esc "$SPEC_VERSION")|g" \ | |
-e "s|__OWNER__|$(esc "$OWNER")|g" \ | |
-e "s|__TODAY__|$(esc "$TODAY")|g" \ | |
-e "s|__ISSUE__|$(esc "$ISSUE_NUMBER")|g" \ | |
-e "s|__SUMMARY__|$(esc "$SUMMARY")|g" \ | |
-e "s|__SCOPE__|$(esc "$SCOPE")|g" \ | |
-e "s|__RISKS__|$(esc "$RISKS")|g" \ | |
"$DIR/index.md" | |
# Append Related Docs bullets based on selections | |
{ | |
$HLD && echo "- [High-Level Design](./hld.md)" | |
$LLD && echo "- [Low-Level Design](./lld.md)" | |
$API && echo "- [API](./api.md)" | |
$UX && echo "- [UX](./ux.md)" | |
$DM && echo "- [Data Model](./data-model.md)" | |
$ROPS && echo "- [Rollout & Ops](./rollout-ops.md)" | |
$QA && echo "- [QA / Test Plan](./qa-test.md)" | |
} >> "$DIR/index.md" | |
# Append Related Links from the Issue Form (if any) | |
if [ -n "$LINKS" ]; then | |
printf '\n## Related Links\n' >> "$DIR/index.md" | |
echo "$LINKS" | sed 's/^/- /' >> "$DIR/index.md" | |
fi | |
# Helper to write a sibling page with skeleton content | |
write_page () { | |
local file="$1" | |
local title nav order | |
case "$file" in | |
hld) title="HLD"; nav="High-Level Design"; order=10 ;; | |
lld) title="LLD"; nav="Low-Level Design"; order=20 ;; | |
data-model) title="Data Model"; nav="Data Model"; order=25 ;; | |
api) title="API"; nav="API"; order=30 ;; | |
ux) title="UX"; nav="UX"; order=40 ;; | |
rollout-ops) title="Rollout & Ops"; nav="Rollout & Ops"; order=50 ;; | |
qa-test) title="QA / Test Plan"; nav="QA / Test Plan"; order=60 ;; | |
*) echo "unknown page type $file" ; return 1 ;; | |
esac | |
# Front matter + heading | |
cat > "$DIR/${file}.md" <<EOF | |
--- | |
layout: spec | |
title: ${TITLE} — ${title} | |
nav_title: "${nav}" | |
nav_order: ${order} | |
--- | |
# ${title} | |
EOF | |
# Skeleton content per type | |
case "$file" in | |
hld) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Context & Goals | |
- … | |
## Architecture (diagram) | |
- Place diagrams under `assets/diagrams/` and embed here. | |
## Components | |
| Component | Responsibility | Runs where | Notes | | |
|---|---|---|---| | |
| | | | | | |
## Interactions | |
- Key request/response flows | |
- External integrations | |
## Non-functional Requirements | |
- Performance, scalability, security, observability | |
## Constraints & Assumptions | |
- … | |
## Alternatives Considered | |
- … | |
## Risks & Mitigations | |
- … | |
## Dependencies | |
- … | |
## Open Questions | |
- … | |
EOF | |
;; | |
lld) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Module Breakdown | |
- Module A/B with public interfaces and internals | |
## Data Structures & Storage | |
- Entities/DTOs, schemas, migrations | |
## Algorithms & State | |
- Pseudocode / state machines | |
## Configuration | |
```yaml | |
feature_enabled: true | |
timeout_ms: 5000 | |
retry: { attempts: 3 } | |
``` | |
## Error Handling | |
- Taxonomy, retries, idempotency, backoff | |
## Logging & Instrumentation | |
- Logs, metrics (names/types/labels), tracing spans | |
## Security Details | |
- Permissions, secrets, validation | |
## I18n/Accessibility (if applicable) | |
- … | |
## Edge Cases | |
- … | |
EOF | |
;; | |
data-model) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## ERD | |
- Embed from `assets/diagrams/` or link to an ERD tool export. | |
## Tables / Collections | |
- Fields, types, indexes | |
## Retention / Archival / PII | |
- Classification & masking | |
## Example Queries | |
- … | |
EOF | |
;; | |
api) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Overview | |
- Audience & usage; versioning policy | |
## Base Info | |
- **Base URL:** `https://api.example.com` | |
- **Auth:** Bearer / API key / OAuth2 | |
- **Content-Type:** `application/json` | |
- **Rate limits / Pagination / Idempotency** | |
- **Webhooks** (delivery, retries, signatures) | |
## Errors | |
```json | |
{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "…", "details": {} } } | |
``` | |
## Endpoints | |
### GET /v1/things | |
- Params: `page`, `limit` | |
- Response: | |
```json | |
{ "items": [], "next": "…" } | |
``` | |
- Sample: | |
```bash | |
curl -H "Authorization: Bearer $TOKEN" \ | |
"https://api.example.com/v1/things?limit=20" | |
``` | |
## Webhooks (if any) | |
- … | |
## SDK Mapping | |
- … | |
EOF | |
;; | |
ux) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Personas & Jobs-to-be-Done | |
- … | |
## Use-cases / Scenarios | |
- … | |
## Information Architecture | |
- Navigation map | |
## Key Flows | |
- Link wireframes in `assets/images/` or Figma | |
## Screens / States | |
- Loading/empty/error, edge cases | |
## Content & Microcopy | |
- Tone guidelines, validation messages | |
## Accessibility | |
- Keyboard flows, contrast, ARIA notes | |
## Responsiveness & Platforms | |
- Breakpoints | |
## Telemetry | |
- Events & properties for UX analysis | |
EOF | |
;; | |
rollout-ops) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Launch Plan | |
- Flags/config, migrations/backfill, rollback | |
## Monitoring & SLOs | |
- SLIs/SLOs, dashboards, alerts | |
## Runbook | |
- On-call steps; rollback strategy | |
## Post-launch | |
- Success criteria; cleanup tasks | |
EOF | |
;; | |
qa-test) | |
cat >> "$DIR/${file}.md" <<'EOF' | |
## Strategy | |
- Unit / integration / E2E | |
## Coverage vs Acceptance Criteria | |
- Trace each AC to tests | |
## Test Data & Environments | |
- … | |
## Performance / Security / UAT | |
- … | |
## Sign-off Checklist | |
- … | |
EOF | |
;; | |
esac | |
} | |
# Generate the selected pages | |
$HLD && write_page hld | |
$LLD && write_page lld | |
$DM && write_page data-model | |
$API && write_page api | |
$UX && write_page ux | |
$ROPS && write_page rollout-ops | |
$QA && write_page qa-test | |
# assets README | |
cat > "$DIR/assets/README.md" <<'EOF' | |
# Assets for this spec | |
Use this folder for diagrams, images, and attachments referenced by pages in this spec. | |
Suggested: | |
- `assets/diagrams/` — draw.io, Excalidraw, PlantUML exports, ERDs | |
- `assets/images/` — screenshots/static images | |
- `assets/attachments/` — PDFs, sheets, other binaries | |
EOF | |
touch "$DIR/assets/.gitkeep" | |
git add "$DIR" | |
- name: Show branch/debug | |
run: | | |
echo "BRANCH=${BRANCH}" | |
echo "DIR=${DIR}" | |
ls -la "${DIR}" || true | |
- name: Create draft PR | |
if: steps.guard.outputs.ok == 'true' | |
id: cpr | |
uses: peter-evans/create-pull-request@v6 | |
with: | |
branch: ${{ env.BRANCH }} # spec/<EPIC> | |
title: "[Spec] ${{ env.TITLE }} (scaffold)" | |
body: | | |
Closes #${{ env.ISSUE_NUMBER }} | |
**Epic:** ${{ vars.JIRA_BASE && format('[{0}]({1}/{0})', env.EPIC, vars.JIRA_BASE) || env.EPIC }} | |
**Stage:** ${{ env.STAGE }} | |
**Spec version (initial):** ${{ env.SPEC_VERSION }} | |
Scaffolds `_specs/${{ env.FAMILY }}/${{ env.VERSION }}/` with: | |
- `index.md` (status: in-progress) | |
- Sibling pages (selected): ${{ env.ARTIFACTS_LIST }} | |
- `assets/` folder (diagrams/images/attachments) | |
Please fill out content and adjust metadata as needed. | |
commit-message: "chore(spec): scaffold ${{ env.FAMILY }}/${{ env.VERSION }} from issue #${{ env.ISSUE_NUMBER }}" | |
draft: true | |
labels: spec:proposal,in-progress | |
- name: Comment PR link on the issue | |
if: steps.guard.outputs.ok == 'true' && steps.cpr.outputs.pull-request-url != '' | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const prUrl = "${{ steps.cpr.outputs.pull-request-url }}"; | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: Number(process.env.ISSUE_NUMBER), | |
body: `🚀 Scaffolded a draft PR for **${process.env.EPIC}**: ${prUrl}` | |
}); |