Skip to content

[Spec] sse

[Spec] sse #28

# .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}`
});