diff --git a/README.md b/README.md index a4e7fdafb9..35a4d793a4 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,9 @@ Essential commands for the Spec-Driven Development workflow: | `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | | `/speckit.converge` | `speckit-converge` | Assess the codebase against spec/plan/tasks and append remaining work as new tasks | +> [!NOTE] +> GitHub issue tracking is moving out of core into the bundled, opt-in [`github` extension](./extensions/github/README.md). `/speckit.taskstoissues` still works and is unchanged, but its replacement, `/speckit.github.taskstoissues`, is available today via `specify extension add github`. See the [migration notes](./extensions/github/README.md#migrating-from-the-core-taskstoissues-command). + ### Optional Commands Additional commands for enhanced quality and validation: diff --git a/docs/installation.md b/docs/installation.md index 67b69505e6..48bc58cbfe 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -126,7 +126,8 @@ After initialization, you should see the following commands available in your co - `/speckit.checklist` - Generate quality checklists - `/speckit.constitution` - Create or update project principles - `/speckit.converge` - Assess codebase against artifacts and append remaining tasks -- `/speckit.taskstoissues` - Convert tasks to issues +- `/speckit.taskstoissues` - Convert tasks to issues (moving to the bundled `github` extension as + `/speckit.github.taskstoissues`; install it with `specify extension add github`) Scripts are installed into a variant subdirectory matching the chosen script type: diff --git a/extensions/catalog.json b/extensions/catalog.json index d05c48e0e5..a1621f44f9 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-17T00:00:00Z", + "updated_at": "2026-09-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "agent-context": { @@ -62,6 +62,20 @@ "workflow", "core" ] + }, + "github": { + "name": "GitHub Integration", + "id": "github", + "version": "1.0.0", + "description": "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "github", + "issues", + "integration" + ] } } } diff --git a/extensions/github/README.md b/extensions/github/README.md new file mode 100644 index 0000000000..19ee01ed40 --- /dev/null +++ b/extensions/github/README.md @@ -0,0 +1,89 @@ +# GitHub Integration Extension + +This bundled, **opt-in** extension is the home for Spec Kit's GitHub *platform* functionality. Today it provides one command, `speckit.github.taskstoissues`, which turns a feature's `tasks.md` into dependency-ordered GitHub issues. + +> NOTE: `git` and `github` are deliberately separate domains. The [`git` extension](../git/README.md) owns local version-control workflow (feature branches, commits, remote detection); this extension owns interactions with the GitHub platform itself. + +## Why an extension? + +Creating GitHub issues is provider-specific project management, not part of the provider-neutral Spec-Driven Development lifecycle. Keeping it in a dedicated, opt-in extension lets users: + +- **Choose whether to install it at all** — `specify init` does **not** install it. +- **Use a different issue tracker** without having to work around a GitHub-specific core command. +- **Depend on a stable, namespaced command** from other extensions. + +## Installation + +From the root of an initialized Spec Kit project: + +```bash +specify extension add github +``` + +## Removal + +```bash +specify extension remove github + +# Or keep it installed but inert +specify extension disable github +specify extension enable github +``` + +## Commands + +| Command | Description | +| ------------------------------ | -------------------------------------------------------------------- | +| `speckit.github.taskstoissues` | Convert tasks from `tasks.md` into dependency-ordered GitHub issues. | + +> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.github.taskstoissues` for dot-command integrations; `/speckit-github-taskstoissues` for hyphen/skills integrations (including Forge and Cline); `$speckit-github-taskstoissues` for Codex, ZCode, or Command Code in skills mode; or `/skill:speckit-github-taskstoissues` for Kimi. + +### What the command does + +1. Resolves the active feature and loads its `tasks.md` (via this extension's own `resolve-tasks` script — see [Scripts](#scripts)). +2. Reads the Git remote and **stops unless it points at GitHub**. +3. Lists existing repository issues — open *and* closed — and matches their titles against the task IDs in `tasks.md` (`\bT\d{3,}\b`, so four-digit and longer IDs are handled). +4. Creates issues titled `T001: ` only for task IDs that do not already have one, in the repository identified by the remote. + +## Hooks + +This extension consumes the existing `before_taskstoissues` and `after_taskstoissues` hook points, which are read from `.specify/extensions.yml` at run time. The hook keys are unchanged from the core command, so hooks registered by other extensions — for example the `git` extension's auto-commit hooks — keep firing exactly as before. + +## Requirements + +- A Git remote pointing at GitHub. +- The **GitHub MCP server** available to your coding agent, providing the `list_issues` and `issue_write` tools. + +> NOTE: Agents without MCP support (for example the Pi Coding Agent out of the box) cannot run this command as intended. + +## Scripts + +The extension ships its own feature-resolution script in all three supported runtimes, so it is self-contained and does not reach into core: + +| Runtime | Script | +| ---------- | ---------------------------------------- | +| Bash | `scripts/bash/resolve-tasks.sh` | +| PowerShell | `scripts/powershell/resolve-tasks.ps1` | +| Python | `scripts/python/resolve_tasks.py` | + +Once installed they live under `.specify/extensions/github/scripts/`. The script is a trimmed twin of core's `check-prerequisites`: it resolves the project root and active feature directory, requires `tasks.md`, and reports the design docs alongside it. It performs none of core's `plan.md`/`spec.md` gating, and it never writes `.specify/feature.json`. + +## Migrating from the core `taskstoissues` command + +Spec Kit is moving GitHub issue tracking out of core in three stages: + +1. **Now** — this extension is available, and the core `/speckit.taskstoissues` command remains available and unchanged. Nothing breaks if you do nothing. +2. **Next** — the core command is deprecated once the replacement has been available for a release. +3. **Later** — the core command is removed in a minor release. + +To migrate, install the extension and use the namespaced command instead: + +```bash +specify extension add github +``` + +| Before | After | +| ------------------------- | --------------------------------- | +| `/speckit.taskstoissues` | `/speckit.github.taskstoissues` | + +Behavior is unchanged: the same remote validation, the same deduplication across open and closed issues, the same issue titles, and the same hook contract. This extension does **not** register `speckit.taskstoissues` as an alias, so the two commands coexist without shadowing each other while the core command still exists. diff --git a/extensions/github/commands/speckit.github.taskstoissues.md b/extensions/github/commands/speckit.github.taskstoissues.md new file mode 100644 index 0000000000..6f7ac4e0f1 --- /dev/null +++ b/extensions/github/commands/speckit.github.taskstoissues.md @@ -0,0 +1,106 @@ +--- +description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. +tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_write'] +scripts: + sh: scripts/bash/resolve-tasks.sh --json + ps: scripts/powershell/resolve-tasks.ps1 -Json + py: scripts/python/resolve_tasks.py --json +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Pre-Execution Checks + +**Check for extension hooks (before tasks-to-issues conversion)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + +## Outline + +1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS and the AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints. +1. From the executed script, extract the path to **tasks** (the `TASKS` value). +1. Get the Git remote by running: + +```bash +git config --get remote.origin.url +``` + +> [!CAUTION] +> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL + +1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. +1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: `, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`). + - **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`). + - Only create issues for tasks that do not yet have a matching issue. + +> [!CAUTION] +> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL + +## Post-Execution Checks + +**Check for extension hooks (after tasks-to-issues conversion)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/extensions/github/extension.yml b/extensions/github/extension.yml new file mode 100644 index 0000000000..ba7085b0e5 --- /dev/null +++ b/extensions/github/extension.yml @@ -0,0 +1,27 @@ +schema_version: "1.0" + +extension: + id: github + name: "GitHub Integration" + version: "1.0.0" + description: "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.github.taskstoissues + file: commands/speckit.github.taskstoissues.md + description: "Convert tasks from tasks.md into dependency-ordered GitHub issues" + +tags: + - "github" + - "issues" + - "integration" diff --git a/extensions/github/scripts/bash/resolve-tasks.sh b/extensions/github/scripts/bash/resolve-tasks.sh new file mode 100755 index 0000000000..e0c788fb89 --- /dev/null +++ b/extensions/github/scripts/bash/resolve-tasks.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash + +# Resolve the active feature and its tasks.md for the github extension. +# +# Deliberately self-contained: the github extension owns this script so that +# `speckit.github.taskstoissues` keeps working when the core `taskstoissues` +# command (and its `check-prerequisites` helper invocation) is deprecated and +# removed. It is a trimmed twin of core `check-prerequisites.sh` — it resolves +# the project root and the active feature directory, requires tasks.md, and +# reports the optional design docs that sit next to it. It performs none of +# core's plan.md/spec.md gating and never writes .specify/feature.json. +# +# Usage: ./resolve-tasks.sh [--json] +# +# OPTIONS: +# --json Output in JSON format +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...","TASKS":"...","AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n TASKS:... \n AVAILABLE_DOCS: \n ✓/✗ file.md + +set -e + +JSON_MODE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --json) + JSON_MODE=true + ;; + --help|-h) + cat << 'HELP' +Usage: resolve-tasks.sh [OPTIONS] + +Resolve the active feature and its tasks.md for the github extension. + +OPTIONS: + --json Output in JSON format + --help, -h Show this help message + +EXAMPLES: + ./resolve-tasks.sh --json +HELP + exit 0 + ;; + *) + echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2 + exit 1 + ;; + esac + shift +done + +# Escape a string for safe embedding in a JSON value (RFC 8259). +# Kept byte-for-byte in step with core's json_escape (scripts/bash/common.sh) +# rather than sourcing it, so this script stays self-contained. +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +# Find the project root by searching upward for the .specify marker directory. +find_specify_root() { + local dir="${1:-$(pwd)}" + dir="$(CDPATH="" cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + printf '%s\n' "$dir" + return 0 + fi + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Resolve the project root, honouring an explicit SPECIFY_INIT_DIR override. +# Mirrors core get_repo_root: strict on an invalid override, no silent fallback. +get_repo_root() { + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + local init_root + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 + return 1 + fi + if [[ ! -d "$init_root/.specify" ]]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 + return 1 + fi + printf '%s\n' "$init_root" + return 0 + fi + + local specify_root + if specify_root=$(find_specify_root); then + printf '%s\n' "$specify_root" + return 0 + fi + + # Installed scripts live at .specify/extensions/github/scripts/bash/. + local script_dir + script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if specify_root=$(find_specify_root "$script_dir"); then + printf '%s\n' "$specify_root" + return 0 + fi + + echo "ERROR: Not inside a Spec Kit project (no .specify/ directory found)." >&2 + return 1 +} + +# Read .specify/feature.json's "feature_directory" value, or empty string. +# Parser order mirrors core common.sh (jq -> python3 -> grep/sed) and selects +# by parse success rather than availability, so a Windows python3 App Execution +# Alias stub cannot swallow the fallback (issue #3304). +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]]; then + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +REPO_ROOT=$(get_repo_root) || exit 1 + +# Resolve the feature directory. Priority: +# 1. SPECIFY_FEATURE_DIRECTORY (explicit override) +# 2. .specify/feature.json "feature_directory" +# Read-only by design: unlike core, this never persists feature.json (#3025). +if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + FEATURE_DIR="$SPECIFY_FEATURE_DIRECTORY" +else + FEATURE_DIR=$(read_feature_json_feature_directory "$REPO_ROOT") + if [[ -z "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 + exit 1 + fi +fi +[[ "$FEATURE_DIR" != /* ]] && FEATURE_DIR="$REPO_ROOT/$FEATURE_DIR" + +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run the Spec Kit specify command (e.g. /speckit.specify) first to create the feature structure." >&2 + exit 1 +fi + +TASKS="$FEATURE_DIR/tasks.md" +if [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run the Spec Kit tasks command (e.g. /speckit.tasks) first to create the task list." >&2 + exit 1 +fi + +RESEARCH="$FEATURE_DIR/research.md" +DATA_MODEL="$FEATURE_DIR/data-model.md" +QUICKSTART="$FEATURE_DIR/quickstart.md" +CONTRACTS_DIR="$FEATURE_DIR/contracts" + +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") +docs+=("tasks.md") + +if $JSON_MODE; then + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + printf '{"FEATURE_DIR":"%s","TASKS":"%s","AVAILABLE_DOCS":%s}\n' \ + "$(json_escape "$FEATURE_DIR")" "$(json_escape "$TASKS")" "$json_docs" +else + echo "FEATURE_DIR:$FEATURE_DIR" + echo "TASKS:$TASKS" + echo "AVAILABLE_DOCS:" + for d in "${docs[@]}"; do + echo " ✓ $d" + done +fi diff --git a/extensions/github/scripts/powershell/resolve-tasks.ps1 b/extensions/github/scripts/powershell/resolve-tasks.ps1 new file mode 100644 index 0000000000..80dec1a564 --- /dev/null +++ b/extensions/github/scripts/powershell/resolve-tasks.ps1 @@ -0,0 +1,178 @@ +#!/usr/bin/env pwsh + +# Resolve the active feature and its tasks.md for the github extension. +# +# Deliberately self-contained: the github extension owns this script so that +# speckit.github.taskstoissues keeps working when the core taskstoissues +# command (and its check-prerequisites helper invocation) is deprecated and +# removed. It is a trimmed twin of core check-prerequisites.ps1 -- it resolves +# the project root and the active feature directory, requires tasks.md, and +# reports the optional design docs that sit next to it. It performs none of +# core's plan.md/spec.md gating and never writes .specify/feature.json. +# +# Usage: ./resolve-tasks.ps1 [-Json] +# +# OPTIONS: +# -Json Output in JSON format +# -Help Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...","TASKS":"...","AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... / TASKS:... / AVAILABLE_DOCS: list + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output @" +Usage: resolve-tasks.ps1 [OPTIONS] + +Resolve the active feature and its tasks.md for the github extension. + +OPTIONS: + -Json Output in JSON format + -Help Show this help message + +EXAMPLES: + ./resolve-tasks.ps1 -Json +"@ + exit 0 +} + +# Find the project root by searching upward for the .specify marker directory. +function Find-SpecifyRoot { + param([string]$StartDir = (Get-Location).Path) + + $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue + $current = if ($resolved) { $resolved.Path } else { $null } + if (-not $current) { return $null } + + while ($true) { + if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) { + return $current + } + $parent = Split-Path $current -Parent + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { + return $null + } + $current = $parent + } +} + +# Resolve the project root, honouring an explicit SPECIFY_INIT_DIR override. +# Mirrors core Get-RepoRoot: strict on an invalid override, no silent fallback. +function Get-ProjectRoot { + if ($env:SPECIFY_INIT_DIR) { + $initDir = $env:SPECIFY_INIT_DIR + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + exit 1 + } + # TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core + # only) keeps this working on Windows PowerShell 5.1, while the + # GetPathRoot check preserves a path that *is* its own root ('C:\'). + $initRoot = $resolved.Path.TrimEnd('/', '\') + if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) { + $initRoot = $resolved.Path + } + if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot") + exit 1 + } + return $initRoot + } + + $specifyRoot = Find-SpecifyRoot + if ($specifyRoot) { return $specifyRoot } + + # Installed scripts live at .specify/extensions/github/scripts/powershell/. + $fromScript = Find-SpecifyRoot -StartDir $PSScriptRoot + if ($fromScript) { return $fromScript } + + [Console]::Error.WriteLine("ERROR: Not inside a Spec Kit project (no .specify/ directory found).") + exit 1 +} + +$repoRoot = Get-ProjectRoot + +# Resolve the feature directory. Priority: +# 1. SPECIFY_FEATURE_DIRECTORY (explicit override) +# 2. .specify/feature.json "feature_directory" +# Read-only by design: unlike core, this never persists feature.json (#3025). +$featureJson = Join-Path $repoRoot '.specify/feature.json' +if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY +} elseif (Test-Path -LiteralPath $featureJson -PathType Leaf) { + # Read as UTF-8 explicitly: Windows PowerShell 5.1 otherwise decodes with + # the legacy ANSI code page and mangles non-ASCII feature paths (#4359). + $featureJsonRaw = [System.IO.File]::ReadAllText($featureJson, [System.Text.Encoding]::UTF8) + try { + $featureConfig = $featureJsonRaw | ConvertFrom-Json + } catch { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.") + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + } else { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.") + exit 1 + } +} else { + [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.") + exit 1 +} + +if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir +} + +if (-not (Test-Path -LiteralPath $featureDir -PathType Container)) { + [Console]::Error.WriteLine("ERROR: Feature directory not found: $featureDir") + [Console]::Error.WriteLine("Run the Spec Kit specify command (e.g. /speckit.specify) first to create the feature structure.") + exit 1 +} + +$tasks = Join-Path $featureDir 'tasks.md' +if (-not (Test-Path -LiteralPath $tasks -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: tasks.md not found in $featureDir") + [Console]::Error.WriteLine("Run the Spec Kit tasks command (e.g. /speckit.tasks) first to create the task list.") + exit 1 +} + +$docs = @() +if (Test-Path -LiteralPath (Join-Path $featureDir 'research.md') -PathType Leaf) { $docs += 'research.md' } +if (Test-Path -LiteralPath (Join-Path $featureDir 'data-model.md') -PathType Leaf) { $docs += 'data-model.md' } +$contractsDir = Join-Path $featureDir 'contracts' +if ((Test-Path -LiteralPath $contractsDir -PathType Container) -and + (Get-ChildItem -LiteralPath $contractsDir -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { + $docs += 'contracts/' +} +if (Test-Path -LiteralPath (Join-Path $featureDir 'quickstart.md') -PathType Leaf) { $docs += 'quickstart.md' } +$docs += 'tasks.md' + +if ($Json) { + # -Compress keeps the payload on a single line, matching the bash twin. + $payload = [ordered]@{ + FEATURE_DIR = $featureDir + TASKS = $tasks + AVAILABLE_DOCS = @($docs) + } + Write-Output ($payload | ConvertTo-Json -Compress) +} else { + Write-Output "FEATURE_DIR:$featureDir" + Write-Output "TASKS:$tasks" + Write-Output "AVAILABLE_DOCS:" + foreach ($d in $docs) { + Write-Output " [OK] $d" + } +} diff --git a/extensions/github/scripts/python/resolve_tasks.py b/extensions/github/scripts/python/resolve_tasks.py new file mode 100644 index 0000000000..dd7e4afa0a --- /dev/null +++ b/extensions/github/scripts/python/resolve_tasks.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Resolve the active feature and its tasks.md for the github extension. + +Deliberately self-contained: the github extension owns this script so that +``speckit.github.taskstoissues`` keeps working when the core ``taskstoissues`` +command (and its ``check_prerequisites`` helper invocation) is deprecated and +removed. It is a trimmed twin of core ``check_prerequisites.py`` -- it resolves +the project root and the active feature directory, requires ``tasks.md``, and +reports the optional design docs that sit next to it. It performs none of +core's ``plan.md``/``spec.md`` gating and never writes ``.specify/feature.json``. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +HELP_TEXT = """Usage: resolve_tasks.py [OPTIONS] + +Resolve the active feature and its tasks.md for the github extension. + +OPTIONS: + --json Output in JSON format + --help, -h Show this help message + +EXAMPLES: + ./resolve_tasks.py --json +""" + + +def _status_marker() -> str: + """Return the status glyph, downgraded to ASCII when stdout cannot encode it. + + On Windows sys.stdout falls back to the ANSI code page whenever it is not a + console - a pipe or a file redirect, which is how agents and workflow steps + invoke these scripts - and U+2713 is unencodable in cp1252, so printing it + raises UnicodeEncodeError and aborts the report right after + "AVAILABLE_DOCS:". Mirrors core's _status_marker in + scripts/python/check_prerequisites.py; "[OK]" is also what this script's + PowerShell twin emits. + """ + glyph = "✓" + try: + glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8") + except (LookupError, UnicodeEncodeError): + return "[OK]" + return glyph + + +def _die(*lines: str) -> "None": + for line in lines: + print(line, file=sys.stderr) + raise SystemExit(1) + + +def find_specify_root(start_dir: Path | None = None) -> Path | None: + """Find the project root by searching upward for the .specify marker.""" + current = (start_dir or Path.cwd()).resolve() + while True: + if (current / ".specify").is_dir(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def get_project_root(script_file: Path) -> Path: + """Resolve the project root, honouring an explicit SPECIFY_INIT_DIR override. + + Mirrors core ``get_repo_root``: strict on an invalid override, with no + silent fallback to the current directory. + """ + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if raw: + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = Path.cwd() / candidate + try: + init_root = candidate.resolve(strict=True) + except OSError: + init_root = None + if init_root is None or not init_root.is_dir(): + _die( + "ERROR: SPECIFY_INIT_DIR does not point to an existing " + f"directory: {raw}" + ) + if not (init_root / ".specify").is_dir(): + _die( + "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project " + f"(no .specify/ directory): {init_root}" + ) + return init_root + + root = find_specify_root() + if root is not None: + return root + + # Installed scripts live at .specify/extensions/github/scripts/python/. + root = find_specify_root(script_file.resolve().parent) + if root is not None: + return root + + _die("ERROR: Not inside a Spec Kit project (no .specify/ directory found).") + raise AssertionError("unreachable") # pragma: no cover - _die always exits + + +def read_feature_json_feature_directory(repo_root: Path) -> str: + """Read .specify/feature.json's feature_directory value, or empty string.""" + feature_json = repo_root / ".specify" / "feature.json" + if not feature_json.is_file(): + return "" + try: + data = json.loads(feature_json.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return "" + value = data.get("feature_directory") if isinstance(data, dict) else None + return value if isinstance(value, str) else "" + + +def main(argv: list[str]) -> int: + json_mode = False + for arg in argv: + if arg == "--json": + json_mode = True + elif arg in ("--help", "-h"): + print(HELP_TEXT, end="") + return 0 + else: + print( + f"ERROR: Unknown option '{arg}'. Use --help for usage information.", + file=sys.stderr, + ) + return 1 + + repo_root = get_project_root(Path(__file__)) + + # Resolve the feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY (explicit override) + # 2. .specify/feature.json "feature_directory" + # Read-only by design: unlike core, this never persists feature.json (#3025). + raw_feature_dir = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "") + if not raw_feature_dir: + raw_feature_dir = read_feature_json_feature_directory(repo_root) + if not raw_feature_dir: + _die( + "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY " + "or run the specify command to create .specify/feature.json." + ) + + feature_dir = Path(raw_feature_dir) + if not feature_dir.is_absolute(): + feature_dir = repo_root / feature_dir + + if not feature_dir.is_dir(): + _die( + f"ERROR: Feature directory not found: {feature_dir}", + "Run the Spec Kit specify command (e.g. /speckit.specify) first to " + "create the feature structure.", + ) + + tasks = feature_dir / "tasks.md" + if not tasks.is_file(): + _die( + f"ERROR: tasks.md not found in {feature_dir}", + "Run the Spec Kit tasks command (e.g. /speckit.tasks) first to " + "create the task list.", + ) + + docs: list[str] = [] + if (feature_dir / "research.md").is_file(): + docs.append("research.md") + if (feature_dir / "data-model.md").is_file(): + docs.append("data-model.md") + contracts_dir = feature_dir / "contracts" + if contracts_dir.is_dir() and any(contracts_dir.iterdir()): + docs.append("contracts/") + if (feature_dir / "quickstart.md").is_file(): + docs.append("quickstart.md") + docs.append("tasks.md") + + if json_mode: + payload = { + "FEATURE_DIR": str(feature_dir), + "TASKS": str(tasks), + "AVAILABLE_DOCS": docs, + } + sys.stdout.write( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + else: + print(f"FEATURE_DIR:{feature_dir}") + print(f"TASKS:{tasks}") + print("AVAILABLE_DOCS:") + marker = _status_marker() + for doc in docs: + print(f" {marker} {doc}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/pyproject.toml b/pyproject.toml index 06e8f5df56..cb13f63202 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ packages = ["src/specify_cli"] "extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context" "extensions/assess" = "specify_cli/core_pack/extensions/assess" "extensions/bug" = "specify_cli/core_pack/extensions/bug" +"extensions/github" = "specify_cli/core_pack/extensions/github" # Bundled workflows (auto-installed during `specify init`) "workflows/speckit" = "specify_cli/core_pack/workflows/speckit" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) diff --git a/tests/extensions/github/__init__.py b/tests/extensions/github/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py new file mode 100644 index 0000000000..389d0c9ece --- /dev/null +++ b/tests/extensions/github/test_github_extension.py @@ -0,0 +1,690 @@ +"""Tests for the bundled ``github`` extension (extensions/github/). + +Validates: +- Bundled layout (manifest, README, command file, script twins) +- Catalog registration and wheel/source resolution via ``_locate_bundled_extension`` +- Manifest validation, including that no alias claims the core command name +- Install/uninstall through ``ExtensionManager`` +- Rendered command artifacts across command mode and skills mode, and in + particular that ``{SCRIPT}`` resolves to a script the extension actually + ships under ``.specify/extensions/github/scripts/`` rather than to core +- The ``before_taskstoissues`` / ``after_taskstoissues`` hook contract +- Behaviour of the bash and Python ``resolve-tasks`` twins +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest +import yaml + +from specify_cli import _locate_bundled_extension +from tests.conftest import requires_bash + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +EXT_DIR = PROJECT_ROOT / "extensions" / "github" +CORE_COMMAND = PROJECT_ROOT / "templates" / "commands" / "taskstoissues.md" + +COMMAND_NAME = "speckit.github.taskstoissues" +COMMAND_FILE = EXT_DIR / "commands" / f"{COMMAND_NAME}.md" + +# The three script twins, keyed by the frontmatter variant that selects them. +SCRIPT_TWINS = { + "sh": "scripts/bash/resolve-tasks.sh", + "ps": "scripts/powershell/resolve-tasks.ps1", + "py": "scripts/python/resolve_tasks.py", +} + + +def _supported_agents() -> list[str]: + """Every integration Spec Kit can register commands for.""" + from specify_cli.agents import CommandRegistrar + + return sorted(CommandRegistrar().AGENT_CONFIGS) + + +SUPPORTED_AGENTS = _supported_agents() + + +def _manifest_dict() -> dict: + return yaml.safe_load((EXT_DIR / "extension.yml").read_text(encoding="utf-8")) + + +def _command_frontmatter() -> dict: + from specify_cli.agents import CommandRegistrar + + frontmatter, _ = CommandRegistrar().parse_frontmatter( + COMMAND_FILE.read_text(encoding="utf-8") + ) + return frontmatter + + +# -- Bundled extension layout ------------------------------------------------- + + +class TestExtensionLayout: + def test_extension_yml_has_required_fields(self): + manifest = _manifest_dict() + assert manifest["extension"]["id"] == "github" + assert manifest["extension"]["name"] == "GitHub Integration" + assert manifest["extension"]["author"] == "spec-kit-core" + # Install rejects a manifest without a requires block. + assert manifest["requires"]["speckit_version"] + commands = {c["name"] for c in manifest["provides"]["commands"]} + assert commands == {COMMAND_NAME} + + def test_readme_exists(self): + readme = EXT_DIR / "README.md" + assert readme.is_file() + assert "GitHub Integration Extension" in readme.read_text(encoding="utf-8") + + def test_readme_documents_migration_from_core(self): + text = (EXT_DIR / "README.md").read_text(encoding="utf-8") + assert "specify extension add github" in text + assert "/speckit.taskstoissues" in text + assert COMMAND_NAME in text + + def test_command_file_exists(self): + assert COMMAND_FILE.is_file() + + @pytest.mark.parametrize("rel_path", sorted(SCRIPT_TWINS.values())) + def test_script_twin_ships(self, rel_path: str): + assert (EXT_DIR / rel_path).is_file(), f"Missing script: {rel_path}" + + +# -- Catalog registration and bundle resolution ------------------------------- + + +class TestCatalogEntry: + def test_catalog_lists_github_as_bundled(self): + catalog = json.loads( + (PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8") + ) + entry = catalog["extensions"]["github"] + assert entry["bundled"] is True + assert entry["id"] == "github" + assert entry["author"] == "spec-kit-core" + + def test_locate_bundled_extension_finds_github(self): + located = _locate_bundled_extension("github") + assert located is not None + assert (located / "extension.yml").is_file() + + def test_pyproject_bundles_the_extension_into_the_wheel(self): + pyproject = tomllib.loads( + (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "force-include" + ] + assert ( + force_include["extensions/github"] + == "specify_cli/core_pack/extensions/github" + ) + + +# -- Manifest validation ------------------------------------------------------ + + +class TestManifest: + def test_manifest_validates(self): + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + assert m.id == "github" + assert m.version == "1.0.0" + assert [c["name"] for c in m.commands] == [COMMAND_NAME] + + def test_manifest_command_files_exist(self): + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + for cmd in m.commands: + assert (EXT_DIR / cmd["file"]).is_file() + + def test_no_alias_claims_the_core_command(self): + """Stage 1 keeps the core command; nothing here may shadow it. + + Alias names are not pattern-checked and the core-namespace guard + applies to primary names only, so this is author discipline that a + test has to hold in place. + """ + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + aliases = [a for cmd in m.commands for a in cmd.get("aliases", []) or []] + assert aliases == [] + + def test_core_command_remains_unchanged(self): + """Stage 1 is additive: the core command still ships.""" + assert CORE_COMMAND.is_file() + + +# -- Install / uninstall ------------------------------------------------------ + + +class TestExtensionInstall: + def test_install_copies_command_and_scripts(self, tmp_path: Path): + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manifest = manager.install_from_directory( + EXT_DIR, "0.9.0", register_commands=False + ) + + assert manifest.id == "github" + assert manager.registry.is_installed("github") + assert {c["name"] for c in manifest.commands} == {COMMAND_NAME} + + installed = tmp_path / ".specify" / "extensions" / "github" + assert (installed / "commands" / f"{COMMAND_NAME}.md").is_file() + for rel_path in SCRIPT_TWINS.values(): + assert (installed / rel_path).is_file(), f"Missing script: {rel_path}" + + def test_remove_uninstalls_cleanly(self, tmp_path: Path): + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manager.install_from_directory(EXT_DIR, "0.9.0", register_commands=False) + + assert manager.remove("github") is True + assert not manager.registry.is_installed("github") + assert not (tmp_path / ".specify" / "extensions" / "github").exists() + + +# -- Rendered command artifacts ----------------------------------------------- + + +class TestScriptPathResolution: + def test_frontmatter_uses_plain_extension_local_spelling(self): + """No ``../../`` escape hatch back into core scripts.""" + scripts = _command_frontmatter()["scripts"] + assert set(scripts) == set(SCRIPT_TWINS) + for variant, rel_path in SCRIPT_TWINS.items(): + assert scripts[variant].startswith(rel_path), scripts[variant] + assert ".." not in scripts[variant] + + def test_adjusted_paths_resolve_under_the_installed_extension(self): + from specify_cli.agents import CommandRegistrar + + adjusted = CommandRegistrar()._adjust_script_paths( + _command_frontmatter(), extension_id="github" + )["scripts"] + + for variant, rel_path in SCRIPT_TWINS.items(): + assert adjusted[variant].startswith( + f".specify/extensions/github/{rel_path}" + ), adjusted[variant] + + @pytest.mark.parametrize("variant,rel_path", sorted(SCRIPT_TWINS.items())) + def test_rendered_command_points_at_a_script_that_ships( + self, tmp_path: Path, variant: str, rel_path: str + ): + """End to end: install, render, and confirm the path exists on disk. + + A verbatim copy of the core command renders an extension-local path + for core's ``check-prerequisites``, installs happily, and only fails + when a user runs it. This pins the working spelling. + """ + from specify_cli.extensions import CommandRegistrar, ExtensionManager + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + (project / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": variant}), encoding="utf-8" + ) + (project / ".github" / "agents").mkdir(parents=True) + + manager = ExtensionManager(project) + manifest = manager.install_from_directory( + EXT_DIR, "0.9.0", register_commands=False + ) + extension_dir = project / ".specify" / "extensions" / "github" + + CommandRegistrar().register_commands_for_agent( + "copilot", manifest, extension_dir, project + ) + + rendered = project / ".github" / "agents" / f"{COMMAND_NAME}.agent.md" + assert rendered.is_file() + content = rendered.read_text(encoding="utf-8") + + assert "{SCRIPT}" not in content + expected = f".specify/extensions/github/{rel_path}" + assert expected in content + # The rendered path must resolve to a file the extension ships. + assert (project / expected).is_file() + # And it must not have been rewritten into the core script tree. + assert ".specify/scripts/" not in content + + @pytest.mark.parametrize("agent", SUPPORTED_AGENTS) + def test_every_supported_integration_renders_the_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent: str + ): + """Acceptance criterion: the command works for *every* integration. + + Covers both layouts in one sweep — command-file agents, skills-mode + agents, and Hermes, which installs to ``~/.hermes/skills`` rather than + a project-local directory (hence the redirected home). + """ + from specify_cli.extensions import CommandRegistrar, ExtensionManager + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + (project / ".specify" / "init-options.json").write_text( + json.dumps({"ai": agent, "script": "sh"}), encoding="utf-8" + ) + + manager = ExtensionManager(project) + manifest = manager.install_from_directory( + EXT_DIR, "0.9.0", register_commands=False + ) + CommandRegistrar().register_commands_for_agent( + agent, manifest, project / ".specify" / "extensions" / "github", project + ) + + installed = project / ".specify" / "extensions" + artifacts = [ + p + for root in (project, home) + for p in root.rglob("*") + if p.is_file() + and installed not in p.parents + and "taskstoissues" in p.as_posix().lower() + ] + assert artifacts, f"{agent} produced no command artifact" + + expected = f".specify/extensions/github/{SCRIPT_TWINS['sh']}" + bodies = [p.read_text(encoding="utf-8") for p in artifacts] + + # No artifact may leak an unresolved placeholder... + for artifact, content in zip(artifacts, bodies): + assert "{SCRIPT}" not in content, artifact + # ...and the command body must carry the resolved extension-local path. + # Some integrations also emit a thin companion file (e.g. Copilot's + # prompt shim, which only points at the agent), so this is "at least + # one" rather than "all". + assert any(expected in content for content in bodies), ( + f"{agent}: no artifact references {expected} " + f"(wrote {[p.name for p in artifacts]})" + ) + + def test_rendered_skill_points_at_a_script_that_ships(self, tmp_path: Path): + """Skills-mode layouts resolve ``{SCRIPT}`` the same way.""" + from specify_cli.extensions import CommandRegistrar, ExtensionManager + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + (project / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "codex", "ai_skills": True, "script": "sh"}), + encoding="utf-8", + ) + (project / ".agents" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project) + manifest = manager.install_from_directory( + EXT_DIR, "0.9.0", register_commands=False + ) + extension_dir = project / ".specify" / "extensions" / "github" + + CommandRegistrar().register_commands_for_agent( + "codex", manifest, extension_dir, project + ) + + skill = ( + project / ".agents" / "skills" / "speckit-github-taskstoissues" / "SKILL.md" + ) + assert skill.is_file() + content = skill.read_text(encoding="utf-8") + + assert "{SCRIPT}" not in content + expected = f".specify/extensions/github/{SCRIPT_TWINS['sh']}" + assert expected in content + assert (project / expected).is_file() + + +# -- Behaviour parity with the core command ----------------------------------- + + +class TestCommandBody: + def test_preserves_the_hook_contract(self): + """The hook keys are literal strings read out of extensions.yml.""" + body = COMMAND_FILE.read_text(encoding="utf-8") + assert "hooks.before_taskstoissues" in body + assert "hooks.after_taskstoissues" in body + + def test_git_extension_hooks_still_target_those_keys(self): + """The live consumers of the hook contract keep firing.""" + git_manifest = yaml.safe_load( + (PROJECT_ROOT / "extensions" / "git" / "extension.yml").read_text( + encoding="utf-8" + ) + ) + hooks = git_manifest["hooks"] + assert "before_taskstoissues" in hooks + assert "after_taskstoissues" in hooks + + def test_declares_the_github_mcp_tools(self): + tools = _command_frontmatter()["tools"] + assert "github/github-mcp-server/list_issues" in tools + assert "github/github-mcp-server/issue_write" in tools + + def test_preserves_remote_validation(self): + body = COMMAND_FILE.read_text(encoding="utf-8") + assert "git config --get remote.origin.url" in body + assert "ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL" in body + assert ( + "UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT " + "MATCH THE REMOTE URL" in body + ) + + def test_readme_lists_every_dollar_skills_agent(self): + """The invocation guide must name all `$speckit-` agents, not some. + + Regression: Command Code was omitted, so its users were pointed at a + syntax their agent does not use. + """ + from specify_cli._invocation_style import DOLLAR_SKILLS_AGENTS + + readme = (EXT_DIR / "README.md").read_text(encoding="utf-8") + note = next( + line for line in readme.splitlines() if "$speckit-github-taskstoissues" in line + ) + + display_names = { + "codex": "Codex", + "zcode": "ZCode", + "command-code": "Command Code", + } + # If a new dollar-skills agent appears, this fails until it is named. + assert set(display_names) == set(DOLLAR_SKILLS_AGENTS), ( + "DOLLAR_SKILLS_AGENTS changed; update the README invocation note " + f"and this mapping: {sorted(DOLLAR_SKILLS_AGENTS)}" + ) + for agent, display in display_names.items(): + assert display in note, f"README omits {display} ({agent}): {note}" + + def test_preserves_deduplication_and_pagination(self): + body = COMMAND_FILE.read_text(encoding="utf-8") + assert "list_issues" in body + # Both open and closed issues: the tool returns both when `state` is omitted. + assert "Do not pass a `state` value" in body + # Cursor-based pagination, and the early exit that bounds the call count. + assert "perPage: 100" in body + assert "`after` parameter" in body + assert "endCursor" in body + assert "Stop paginating as soon as every task ID has been matched" in body + # Four-digit and longer task IDs must still match. + assert r"\bT\d{3,}\b" in body + + def test_body_differs_from_core_only_in_the_script_invocation(self): + """Behaviour parity, enforced as a diff rather than as spot checks. + + Everything except the ``scripts:`` frontmatter and the two lines that + read the new ``TASKS`` value must match the core command verbatim, so + the two cannot silently drift while both exist. + """ + import difflib + + core = CORE_COMMAND.read_text(encoding="utf-8").splitlines() + ext = COMMAND_FILE.read_text(encoding="utf-8").splitlines() + changed = [ + line + for line in difflib.unified_diff(core, ext, n=0) + if line.startswith(("+", "-")) and not line.startswith(("+++", "---")) + ] + + # 3 script lines + 2 outline lines, each as one removal and one addition. + assert len(changed) == 10, "\n".join(changed) + + markers = ( + "check-prerequisites", + "check_prerequisites", + "resolve-tasks", + "resolve_tasks", + "AVAILABLE_DOCS", + "path to **tasks**", + ) + assert all( + any(marker in line for marker in markers) for line in changed + ), "\n".join(changed) + + def test_uses_the_portable_command_reference_token(self): + """A literal invocation would be correct for exactly one agent.""" + body = COMMAND_FILE.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_CONVERGE__" in body + + +# -- resolve-tasks script twins ----------------------------------------------- + +PY_SCRIPT = EXT_DIR / SCRIPT_TWINS["py"] +SH_SCRIPT = EXT_DIR / SCRIPT_TWINS["sh"] + + +def _make_feature_project(tmp_path: Path) -> Path: + project = tmp_path / "project" + feature = project / "specs" / "001-demo" + feature.mkdir(parents=True) + (project / ".specify").mkdir(exist_ok=True) + (project / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-demo"}), encoding="utf-8" + ) + (feature / "tasks.md").write_text( + "- [ ] T001 Create project structure\n", encoding="utf-8" + ) + (feature / "research.md").write_text("# Research\n", encoding="utf-8") + return project + + +def _run(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +class TestResolveTasksPython: + def test_reports_feature_dir_tasks_and_docs(self, tmp_path: Path): + project = _make_feature_project(tmp_path) + result = _run([sys.executable, str(PY_SCRIPT), "--json"], project) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert Path(payload["FEATURE_DIR"]) == project / "specs" / "001-demo" + assert Path(payload["TASKS"]) == project / "specs" / "001-demo" / "tasks.md" + assert payload["AVAILABLE_DOCS"] == ["research.md", "tasks.md"] + + def test_errors_when_tasks_md_is_missing(self, tmp_path: Path): + project = _make_feature_project(tmp_path) + (project / "specs" / "001-demo" / "tasks.md").unlink() + + result = _run([sys.executable, str(PY_SCRIPT), "--json"], project) + + assert result.returncode == 1 + assert "tasks.md not found" in result.stderr + + def test_text_mode_survives_a_cp1252_stdout(self, tmp_path: Path): + """Regression: U+2713 is unencodable in cp1252. + + On Windows stdout falls back to the ANSI code page whenever it is not + a console — a pipe or a redirect, which is exactly how agents invoke + these scripts — so printing the glyph raised UnicodeEncodeError and + aborted the report right after ``AVAILABLE_DOCS:``. + """ + project = _make_feature_project(tmp_path) + env = {**os.environ, "PYTHONIOENCODING": "cp1252"} + result = subprocess.run( + [sys.executable, str(PY_SCRIPT)], + cwd=project, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "UnicodeEncodeError" not in result.stderr + # The docs section must be complete, not truncated at the first marker. + assert "AVAILABLE_DOCS:" in result.stdout + assert "research.md" in result.stdout + assert "tasks.md" in result.stdout + # ASCII fallback, matching core and the PowerShell twin. + assert "[OK] tasks.md" in result.stdout + + def test_text_mode_uses_the_glyph_when_stdout_can_encode_it( + self, tmp_path: Path + ): + project = _make_feature_project(tmp_path) + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + result = subprocess.run( + [sys.executable, str(PY_SCRIPT)], + cwd=project, + capture_output=True, + text=True, + encoding="utf-8", + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "✓ tasks.md" in result.stdout + + def test_does_not_write_feature_json(self, tmp_path: Path): + """Resolution is read-only; it must not dirty the working tree.""" + project = _make_feature_project(tmp_path) + feature_json = project / ".specify" / "feature.json" + before = feature_json.read_bytes() + + result = _run([sys.executable, str(PY_SCRIPT), "--json"], project) + + assert result.returncode == 0, result.stderr + assert feature_json.read_bytes() == before + + +@requires_bash +class TestResolveTasksBashJsonEscape: + """``json_escape`` must emit valid JSON, matching core's implementation. + + Regression: the escape table silently lost a level of backslash quoting, + so backslashes passed through unescaped and ``\\n``/``\\t`` collapsed to + the bare letters ``n``/``t``. A Windows feature path would have produced + invalid JSON that the agent then failed to parse. + """ + + CASES = { + "backslash": "a\\b", + "windows_path": "C:\\Users\\dev\\specs", + "quote": 'a"b', + "newline": "a\nb", + "tab": "a\tb", + "carriage_return": "a\rb", + "control": "a\x01b", + } + + @staticmethod + def _escape(script: Path, tmp_path: Path, value: str) -> str: + """Run the script's own ``json_escape`` over *value*, byte-exactly.""" + import re + + body = re.search( + r"(json_escape\(\) \{.*?\n\})", + script.read_text(encoding="utf-8"), + re.S, + ) + assert body, f"no json_escape found in {script}" + + payload = tmp_path / "value.txt" + payload.write_text(value, encoding="utf-8", newline="") + harness = tmp_path / "harness.sh" + # Read the raw value from a file so the harness itself needs no quoting. + harness.write_text( + body.group(1) + '\nvalue="$(cat "$1")"\njson_escape "$value"\n', + encoding="utf-8", + newline="\n", + ) + return subprocess.run( + ["bash", str(harness), str(payload)], + capture_output=True, + check=True, + ).stdout.decode("utf-8") + + @pytest.mark.parametrize("name", sorted(CASES)) + def test_escaping_round_trips_through_json(self, tmp_path: Path, name: str): + value = self.CASES[name] + escaped = self._escape(SH_SCRIPT, tmp_path, value) + + # The escaped text must be a valid JSON string body that decodes back + # to exactly what went in. + assert json.loads(f'"{escaped}"') == value, escaped + + @pytest.mark.parametrize("name", sorted(CASES)) + def test_matches_core_json_escape(self, tmp_path: Path, name: str): + """Kept in step with core rather than diverging quietly.""" + core_common = PROJECT_ROOT / "scripts" / "bash" / "common.sh" + value = self.CASES[name] + + ours = self._escape(SH_SCRIPT, tmp_path / "ours", value) + theirs = self._escape(core_common, tmp_path / "theirs", value) + + assert ours == theirs + + def test_json_output_parses_for_a_path_with_a_backslash(self, tmp_path: Path): + """End to end: a feature directory containing a backslash. + + POSIX allows a backslash in a filename, so this exercises the real + emit path rather than the helper in isolation. + """ + project = _make_feature_project(tmp_path) + odd = project / "specs" / "we\\ird" + try: + odd.mkdir() + except OSError: + pytest.skip("filesystem rejects backslash in a path component") + (odd / "tasks.md").write_text("- [ ] T001 x\n", encoding="utf-8") + (project / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/we\\ird"}), encoding="utf-8" + ) + + result = _run(["bash", str(SH_SCRIPT), "--json"], project) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) # would raise on invalid escaping + assert payload["FEATURE_DIR"].endswith("we\\ird") + + +@requires_bash +class TestResolveTasksBash: + def test_matches_the_python_twin(self, tmp_path: Path): + project = _make_feature_project(tmp_path) + + bash_result = _run(["bash", str(SH_SCRIPT), "--json"], project) + py_result = _run([sys.executable, str(PY_SCRIPT), "--json"], project) + + assert bash_result.returncode == 0, bash_result.stderr + assert py_result.returncode == 0, py_result.stderr + + bash_payload = json.loads(bash_result.stdout) + py_payload = json.loads(py_result.stdout) + assert bash_payload["AVAILABLE_DOCS"] == py_payload["AVAILABLE_DOCS"] + assert Path(bash_payload["FEATURE_DIR"]).name == Path( + py_payload["FEATURE_DIR"] + ).name + assert Path(bash_payload["TASKS"]).name == Path(py_payload["TASKS"]).name + + def test_errors_when_tasks_md_is_missing(self, tmp_path: Path): + project = _make_feature_project(tmp_path) + (project / "specs" / "001-demo" / "tasks.md").unlink() + + result = _run(["bash", str(SH_SCRIPT), "--json"], project) + + assert result.returncode == 1 + assert "tasks.md not found" in result.stderr diff --git a/tests/test_ps1_encoding.py b/tests/test_ps1_encoding.py index dcd969cd29..ea0e1e99f6 100644 --- a/tests/test_ps1_encoding.py +++ b/tests/test_ps1_encoding.py @@ -18,6 +18,7 @@ _PS1_DIRS = [ REPO_ROOT / "scripts" / "powershell", REPO_ROOT / "extensions" / "git" / "scripts" / "powershell", + REPO_ROOT / "extensions" / "github" / "scripts" / "powershell", ]