diff --git a/.copilot/skills/auth0-token-forwarding/SKILL.md b/.copilot/skills/auth0-token-forwarding/SKILL.md index 00d9bb81..03b0165b 100644 --- a/.copilot/skills/auth0-token-forwarding/SKILL.md +++ b/.copilot/skills/auth0-token-forwarding/SKILL.md @@ -58,7 +58,7 @@ public sealed class TokenForwardingHandler : DelegatingHandler return await base.SendAsync(request, cancellationToken); } } -``` +```text ### Step 2: Register Handler and Attach to HttpClients @@ -76,7 +76,7 @@ builder.Services.AddHttpClient(client => client.BaseAddress = new Uri("https+http://api")) .AddServiceDiscovery() .AddHttpMessageHandler(); -``` +```text Repeat `.AddHttpMessageHandler()` for **all HttpClient registrations** that call protected APIs. @@ -92,7 +92,7 @@ builder.Services.AddAuth0WebAppAuthentication(options => options.ClientSecret = clientSecret; options.SaveTokens = true; // CRITICAL: Enables GetTokenAsync("access_token") }); -``` +```text ## How It Works @@ -165,7 +165,7 @@ public async Task TokenForwardingHandler_AttachesTokenWhenPresent() request.Headers.Authorization.Scheme.Should().Be("Bearer"); request.Headers.Authorization.Parameter.Should().Be("fake-jwt-token"); } -``` +```text ## Common Pitfalls diff --git a/.copilot/skills/cli-wiring/SKILL.md b/.copilot/skills/cli-wiring/SKILL.md index 60b8f529..66a888ba 100644 --- a/.copilot/skills/cli-wiring/SKILL.md +++ b/.copilot/skills/cli-wiring/SKILL.md @@ -16,14 +16,14 @@ await run(process.cwd(), options); return; } - ``` + ```text 3. **Add help text** in the help section of `cli-entry.ts` (search for `Commands:`): ```ts console.log(` ${BOLD}${RESET} `); console.log(` Usage: [flags]`); - ``` + ```text 4. **Verify both exist** — the recurring bug is doing step 1 but missing steps 2-3. @@ -40,7 +40,7 @@ ```ts import { BOLD, RESET, DIM, RED, GREEN, YELLOW } from './cli/core/output.js'; -``` +```text Use dynamic `await import()` for command modules to keep startup fast (lazy loading). diff --git a/.copilot/skills/git-workflow/SKILL.md b/.copilot/skills/git-workflow/SKILL.md index dd07609a..f2779989 100644 --- a/.copilot/skills/git-workflow/SKILL.md +++ b/.copilot/skills/git-workflow/SKILL.md @@ -33,19 +33,19 @@ Examples: git checkout dev git pull origin dev git checkout -b squad/{issue-number}-{slug} - ``` + ```text 2. **Mark issue in-progress:** ```bash gh issue edit {number} --add-label "status:in-progress" - ``` + ```text 3. **Create draft PR targeting dev:** ```bash gh pr create --base dev --title "{description}" --body "Closes #{issue-number}" --draft - ``` + ```text 4. **Do the work.** Make changes, write tests, commit with issue reference. @@ -54,7 +54,7 @@ Examples: ```bash git push -u origin squad/{issue-number}-{slug} gh pr ready - ``` + ```text 6. **After merge to dev:** @@ -63,7 +63,7 @@ Examples: git pull origin dev git branch -d squad/{issue-number}-{slug} git push origin --delete squad/{issue-number}-{slug} - ``` + ```text ## Parallel Multi-Issue Work (Worktrees) @@ -88,7 +88,7 @@ git fetch origin dev # Create a worktree per issue — siblings to the main clone git worktree add ../squad-195 -b squad/195-fix-stamp-bug origin/dev git worktree add ../squad-193 -b squad/193-refactor-loader origin/dev -``` +```text **Naming convention:** `../{repo-name}-{issue-number}` (e.g., `../squad-195`, `../squad-pr-42`). @@ -111,7 +111,7 @@ git push -u origin squad/195-fix-stamp-bug # Create PR targeting dev gh pr create --base dev --title "fix: stamp bug" --body "Closes #195" --draft -``` +```text All PRs target `dev` independently. Agents never interfere with each other's filesystem. @@ -133,7 +133,7 @@ git worktree remove ../squad-195 git worktree prune # clean stale metadata git branch -d squad/195-fix-stamp-bug git push origin --delete squad/195-fix-stamp-bug -``` +```text If a worktree was deleted manually (rm -rf), `git worktree prune` recovers the state. @@ -147,12 +147,12 @@ When work spans multiple repositories (e.g., squad-cli changes need squad-sdk ch Clone downstream repos as siblings to the main repo: -``` +```text ~/work/ squad-pr/ # main repo squad-sdk/ # downstream dependency user-app/ # consumer project -``` +```text Each repo gets its own issue branch following its own naming convention. If the downstream repo also uses Squad conventions, use `squad/{issue-number}-{slug}`. @@ -161,11 +161,11 @@ Each repo gets its own issue branch following its own naming convention. If the - Create PRs in each repo independently - Link them in PR descriptions: - ``` + ```markdown Closes #42 **Depends on:** squad-sdk PR #17 (squad-sdk changes required for this feature) - ``` + ```text - Merge order: dependencies first (e.g., squad-sdk), then dependents (e.g., squad-cli) @@ -184,7 +184,7 @@ cd ../squad-pr && npm link squad-sdk # Python cd ../squad-sdk && pip install -e . -``` +```text **Important:** Remove local links before committing. `npm link` and `go replace` are dev-only — CI must use published packages or PR-specific refs. diff --git a/.copilot/skills/model-selection/SKILL.md b/.copilot/skills/model-selection/SKILL.md index 4d59ea3a..a79690f8 100644 --- a/.copilot/skills/model-selection/SKILL.md +++ b/.copilot/skills/model-selection/SKILL.md @@ -101,7 +101,7 @@ After resolving the model and including it in the spawn template, this skill is "mcmanus": "claude-haiku-4.5" } } -``` +```text - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` @@ -112,11 +112,11 @@ After resolving the model and including it in the spawn template, this skill is If a model is unavailable (rate limit, plan restriction), retry within the same tier until the documented chain is exhausted: -``` +```text Premium: claude-opus-4.6 → claude-opus-4.5 → claude-sonnet-4.6 → claude-sonnet-4.5 Standard: claude-sonnet-4.6 → gpt-5.4 → claude-sonnet-4.5 → gpt-5.3-codex → claude-sonnet-4 Fast: claude-haiku-4.5 → gpt-5.4-mini → gpt-4.1 → gpt-5-mini -``` +```text If the user explicitly selects `claude-opus-4.7`, start at the top of the premium chain with `claude-opus-4.6` as the first fallback. diff --git a/.copilot/skills/mongodb-filter-pattern/SKILL.md b/.copilot/skills/mongodb-filter-pattern/SKILL.md index 2d5a27a1..579b693b 100644 --- a/.copilot/skills/mongodb-filter-pattern/SKILL.md +++ b/.copilot/skills/mongodb-filter-pattern/SKILL.md @@ -24,7 +24,7 @@ Task Items, long Total)>> GetAllAsync( string? searchTerm = null, string? authorName = null, CancellationToken cancellationToken = default); -``` +```text **Key principles:** @@ -68,7 +68,7 @@ var entities = await _collection .Skip((page - 1) * pageSize) .Limit(pageSize) .ToListAsync(cancellationToken); -``` +```text **Key principles:** @@ -93,7 +93,7 @@ RuleFor(x => x.AuthorName) .MaximumLength(200) .When(x => !string.IsNullOrWhiteSpace(x.AuthorName)) .WithMessage("Author name must not exceed 200 characters."); -``` +```text **Key principles:** @@ -122,7 +122,7 @@ group.MapGet("", async ( var result = await handler.Handle(query); return Results.Ok(result); }) -``` +```text **Key principles:** @@ -146,7 +146,7 @@ if (!string.IsNullOrWhiteSpace(authorName)) } var result = await _httpClient.GetFromJsonAsync(url, cancellationToken); -``` +```text **Key principles:** @@ -162,7 +162,7 @@ Update test mocks to match new interface signature: ```csharp _repository.GetAllAsync(1, 20, null, null, Arg.Any()) .Returns(((IReadOnlyList)items, total)); -``` +```text **Key principles:** @@ -187,13 +187,13 @@ Combine flags: `"im"` for case-insensitive multi-line ```csharp filterBuilder.Eq(x => x.Status, "Active") -``` +```text ### Text search (case-insensitive) ```csharp filterBuilder.Regex(x => x.Title, new BsonRegularExpression(searchTerm, "i")) -``` +```text ### Multi-field search (OR) @@ -202,13 +202,13 @@ filterBuilder.Or( filterBuilder.Regex(x => x.Title, new BsonRegularExpression(term, "i")), filterBuilder.Regex(x => x.Description, new BsonRegularExpression(term, "i")) ) -``` +```text ### Nested field search ```csharp filterBuilder.Regex(x => x.Author.Name, new BsonRegularExpression(name, "i")) -``` +```text ### Date range @@ -217,13 +217,13 @@ filterBuilder.And( filterBuilder.Gte(x => x.CreatedAt, startDate), filterBuilder.Lte(x => x.CreatedAt, endDate) ) -``` +```text ### Array contains ```csharp filterBuilder.AnyEq(x => x.Tags, tagValue) -``` +```text ## Gotchas diff --git a/.copilot/skills/personal-squad/SKILL.md b/.copilot/skills/personal-squad/SKILL.md index 223d9087..a0afdeec 100644 --- a/.copilot/skills/personal-squad/SKILL.md +++ b/.copilot/skills/personal-squad/SKILL.md @@ -6,7 +6,7 @@ A personal squad is a user-level collection of AI agents that travel with you ac ## Directory Structure -``` +```text ~/.config/squad/personal-squad/ # Linux/macOS %APPDATA%/squad/personal-squad/ # Windows ├── agents/ @@ -15,7 +15,7 @@ A personal squad is a user-level collection of AI agents that travel with you ac │ │ └── history.md │ └── ... └── config.json # Optional: personal squad config -``` +```text ## How It Works @@ -51,7 +51,7 @@ Optional `config.json` in the personal squad directory: "ghostProtocol": true, "agents": {} } -``` +```text ## Environment Variables diff --git a/.copilot/skills/pre-push-test-gate/SKILL.md b/.copilot/skills/pre-push-test-gate/SKILL.md index 8b4c6e2b..3492ecd0 100644 --- a/.copilot/skills/pre-push-test-gate/SKILL.md +++ b/.copilot/skills/pre-push-test-gate/SKILL.md @@ -28,16 +28,16 @@ The pre-push hook (`.github/hooks/pre-push`) enforces **5 gates** that mirror CI ### Gate 3 — Unit Test Projects (2 total) -``` +```text tests/Architecture.Tests/Architecture.Tests.csproj tests/Unit.Tests/Unit.Tests.csproj -``` +```text ### Gate 4 — Integration Test Projects (1 total, Docker required) -``` +```text tests/Integration.Tests/Integration.Tests.csproj -``` +```text These use Testcontainers (MongoDb) and Aspire DCP. Docker daemon MUST be running. @@ -53,7 +53,7 @@ The hook source is committed at `.github/hooks/pre-push`. Install once per clone ```bash cp .github/hooks/pre-push .git/hooks/pre-push chmod +x .git/hooks/pre-push -``` +```text > ⚠️ Do NOT create inline hook scripts. Always copy from `.github/hooks/pre-push` to get the full 5-gate version. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 3058d32b..830ed2ea 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -42,14 +42,14 @@ No team exists yet. Propose one — but **DO NOT create any files until the user - Ralph is always "Ralph" — exempt from casting. 4. Propose the team with their cast names. Example (names will vary per cast): -``` +```text 🏗️ {CastName1} — Lead Scope, decisions, code review ⚛️ {CastName2} — Frontend Dev React, UI, components 🔧 {CastName3} — Backend Dev APIs, database, services 🧪 {CastName4} — Tester Tests, quality, edge cases 📋 Scribe — (silent) Memory, decisions, session logs 🔄 Ralph — (monitor) Work queue, backlog, keep-alive -``` +```text 5. Use the `ask_user` tool to confirm the roster. Provide choices so the user sees a selectable menu: - **question:** *"Look right?"* @@ -74,12 +74,12 @@ No team exists yet. Propose one — but **DO NOT create any files until the user **Team.md structure:** `team.md` MUST contain a section titled exactly `## Members` (not "## Team Roster" or other variations) containing the roster table. This header is hard-coded in GitHub workflows (`squad-heartbeat.yml`, `squad-issue-assign.yml`, `squad-triage.yml`, `sync-squad-labels.yml`) for label automation. If the header is missing or titled differently, label routing breaks. **Merge driver for append-only files:** Create or update `.gitattributes` at the repo root to enable conflict-free merging of `.squad/` state across branches: -``` +```text .squad/decisions.md merge=union .squad/agents/*/history.md merge=union .squad/log/** merge=union .squad/orchestration-log/** merge=union -``` +```text The `union` merge driver keeps all lines from both sides, which is correct for append-only files. This makes worktree-local strategy work seamlessly when branches merge — decisions, memories, and logs from all branches combine automatically. 7. Say: *"✅ Team hired. Try: '{FirstCastName}, set up the project structure'"* @@ -132,17 +132,17 @@ Before assembling the session cast, check for personal agents: **On every session start (after resolving team root):** Check for open GitHub issues assigned to squad members via labels. Use the GitHub CLI or API to list issues with `squad:*` labels: -``` +```text gh issue list --label "squad:{member-name}" --state open --json number,title,labels,body --limit 10 -``` +```text For each squad member with assigned issues, note them in the session context. When presenting a catch-up or when the user asks for status, include pending issues: -``` +```text 📋 Open issues assigned to squad members: 🔧 {Backend} — #42: Fix auth endpoint timeout (squad:ripley) ⚛️ {Frontend} — #38: Add dark mode toggle (squad:dallas) -``` +```text **Proactive issue pickup:** If a user starts a session and there are open `squad:{member}` issues, mention them: *"Hey {user}, {AgentName} has an open issue — #42: Fix auth endpoint timeout. Want them to pick it up?"* @@ -175,11 +175,11 @@ and PR metadata. - **Single agent:** `"Fenster's on it — looking at the error handling now."` - **Multi-agent spawn:** Show a quick launch table: - ``` + ```text 🔧 Fenster — error handling in index.js 🧪 Hockney — writing test cases 📋 Scribe — logging session - ``` + ```text The acknowledgment goes in the same response as the `task` tool calls — text first, then tool calls. Keep it to 1-2 sentences plus the table. Don't narrate the plan; just show who's working on what. @@ -235,12 +235,12 @@ The emoji makes task spawn notifications visually consistent with the launch tab **When you detect a directive:** 1. Write it immediately to `.squad/decisions/inbox/copilot-directive-{timestamp}.md` using this format: - ``` + ```bash ### {timestamp}: User directive **By:** {user name} (via Copilot) **What:** {the directive, verbatim or lightly paraphrased} **Why:** User request — captured for team memory - ``` + ```text 2. Acknowledge briefly: `"📌 Captured. {one-line summary of the directive}."` 3. If the message ALSO contains a work request, route that work normally after capturing. If it's directive-only, you're done — no agent spawn needed. @@ -329,7 +329,7 @@ After routing determines WHO handles work, select the response MODE based on tas **Lightweight Spawn Template** (skip charter, history, and decisions reads — just the task): -``` +```text agent_type: "general-purpose" model: "{resolved_model}" mode: "background" @@ -353,7 +353,7 @@ prompt: | ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. -``` +```text For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` @@ -406,11 +406,11 @@ Before spawning an agent, determine which model to use. Check these layers in or If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the relevant chain until that chain is exhausted. Do NOT tell the user about fallback attempts. -``` +```text Premium: claude-opus-4.6 → claude-opus-4.5 → claude-sonnet-4.6 → claude-sonnet-4.5 → (omit model param) Standard: claude-sonnet-4.6 → gpt-5.4 → claude-sonnet-4.5 → gpt-5.3-codex → claude-sonnet-4 → gpt-5.2 → (omit model param) Fast: claude-haiku-4.5 → gpt-5.4-mini → gpt-4.1 → gpt-5-mini → (omit model param) -``` +```text `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the nuclear fallback — it always works. @@ -423,14 +423,14 @@ Fast: claude-haiku-4.5 → gpt-5.4-mini → gpt-4.1 → gpt-5-mini → (omit Pass the resolved model as the `model` parameter on every `task` tool call: -``` +```text agent_type: "general-purpose" model: "{resolved_model}" mode: "background" description: "{emoji} {Name}: {brief task summary}" prompt: | ... -``` +```text Pass the resolved model as the `model` parameter on `task` tool calls when it differs from the platform default. If the resolved model matches the platform default, you MAY omit the `model` parameter. @@ -440,13 +440,13 @@ If you've exhausted the fallback chain and reached nuclear fallback, omit the `m When spawning, include the model in your acknowledgment: -``` +```text 🔧 Fenster (claude-sonnet-4.6) — refactoring auth module 🎨 Redfoot (claude-opus-4.6 · vision) — designing color system 📋 Scribe (claude-haiku-4.5 · fast) — logging session ⚡ Keaton (claude-opus-4.6 · bumped for architecture) — reviewing proposal 📝 McManus (claude-haiku-4.5 · fast) — updating docs -``` +```text Include tier annotation only when the model was bumped or a specialist was chosen. Default-tier spawns just show the model name. @@ -584,12 +584,12 @@ When the user gives any task, the Coordinator MUST: 2. **Check for hard data dependencies only.** Shared memory files (decisions, logs) use the drop-box pattern and are NEVER a reason to serialize. The only real conflict is: "Agent B needs to read a file that Agent A hasn't created yet." 3. **Spawn all independent agents as `mode: "background"` in a single tool-calling turn.** Multiple `task` calls in one response is what enables true parallelism. 4. **Show the user the full launch immediately:** - ``` + ```text 🏗️ {Lead} analyzing project structure... ⚛️ {Frontend} building login form components... 🔧 {Backend} setting up auth API endpoints... 🧪 {Tester} writing test cases from requirements... - ``` + ```text 5. **Chain follow-ups.** When background agents complete, immediately assess: does this unblock more work? Launch it without waiting for the user to ask. **Example — "Team, build the login page":** @@ -637,9 +637,9 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha 2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` + ```bash git worktree list --porcelain - ``` + ```text The first `worktree` line is the main working tree. Team root = that path. 3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). @@ -766,7 +766,7 @@ e. **Include worktree context in spawn:** **Template for any agent** (substitute `{Name}`, `{Role}`, `{name}`, and inline the charter): -``` +```text agent_type: "general-purpose" model: "{resolved_model}" mode: "background" @@ -833,7 +833,7 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a 2-3 sentence plain text summary as your FINAL output. No tool calls after this summary. -``` +```text ### ❌ What NOT to Do (Anti-Patterns) @@ -869,7 +869,7 @@ After each batch of agent work: 4. **Spawn Scribe** (background, never wait). Only if agents ran or inbox has files: -``` +```text agent_type: "general-purpose" model: "claude-haiku-4.5" mode: "background" @@ -890,7 +890,7 @@ prompt: | 7. HISTORY SUMMARIZATION: If any history.md >12KB, summarize old entries to ## Core Context. Never speak to user. ⚠️ End with plain text summary after all tool calls. -``` +```text 5. **Immediately assess:** Does anything trigger follow-up work? Launch it NOW. @@ -1154,7 +1154,7 @@ gh pr list --state open --json number,title,author,labels,isDraft,reviewDecision # Draft PRs (agent work in progress) gh pr list --state open --draft --json number,title,author,labels,checks --limit 20 -``` +```text **Step 2 — Categorize findings:** @@ -1178,12 +1178,12 @@ gh pr list --state open --draft --json number,title,author,labels,checks --limit After every 3-5 rounds, pause and report before continuing: -``` +```text 🔄 Ralph: Round {N} complete. ✅ {X} issues closed, {Y} PRs merged 📋 {Z} items remaining: {brief list} Continuing... (say "Ralph, idle" to stop) -``` +```text **Do NOT ask for permission to continue.** Just report and keep going. The user must explicitly say "idle" or "stop" to break the loop. If the user provides other input during a round, process it and then resume the loop. @@ -1195,7 +1195,7 @@ Ralph's in-session loop processes work while it exists, then idles. For **persis npx @bradygaster/squad-cli watch # polls every 10 minutes (default) npx @bradygaster/squad-cli watch --interval 5 # polls every 5 minutes npx @bradygaster/squad-cli watch --interval 30 # polls every 30 minutes -``` +```text This runs as a standalone local process (not inside Copilot) that: - Checks GitHub every N minutes for untriaged squad work @@ -1223,7 +1223,7 @@ Ralph's state is session-scoped (not persisted to disk): When Ralph reports status, use this format: -``` +```text 🔄 Ralph — Work Monitor ━━━━━━━━━━━━━━━━━━━━━━ 📊 Board Status: @@ -1233,7 +1233,7 @@ When Ralph reports status, use this format: ✅ Done: 5 issues closed this session Next action: Triaging #42 — "Fix auth endpoint timeout" -``` +```text ### Integration with Follow-Up Work diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 17a35b1c..3c85c7da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,7 +23,7 @@ updates: - "mpaulosky" groups: all-actions: - patterns: [ "*" ] + patterns: ["*"] # Maintain dependencies for nuget - package-ecosystem: "nuget" @@ -41,7 +41,7 @@ updates: - "mpaulosky" groups: all-actions: - patterns: [ "*" ] + patterns: ["*"] # Maintain DotNet Sdk - package-ecosystem: "dotnet-sdk" @@ -59,4 +59,4 @@ updates: - "mpaulosky" groups: all-actions: - patterns: [ "*" ] + patterns: ["*"] diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push index b74bad95..6233f74d 100755 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -9,10 +9,25 @@ set -uo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" +# Ensure dotnet SDK is in PATH for the hook +export DOTNET_ROOT="${DOTNET_ROOT:-${HOME}/.dotnet}" +export PATH="${DOTNET_ROOT}:${PATH}" + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RESET='\033[0m' echo -e "${CYAN}━━━ Pre-Push Gate ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +# ── Tag passthrough: allow version tags without branch gates ─────────────── +# git passes refspecs on stdin: " " +# Save stdin and check if ALL refs being pushed are tags. +PUSH_REFS=$(cat) +if [[ -n "$PUSH_REFS" ]] && echo "$PUSH_REFS" | grep -q "^refs/tags/"; then + if ! echo "$PUSH_REFS" | grep -qv "^refs/tags/"; then + echo -e "${GREEN}✅ Tag push — skipping branch gates.${RESET}" + exit 0 + fi +fi + # ── Gate 0: Enforce branch naming conventions ────────────────────────────── # Merge hierarchy: squad/{issue}-{slug} → sprint/{N}-{slug} → dev → main (release only) CURRENT_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || echo "")" @@ -53,8 +68,8 @@ fi # ── Gate 2: markdownlint check ───────────────────────────────────────────── echo -e "\n${CYAN}📝 Checking Markdown lint (markdownlint-cli2)...${RESET}" -if [[ -x "$ROOT/node_modules/.bin/markdownlint-cli2" ]]; then - "$ROOT/node_modules/.bin/markdownlint-cli2" "**/*.md" \ +if [[ -x "$ROOT/src/Web/node_modules/.bin/markdownlint-cli2" ]]; then + "$ROOT/src/Web/node_modules/.bin/markdownlint-cli2" "**/*.md" \ "!**/node_modules/**" \ "!**/bin/**" \ "!**/obj/**" \ @@ -66,13 +81,14 @@ if [[ -x "$ROOT/node_modules/.bin/markdownlint-cli2" ]]; then --config "$ROOT/.markdownlint.json" MD_EXIT=$? else - echo -e "${YELLOW}⚠️ markdownlint-cli2 not found at node_modules/.bin — run 'npm install'.${RESET}" - exit 1 + echo -e "${YELLOW}⚠️ markdownlint-cli2 not found — skipping local lint (CI enforces it).${RESET}" + echo -e "${YELLOW} To run locally: cd src/Web && npm ci${RESET}" + MD_EXIT=0 fi if [[ $MD_EXIT -ne 0 ]]; then echo -e "${RED}❌ Markdownlint violations detected.${RESET}" - echo -e "${YELLOW} Fix: npx markdownlint-cli2 \"**/*.md\"${RESET}" + echo -e "${YELLOW} Fix: cd src/Web && npx markdownlint-cli2 \"**/*.md\"${RESET}" echo -e "${YELLOW} Then: git add -u && git commit (or --amend), then re-push.${RESET}" exit 1 fi diff --git a/.github/workflows/add-issues-to-project.yml b/.github/workflows/add-issues-to-project.yml index 45860039..f6f505d0 100644 --- a/.github/workflows/add-issues-to-project.yml +++ b/.github/workflows/add-issues-to-project.yml @@ -54,7 +54,12 @@ jobs: // Set Status to Backlog await github.graphql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + mutation( + $projectId: ID! + $itemId: ID! + $fieldId: ID! + $optionId: String! + ) { updateProjectV2ItemFieldValue(input: { projectId: $projectId itemId: $itemId @@ -74,7 +79,9 @@ jobs: core.info(`✅ Issue #${issue.number} added to project → Backlog`); } catch (err) { if (err.message.includes('403')) { - core.warning(`Access denied (403). If this persists, create a PAT with 'project' scope and store as secrets.GH_PROJECT_TOKEN`); + const tokenMsg = "Access denied (403). Create a PAT with 'project' scope " + + "and store as secrets.GH_PROJECT_TOKEN"; + core.warning(tokenMsg); } else { core.warning(`Could not add issue to project: ${err.message}`); } diff --git a/.github/workflows/blog-readme-sync.yml b/.github/workflows/blog-readme-sync.yml index 75b398b7..08e6bb75 100644 --- a/.github/workflows/blog-readme-sync.yml +++ b/.github/workflows/blog-readme-sync.yml @@ -13,7 +13,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 1 token: ${{ secrets.GITHUB_TOKEN }} @@ -27,10 +27,12 @@ jobs: index = f.read() # Extract table rows after the header and separator - table_match = re.search( - r'\| Date \| Title \| Tags \|\s*\n\|[-| ]+\|\s*\n((?:\|.+\|\s*\n?)*)', - index + table_pattern = ( + r'\| Date \| Title \| Tags \|\s*\n' + r'\|[-| ]+\|\s*\n' + r'((?:\|.+\|\s*\n?)*)' ) + table_match = re.search(table_pattern, index) if not table_match: print("No table found in docs/blog/index.md — skipping") exit(0) @@ -40,7 +42,8 @@ jobs: line = line.strip() if line.startswith('|') and line.endswith('|') and '---' not in line: # Rewrite relative links to docs/blog/ prefix - line = re.sub(r'\]\((?!https?://)(?!docs/)([^)]+\.md)\)', r'](docs/blog/\1)', line) + link_pattern = r'\]\((?!https?://)(?!docs/)([^)]+\.md)\)' + line = re.sub(link_pattern, r'](docs/blog/\1)', line) rows.append(line) recent = rows[:5] diff --git a/.github/workflows/code-metrics.yml b/.github/workflows/code-metrics.yml index 749e2606..5e3f0a33 100644 --- a/.github/workflows/code-metrics.yml +++ b/.github/workflows/code-metrics.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: 'Print manual run reason' if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 44a296dc..e31d5b87 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/lint-markdown.yml b/.github/workflows/lint-markdown.yml index 0691e7d7..e1395312 100644 --- a/.github/workflows/lint-markdown.yml +++ b/.github/workflows/lint-markdown.yml @@ -15,7 +15,7 @@ jobs: name: markdownlint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run markdownlint uses: DavidAnson/markdownlint-cli2-action@v23 diff --git a/.github/workflows/lint-yaml.yml b/.github/workflows/lint-yaml.yml index af66e806..a26706b9 100644 --- a/.github/workflows/lint-yaml.yml +++ b/.github/workflows/lint-yaml.yml @@ -17,7 +17,7 @@ jobs: name: yamllint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run yamllint uses: ibiqlik/action-yamllint@v3 diff --git a/.github/workflows/project-board-automation.yml b/.github/workflows/project-board-automation.yml index 0c69daf2..064e4be2 100644 --- a/.github/workflows/project-board-automation.yml +++ b/.github/workflows/project-board-automation.yml @@ -30,8 +30,13 @@ jobs: with: github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | + const owner = context.repo.owner; + const repo = context.repo.repo; const action = context.payload.action; const pr = context.payload.pull_request; + const PROJECT_ID = process.env.PROJECT_ID; + const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID; + const DONE_OPTION_ID = process.env.DONE_OPTION_ID; const statusTargets = { inSprint: { optionId: process.env.IN_SPRINT_OPTION_ID, @@ -51,6 +56,40 @@ jobs: }, }; + const parseClosingIssueNumbers = text => { + const issueNumbers = new Set(); + const pattern = new RegExp( + `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s+(?:${owner}\\/${repo})?#(\\d+)`, + 'gi' + ); + + for (const match of (text ?? '').matchAll(pattern)) { + issueNumbers.add(Number.parseInt(match[1], 10)); + } + + return [...issueNumbers].sort((left, right) => left - right); + }; + + const looksLikeReleasePullRequest = pullRequest => { + const title = pullRequest.title ?? ''; + const headRef = pullRequest.head?.ref ?? ''; + const looksLikeReleaseTitle = + /^\[RELEASE\]\b/i.test(title) || + /^release:/i.test(title) || + /promote dev\s*(?:→|->|to)\s*main/i.test(title); + const looksLikeReleaseBranch = + headRef === 'dev' || + headRef.startsWith('release/') || + headRef.startsWith('hotfix/'); + + return pullRequest.base?.ref === 'main' && (looksLikeReleaseTitle || looksLikeReleaseBranch); + }; + + const isReleasePullRequest = pullRequest => + action === 'closed' && + pullRequest.merged === true && + looksLikeReleasePullRequest(pullRequest); + const updateStatus = async (itemId, target) => { await github.graphql(` mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { @@ -64,17 +103,17 @@ jobs: } } `, { - projectId: process.env.PROJECT_ID, + projectId: PROJECT_ID, itemId, - fieldId: process.env.STATUS_FIELD_ID, + fieldId: STATUS_FIELD_ID, optionId: target.optionId, }); }; const getIssueProjectItem = async issueNumber => { const { data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, + owner, + repo, issue_number: issueNumber, }); @@ -86,6 +125,16 @@ jobs: nodes { id project { id } + fieldValues(first: 50) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + optionId + field { + ... on ProjectV2SingleSelectField { id } + } + } + } + } } } } @@ -93,15 +142,23 @@ jobs: } `, { nodeId: issue.node_id }); - return projectQuery.node?.projectItems?.nodes?.find( - item => item.project.id === process.env.PROJECT_ID + const projectItem = projectQuery.node?.projectItems?.nodes?.find( + item => item.project.id === PROJECT_ID ); + + const isDone = projectItem?.fieldValues?.nodes?.some( + fieldValue => fieldValue.field?.id === STATUS_FIELD_ID && fieldValue.optionId === DONE_OPTION_ID + ) ?? false; + + return { + issue, + isDone, + projectItem, + }; }; const moveLinkedIssues = async target => { - const body = pr.body || ''; - const issueNumbers = [...body.matchAll(/(?:closes|fixes|resolves)\s+#(\d+)/gi)] - .map(match => parseInt(match[1], 10)); + const issueNumbers = parseClosingIssueNumbers(pr.body); if (issueNumbers.length === 0) { core.info(`PR #${pr.number}: no linked issues found — skipping`); @@ -114,7 +171,7 @@ jobs: for (const issueNumber of issueNumbers) { try { - const projectItem = await getIssueProjectItem(issueNumber); + const { projectItem } = await getIssueProjectItem(issueNumber); if (!projectItem) { core.warning(`Issue #${issueNumber} is not on the MyBlog project board — skipping`); @@ -129,71 +186,141 @@ jobs: } }; - const getAllProjectItems = async () => { - const items = []; - let cursor = null; - - do { - const project = await github.graphql(` - query($projectId: ID!, $cursor: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $cursor) { - nodes { - id - fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { - name - } - } - content { - __typename - ... on Issue { - number - title - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `, { - projectId: process.env.PROJECT_ID, - cursor, - }); + const getReleaseCommits = async pullNumber => { + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + + return commits.map(commit => ({ + sha: commit.sha, + message: commit.commit?.message ?? '', + })); + }; + + const getAssociatedPullRequests = async commitSha => { + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commitSha, + }); + + return response.data ?? []; + }; + + const getPreviousReleasePullRequest = async currentPullRequest => { + const closedPullRequests = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'closed', + base: 'main', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); - const page = project.node?.items; - items.push(...(page?.nodes ?? [])); - cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null; - } while (cursor); + const previousRelease = closedPullRequests + .filter(candidate => + candidate.number !== currentPullRequest.number && + candidate.merged_at && + candidate.merged_at < currentPullRequest.merged_at && + looksLikeReleasePullRequest(candidate) + ) + .sort((left, right) => right.merged_at.localeCompare(left.merged_at))[0]; - return items; + return previousRelease ?? null; }; - const moveDoneIssuesToReleased = async () => { - const projectItems = await getAllProjectItems(); + // Compare the current release PR commit set to the previous release PR commit + // set so only newly shipped commits contribute Released board transitions. + const getShippedReleaseCommits = async currentPullRequest => { + const currentReleaseCommits = await getReleaseCommits(currentPullRequest.number); + const previousReleasePullRequest = await getPreviousReleasePullRequest(currentPullRequest); - const doneIssues = projectItems.filter(item => - item.content?.__typename === 'Issue' && - item.fieldValueByName?.name === 'Done' - ) ?? []; + if (!previousReleasePullRequest) { + return currentReleaseCommits; + } + + const previousReleaseCommits = await getReleaseCommits(previousReleasePullRequest.number); + const previousReleaseCommitShas = new Set(previousReleaseCommits.map(commit => commit.sha)); + + return currentReleaseCommits.filter(commit => !previousReleaseCommitShas.has(commit.sha)); + }; + + const moveReleasedIssues = async () => { + const releaseCommits = await getShippedReleaseCommits(pr); + const relatedPullRequests = new Map(); + const issueNumbers = new Set(); + + for (const { sha, message } of releaseCommits) { + const associatedPullRequests = await getAssociatedPullRequests(sha); + + for (const associatedPullRequest of associatedPullRequests) { + if ( + !associatedPullRequest.merged_at || + associatedPullRequest.number === pr.number || + looksLikeReleasePullRequest(associatedPullRequest) + ) { + continue; + } + + relatedPullRequests.set(associatedPullRequest.number, associatedPullRequest); + } + + for (const issueNumber of parseClosingIssueNumbers(message)) { + issueNumbers.add(issueNumber); + } + } + + for (const relatedPullRequest of relatedPullRequests.values()) { + for (const issueNumber of parseClosingIssueNumbers(relatedPullRequest.body)) { + issueNumbers.add(issueNumber); + } + } + + // Also parse the release/hotfix PR body itself. + // For hotfix PRs the Closes #N reference lives only on the PR body + // (there is no intermediary squad PR in the commit walk). + for (const issueNumber of parseClosingIssueNumbers(pr.body)) { + issueNumbers.add(issueNumber); + } - if (doneIssues.length === 0) { - core.info('No Done issues found to move to Released'); + if (issueNumbers.size === 0) { + core.notice(`Release PR #${pr.number}: no shipped issues were identified`); return; } - core.info(`Release merge detected — moving ${doneIssues.length} issue(s) → Released`); + core.info( + `Release PR #${pr.number}: evaluating issues [${[...issueNumbers].sort((left, right) => left - right).join(', ')}] for Released` + ); + + let moved = 0; + + for (const issueNumber of [...issueNumbers].sort((left, right) => left - right)) { + try { + const { issue, isDone, projectItem } = await getIssueProjectItem(issueNumber); + + if (!projectItem) { + core.info(`Skipping issue #${issueNumber}: not on the MyBlog project board`); + continue; + } + + if (!isDone) { + core.info(`Skipping issue #${issueNumber}: current project status is not Done`); + continue; + } - for (const item of doneIssues) { - await updateStatus(item.id, statusTargets.released); - core.info(`✅ Issue #${item.content.number} → Released`); + await updateStatus(projectItem.id, statusTargets.released); + moved++; + core.info(`✅ Issue #${issue.number} → Released`); + } catch (err) { + core.warning(`Could not evaluate issue #${issueNumber}: ${err.message}`); + } } + + core.notice(`Release PR #${pr.number}: moved ${moved} shipped issue(s) to Released`); }; const getTargetStatus = () => { @@ -216,14 +343,8 @@ jobs: return null; }; - const isReleasePromotion = - action === 'closed' && - pr.merged === true && - pr.base.ref === 'main' && - pr.head.ref === 'dev'; - - if (isReleasePromotion) { - await moveDoneIssuesToReleased(); + if (isReleasePullRequest(pr)) { + await moveReleasedIssues(); return; } diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 0ca263b7..7d715fd8 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -3,10 +3,10 @@ name: Squad CI on: pull_request: - branches: [dev, preview, main, insider] + branches: [dev, main] types: [opened, synchronize, reopened] push: - branches: [dev, insider] + branches: [dev] permissions: contents: read @@ -18,15 +18,29 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 with: global-json-file: global.json + - name: Discover solution file + run: | + mapfile -t matches < <(find . -maxdepth 1 -name '*.slnx') + count=${#matches[@]} + if [[ $count -eq 0 ]]; then + echo "::error::No *.slnx file found at the repository root." + exit 1 + elif [[ $count -gt 1 ]]; then + echo "::error::Multiple *.slnx files found at the repository root: ${matches[*]}" + exit 1 + fi + echo "SOLUTION_FILE=${matches[0]}" >> "$GITHUB_ENV" + echo "Discovered solution: ${matches[0]}" + - name: Restore dependencies - run: dotnet restore MyBlog.slnx + run: dotnet restore "$SOLUTION_FILE" - name: Build (Release) - run: dotnet build MyBlog.slnx --configuration Release --no-restore + run: dotnet build "$SOLUTION_FILE" --configuration Release --no-restore diff --git a/.github/workflows/squad-dependabot-auto-merge.yml b/.github/workflows/squad-dependabot-auto-merge.yml new file mode 100644 index 00000000..a67d2354 --- /dev/null +++ b/.github/workflows/squad-dependabot-auto-merge.yml @@ -0,0 +1,44 @@ +name: Dependabot Auto-Merge + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + branches: [dev] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + enable-auto-merge: + if: >- + github.event_name == 'pull_request_target' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.base.ref == 'dev' && + github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const pullRequestId = context.payload.pull_request.node_id; + const pullNumber = context.payload.pull_request.number; + try { + await github.graphql( + `mutation EnableAutoMerge($pullRequestId: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) { + pullRequest { number } + } + }`, + { pullRequestId } + ); + core.info(`Enabled auto-merge for PR #${pullNumber}.`); + } catch (error) { + const message = error.message ?? String(error); + if (message.includes("already enabled")) { + core.info(`Auto-merge already enabled for PR #${pullNumber}.`); + return; + } + throw error; + } diff --git a/.github/workflows/squad-docs.yml b/.github/workflows/squad-docs.yml index 6633dd61..a8a3f82c 100644 --- a/.github/workflows/squad-docs.yml +++ b/.github/workflows/squad-docs.yml @@ -16,7 +16,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Build docs run: | diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml index 6f00d091..3ea045c0 100644 --- a/.github/workflows/squad-insider-release.yml +++ b/.github/workflows/squad-insider-release.yml @@ -11,7 +11,7 @@ jobs: insider-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 045acdcd..51c2226b 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -14,7 +14,7 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Identify assigned member and trigger work uses: actions/github-script@v9 diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml index 2cc36a93..9df089d7 100644 --- a/.github/workflows/squad-label-enforce.yml +++ b/.github/workflows/squad-label-enforce.yml @@ -13,7 +13,7 @@ jobs: enforce: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Enforce mutual exclusivity uses: actions/github-script@v9 diff --git a/.github/workflows/squad-main-from-dev-guard.yml b/.github/workflows/squad-main-from-dev-guard.yml new file mode 100644 index 00000000..798af8c6 --- /dev/null +++ b/.github/workflows/squad-main-from-dev-guard.yml @@ -0,0 +1,22 @@ +name: guard-main-source + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + guard-main-source: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + if (context.eventName !== "pull_request") { + core.info("Not a pull_request event."); + return; + } + const source = context.payload.pull_request.head.ref; + if (source !== "dev") { + core.setFailed(`PRs to main must come from dev. Current source: ${source}`); + } diff --git a/.github/workflows/squad-mark-released.yml b/.github/workflows/squad-mark-released.yml index cde3006e..e9846375 100644 --- a/.github/workflows/squad-mark-released.yml +++ b/.github/workflows/squad-mark-released.yml @@ -3,7 +3,16 @@ name: Squad Mark Released on: release: types: [published, released] - workflow_dispatch: {} + workflow_dispatch: + inputs: + release_pr_number: + description: "Merged release PR number targeting main" + required: false + type: string + tag_name: + description: "Tag name to resolve to a merged release PR" + required: false + type: string # NOTE: The default GITHUB_TOKEN cannot access GitHub Projects V2 via GraphQL. # This workflow requires a repository secret named GH_PROJECT_TOKEN set to a @@ -37,28 +46,91 @@ jobs: fi echo "✅ GH_PROJECT_TOKEN secret is present." - - name: Move Done → Released on project board + - name: Checkout repository for tag resolution + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Move shipped Done items → Released on project board uses: actions/github-script@v9 with: github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | - const PROJECT_ID = process.env.PROJECT_ID; - const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID; - const DONE_OPTION_ID = process.env.DONE_OPTION_ID; + const { execFileSync } = require('node:child_process'); + const owner = context.repo.owner; + const repo = context.repo.repo; + const PROJECT_ID = process.env.PROJECT_ID; + const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID; + const DONE_OPTION_ID = process.env.DONE_OPTION_ID; const RELEASED_OPTION_ID = process.env.RELEASED_OPTION_ID; - let cursor = null; - let moved = 0; + const parseClosingIssueNumbers = text => { + const issueNumbers = new Set(); + const pattern = new RegExp( + `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s+(?:${owner}\\/${repo})?#(\\d+)`, + 'gi' + ); + + for (const match of (text ?? '').matchAll(pattern)) { + issueNumbers.add(Number.parseInt(match[1], 10)); + } + + return [...issueNumbers].sort((left, right) => left - right); + }; - do { - const result = await github.graphql(` - query($projectId: ID!, $cursor: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } + const isReleasePullRequest = pullRequest => { + const title = pullRequest.title ?? ''; + const headRef = pullRequest.head?.ref ?? ''; + + return ( + pullRequest.merged_at && + pullRequest.base?.ref === 'main' && + ( + /^\[RELEASE\]\b/i.test(title) || + /^release:/i.test(title) || + /promote dev\s*(?:→|->|to)\s*main/i.test(title) || + headRef === 'dev' || + headRef.startsWith('release/') || + headRef.startsWith('hotfix/') + ) + ); + }; + + const updateReleasedStatus = async itemId => { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `, { + projectId: PROJECT_ID, + itemId, + fieldId: STATUS_FIELD_ID, + optionId: RELEASED_OPTION_ID, + }); + }; + + const getIssueProjectItem = async issueNumber => { + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const projectQuery = await github.graphql(` + query($nodeId: ID!) { + node(id: $nodeId) { + ... on Issue { + projectItems(first: 20) { nodes { id + project { id } fieldValues(first: 50) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { @@ -74,40 +146,212 @@ jobs: } } } - `, { projectId: PROJECT_ID, cursor }); - - const items = result.node.items; - cursor = items.pageInfo.hasNextPage ? items.pageInfo.endCursor : null; - - for (const item of items.nodes) { - // Match by field ID (not name) to avoid brittleness on renames - const isDone = item.fieldValues.nodes.some( - fv => fv.field?.id === STATUS_FIELD_ID && fv.optionId === DONE_OPTION_ID - ); - if (!isDone) continue; - - await github.graphql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - `, { - projectId: PROJECT_ID, - itemId: item.id, - fieldId: STATUS_FIELD_ID, - optionId: RELEASED_OPTION_ID, + `, { nodeId: issue.node_id }); + + const projectItem = projectQuery.node?.projectItems?.nodes?.find( + item => item.project.id === PROJECT_ID + ); + + const isDone = projectItem?.fieldValues?.nodes?.some( + fieldValue => fieldValue.field?.id === STATUS_FIELD_ID && fieldValue.optionId === DONE_OPTION_ID + ) ?? false; + + return { + issue, + isDone, + projectItem, + }; + }; + + const getReleaseCommits = async pullNumber => { + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + + return commits.map(commit => ({ + sha: commit.sha, + message: commit.commit?.message ?? '', + })); + }; + + const getAssociatedPullRequests = async commitSha => { + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commitSha, + }); + + return response.data ?? []; + }; + + const getPreviousReleasePullRequest = async currentPullRequest => { + const closedPullRequests = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'closed', + base: 'main', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + + const previousRelease = closedPullRequests + .filter(candidate => + candidate.number !== currentPullRequest.number && + candidate.merged_at && + candidate.merged_at < currentPullRequest.merged_at && + isReleasePullRequest(candidate) + ) + .sort((left, right) => right.merged_at.localeCompare(left.merged_at))[0]; + + return previousRelease ?? null; + }; + + // Compare the current release PR commit set to the previous release PR commit + // set so only newly shipped commits contribute Released board transitions. + const getShippedReleaseCommits = async currentPullRequest => { + const currentReleaseCommits = await getReleaseCommits(currentPullRequest.number); + const previousReleasePullRequest = await getPreviousReleasePullRequest(currentPullRequest); + + if (!previousReleasePullRequest) { + return currentReleaseCommits; + } + + const previousReleaseCommits = await getReleaseCommits(previousReleasePullRequest.number); + const previousReleaseCommitShas = new Set(previousReleaseCommits.map(commit => commit.sha)); + + return currentReleaseCommits.filter(commit => !previousReleaseCommitShas.has(commit.sha)); + }; + + const resolveReleasePullRequest = async () => { + const manualPullNumber = context.payload.inputs?.release_pr_number?.trim(); + const manualTagName = context.payload.inputs?.tag_name?.trim(); + const releaseTagName = manualTagName || context.payload.release?.tag_name; + + if (manualPullNumber) { + const pullNumber = Number.parseInt(manualPullNumber, 10); + + if (!Number.isInteger(pullNumber)) { + core.setFailed(`Invalid release_pr_number: ${manualPullNumber}`); + return null; + } + + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, }); + return { pullRequest, selectionLabel: `PR #${pullRequest.number}` }; + } + + if (!releaseTagName) { + core.setFailed('Manual dispatch requires release_pr_number or tag_name.'); + return null; + } + + let tagCommitSha; + + try { + tagCommitSha = execFileSync('git', ['rev-list', '-n', '1', releaseTagName], { + encoding: 'utf8', + }).trim(); + } catch (error) { + core.setFailed(`Could not resolve tag ${releaseTagName}: ${error.message}`); + return null; + } + + const associatedPullRequests = await getAssociatedPullRequests(tagCommitSha); + const releasePullRequest = associatedPullRequests.find(isReleasePullRequest); + + if (!releasePullRequest) { + core.setFailed(`Could not find a merged release PR targeting main for tag ${releaseTagName}.`); + return null; + } + + return { pullRequest: releasePullRequest, selectionLabel: releaseTagName }; + }; + + const resolvedRelease = await resolveReleasePullRequest(); + + if (!resolvedRelease) { + return; + } + + const { pullRequest, selectionLabel } = resolvedRelease; + + if (!isReleasePullRequest(pullRequest)) { + core.setFailed(`PR #${pullRequest.number} is not a merged MyBlog release PR to main.`); + return; + } + + const releaseCommits = await getShippedReleaseCommits(pullRequest); + const relatedPullRequests = new Map(); + const issueNumbers = new Set(); + + for (const { sha, message } of releaseCommits) { + const associatedPullRequests = await getAssociatedPullRequests(sha); + + for (const associatedPullRequest of associatedPullRequests) { + if ( + !associatedPullRequest.merged_at || + associatedPullRequest.number === pullRequest.number || + isReleasePullRequest(associatedPullRequest) + ) { + continue; + } + + relatedPullRequests.set(associatedPullRequest.number, associatedPullRequest); + } + + for (const issueNumber of parseClosingIssueNumbers(message)) { + issueNumbers.add(issueNumber); + } + } + + for (const relatedPullRequest of relatedPullRequests.values()) { + for (const issueNumber of parseClosingIssueNumbers(relatedPullRequest.body)) { + issueNumbers.add(issueNumber); + } + } + + // Also parse the release/hotfix PR body itself. + // For hotfix PRs the Closes #N reference lives only on the PR body + // (there is no intermediary squad PR in the commit walk). + for (const issueNumber of parseClosingIssueNumbers(pullRequest.body)) { + issueNumbers.add(issueNumber); + } + + if (issueNumbers.size === 0) { + core.notice(`${selectionLabel}: no shipped issues were identified`); + return; + } + + let moved = 0; + + for (const issueNumber of [...issueNumbers].sort((left, right) => left - right)) { + try { + const { issue, isDone, projectItem } = await getIssueProjectItem(issueNumber); + + if (!projectItem) { + core.info(`Skipping issue #${issueNumber}: not on the MyBlog project board`); + continue; + } + + if (!isDone) { + core.info(`Skipping issue #${issueNumber}: current project status is not Done`); + continue; + } + + await updateReleasedStatus(projectItem.id); moved++; - core.info(`✅ Item ${item.id} → Released`); + core.info(`✅ Issue #${issue.number} → Released`); + } catch (error) { + core.warning(`Could not evaluate issue #${issueNumber}: ${error.message}`); } - } while (cursor); + } - const tagName = context.payload.release?.tag_name ?? 'manual dispatch'; - core.notice(`🎉 Moved ${moved} item(s) from Done → Released for ${tagName}`); + core.notice(`🎉 ${selectionLabel}: moved ${moved} shipped item(s) from Done → Released`); diff --git a/.github/workflows/squad-milestone-release.yml b/.github/workflows/squad-milestone-release.yml index f42b78b3..9a94a818 100644 --- a/.github/workflows/squad-milestone-release.yml +++ b/.github/workflows/squad-milestone-release.yml @@ -21,7 +21,7 @@ jobs: cut-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml index e456212f..5d18ef93 100644 --- a/.github/workflows/squad-preview.yml +++ b/.github/workflows/squad-preview.yml @@ -12,7 +12,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml index 55fb25bc..315971ed 100644 --- a/.github/workflows/squad-promote.yml +++ b/.github/workflows/squad-promote.yml @@ -22,7 +22,7 @@ jobs: name: Promote dev → main (open PR) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 ref: dev diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 2942e8a2..cb82dc36 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -11,7 +11,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/squad-standard-lint-markdown.yml b/.github/workflows/squad-standard-lint-markdown.yml new file mode 100644 index 00000000..bd14e5cf --- /dev/null +++ b/.github/workflows/squad-standard-lint-markdown.yml @@ -0,0 +1,23 @@ +name: lint-markdown + +on: + pull_request: + branches: [dev, main] + workflow_dispatch: + +jobs: + lint-markdown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v5 + with: + node-version: "22" + - name: Run markdownlint + run: | + files=$(git ls-files '*.md') + if [ -z "$files" ]; then + echo "No markdown files found." + exit 0 + fi + npx --yes markdownlint-cli $files diff --git a/.github/workflows/squad-standard-lint-yaml.yml b/.github/workflows/squad-standard-lint-yaml.yml new file mode 100644 index 00000000..b8049c03 --- /dev/null +++ b/.github/workflows/squad-standard-lint-yaml.yml @@ -0,0 +1,24 @@ +name: lint-yaml + +on: + pull_request: + branches: [dev, main] + workflow_dispatch: + +jobs: + lint-yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Run yamllint + run: | + python -m pip install --quiet yamllint + files=$(git ls-files '*.yml' '*.yaml') + if [ -z "$files" ]; then + echo "No YAML files found." + exit 0 + fi + yamllint -s $files diff --git a/.github/workflows/squad-test.yml b/.github/workflows/squad-test.yml index 37cfd710..3f698584 100644 --- a/.github/workflows/squad-test.yml +++ b/.github/workflows/squad-test.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -64,21 +64,35 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: | ${{ runner.os }}-nuget- + - name: Discover solution file + run: | + mapfile -t matches < <(find . -maxdepth 1 -name '*.slnx') + count=${#matches[@]} + if [[ $count -eq 0 ]]; then + echo "::error::No *.slnx file found at the repository root." + exit 1 + elif [[ $count -gt 1 ]]; then + echo "::error::Multiple *.slnx files found at the repository root: ${matches[*]}" + exit 1 + fi + echo "SOLUTION_FILE=${matches[0]}" >> "$GITHUB_ENV" + echo "Discovered solution: ${matches[0]}" + - name: Restore dependencies - run: dotnet restore + run: dotnet restore "$SOLUTION_FILE" - name: Build solution - run: dotnet build MyBlog.slnx --configuration Release --no-restore + run: dotnet build "$SOLUTION_FILE" --configuration Release --no-restore - name: Cache build artifacts - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | **/bin/Release/ @@ -95,7 +109,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -103,7 +117,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -137,7 +151,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -145,7 +159,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -197,7 +211,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -205,7 +219,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -257,7 +271,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -265,7 +279,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -344,7 +358,7 @@ jobs: done - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -352,7 +366,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -412,7 +426,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -420,7 +434,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} @@ -498,7 +512,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -546,7 +560,7 @@ jobs: path: coverage-output - name: Publish to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 if: always() with: fail_ci_if_error: false @@ -569,7 +583,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download all test results uses: actions/download-artifact@v8 @@ -577,7 +591,7 @@ jobs: path: all-test-results - name: Publish test results - uses: EnricoMi/publish-unit-test-result-action@v2.23.0 + uses: EnricoMi/publish-unit-test-result-action@v2.24.0 if: always() with: files: | diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index c7c94240..7b9bf1a9 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -13,7 +13,7 @@ jobs: if: github.event.label.name == 'squad' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Triage issue via Lead agent uses: actions/github-script@v9 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index e72852cc..b4b46252 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -26,7 +26,7 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Pages uses: actions/configure-pages@v6 - name: Upload artifact diff --git a/.github/workflows/sync-readme.yml b/.github/workflows/sync-readme.yml index 3610a564..ac8f1cc1 100644 --- a/.github/workflows/sync-readme.yml +++ b/.github/workflows/sync-readme.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml index 59943e40..c2911ad9 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -15,7 +15,7 @@ jobs: sync-labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Parse roster and sync labels uses: actions/github-script@v9 diff --git a/.gitignore b/.gitignore index bb23b720..7aab9c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ src/Web/wwwroot/**/*.gz .fake *.lscache +.directory diff --git a/.squad/agents/aragorn/charter.md b/.squad/agents/aragorn/charter.md index 27e19054..e6a7c49d 100644 --- a/.squad/agents/aragorn/charter.md +++ b/.squad/agents/aragorn/charter.md @@ -42,8 +42,8 @@ You are Aragorn, the Lead Developer on the {ProjectName} project. You own archit Preferred: auto -- Architecture proposals, reviewer gates → claude-opus-4.6 -- Code review, design-heavy implementation work → claude-sonnet-4.6 +- Architecture proposals, reviewer gates → gpt-5.4 +- Code review, design-heavy implementation work → gpt-5.4 - Triage, planning, issue routing → claude-haiku-4.5 ## Critical Rules diff --git a/.squad/agents/aragorn/history.md b/.squad/agents/aragorn/history.md index 2b6005ff..fefa2350 100644 --- a/.squad/agents/aragorn/history.md +++ b/.squad/agents/aragorn/history.md @@ -1767,3 +1767,99 @@ Ran the full PR gate for #340 (Sprint 19 Categories feature, issue #339) request `already merged` and you can clean up the local branch separately. - For PRs where the human repo owner is the GitHub author of record, `gh pr review --approve` still typically blocks self-approval. Aragorn gate-pass via `gh pr comment` documenting Copilot dispositions + CI/Codecov status remains the working approval signal, then `gh pr merge --squash --delete-branch` performs the merge directly. - When Codecov fails to post a bot comment, prefer the in-pipeline `Coverage Analysis` job result as the coverage gate signal rather than blocking the PR for missing bot output. + +## Issue #362 — ObjectId Cache Serialization Fix (2026-05-XX) + +Diagnosed and fixed the production-code gap reported by Gimli: `BlogPostDto.Id` and +`BlogPostDto.CategoryId` (both `ObjectId`) were silently round-tripping to +`ObjectId.Empty` when deserializing from Redis because `System.Text.Json` has no +built-in `ObjectId` converter and was reflecting private fields, producing a zero struct. + +### Changes made + +| File | Change | +| --- | --- | +| `src/Web/Infrastructure/Caching/ObjectIdJsonConverter.cs` | New — `JsonConverter` that reads/writes the 24-char hex string | +| `src/Web/Infrastructure/Caching/BlogPostCacheService.cs` | `JsonOpts` changed from `private static` to `internal static`; wired in `ObjectIdJsonConverter` via `BuildJsonOpts()` helper | +| `tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs` | Updated local `JsonOpts` to reference `BlogPostCacheService.JsonOpts` so both sides stay in sync | + +### Learnings + +- `System.Text.Json` silently produces `default(T)` (i.e., `ObjectId.Empty`) when + deserializing an unrecognised struct without a converter, rather than throwing. + This makes the bug invisible at the serialization site but manifests as bad IDs at read time. +- The correct fix is a `JsonConverter` using `ObjectId.TryParse` + `ToString()`. +- Exposing the `JsonSerializerOptions` as `internal static` (visibility widened, not public) + lets test code share the same instance without duplicating converter registration — + prevents future drift if new converters are added. +- Scope is correctly limited to the BlogPost caching path; `UserManagementCacheService` + does not serialize `ObjectId` values and was left untouched. + +## 2026-05-24 — Release PR #383 conflict review + +Reviewed release PR #383 (`dev` → `main`) after the user reported a large merge-conflict +burst on the release branch. + +### Findings + +- `origin/main` is only **1 commit** ahead of `origin/dev`, but that one commit is the + squash-merged Sprint 19 release commit: `c7e4c3a` (`[RELEASE] Sprint 19 — Polish, + Markdown Editor & Categories (#352)`). +- `origin/dev` does **not** contain `c7e4c3a`; `git branch --contains c7e4c3a` returns + only `main`. +- The earlier `main` → `dev` ancestry-unblock work for PR #352 (`bf8919a`, `3b1f1dc`, + `e0a46c6`) happened **before** GitHub created the final squash commit on `main`, so the + exact release commit was never replayed back into `dev`. +- `git merge-tree --write-tree --name-only --no-messages origin/main origin/dev` reports + **55 real conflicted files**, concentrated in: + - `src/Web/Features/BlogPosts` (6) + - `src/Web/Features/Categories` (6) + - `src/Web/Data` (4) + - `src/Domain` (4) + - `tests/Web.Tests/Categories` (7) + - `tests/Web.Tests.Bunit/Features` (4) + - plus `Directory.Packages.props`, `src/AppHost/MongoDbResourceBuilderExtensions.cs`, + `tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs`, `.github/workflows/squad-heartbeat.yml`, + and `.squad/playbooks/pre-push-process.md` + +### Learnings + +- **Squash-merging recurring `dev` → `main` release PRs causes ancestry drift.** The next + release PR sees the prior release as a brand-new main-only commit and re-conflicts on + every overlapping file. +- **Pre-merge ancestry fixes are not enough** if the final GitHub merge creates a new + single-parent squash commit afterward; that exact commit must either be merged back to + `dev` or future release work should use a merge strategy that preserves ancestry. +- **Do release-conflict recovery in a dedicated clean worktree.** This repo currently has + many local modifications on `dev`, including files inside the present conflict set, so + resolving on the primary checkout would be high risk. + +--- + +## 2026-05-24 — PR #385 final release gate (replacement for #383) + +Conducted the final release-candidate gate for replacement PR #385 after recovery commits +`04e4847`, `872a732`, and `fcb7f5a`, using Boromir's recovery summary plus Sam and Gimli +review evidence. + +### Gate Outcome + +- **Verdict:** approve-with-notes +- **Supersession:** PR #385 should replace PR #383 for the Sprint 20 release path; PR #383 + remains conflict-stuck and should not be merged. +- **Branch shape:** clean worktree recovery from `origin/main` with a single + `origin/dev` merge is intact, and the two follow-up fixes were applied on the same + recovery branch without disturbing the recovered release payload. +- **Validation:** the create/edit category regressions are now covered, and local + `Web.Tests` + `Web.Tests.Bunit` validation passed on the recovery branch. +- **Final outstanding requirement before merge:** wait for the in-flight + `AppHost.Tests` / downstream PR checks on #385 to finish green. + +### Learnings + +- A recovery branch can be judged the correct release candidate before it is fully + merge-ready, but the actual merge still waits for terminal green CI on the replacement + PR. +- Once squash ancestry drift has already exploded the original `dev` → `main` PR, a clean + replacement PR is the safer release path than trying to keep resolving conflicts in the + original branch. diff --git a/.squad/agents/boromir/charter.md b/.squad/agents/boromir/charter.md index d160a40b..d2320a61 100644 --- a/.squad/agents/boromir/charter.md +++ b/.squad/agents/boromir/charter.md @@ -38,4 +38,4 @@ You are Boromir, the DevOps engineer on the {ProjectName} project. You own CI/CD Preferred: auto - Routine config, changelog, and mechanical ops → claude-haiku-4.5 -- Complex workflow, AppHost, and CI logic → claude-sonnet-4.6 +- Complex workflow, AppHost, and CI logic → gpt-5.4 diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index 424e1d45..a1594453 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -1,3 +1,30 @@ +## 2026-05-24 Session Log: Issue #371 Docs Recovery & Branch Alignment Guardrail + +Boromir recovered dirty issue #371 documentation work that had been started on `dev` +and rehomed it onto the proper branch: +`squad/371-our-documentation-is-outdated-and-missing-blog-and-release-information`. + +### What changed + +- Safely stashed only the issue-owned docs files from `dev` and reapplied them on the + issue branch created from `origin/dev` +- Preserved the recovered docs scope to: + `README.md`, `docs/blog/index.md`, `docs/index.html`, and five release posts under + `docs/blog/` +- Added a reusable process guardrail: + `.squad/skills/issue-branch-alignment/SKILL.md` +- Routed the new guardrail in `.squad/routing.md` +- Updated `.squad/playbooks/pre-push-process.md` with the stash-and-rehome recovery flow +- Recorded the team-facing decision in + `.squad/decisions/inbox/boromir-issue-branch-alignment.md` + +### Verification + +- Markdown lint passed for all changed Markdown files +- Local gate passed with: + `dotnet restore`, `dotnet build MyBlog.slnx --configuration Release`, and all required + test projects including `tests/AppHost.Tests/AppHost.Tests.csproj` + ## Core Context ### MyBlog DevOps & Infrastructure Patterns @@ -48,6 +75,27 @@ ## Learnings +### 2026-05-24 — Issue #384: Recover release PR #383 after squash-merge ancestry drift + +**What worked:** Rebuild the replacement release branch from `origin/main` in a +clean worktree, merge `origin/dev`, resolve conflicts once, then strip every +`.squad/` path back to the `main` version before commit. That keeps the release +PR scoped to product/docs/workflow changes only. + +**Release recovery rule:** For ancestry-drift repairs, treat `origin/dev` as the +source of truth for active product code and tests. Preserve only intentional +main-only release differences, such as workflow deletions already accepted on +`dev`. + +**Validation gotchas:** Local toolchains placed inside the repo can break the +pre-push markdownlint gate because it scans `**/*.md`. Keep ad-hoc SDK/Node +installs outside the worktree, and install Playwright Chromium before running +`tests/AppHost.Tests`. + +**Key paths:** `src/AppHost/MongoDbResourceBuilderExtensions.cs`, +`Directory.Packages.props`, `.github/workflows/dependabot-auto-merge.yml`, +`.github/workflows/squad-heartbeat.yml`, and `tests/AppHost.Tests`. + ### 2026-05-19 — Issue #348: Resolve Remaining Database Runtime Issues (post-PR #346 investigation) **Context:** Issue #348 was opened because MongoDB container crashes were still visible after PR #346 (which pinned `mongo:7` + `mongo-data-v7`). Assigned to Boromir + Sam + Gimli. @@ -1495,6 +1543,17 @@ Decision #26: Lint Workflow Pattern for MyBlog (merged into `.squad/decisions.md ## Learnings +### 2026-05-29 — Issue #407: AppHost console URL is the dashboard source of truth + +- `dotnet run --project src/AppHost/AppHost.csproj` started successfully on the + issue branch; the perceived local-startup failure came from stale docs that + still pointed to `http://localhost:15100`. +- For local Aspire troubleshooting, trust the dashboard URL printed by the + running AppHost console instead of any hard-coded port in docs or prior + sessions. +- On this machine, the direct runtime check reported the active AppHost URL as + `https://localhost:17091`. + ### Issue #299 — Pre-Push Gate: AppHost.Tests Was Missing from Live Hook (2026-05-11) **Root cause:** The playbook and SKILL.md documented `AppHost.Tests` as mandatory in Gate 5, but the live `.github/hooks/pre-push` `INTEGRATION_PROJECTS` array only contained `Web.Tests.Integration`. The hook and docs were out of sync. @@ -1664,6 +1723,34 @@ Changes shipped clean with zero test failures (Architecture.Tests: 16/16 passed) --- +## 2026-05-25 — Issue #393: Released board promotion now follows shipped release commit deltas + +**Context:** Sprint 20 release board automation was moving unrelated Done items to Released after +`main` releases because the workflows trusted broad Done-column state and release PR body refs. + +### What changed + +- Updated `.github/workflows/project-board-automation.yml` and + `.github/workflows/squad-mark-released.yml` to compare the current release PR's commit list to + the previous merged release PR's commit list. +- The workflows now derive shipped issues from newly introduced commit messages plus associated + non-release PR bodies, then move only matching Done cards to Released. +- Manual recovery docs in `docs/SQUAD-COMMANDS.md` now call out both supported inputs: + `release_pr_number` and `tag_name`. + +### Key Learnings + +- **Release PR bodies are not reliable shipment scope.** Recovery release PRs can close meta issues + like `#384`; those refs must not drive board promotion. +- **Release commit deltas are more reliable than merge timestamps.** Subtracting the previous + release PR commit set from the current one correctly isolated Sprint 20's 13 newly shipped + commits and excluded already released history. +- **`listPullRequestsAssociatedWithCommit` still needs release-PR filtering.** Merge-back or + recovery commits can resolve to old release PRs, so Released selection must ignore release-shaped + PRs and use only feature PRs plus commit-message issue refs. + +--- + ## 2026-07-08 — Issue #350 Round 2: AppHost.Tests Mongo/Aspire Startup Verification **Context:** Gimli's verification reported 3 remaining AppHost.Tests Mongo/Aspire startup failures after prior round (node_modules, aspire.config.json fixes). Tasked with reproducing and diagnosing. @@ -1696,3 +1783,45 @@ container crashed (exit 139/SIGSEGV). Each collection's fixture failure cascaded - When Aspire integration test collections fail at fixture init (not test body), ALL tests in the collection report as failed — making "3 failures" actually mean "3 fixture startup failures affecting N tests total" - `Assert.Skip()` (xUnit v3) skips appear in CI as Skipped not Failed — a 1-skip result on the theme toggle test is expected/normal behavior - The Mongo volume state matters across sessions: `mongo-data` (FCV-contaminated with mongo:8.2 UUID idents) must never be used with `mongo:7`; only `mongo-data-v7` is safe + +--- + +## Issue #420 — Dependabot npm_and_yarn bump (js-yaml, markdown-it) + +**Date:** 2026-07-01 +**PR created:** #423 (Node 22 workflow fix) + +### Work Done + +Reviewed Dependabot PR #420 bumping root-level npm devDependencies: + +| Package | Before | After | +|---|---|---| +| markdownlint-cli2 | 0.22.1 | 0.23.0 | +| js-yaml (transitive) | 4.1.1 | 5.2.0 | +| markdown-it (transitive) | 14.1.1 | 14.2.0 | +| markdownlint (transitive) | 0.40.0 | 0.41.0 | + +- js-yaml 5.x is a **major version** but only used internally by markdownlint-cli2; no direct usage in our code. +- All affected packages are **devDependencies** — zero production impact. +- `markdownlint` CI check passes ✅ on the PR. +- Build failures on the PR are pre-existing `MessagePack 2.5.192` vulnerability (NU1902/NU1903) — unrelated. + +### Node 22 Fix (PR #423) + +`markdownlint-cli2 0.23.0` and `markdownlint 0.41.0` raised minimum Node from 20 → 22. +Updated `.github/workflows/squad-standard-lint-markdown.yml` `node-version: "20"` → `"22"`. +Note: `lint-markdown.yml` uses `markdownlint-cli2-action@v23` (GitHub composite action) — no `setup-node` needed, unaffected. + +Approved PR #420 and enabled auto-merge. Created PR #423 as companion Node 22 workflow fix. + +### Key File Paths + +- `.github/workflows/squad-standard-lint-markdown.yml` — uses `setup-node@v5` (node-version must be kept current) +- `.github/workflows/lint-markdown.yml` — uses `markdownlint-cli2-action@v23` (node-agnostic) + +## Learnings + +**Pattern:** When reviewing Dependabot npm PRs, check both the direct dep version AND the transitive deps' engine requirements. A minor bump of `markdownlint-cli2` carried a Node >=22 requirement via its transitive `markdownlint` dep. + +**Pattern:** `squad-dependabot-auto-merge.yml` only fires for PRs targeting `dev`. Dependabot PRs that target `main` must be manually approved/merged or have auto-merge enabled by hand. diff --git a/.squad/agents/gandalf/history.md b/.squad/agents/gandalf/history.md index 4d6fcad3..c80f2b3e 100644 --- a/.squad/agents/gandalf/history.md +++ b/.squad/agents/gandalf/history.md @@ -93,7 +93,10 @@ Security findings: **Key Findings:** -1. **[HIGH] Open Redirect in `/Account/Login`** — `returnUrl` query parameter is passed directly to `WithRedirectUri(returnUrl ?? "/")` in `Program.cs:111` with no local-path validation. An attacker could craft `/Account/Login?returnUrl=https://evil.com` to redirect a user to a phishing site after login. Fix: validate `returnUrl` is a relative/local path before use (e.g., `LocalRedirect` or `Uri.IsWellFormedUriString` check). +1. **[HIGH] Open Redirect in `/Account/Login`** — `returnUrl` parameter is passed directly to + `WithRedirectUri(returnUrl ?? "/")` in `Program.cs:111` with no local-path validation. + An attacker could craft `/Account/Login?returnUrl=https://evil.com` to redirect after login. + Fix: validate `returnUrl` is a relative/local path (e.g., `Uri.IsWellFormedUriString` check). 2. **[MEDIUM] Potential NullReferenceException in `OnTokenValidated` handler** — `Program.cs:46` captures `options.Events.OnTokenValidated` before the PostConfigure, but Auth0 SDK may set this to `null`. The line `await existingOnTokenValidated(context)` will throw `NullReferenceException` if null, breaking all logins. Fix: guard with `if (existingOnTokenValidated != null)`. @@ -216,3 +219,50 @@ Resolved 7 add/add conflicts in `.squad/skills/` by accepting `origin/dev` versi - webapp-testing/SKILL.md **Learning:** Add/add conflicts in skill files result from parallel imports. The `origin/dev` versions are authoritative when adapted for MyBlog conventions (file paths, ownership rules, real examples). + +### Issue #396 — Fix Local Login Failure with Placeholder Auth0 Config — 2026-05-14 + +**Branch:** `squad/396-fix-local-login-placeholder-auth0` +**PR:** Opens against `dev` + +**Problem:** In Development/Testing, missing Auth0 credentials caused `Program.cs` to fall +back to `test.auth0.com` / `test-client-id`. Navigating to `/Account/Login` then triggered +a real OIDC discovery call against that non-existent domain, producing `IDX20803` timeout. + +**Fix applied (Program.cs):** + +1. **`isPlaceholderAuth0Config` flag** — set to `true` only when the `else if` branch + activates (Dev/Testing, no real credentials). Developers who supply real user secrets are + unaffected; the flag stays `false` and the live OIDC flow is preserved. + +2. **`/Account/Login` short-circuit** — when `isPlaceholderAuth0Config` is `true`, the + endpoint redirects to `/test/login` instead of issuing a `ChallengeAsync` to the + placeholder domain. The test-only cookie login completes instantly. + +3. **`existingOnTokenValidated` null guard** — added `if (existingOnTokenValidated != null)` + before invoking the captured delegate. Without this guard any login where the Auth0 SDK + left that delegate null would throw a `NullReferenceException` on token validation. + +**Docs updated:** `docs/AUTH0_SETUP.md` — new troubleshooting entry for the `IDX20803` +timeout pattern, explaining the auto-redirect and how to enable real Auth0 login locally. + +**Security test scenarios specified for Gimli:** + +1. `GET /Account/Login` when `isPlaceholderAuth0Config = true` → assert 302 redirect to `/test/login`, no OIDC challenge issued. +2. `GET /Account/Login` when real Auth0 credentials are configured → assert OIDC challenge is issued (302 to Auth0 domain), `/test/login` is NOT called. +3. `GET /Account/Login` in Production environment without credentials → assert `InvalidOperationException` is thrown at startup (not a redirect). +4. `GET /test/login` in Development → assert cookie set, redirect to `/`, user is authenticated. +5. `GET /test/login` in Production → assert 404 (endpoint not registered outside Dev/Testing). +6. `OnTokenValidated` with null prior handler → assert no `NullReferenceException`; role claims are added normally. + +**Decision record:** `.squad/decisions/inbox/gandalf-auth0-placeholder-login.md` + +**Key learnings:** + +- Placeholder fallback values (`test.auth0.com`) are safe for DI registration but MUST be + guarded at the request boundary — the OIDC discovery call fires at request time, not at + startup, so startup guards alone are insufficient. +- A boolean flag captures intent cleanly without duplicating environment checks across + multiple handlers; the closure captures it safely in a top-level statement program. +- The `existingOnTokenValidated` null-guard is a recurring pattern when wrapping + Auth0 SDK event delegates — always check for null before invoking captured delegates. diff --git a/.squad/agents/gimli/history.md b/.squad/agents/gimli/history.md index 04338212..e0e9df92 100644 --- a/.squad/agents/gimli/history.md +++ b/.squad/agents/gimli/history.md @@ -237,30 +237,76 @@ and conventions. ### Key Learnings 1. **MyBlog's testing stack is mature and battle-tested:** + - Integration tests: MongoDbFixture + collection isolation working well (9 tests) - - Unit tests: 59 tests with 91.64% line coverage, all handlers + domain + components covered - - Architecture tests: VSA + layer rules enforced - - bUnit component tests: Clean auth mocking pattern with TestAuthorizationService -2. **Patterns extraction requires grounding in real code:** +## Session: PR #385 Re-review — Clear Category Fix (2026-05-24) + +### Task + +Re-review PR #385 after Sam's category-clear fix and determine whether the prior +release rejection is resolved. + +### Findings + +- `Edit.razor` now normalizes the empty `