diff --git a/.github/schemas/signals.schema.json b/.github/schemas/signals.schema.json new file mode 100644 index 00000000..51d5f655 --- /dev/null +++ b/.github/schemas/signals.schema.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/petry-projects/TalkTerm/.github/schemas/signals.schema.json", + "title": "Feature Ideation Signals", + "description": "Canonical contract between collect-signals.sh and the BMAD Analyst (Mary) prompt. Any change to this schema is a breaking change to the workflow.", + "type": "object", + "required": [ + "schema_version", + "scan_date", + "repo", + "open_issues", + "closed_issues_30d", + "ideas_discussions", + "releases", + "merged_prs_30d", + "feature_requests", + "bug_reports", + "truncation_warnings" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "scan_date": { + "type": "string", + "format": "date-time" + }, + "repo": { + "type": "string", + "pattern": "^[^/]+/[^/]+$" + }, + "open_issues": { + "$ref": "#/$defs/issueBucket" + }, + "closed_issues_30d": { + "$ref": "#/$defs/issueBucket" + }, + "ideas_discussions": { + "type": "object", + "required": ["count", "items"], + "additionalProperties": false, + "properties": { + "count": { "type": "integer", "minimum": 0 }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/discussion" } + } + } + }, + "releases": { + "type": "array", + "items": { "$ref": "#/$defs/release" } + }, + "merged_prs_30d": { + "type": "object", + "required": ["count", "items"], + "additionalProperties": false, + "properties": { + "count": { "type": "integer", "minimum": 0 }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/pullRequest" } + } + } + }, + "feature_requests": { + "$ref": "#/$defs/issueBucket" + }, + "bug_reports": { + "$ref": "#/$defs/issueBucket" + }, + "truncation_warnings": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "limit", "message"], + "additionalProperties": false, + "properties": { + "source": { "type": "string" }, + "limit": { "type": "integer", "minimum": 0 }, + "message": { "type": "string" } + } + } + } + }, + "$defs": { + "issueBucket": { + "type": "object", + "required": ["count", "items"], + "additionalProperties": false, + "properties": { + "count": { "type": "integer", "minimum": 0 }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/issue" } + } + } + }, + "issue": { + "type": "object", + "required": ["number", "title", "labels"], + "additionalProperties": true, + "properties": { + "number": { "type": "integer" }, + "title": { "type": "string" }, + "labels": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + }, + "createdAt": { "type": "string" }, + "closedAt": { "type": ["string", "null"] }, + "author": { + "type": ["object", "null"], + "properties": { + "login": { "type": "string" } + } + } + } + }, + "discussion": { + "type": "object", + "required": ["id", "number", "title"], + "additionalProperties": true, + "properties": { + "id": { "type": "string" }, + "number": { "type": "integer" }, + "title": { "type": "string" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "labels": { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { "name": { "type": "string" } } + } + } + } + }, + "comments": { + "type": "object", + "properties": { + "totalCount": { "type": "integer" } + } + } + } + }, + "release": { + "type": "object", + "required": ["tagName"], + "additionalProperties": true, + "properties": { + "tagName": { "type": "string" }, + "name": { "type": ["string", "null"] }, + "publishedAt": { "type": "string" }, + "isPrerelease": { "type": "boolean" } + } + }, + "pullRequest": { + "type": "object", + "required": ["number", "title"], + "additionalProperties": true, + "properties": { + "number": { "type": "integer" }, + "title": { "type": "string" }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { "name": { "type": "string" } } + } + }, + "mergedAt": { "type": "string" } + } + } + } +} diff --git a/.github/scripts/feature-ideation/README.md b/.github/scripts/feature-ideation/README.md new file mode 100644 index 00000000..9ef03b97 --- /dev/null +++ b/.github/scripts/feature-ideation/README.md @@ -0,0 +1,81 @@ +# Feature Ideation — Scripts & Test Strategy + +This directory contains the bash + Python helpers that back +`.github/workflows/feature-ideation.yml`. Every line of logic that used to +live inside the workflow's heredoc has been extracted here so it can be unit +tested with bats. + +## Why this exists + +The original workflow was a 500+ line YAML file with bash, jq, and GraphQL +queries inlined into a heredoc, plus a `direct_prompt:` block that asked +the BMAD Analyst (Mary) to call `gh api` and parse responses with no schema +or error handling. Every defect was discovered post-merge by reviewers. +The risks (R1–R11) are documented in the test architect's risk register — +search for "Murat" in the project history. + +This refactor moves the parsing surface into testable units so failures are +caught **before UAT** instead of after. + +## File map + +| File | Purpose | Killed risk | +|------|---------|------------| +| `lib/gh-safe.sh` | Wraps every `gh` and `gh api graphql` call. Fails loud on auth, rate-limit, network, GraphQL errors envelope, or `data: null`. Replaces the original `2>/dev/null \|\| echo '[]'` swallow. | R1, R7, R8 | +| `lib/compose-signals.sh` | Validates JSON inputs before `jq --argjson` and assembles the canonical signals.json document. | R3, R4 | +| `lib/filter-bots.sh` | Configurable bot allowlist via `FEATURE_IDEATION_BOT_AUTHORS`. | R10 | +| `lib/date-utils.sh` | Cross-platform date arithmetic helpers. | R9 | +| `collect-signals.sh` | Orchestrator: drives all `gh` calls, composes signals.json, emits truncation warnings. | R1, R3, R4, R11 | +| `validate-signals.py` | JSON Schema 2020-12 validator for signals.json against `../schemas/signals.schema.json`. | R3 | +| `match-discussions.sh` | Deterministic Jaccard-similarity matcher between Mary's proposals and existing Ideas Discussions. Replaces the prose "use fuzzy matching" instruction. | R5, R6 | +| `discussion-mutations.sh` | `create_discussion`, `comment_on_discussion`, `add_label_to_discussion` wrappers with `DRY_RUN=1` audit-log mode. | Smoke testing | +| `lint-prompt.sh` | Scans every workflow file for unescaped `$()` / `${VAR}` inside `direct_prompt:` blocks (which YAML and `claude-code-action` pass verbatim instead of expanding). | R2 | + +## Running the tests + +```bash +# Install once +brew install bats-core shellcheck jq # macOS +sudo apt-get install bats shellcheck jq # Ubuntu (CI) +pip install jsonschema # Python schema validator + +# Run everything +bats test/workflows/feature-ideation/ + +# Run a single suite +bats test/workflows/feature-ideation/gh-safe.bats + +# Lint shell scripts +(cd .github/scripts/feature-ideation && \ + shellcheck -x collect-signals.sh lint-prompt.sh match-discussions.sh \ + discussion-mutations.sh lib/*.sh) + +# Lint the workflow's direct_prompt block +bash .github/scripts/feature-ideation/lint-prompt.sh +``` + +CI runs all of the above on every PR that touches this directory or the +workflow file. See `.github/workflows/feature-ideation-tests.yml`. + +## DRY_RUN mode + +To smoke-test the workflow on a fork without writing to GitHub Discussions: + +1. Trigger via `workflow_dispatch` with `dry_run: true`. +2. The `analyze` job will source `discussion-mutations.sh` with `DRY_RUN=1`, + so every `create_discussion` / `comment_on_discussion` / `add_label_to_discussion` + call writes a JSONL entry to `$DRY_RUN_LOG` instead of executing. +3. The dry-run log is uploaded as the `dry-run-log` artifact for human review. + +## Schema as contract + +`/.github/schemas/signals.schema.json` is the **producer/consumer contract** +between `collect-signals.sh` and Mary's prompt. Any change to the shape of +signals.json is a breaking change and must: + +1. Bump `SCHEMA_VERSION` in `collect-signals.sh`. +2. Update fixtures under `test/workflows/feature-ideation/fixtures/expected/`. +3. Update Mary's prompt in `feature-ideation.yml` if any field references move. + +CI validates every fixture against the schema, and the workflow validates +the runtime output before handing it to Mary. diff --git a/.github/scripts/feature-ideation/collect-signals.sh b/.github/scripts/feature-ideation/collect-signals.sh new file mode 100755 index 00000000..cf512290 --- /dev/null +++ b/.github/scripts/feature-ideation/collect-signals.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# collect-signals.sh — gather project signals into signals.json. +# +# Replaces the inline bash heredoc that previously lived in +# .github/workflows/feature-ideation.yml. Each step is delegated to a +# library function so it can be unit-tested in isolation. +# +# Required env: +# REPO "owner/name" +# GH_TOKEN GitHub token with read scopes +# +# Optional env: +# SIGNALS_OUTPUT Path to write signals.json (default: ./signals.json) +# ISSUE_LIMIT Max issues per query (default: 50) +# PR_LIMIT Max PRs per query (default: 30) +# DISCUSSION_LIMIT Max Ideas discussions to fetch (default: 100) +# FEATURE_IDEATION_BOT_AUTHORS Extra comma-separated bot logins to filter +# +# Exit codes: +# 0 signals.json written successfully +# 64 bad usage / missing env +# 65 data validation error +# 66 GraphQL refused our request (errors / null data) +# 1+ underlying gh failure (auth, rate limit, network) + +set -euo pipefail + +SCHEMA_VERSION="1.0.0" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/gh-safe.sh +. "${SCRIPT_DIR}/lib/gh-safe.sh" +# shellcheck source=lib/filter-bots.sh +. "${SCRIPT_DIR}/lib/filter-bots.sh" +# shellcheck source=lib/compose-signals.sh +. "${SCRIPT_DIR}/lib/compose-signals.sh" +# shellcheck source=lib/date-utils.sh +. "${SCRIPT_DIR}/lib/date-utils.sh" + +main() { + if [ -z "${REPO:-}" ]; then + printf '[collect-signals] REPO env var is required\n' >&2 + return 64 + fi + if [ -z "${GH_TOKEN:-}" ]; then + printf '[collect-signals] GH_TOKEN env var is required\n' >&2 + return 64 + fi + + local issue_limit="${ISSUE_LIMIT:-50}" + local pr_limit="${PR_LIMIT:-30}" + local discussion_limit="${DISCUSSION_LIMIT:-100}" + local output_path="${SIGNALS_OUTPUT:-./signals.json}" + + local owner repo_name + owner="${REPO%%/*}" + repo_name="${REPO##*/}" + if [ "$owner" = "$REPO" ] || [ -z "$repo_name" ]; then + printf '[collect-signals] REPO must be in owner/name format, got: %s\n' "$REPO" >&2 + return 64 + fi + + local thirty_days_ago + thirty_days_ago=$(date_days_ago 30) + local scan_date + scan_date=$(date_now_iso) + + local truncation_warnings='[]' + + # --- Open issues ----------------------------------------------------------- + printf '[collect-signals] fetching open issues (limit=%s)\n' "$issue_limit" >&2 + local open_issues_raw + open_issues_raw=$(gh_safe_rest issue list --repo "$REPO" --state open --limit "$issue_limit" \ + --json number,title,labels,createdAt,author) + local open_issues + open_issues=$(printf '%s' "$open_issues_raw" | filter_bots_apply) + + if [ "$(printf '%s' "$open_issues" | jq 'length')" -ge "$issue_limit" ]; then + truncation_warnings=$(printf '%s' "$truncation_warnings" \ + | jq --arg src "open_issues" --argjson lim "$issue_limit" \ + '. + [{source: $src, limit: $lim, message: "result count equals limit; possible truncation"}]') + fi + + # --- Recently closed issues ------------------------------------------------ + printf '[collect-signals] fetching closed issues (since %s)\n' "$thirty_days_ago" >&2 + local closed_issues_raw + closed_issues_raw=$(gh_safe_rest issue list --repo "$REPO" --state closed --limit "$issue_limit" \ + --json number,title,labels,closedAt) + local closed_issues + closed_issues=$(printf '%s' "$closed_issues_raw" \ + | jq --arg cutoff "$thirty_days_ago" '[.[] | select(.closedAt >= $cutoff)]') + + # --- Ideas discussions (paginated, category-filtered) ---------------------- + printf '[collect-signals] resolving Ideas discussion category\n' >&2 + local categories_query + read -r -d '' categories_query <<'GRAPHQL' || true +query($repo: String!, $owner: String!) { + repository(name: $repo, owner: $owner) { + discussionCategories(first: 25) { + nodes { id name } + } + } +} +GRAPHQL + + local categories + categories=$(gh_safe_graphql -f query="$categories_query" \ + -f owner="$owner" -f repo="$repo_name" \ + --jq '.data.repository.discussionCategories.nodes') + + local ideas_cat_id + ideas_cat_id=$(printf '%s' "$categories" \ + | jq -r '[.[] | select(.name == "Ideas")][0].id // empty') + + local ideas_discussions='[]' + if [ -n "$ideas_cat_id" ]; then + printf '[collect-signals] fetching Ideas discussions (limit=%s)\n' "$discussion_limit" >&2 + local discussions_query + read -r -d '' discussions_query <<'GRAPHQL' || true +query($repo: String!, $owner: String!, $categoryId: ID!, $limit: Int!) { + repository(name: $repo, owner: $owner) { + discussions(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, categoryId: $categoryId) { + pageInfo { hasNextPage } + nodes { + id + number + title + createdAt + updatedAt + labels(first: 10) { nodes { name } } + comments(first: 1) { totalCount } + } + } + } +} +GRAPHQL + + local discussions_full + discussions_full=$(gh_safe_graphql -f query="$discussions_query" \ + -f owner="$owner" -f repo="$repo_name" -f categoryId="$ideas_cat_id" \ + -F limit="$discussion_limit") + + ideas_discussions=$(printf '%s' "$discussions_full" \ + | jq -c '.data.repository.discussions.nodes // []') + + local has_next_page + has_next_page=$(printf '%s' "$discussions_full" \ + | jq -r '.data.repository.discussions.pageInfo.hasNextPage // false') + if [ "$has_next_page" = "true" ]; then + truncation_warnings=$(printf '%s' "$truncation_warnings" \ + | jq --arg src "ideas_discussions" --argjson lim "$discussion_limit" \ + '. + [{source: $src, limit: $lim, message: "hasNextPage=true; results truncated"}]') + fi + else + printf '[collect-signals] no "Ideas" category found, skipping discussions\n' >&2 + fi + + # --- Releases -------------------------------------------------------------- + printf '[collect-signals] fetching recent releases\n' >&2 + local releases + releases=$(gh_safe_rest release list --repo "$REPO" --limit 5 \ + --json tagName,name,publishedAt,isPrerelease) + + # --- Merged PRs (last 30 days) --------------------------------------------- + printf '[collect-signals] fetching merged PRs (limit=%s)\n' "$pr_limit" >&2 + local merged_prs_raw + merged_prs_raw=$(gh_safe_rest pr list --repo "$REPO" --state merged --limit "$pr_limit" \ + --json number,title,labels,mergedAt) + local merged_prs + merged_prs=$(printf '%s' "$merged_prs_raw" \ + | jq --arg cutoff "$thirty_days_ago" '[.[] | select(.mergedAt >= $cutoff)]') + + # --- Derived: feature requests + bug reports ------------------------------- + local feature_requests + feature_requests=$(printf '%s' "$open_issues" \ + | jq -c '[.[] | select(.labels | map(.name) | any(test("enhancement|feature|idea"; "i")))]') + local bug_reports + bug_reports=$(printf '%s' "$open_issues" \ + | jq -c '[.[] | select(.labels | map(.name) | any(test("bug"; "i")))]') + + # --- Compose --------------------------------------------------------------- + local signals + signals=$(compose_signals \ + "$open_issues" \ + "$closed_issues" \ + "$ideas_discussions" \ + "$releases" \ + "$merged_prs" \ + "$feature_requests" \ + "$bug_reports" \ + "$REPO" \ + "$scan_date" \ + "$SCHEMA_VERSION" \ + "$truncation_warnings") + + printf '%s\n' "$signals" >"$output_path" + printf '[collect-signals] wrote %s\n' "$output_path" >&2 + + # --- Step summary (only when running inside GitHub Actions) ---------------- + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + printf '## Signals Collected\n\n' + printf -- '- **Schema version:** %s\n' "$SCHEMA_VERSION" + printf -- '- **Open issues:** %s\n' "$(jq '.open_issues.count' "$output_path")" + printf -- '- **Feature requests:** %s\n' "$(jq '.feature_requests.count' "$output_path")" + printf -- '- **Bug reports:** %s\n' "$(jq '.bug_reports.count' "$output_path")" + printf -- '- **Merged PRs (30d):** %s\n' "$(jq '.merged_prs_30d.count' "$output_path")" + printf -- '- **Existing Ideas discussions:** %s\n' "$(jq '.ideas_discussions.count' "$output_path")" + local warn_count + warn_count=$(jq '.truncation_warnings | length' "$output_path") + if [ "$warn_count" -gt 0 ]; then + printf -- '- **⚠️ Truncation warnings:** %s\n' "$warn_count" + jq -r '.truncation_warnings[] | " - " + .source + " (limit " + (.limit|tostring) + "): " + .message' "$output_path" + fi + } >>"$GITHUB_STEP_SUMMARY" + fi +} + +# Allow `source`-ing for tests; only run main when executed directly. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/.github/scripts/feature-ideation/discussion-mutations.sh b/.github/scripts/feature-ideation/discussion-mutations.sh new file mode 100755 index 00000000..843d9d6f --- /dev/null +++ b/.github/scripts/feature-ideation/discussion-mutations.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# discussion-mutations.sh — wrappers around the GraphQL mutations Mary uses +# to create / comment on Ideas Discussions, with a DRY_RUN switch. +# +# Why this exists: +# The original prompt instructed Mary to call `gh api graphql` directly for +# createDiscussion / addDiscussionComment. There was no way to: +# - safely smoke-test the workflow on a sandbox repo +# - replay a run without writing to GitHub +# - audit what Mary was about to do before she did it +# +# With DRY_RUN=1, every mutation logs a structured "planned action" entry +# instead of executing. The plan log is the contract Mary's prompt now +# references. +# +# Env: +# DRY_RUN "1" → log instead of execute +# DRY_RUN_LOG path to JSONL log file (default: ./dry-run.jsonl) +# GH_TOKEN required when not in DRY_RUN +# +# Functions: +# create_discussion <body> +# comment_on_discussion <discussion_id> <body> +# add_label_to_discussion <discussion_id> <label_id> + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/gh-safe.sh +. "${SCRIPT_DIR}/lib/gh-safe.sh" + +_dry_run_log() { + local action_json="$1" + local log_path="${DRY_RUN_LOG:-./dry-run.jsonl}" + printf '%s\n' "$action_json" >>"$log_path" +} + +_is_dry_run() { + [ "${DRY_RUN:-0}" = "1" ] +} + +create_discussion() { + if [ "$#" -ne 4 ]; then + printf '[create_discussion] expected 4 args (repo_id category_id title body), got %d\n' "$#" >&2 + return 64 + fi + local repo_id="$1" + local category_id="$2" + local title="$3" + local body="$4" + + if _is_dry_run; then + local entry + entry=$(jq -nc \ + --arg op "create_discussion" \ + --arg repo_id "$repo_id" \ + --arg category_id "$category_id" \ + --arg title "$title" \ + --arg body "$body" \ + '{op: $op, repo_id: $repo_id, category_id: $category_id, title: $title, body: $body}') + _dry_run_log "$entry" + printf '%s' "$entry" + return 0 + fi + + local query + read -r -d '' query <<'GRAPHQL' || true +mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repoId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { id number url } + } +} +GRAPHQL + + gh_safe_graphql -f query="$query" \ + -f repoId="$repo_id" \ + -f categoryId="$category_id" \ + -f title="$title" \ + -f body="$body" +} + +comment_on_discussion() { + if [ "$#" -ne 2 ]; then + printf '[comment_on_discussion] expected 2 args (discussion_id body), got %d\n' "$#" >&2 + return 64 + fi + local discussion_id="$1" + local body="$2" + + if _is_dry_run; then + local entry + entry=$(jq -nc \ + --arg op "comment_on_discussion" \ + --arg discussion_id "$discussion_id" \ + --arg body "$body" \ + '{op: $op, discussion_id: $discussion_id, body: $body}') + _dry_run_log "$entry" + printf '%s' "$entry" + return 0 + fi + + local query + read -r -d '' query <<'GRAPHQL' || true +mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { + discussionId: $discussionId, + body: $body + }) { + comment { id url } + } +} +GRAPHQL + + gh_safe_graphql -f query="$query" \ + -f discussionId="$discussion_id" \ + -f body="$body" +} + +add_label_to_discussion() { + if [ "$#" -ne 2 ]; then + printf '[add_label_to_discussion] expected 2 args (discussion_id label_id), got %d\n' "$#" >&2 + return 64 + fi + local discussion_id="$1" + local label_id="$2" + + if _is_dry_run; then + local entry + entry=$(jq -nc \ + --arg op "add_label_to_discussion" \ + --arg discussion_id "$discussion_id" \ + --arg label_id "$label_id" \ + '{op: $op, discussion_id: $discussion_id, label_id: $label_id}') + _dry_run_log "$entry" + printf '%s' "$entry" + return 0 + fi + + local query + read -r -d '' query <<'GRAPHQL' || true +mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { + labelableId: $labelableId, + labelIds: $labelIds + }) { + clientMutationId + } +} +GRAPHQL + + gh_safe_graphql -f query="$query" \ + -f labelableId="$discussion_id" \ + -f labelIds="[\"$label_id\"]" +} diff --git a/.github/scripts/feature-ideation/lib/compose-signals.sh b/.github/scripts/feature-ideation/lib/compose-signals.sh new file mode 100755 index 00000000..6a0f5bd6 --- /dev/null +++ b/.github/scripts/feature-ideation/lib/compose-signals.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# compose-signals.sh — assemble the canonical signals.json document. +# +# Why this exists: +# The original `jq -n --argjson` block crashed if any input variable was an +# empty string. By centralizing composition here, we (a) validate every input +# is JSON before passing to --argjson, (b) emit a stable shape regardless of +# which sub-queries returned empty, and (c) keep the schema source-of-truth +# in one place. +# +# Inputs (all JSON arrays, never empty strings): +# $1 open_issues +# $2 closed_issues +# $3 ideas_discussions +# $4 releases +# $5 merged_prs +# $6 feature_requests +# $7 bug_reports +# $8 repo (string, e.g. "petry-projects/talkterm") +# $9 scan_date (ISO-8601 string) +# $10 schema_version (string) +# $11 truncation_warnings (JSON array, may be []) +# +# Output: signals.json document on stdout. + +set -euo pipefail + +compose_signals() { + if [ "$#" -ne 11 ]; then + printf '[compose-signals] expected 11 args, got %d\n' "$#" >&2 + return 64 # EX_USAGE + fi + + local open_issues="$1" + local closed_issues="$2" + local ideas_discussions="$3" + local releases="$4" + local merged_prs="$5" + local feature_requests="$6" + local bug_reports="$7" + local repo="$8" + local scan_date="$9" + local schema_version="${10}" + local truncation_warnings="${11}" + + # Validate every JSON input before composition. Better to fail loudly here + # than to let `jq --argjson` produce a cryptic parse error. + local idx=0 + for input in "$open_issues" "$closed_issues" "$ideas_discussions" "$releases" \ + "$merged_prs" "$feature_requests" "$bug_reports" "$truncation_warnings"; do + idx=$((idx + 1)) + if ! printf '%s' "$input" | jq -e . >/dev/null 2>&1; then + printf '[compose-signals] arg #%d is not valid JSON: %s\n' "$idx" "${input:0:120}" >&2 + return 65 # EX_DATAERR + fi + done + + jq -n \ + --arg scan_date "$scan_date" \ + --arg repo "$repo" \ + --arg schema_version "$schema_version" \ + --argjson open_issues "$open_issues" \ + --argjson closed_issues "$closed_issues" \ + --argjson ideas_discussions "$ideas_discussions" \ + --argjson releases "$releases" \ + --argjson merged_prs "$merged_prs" \ + --argjson feature_requests "$feature_requests" \ + --argjson bug_reports "$bug_reports" \ + --argjson truncation_warnings "$truncation_warnings" \ + '{ + schema_version: $schema_version, + scan_date: $scan_date, + repo: $repo, + open_issues: { count: ($open_issues | length), items: $open_issues }, + closed_issues_30d: { count: ($closed_issues | length), items: $closed_issues }, + ideas_discussions: { count: ($ideas_discussions | length), items: $ideas_discussions }, + releases: $releases, + merged_prs_30d: { count: ($merged_prs | length), items: $merged_prs }, + feature_requests: { count: ($feature_requests | length), items: $feature_requests }, + bug_reports: { count: ($bug_reports | length), items: $bug_reports }, + truncation_warnings: $truncation_warnings + }' +} diff --git a/.github/scripts/feature-ideation/lib/date-utils.sh b/.github/scripts/feature-ideation/lib/date-utils.sh new file mode 100755 index 00000000..8c4b5a12 --- /dev/null +++ b/.github/scripts/feature-ideation/lib/date-utils.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# date-utils.sh — cross-platform date arithmetic helpers. +# +# Why this exists: +# `date -u -d '30 days ago'` is GNU; `date -u -v-30d` is BSD. The original +# workflow had both forms separated by `||`, but only one branch ever ran +# on `ubuntu-latest`. Centralizing here gives us one tested helper. + +set -euo pipefail + +# Print an ISO date (YYYY-MM-DD) for N days ago in UTC. +date_days_ago() { + local days="$1" + if [ -z "$days" ] || ! printf '%s' "$days" | grep -Eq '^[0-9]+$'; then + printf '[date-utils] days must be a non-negative integer, got: %s\n' "$days" >&2 + return 64 + fi + if date -u -d "${days} days ago" +%Y-%m-%d >/dev/null 2>&1; then + date -u -d "${days} days ago" +%Y-%m-%d + elif date -u -v-"${days}"d +%Y-%m-%d >/dev/null 2>&1; then + date -u -v-"${days}"d +%Y-%m-%d + else + printf '[date-utils] no supported date(1) variant available\n' >&2 + return 69 # EX_UNAVAILABLE + fi +} + +# Print the current UTC timestamp in ISO-8601 (Zulu). +date_now_iso() { + date -u +%Y-%m-%dT%H:%M:%SZ +} + +# Print today's date in UTC (YYYY-MM-DD). +date_today() { + date -u +%Y-%m-%d +} diff --git a/.github/scripts/feature-ideation/lib/filter-bots.sh b/.github/scripts/feature-ideation/lib/filter-bots.sh new file mode 100755 index 00000000..95aabfee --- /dev/null +++ b/.github/scripts/feature-ideation/lib/filter-bots.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# filter-bots.sh — strip bot-authored issues/PRs from a JSON array. +# +# Why this exists: +# The original gather-signals filter only excluded `dependabot[bot]` and +# `github-actions[bot]`. New bots (renovate, copilot, coderabbit, claude) +# pollute the signals payload and crowd out real user feedback. +# +# Configurable via FEATURE_IDEATION_BOT_AUTHORS (comma-separated, optional). +# Defaults to a sensible allowlist of known automation accounts. + +set -euo pipefail + +# Default bot author logins. Override via env to add project-specific bots. +DEFAULT_BOT_AUTHORS=( + "dependabot[bot]" + "github-actions[bot]" + "renovate[bot]" + "copilot[bot]" + "coderabbitai[bot]" + "coderabbit[bot]" + "claude[bot]" + "claude-bot[bot]" + "sonarcloud[bot]" + "sonarqubecloud[bot]" + "codeql[bot]" + "snyk-bot" + "imgbot[bot]" + "allcontributors[bot]" +) + +# Build the active bot list from defaults + env override. +filter_bots_build_list() { + local list=("${DEFAULT_BOT_AUTHORS[@]}") + if [ -n "${FEATURE_IDEATION_BOT_AUTHORS:-}" ]; then + local IFS=',' + # shellcheck disable=SC2206 + local extras=($FEATURE_IDEATION_BOT_AUTHORS) + list+=("${extras[@]}") + fi + printf '%s\n' "${list[@]}" | jq -R . | jq -sc . +} + +# Filter a JSON array of items, removing any whose .author.login is in the bot list. +# Reads JSON from stdin, writes filtered JSON to stdout. +filter_bots_apply() { + local bot_list + bot_list=$(filter_bots_build_list) + jq --argjson bots "$bot_list" '[.[] | select((.author.login // "") as $a | ($bots | index($a)) | not)]' +} diff --git a/.github/scripts/feature-ideation/lib/gh-safe.sh b/.github/scripts/feature-ideation/lib/gh-safe.sh new file mode 100755 index 00000000..7bbdf2c5 --- /dev/null +++ b/.github/scripts/feature-ideation/lib/gh-safe.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# gh-safe.sh — defensive wrapper around `gh` calls used by feature-ideation. +# +# Why this exists: +# The original workflow used the `2>/dev/null || echo '[]'` pattern, which +# silently swallows ALL errors — auth failures, rate limits, network outages, +# GraphQL schema drift — and continues with empty data. The pipeline would +# "succeed" while producing useless signals. +# +# Contract: +# - On a documented empty result (200 OK + []) → exit 0, stdout is the empty array +# - On any infrastructure error → exit non-zero, stderr explains why +# - On a GraphQL response containing an "errors" field → exit non-zero +# - On a GraphQL response with `data: null` → exit non-zero +# +# Usage: +# source .github/scripts/feature-ideation/lib/gh-safe.sh +# ISSUES=$(gh_safe_rest issue list --repo "$REPO" --state open --limit 50 \ +# --json number,title,labels) +# DISCUSSIONS=$(gh_safe_graphql "$query" -f owner=foo -f repo=bar \ +# --jq '.data.repository.discussions.nodes') + +set -euo pipefail + +# Marker emitted by callers when they want to default to "[]" — explicit, not silent. +GH_SAFE_EMPTY_ARRAY='[]' + +# Internal: emit a structured error to stderr. +_gh_safe_err() { + local code="$1" + shift + printf '[gh-safe][%s] %s\n' "$code" "$*" >&2 +} + +# Validate that a string is well-formed JSON. Returns 0 if valid. +gh_safe_is_json() { + local input="$1" + [ -n "$input" ] || return 1 + printf '%s' "$input" | jq -e . >/dev/null 2>&1 +} + +# Run a `gh` REST/CLI call (gh issue list, gh pr list, gh release list, etc.). +# Captures stdout, stderr, and exit code separately so we can distinguish: +# - empty result (success, output is "[]" or empty) +# - hard failure (non-zero exit) — never silently downgraded +# +# Arguments are passed verbatim to `gh`. +gh_safe_rest() { + local stdout stderr rc tmp_err + tmp_err=$(mktemp) + # shellcheck disable=SC2034 + set +e + stdout=$(gh "$@" 2>"$tmp_err") + rc=$? + set -e + stderr=$(cat "$tmp_err") + rm -f "$tmp_err" + + if [ "$rc" -ne 0 ]; then + _gh_safe_err "rest-failure" "exit=$rc args=$* stderr=$stderr" + return "$rc" + fi + + # Empty stdout from a successful gh call means the result set is empty. + # Normalize to an empty JSON array so downstream jq composition never sees "". + if [ -z "$stdout" ]; then + printf '%s' "$GH_SAFE_EMPTY_ARRAY" + return 0 + fi + + # Validate JSON shape — if gh ever returns non-JSON on success, fail loud. + if ! gh_safe_is_json "$stdout"; then + _gh_safe_err "rest-bad-json" "args=$* stdout (first 200 bytes)=${stdout:0:200}" + return 65 # EX_DATAERR + fi + + printf '%s' "$stdout" +} + +# Run a `gh api graphql` call. Same defensive contract as gh_safe_rest, plus: +# - Reject responses where the parsed result is the literal "null" +# - Reject responses with a top-level "errors" field (only meaningful when +# called WITHOUT --jq, i.e. when callers ask for the full response) +# +# When --jq is supplied, we cannot inspect the full response for an errors[] +# field, so we additionally call gh once WITHOUT --jq to validate the envelope. +# This costs an extra round-trip; for the feature-ideation workflow's volume +# (a handful of calls per run) this is the right trade-off. +gh_safe_graphql() { + local args=("$@") + local has_jq=0 + local jq_filter="" + local i=0 + while [ "$i" -lt "${#args[@]}" ]; do + if [ "${args[$i]}" = "--jq" ]; then + has_jq=1 + jq_filter="${args[$((i + 1))]}" + break + fi + i=$((i + 1)) + done + + # Build a no-jq variant for envelope validation. + local raw_args=() + i=0 + while [ "$i" -lt "${#args[@]}" ]; do + if [ "${args[$i]}" = "--jq" ]; then + i=$((i + 2)) + continue + fi + raw_args+=("${args[$i]}") + i=$((i + 1)) + done + + local raw stderr rc tmp_err + tmp_err=$(mktemp) + set +e + raw=$(gh api graphql "${raw_args[@]}" 2>"$tmp_err") + rc=$? + set -e + stderr=$(cat "$tmp_err") + rm -f "$tmp_err" + + if [ "$rc" -ne 0 ]; then + _gh_safe_err "graphql-failure" "exit=$rc stderr=$stderr" + return "$rc" + fi + + if ! gh_safe_is_json "$raw"; then + _gh_safe_err "graphql-bad-json" "first 200 bytes: ${raw:0:200}" + return 65 + fi + + # Reject error envelopes — GraphQL returns 200 OK even on partial errors. + if printf '%s' "$raw" | jq -e '.errors // empty | if . then true else false end' >/dev/null 2>&1; then + if printf '%s' "$raw" | jq -e '(.errors | type) == "array" and (.errors | length > 0)' >/dev/null 2>&1; then + local errs + errs=$(printf '%s' "$raw" | jq -c '.errors') + _gh_safe_err "graphql-errors" "$errs" + return 66 # EX_NOINPUT — repurposed: "GraphQL refused our request" + fi + fi + + # Reject `data: null` — usually means the path didn't resolve (permissions, + # missing field, renamed repo). + if printf '%s' "$raw" | jq -e '.data == null' >/dev/null 2>&1; then + _gh_safe_err "graphql-null-data" "data field is null" + return 66 + fi + + # If caller asked for a jq filter, apply it now and return that. + if [ "$has_jq" -eq 1 ]; then + local filtered + filtered=$(printf '%s' "$raw" | jq -c "$jq_filter" 2>/dev/null || true) + if [ -z "$filtered" ] || [ "$filtered" = "null" ]; then + # The filter resolved to null/empty — caller probably wants "[]" semantics + # for "no nodes found". Return the empty array sentinel. + printf '%s' "$GH_SAFE_EMPTY_ARRAY" + return 0 + fi + printf '%s' "$filtered" + return 0 + fi + + printf '%s' "$raw" +} diff --git a/.github/scripts/feature-ideation/lint-prompt.sh b/.github/scripts/feature-ideation/lint-prompt.sh new file mode 100755 index 00000000..8381b612 --- /dev/null +++ b/.github/scripts/feature-ideation/lint-prompt.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# lint-prompt.sh — guard against unescaped shell expansions in claude-code-action +# `direct_prompt:` blocks. +# +# Why this exists: +# The original feature-ideation.yml contained: +# Date: $(date -u +%Y-%m-%d) +# inside the `direct_prompt:` heredoc. YAML does NOT expand shell, and +# claude-code-action passes the prompt verbatim — Mary received the literal +# string `$(date -u +%Y-%m-%d)` instead of an actual date. This is R2. +# +# This linter scans every workflow file under .github/workflows/ for +# `direct_prompt:` blocks and flags any unescaped `$(...)` or `${VAR}` that +# YAML/the action will not interpolate. ${{ ... }} (GitHub expression syntax) +# is allowed because GitHub Actions evaluates it before the prompt is sent. +# +# Usage: +# lint-prompt.sh [<workflow.yml> ...] +# +# Exit codes: +# 0 no issues +# 1 one or more findings +# 2 bad usage / file error + +set -euo pipefail + +scan_file() { + local file="$1" + + python3 - "$file" <<'PY' +import re +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() +except OSError as exc: + sys.stderr.write(f"[lint-prompt] cannot read {path}: {exc}\n") + sys.exit(2) + +# Find direct_prompt: blocks. We treat everything indented MORE than the +# `direct_prompt:` line as part of that block, until we hit a less-indented +# non-blank line. +in_block = False +block_indent = -1 +findings = [] + +# Pattern matches $(...) and ${VAR} but NOT GitHub Actions ${{ ... }} +# because that's evaluated before the prompt is rendered. +shell_expansion = re.compile(r'(?<!\\)\$\([^)]*\)|(?<!\$)\$\{[A-Za-z_][A-Za-z0-9_]*\}') + +for lineno, raw in enumerate(lines, start=1): + stripped = raw.lstrip(" ") + indent = len(raw) - len(stripped) + + if not in_block: + # Look for `direct_prompt:` or `direct_prompt: |` or `direct_prompt: >` + if re.match(r'direct_prompt:\s*[|>]?\s*$', stripped): + in_block = True + block_indent = indent + continue + else: + # Blank lines stay in the block. + if stripped.strip() == "": + continue + # If we drop back to or below the marker indent, the block ended. + if indent <= block_indent: + in_block = False + block_indent = -1 + continue + + # We're inside the prompt body. Scan for shell expansions. + # First, strip out any GitHub Actions expressions so they don't trip us. + no_gh = re.sub(r'\$\{\{[^}]*\}\}', '', raw) + for match in shell_expansion.finditer(no_gh): + findings.append((lineno, match.group(0), raw.rstrip())) + +if findings: + sys.stderr.write(f"[lint-prompt] {len(findings)} unescaped shell expansion(s) in {path}:\n") + for lineno, expr, line in findings: + sys.stderr.write(f" line {lineno}: {expr}\n") + sys.stderr.write(f" {line}\n") + sys.exit(1) +sys.exit(0) +PY + return $? +} + +main() { + if [ "$#" -eq 0 ]; then + # Default: scan every workflow file. + local repo_root + repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" + local files=() + while IFS= read -r f; do + files+=("$f") + done < <(find "${repo_root}/.github/workflows" -type f \( -name '*.yml' -o -name '*.yaml' \)) + set -- "${files[@]}" + fi + + local exit=0 + for file in "$@"; do + if [ ! -f "$file" ]; then + printf '[lint-prompt] not found: %s\n' "$file" >&2 + exit=2 + continue + fi + if ! scan_file "$file"; then + exit=1 + fi + done + return "$exit" +} + +main "$@" diff --git a/.github/scripts/feature-ideation/match-discussions.sh b/.github/scripts/feature-ideation/match-discussions.sh new file mode 100755 index 00000000..826f29b3 --- /dev/null +++ b/.github/scripts/feature-ideation/match-discussions.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# match-discussions.sh — deterministic matching between Mary's proposed ideas +# and existing Ideas Discussions, replacing the prose "use fuzzy matching" +# instruction in the original prompt. +# +# Why this exists: +# The original Phase-5 instruction told Mary to "use fuzzy matching" against +# existing Discussion titles in her head. There is no way to test, replay, +# or audit that. Two runs could create duplicate Discussions for the same +# idea with slightly different titles. This is R5 + R6. +# +# Approach: +# 1. Normalize titles to a canonical form (strip emoji, punctuation, +# lowercase, collapse whitespace, drop common stopwords). +# 2. Tokenize and compute Jaccard similarity over token sets. +# 3. Match if similarity >= MATCH_THRESHOLD (default 0.6). +# +# Inputs: +# $1 Path to signals.json (must contain .ideas_discussions.items) +# $2 Path to proposals.json — array of { title, summary, ... } +# +# Output (stdout, JSON): +# { +# "matched": [ { "proposal": {...}, "discussion": {...}, "similarity": 0.83 } ], +# "new_candidates": [ { "proposal": {...} } ], +# "threshold": 0.6 +# } +# +# Env: +# MATCH_THRESHOLD Override default Jaccard similarity threshold. + +set -euo pipefail + +normalize_title() { + python3 -c ' +import re, sys, unicodedata +raw = sys.argv[1] +# Strip emoji and other symbol/other categories. +no_emoji = "".join(ch for ch in raw if unicodedata.category(ch)[0] not in ("S",)) +# Lowercase + ascii fold. +ascii_text = unicodedata.normalize("NFKD", no_emoji).encode("ascii", "ignore").decode() +ascii_text = ascii_text.lower() +# Replace non-alphanumeric with space. +collapsed = re.sub(r"[^a-z0-9]+", " ", ascii_text).strip() +# Drop stopwords. +stopwords = {"a","an","the","of","for","to","and","or","with","in","on","by","via","as","is","are","be","support","add","new","feature","idea"} +tokens = [t for t in collapsed.split() if t and t not in stopwords] +print(" ".join(tokens)) +' "$1" +} +export -f normalize_title + +# Compute Jaccard similarity between two normalized token strings. +jaccard_similarity() { + python3 -c ' +import sys +a = set(sys.argv[1].split()) +b = set(sys.argv[2].split()) +if not a and not b: + print("1.0") +elif not a or not b: + print("0.0") +else: + inter = len(a & b) + union = len(a | b) + print(f"{inter / union:.4f}") +' "$1" "$2" +} +export -f jaccard_similarity + +match_discussions_main() { + local signals_path="$1" + local proposals_path="$2" + local threshold="${MATCH_THRESHOLD:-0.6}" + + if [ ! -f "$signals_path" ]; then + printf '[match-discussions] signals not found: %s\n' "$signals_path" >&2 + return 64 + fi + if [ ! -f "$proposals_path" ]; then + printf '[match-discussions] proposals not found: %s\n' "$proposals_path" >&2 + return 64 + fi + + python3 - "$signals_path" "$proposals_path" "$threshold" <<'PY' +import json +import re +import sys +import unicodedata + +signals_path, proposals_path, threshold_str = sys.argv[1:4] +threshold = float(threshold_str) + +STOPWORDS = { + "a", "an", "the", "of", "for", "to", "and", "or", "with", "in", "on", + "by", "via", "as", "is", "are", "be", "support", "add", "new", + "feature", "idea", +} + + +def normalize(title: str) -> set[str]: + no_sym = "".join(ch for ch in title if unicodedata.category(ch)[0] != "S") + folded = ( + unicodedata.normalize("NFKD", no_sym).encode("ascii", "ignore").decode() + ) + folded = folded.lower() + cleaned = re.sub(r"[^a-z0-9]+", " ", folded).strip() + return {t for t in cleaned.split() if t and t not in STOPWORDS} + + +def jaccard(a: set[str], b: set[str]) -> float: + if not a and not b: + return 1.0 + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +with open(signals_path) as f: + signals = json.load(f) +with open(proposals_path) as f: + proposals = json.load(f) + +if not isinstance(proposals, list): + sys.stderr.write("[match-discussions] proposals must be a JSON array\n") + sys.exit(65) + +discussions = signals.get("ideas_discussions", {}).get("items", []) or [] +disc_norm = [(d, normalize(d.get("title", ""))) for d in discussions] + +matched = [] +new_candidates = [] +seen_disc_ids = set() + +for proposal in proposals: + if not isinstance(proposal, dict) or "title" not in proposal: + sys.stderr.write(f"[match-discussions] skipping malformed proposal: {proposal!r}\n") + continue + p_norm = normalize(proposal["title"]) + + best = None + best_sim = 0.0 + for disc, d_norm in disc_norm: + if disc.get("id") in seen_disc_ids: + continue + sim = jaccard(p_norm, d_norm) + if sim > best_sim: + best_sim = sim + best = disc + + if best is not None and best_sim >= threshold: + matched.append( + { + "proposal": proposal, + "discussion": best, + "similarity": round(best_sim, 4), + } + ) + seen_disc_ids.add(best.get("id")) + else: + new_candidates.append({"proposal": proposal, "best_similarity": round(best_sim, 4)}) + +result = { + "matched": matched, + "new_candidates": new_candidates, + "threshold": threshold, +} +print(json.dumps(result, indent=2)) +PY +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + if [ "$#" -ne 2 ]; then + printf 'usage: %s <signals.json> <proposals.json>\n' "$0" >&2 + exit 64 + fi + match_discussions_main "$1" "$2" +fi diff --git a/.github/scripts/feature-ideation/validate-signals.py b/.github/scripts/feature-ideation/validate-signals.py new file mode 100755 index 00000000..b71a1bd6 --- /dev/null +++ b/.github/scripts/feature-ideation/validate-signals.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Validate a signals.json file against signals.schema.json. + +Usage: + validate-signals.py <signals.json> [<schema.json>] + +Exit codes: + 0 valid + 1 invalid (validation error printed to stderr) + 2 usage / file error +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +try: + from jsonschema import Draft202012Validator +except ImportError: + sys.stderr.write( + "[validate-signals] python jsonschema not installed. " + "Install with: pip install jsonschema\n" + ) + sys.exit(2) + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or len(argv) > 3: + sys.stderr.write( + "usage: validate-signals.py <signals.json> [<schema.json>]\n" + ) + return 2 + + signals_path = Path(argv[1]) + if len(argv) == 3: + schema_path = Path(argv[2]) + else: + schema_path = ( + Path(__file__).resolve().parent.parent.parent + / "schemas" + / "signals.schema.json" + ) + + if not signals_path.exists(): + sys.stderr.write(f"[validate-signals] not found: {signals_path}\n") + return 2 + if not schema_path.exists(): + sys.stderr.write(f"[validate-signals] schema not found: {schema_path}\n") + return 2 + + try: + signals = json.loads(signals_path.read_text()) + except json.JSONDecodeError as exc: + sys.stderr.write(f"[validate-signals] invalid JSON in {signals_path}: {exc}\n") + return 1 + + try: + schema = json.loads(schema_path.read_text()) + except json.JSONDecodeError as exc: + sys.stderr.write(f"[validate-signals] invalid schema JSON: {exc}\n") + return 2 + + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(signals), key=lambda e: list(e.absolute_path)) + if not errors: + print(f"[validate-signals] OK: {signals_path}") + return 0 + + sys.stderr.write(f"[validate-signals] {len(errors)} validation error(s) in {signals_path}:\n") + for err in errors: + path = "/".join(str(p) for p in err.absolute_path) or "<root>" + sys.stderr.write(f" - {path}: {err.message}\n") + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/feature-ideation-tests.yml b/.github/workflows/feature-ideation-tests.yml new file mode 100644 index 00000000..c0b5cfbf --- /dev/null +++ b/.github/workflows/feature-ideation-tests.yml @@ -0,0 +1,106 @@ +# Quality gates for the feature-ideation workflow and its supporting scripts. +# +# Triggered on any PR that touches: +# - .github/workflows/feature-ideation*.yml +# - .github/scripts/feature-ideation/** +# - .github/schemas/signals.schema.json +# - test/workflows/feature-ideation/** +# +# Gates (all must pass): +# 1. shellcheck — static analysis for every script +# 2. bats — unit + integration test suite +# 3. lint-prompt — direct_prompt block has no unescaped shell expansions +# 4. schema check — fixtures validate against signals.schema.json + +name: Feature Ideation Tests + +on: + pull_request: + paths: + - '.github/workflows/feature-ideation*.yml' + - '.github/scripts/feature-ideation/**' + - '.github/schemas/signals.schema.json' + - 'test/workflows/feature-ideation/**' + push: + branches: + - main + paths: + - '.github/workflows/feature-ideation*.yml' + - '.github/scripts/feature-ideation/**' + - '.github/schemas/signals.schema.json' + - 'test/workflows/feature-ideation/**' + +permissions: + contents: read + +concurrency: + group: feature-ideation-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint, schema, and bats + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install bats, shellcheck, and jq + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats shellcheck jq + + - name: Install Python jsonschema + run: pip install --quiet 'jsonschema>=4' + + - name: shellcheck + run: | + set -euo pipefail + cd .github/scripts/feature-ideation + shellcheck -x \ + collect-signals.sh \ + lint-prompt.sh \ + match-discussions.sh \ + discussion-mutations.sh \ + lib/gh-safe.sh \ + lib/filter-bots.sh \ + lib/compose-signals.sh \ + lib/date-utils.sh + + - name: Lint direct_prompt blocks + run: bash .github/scripts/feature-ideation/lint-prompt.sh + + - name: Validate schema fixtures + run: | + set -euo pipefail + for fixture in test/workflows/feature-ideation/fixtures/expected/*.signals.json; do + base=$(basename "$fixture") + if [[ "$base" == INVALID-* ]]; then + # INVALID fixtures must fail validation. + if python3 .github/scripts/feature-ideation/validate-signals.py \ + "$fixture" .github/schemas/signals.schema.json; then + echo "::error file=$fixture::expected validation failure but it passed" + exit 1 + fi + else + python3 .github/scripts/feature-ideation/validate-signals.py \ + "$fixture" .github/schemas/signals.schema.json + fi + done + + - name: Run bats suite + run: bats --print-output-on-failure test/workflows/feature-ideation/ + + - name: Upload bats output on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bats-output + path: | + test/workflows/feature-ideation/**/*.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/feature-ideation.yml b/.github/workflows/feature-ideation.yml new file mode 100644 index 00000000..03b24eb0 --- /dev/null +++ b/.github/workflows/feature-ideation.yml @@ -0,0 +1,399 @@ +# Weekly feature research and ideation powered by the BMAD Analyst (Mary). +# +# Two-job pipeline: +# 1. gather-signals → runs collect-signals.sh, validates against schema, +# uploads signals.json as an artifact. +# 2. analyze → BMAD Analyst (Mary) reads signals + runs deterministic +# Discussion-matching, then proposes / updates per-idea Discussion threads. +# +# All bash logic lives in .github/scripts/feature-ideation/, which is unit +# tested via bats. The shape of signals.json is pinned by +# .github/schemas/signals.schema.json. The direct_prompt block is linted by +# .github/scripts/feature-ideation/lint-prompt.sh to prevent unescaped shell +# expansions (R2). +# +# Schedule: every Friday at 07:00 UTC (3 AM EDT / 2 AM EST). + +name: Feature Research & Ideation (BMAD Analyst) + +on: + schedule: + - cron: '0 7 * * 5' # Friday 07:00 UTC (3 AM EDT / 2 AM EST) + workflow_dispatch: + inputs: + focus_area: + description: 'Optional focus area (e.g., "accessibility", "voice UX", "performance")' + required: false + type: string + research_depth: + description: 'Research depth' + required: false + default: 'standard' + type: choice + options: + - quick + - standard + - deep + dry_run: + description: 'Skip Discussion mutations and log them instead' + required: false + default: false + type: boolean + +permissions: {} + +concurrency: + group: feature-ideation + cancel-in-progress: false + +jobs: + # --------------------------------------------------------------------------- + # Job 1: Gather signals — issues, discussions, releases, community feedback + # --------------------------------------------------------------------------- + gather-signals: + name: Gather Project Signals + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: read + pull-requests: read + discussions: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + outputs: + signals_artifact: ${{ steps.collect.outputs.signals_artifact }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install jsonschema + run: pip install --quiet 'jsonschema>=4' + + - name: Collect project signals + id: collect + env: + REPO: ${{ github.repository }} + SIGNALS_OUTPUT: ${{ runner.temp }}/signals.json + run: | + set -euo pipefail + bash .github/scripts/feature-ideation/collect-signals.sh + echo "signals_artifact=signals" >> "$GITHUB_OUTPUT" + + - name: Validate signals.json against schema + run: | + set -euo pipefail + python3 .github/scripts/feature-ideation/validate-signals.py \ + "${{ runner.temp }}/signals.json" \ + .github/schemas/signals.schema.json + + - name: Upload signals artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: signals + path: ${{ runner.temp }}/signals.json + retention-days: 7 + + # --------------------------------------------------------------------------- + # Job 2: BMAD Analyst — Research, ideate, and post per-idea Discussions + # --------------------------------------------------------------------------- + analyze: + name: BMAD Analyst — Feature Ideation + needs: [gather-signals] + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + discussions: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Download signals artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: signals + path: ${{ runner.temp }} + + - name: Compute run date + id: rundate + run: echo "value=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Run Claude Code — BMAD Analyst + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FOCUS_AREA: ${{ inputs.focus_area || '' }} + RESEARCH_DEPTH: ${{ inputs.research_depth || 'standard' }} + DRY_RUN: ${{ inputs.dry_run && '1' || '0' }} + DRY_RUN_LOG: ${{ runner.temp }}/dry-run.jsonl + RUN_DATE: ${{ steps.rundate.outputs.value }} + SIGNALS_PATH: ${{ runner.temp }}/signals.json + uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + timeout_minutes: 40 + allowed_tools: 'Bash,Read,Glob,Grep,WebSearch,WebFetch' + direct_prompt: | + You are **Mary**, the BMAD Strategic Business Analyst, running as an automated + weekly feature research and ideation agent for the ${{ github.repository }} project. + + ## Your Persona + + You are a senior analyst with deep expertise in market research, competitive analysis, + and requirements elicitation. You approach every analysis like a treasure hunt — + thrilled by every clue, energized when patterns emerge. You structure insights with + precision while making analysis feel like discovery. You draw upon Porter's Five Forces, + SWOT analysis, and competitive intelligence methodologies naturally. + + ## Environment + + All env vars below are provided to your shell — DO NOT try to interpolate them in + this prompt body. Read them at runtime with `printenv` or `$VAR_NAME` from your tools. + + - Repository: passed via `$GITHUB_REPOSITORY` + - Workflow run URL: passed via `$GITHUB_SERVER_URL` and `$GITHUB_RUN_ID` + - Run date: passed via `$RUN_DATE` + - Focus area: `$FOCUS_AREA` (empty = open-ended exploration) + - Research depth: `$RESEARCH_DEPTH` + - Dry-run mode: `$DRY_RUN` ("1" = log mutations, do not execute) + - Signals file: `$SIGNALS_PATH` + + ## Phase 1: Load Context + + 1. Read the project signals collected by the previous job: + `cat "$SIGNALS_PATH"`. The shape is pinned by + `.github/schemas/signals.schema.json` — every field is guaranteed present. + + 2. Read planning artifacts for product strategy context: + - `_bmad-output/planning-artifacts/prd.md` + - `_bmad-output/planning-artifacts/architecture.md` + - `_bmad-output/planning-artifacts/ux-design-specification.md` + - `_bmad-output/planning-artifacts/epics.md` + + 3. Read the project README and any recent changelog or release notes. + + 4. Scan the codebase for extensibility points: + - `src/shared/types/ports/` for interfaces that suggest planned extensions + - `src/main/agent/` for backend abstraction points + - `src/renderer/speech/` for voice pipeline capabilities + - `src/renderer/components/` for UI patterns that could be extended + + 5. Read existing Ideas discussions from `.ideas_discussions.items` in the signals + file. These are already-tracked ideas. You will use the deterministic matcher + in Phase 5 to decide which proposals are new vs updates. + + ## Phase 2: Market & Domain Research + + Research the competitive landscape and emerging trends. TalkTerm is a desktop AI + agent interface with voice-enabled animated avatars — focus on: + + ### Competitive Landscape + - Other AI desktop agent tools (Cursor, Windsurf, Continue, Warp, etc.) + - Voice-first AI interfaces and emerging UX patterns + - Animated avatar / embodied AI projects gaining traction + - How non-technical users are being served by AI tools today + + ### Emerging Capabilities + - New Claude API / Agent SDK features and model improvements + - Browser/desktop AI integration trends (local models, hybrid approaches) + - Voice technology advances (real-time STT/TTS, emotion, multilingual) + - Avatar/animation tech (Rive updates, real-time expression mapping) + - Electron ecosystem updates that unlock new possibilities + + ### User Need Signals + - Patterns in `.open_issues`, `.feature_requests`, and `.bug_reports` + - Common pain points from `.bug_reports` that suggest UX gaps + - What shipped recently in `.merged_prs_30d` and what gaps remain + - Discussion themes from `.ideas_discussions` + + Adjust depth based on `$RESEARCH_DEPTH`: + - **quick**: Skim signals, 1-2 web searches, focus on low-hanging fruit + - **standard**: Thorough signal analysis, 5-8 web searches, balanced exploration + - **deep**: Exhaustive research, 10+ web searches, competitor deep dives + + ## Phase 3: Ideation & Scoring + + Synthesize your research into **3-5 feature proposals** (fewer is fine if quality + is high). For each proposal, assess: + + | Dimension | Question | + |-----------|----------| + | Feasibility | Can we build this with the current stack? | + | Impact | How many users does this serve? How much value? | + | Urgency | Is the market moving here now? | + + **Only propose ideas scoring high on at least 2 of 3 dimensions.** + + If a focus area was specified, weight proposals toward that area but don't + exclude other high-scoring discoveries. + + ## Phase 4: Generate proposals.json + + Write your proposals to `${RUNNER_TEMP:-/tmp}/proposals.json` as a JSON array. + Each entry must include at minimum a `title` field. Recommended shape: + + ```json + [ + { + "title": "💡 Concise Idea Title", + "summary": "2-3 sentence description", + "market_signal": "What competitive or market trend supports this", + "user_signal": "Related issues / feedback / themes", + "technical_opportunity": "How architecture enables this", + "feasibility": "high|med|low", + "impact": "high|med|low", + "urgency": "high|med|low", + "next_step": "Concrete action" + } + ] + ``` + + ## Phase 5: Match against existing Discussions (deterministic) + + Run the matcher script. It uses normalized Jaccard similarity, so you do NOT need + to do any fuzzy matching yourself: + + ```bash + bash .github/scripts/feature-ideation/match-discussions.sh \ + "$SIGNALS_PATH" \ + "${RUNNER_TEMP:-/tmp}/proposals.json" \ + > "${RUNNER_TEMP:-/tmp}/match-plan.json" + ``` + + The output has two arrays: + - `.matched` — proposals that align with an existing Discussion (update path) + - `.new_candidates` — proposals with no existing match (create path) + + ## Phase 6: Resolve repository + category IDs + + Use the wrapper to fetch repo and category IDs (it fails loud on errors): + + ```bash + gh api graphql -f query=' + query($repo: String!, $owner: String!) { + repository(name: $repo, owner: $owner) { + id + discussionCategories(first: 25) { nodes { id name } } + } + }' -f owner="${GITHUB_REPOSITORY%%/*}" \ + -f repo="${GITHUB_REPOSITORY##*/}" + ``` + + Save the repository ID and the "Ideas" category ID. Fall back to "General" if + "Ideas" doesn't exist. If neither exists, write a clear error to the step + summary and skip Phase 7. + + ## Phase 7: Execute the plan via discussion-mutations.sh + + For every entry in `.new_candidates` from the match plan, call: + + ```bash + source .github/scripts/feature-ideation/discussion-mutations.sh + create_discussion "$REPO_ID" "$CAT_ID" "$TITLE" "$BODY" + ``` + + For every entry in `.matched` that has genuinely new information, call: + + ```bash + comment_on_discussion "$DISCUSSION_ID" "$UPDATE_BODY" + ``` + + **Important:** When `$DRY_RUN` is "1", these helpers log the planned action to + `$DRY_RUN_LOG` instead of executing. This is the safe smoke-test path — ALWAYS + check `$DRY_RUN` and behave identically (the helper handles the switch internally). + + **Body templates** are unchanged from the prior version — see + `.github/scripts/feature-ideation/templates/` if it exists, otherwise inline + the markdown structure for new proposals and updates documented at the bottom. + + ## Phase 8: Step Summary + + Write to `$GITHUB_STEP_SUMMARY` a markdown table summarizing: + - New ideas proposed (with similarity scores from match-plan.json) + - Existing ideas updated + - Ideas reviewed with no update needed + - Top 2-3 market trend bullets + + If `$DRY_RUN` is "1", append the contents of `$DRY_RUN_LOG` (formatted) so + reviewers can audit what would have been written. + + ## Rules + + - **Do NOT create issues or PRs.** Output goes to Discussions only. + - **Do NOT implement code.** Research and ideation only. + - **Max 5 new Discussions per run.** + - **Use the deterministic matcher** — never invent your own fuzzy match logic. + - **Always go through `discussion-mutations.sh`** — never call createDiscussion or + addDiscussionComment GraphQL mutations directly. + - **Ground proposals in evidence** from the signals file. + - **Be specific about next steps.** + - **Respect the architecture** documented in AGENTS.md. + - **Use the 💡 prefix** in all idea Discussion titles. + + ## Discussion body template (new proposals) + + ```markdown + ## Summary + + <2-3 sentence description> + + ## Market Signal + + <Competitive or market trend> + + ## User Signal + + <Related issues / feedback themes> + + ## Technical Opportunity + + <How current architecture enables this — cite ports/interfaces> + + ## Assessment + + | Dimension | Score | Rationale | + |-----------|-------|-----------| + | Feasibility | high/med/low | ... | + | Impact | high/med/low | ... | + | Urgency | high/med/low | ... | + + ## Suggested Next Step + + <Concrete action> + ``` + + ## Discussion comment template (updates) + + ```markdown + ## Weekly Update + + ### What Changed + + <New signals since last update — be specific> + + ### Updated Assessment + + | Dimension | Previous | Current | Delta | + |-----------|----------|---------|-------| + | Feasibility | ... | ... | ↑/↓/→ | + | Impact | ... | ... | ↑/↓/→ | + | Urgency | ... | ... | ↑/↓/→ | + + ### Recommendation + + <Advance / keep watching / retire — and why> + ``` + + - name: Upload dry-run log (if present) + if: ${{ inputs.dry_run }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dry-run-log + path: ${{ runner.temp }}/dry-run.jsonl + if-no-files-found: ignore + retention-days: 7 diff --git a/test/workflows/feature-ideation/collect-signals.bats b/test/workflows/feature-ideation/collect-signals.bats new file mode 100644 index 00000000..9543b97b --- /dev/null +++ b/test/workflows/feature-ideation/collect-signals.bats @@ -0,0 +1,204 @@ +#!/usr/bin/env bats +# Integration test for collect-signals.sh end-to-end. +# +# Uses the multi-call gh stub to script the exact sequence of gh invocations +# the collector makes, then validates the resulting signals.json against the +# JSON schema. + +load 'helpers/setup' + +setup() { + tt_make_tmpdir + tt_install_gh_stub + export REPO="petry-projects/talkterm" + export GH_TOKEN="fake-token-for-tests" + export SIGNALS_OUTPUT="${TT_TMP}/signals.json" +} + +teardown() { + tt_cleanup_tmpdir +} + +# Build a multi-call gh script for the standard happy path. +# Order MUST match collect-signals.sh: +# 1. gh issue list --state open +# 2. gh issue list --state closed +# 3. gh api graphql (categories) +# 4. gh api graphql (discussions) +# 5. gh release list +# 6. gh pr list --state merged +build_happy_script() { + local script="${TT_TMP}/gh-script.tsv" + : >"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-open.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-closed.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-categories.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-discussions.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/release-list.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/pr-list-merged.json" >>"$script" + export GH_STUB_SCRIPT="$script" + rm -f "${TT_TMP}/.gh-stub-counter" +} + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +@test "collect-signals: happy path produces valid signals.json" { + build_happy_script + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 0 ] + [ -f "$SIGNALS_OUTPUT" ] + run python3 "${TT_SCRIPTS_DIR}/validate-signals.py" "$SIGNALS_OUTPUT" + [ "$status" -eq 0 ] +} + +@test "collect-signals: bot author is filtered out of open_issues" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + count=$(jq '.open_issues.count' "$SIGNALS_OUTPUT") + [ "$count" = "2" ] + bot_present=$(jq '[.open_issues.items[] | select(.author.login == "dependabot[bot]")] | length' "$SIGNALS_OUTPUT") + [ "$bot_present" = "0" ] +} + +@test "collect-signals: closed issues are filtered by 30-day cutoff" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + # The fixture has one recent (2099) and one ancient (2020) closed issue. + # Only the future-dated one should survive the 30-day-ago cutoff. + count=$(jq '.closed_issues_30d.count' "$SIGNALS_OUTPUT") + [ "$count" = "1" ] + num=$(jq '.closed_issues_30d.items[0].number' "$SIGNALS_OUTPUT") + [ "$num" = "95" ] +} + +@test "collect-signals: feature_requests derives from labeled open issues" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + fr_count=$(jq '.feature_requests.count' "$SIGNALS_OUTPUT") + [ "$fr_count" = "1" ] + fr_num=$(jq '.feature_requests.items[0].number' "$SIGNALS_OUTPUT") + [ "$fr_num" = "101" ] +} + +@test "collect-signals: bug_reports derives from labeled open issues" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + br_count=$(jq '.bug_reports.count' "$SIGNALS_OUTPUT") + [ "$br_count" = "1" ] + br_num=$(jq '.bug_reports.items[0].number' "$SIGNALS_OUTPUT") + [ "$br_num" = "102" ] +} + +@test "collect-signals: ideas_discussions populated when category exists" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + idc=$(jq '.ideas_discussions.count' "$SIGNALS_OUTPUT") + [ "$idc" = "1" ] + title=$(jq -r '.ideas_discussions.items[0].title' "$SIGNALS_OUTPUT") + [[ "$title" == *"Streaming voice"* ]] +} + +@test "collect-signals: scan_date is ISO-8601 Zulu" { + build_happy_script + bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + d=$(jq -r '.scan_date' "$SIGNALS_OUTPUT") + [[ "$d" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] +} + +# --------------------------------------------------------------------------- +# Failure modes — pipeline must fail loud, NOT silently produce empty data +# --------------------------------------------------------------------------- + +@test "collect-signals: FAILS LOUD on open-issues auth failure" { + script="${TT_TMP}/gh-script.tsv" + err_file="${TT_TMP}/auth-err.txt" + printf 'HTTP 401: Bad credentials\n' >"$err_file" + printf '4\t-\t%s\n' "$err_file" >"$script" + export GH_STUB_SCRIPT="$script" + rm -f "${TT_TMP}/.gh-stub-counter" + + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -ne 0 ] + [ ! -f "$SIGNALS_OUTPUT" ] +} + +@test "collect-signals: FAILS LOUD on GraphQL errors envelope (categories)" { + script="${TT_TMP}/gh-script.tsv" + : >"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-open.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-closed.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-errors-envelope.json" >>"$script" + export GH_STUB_SCRIPT="$script" + rm -f "${TT_TMP}/.gh-stub-counter" + + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -ne 0 ] + [ ! -f "$SIGNALS_OUTPUT" ] +} + +@test "collect-signals: missing REPO env causes usage error" { + unset REPO + build_happy_script + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 64 ] +} + +@test "collect-signals: missing GH_TOKEN env causes usage error" { + unset GH_TOKEN + build_happy_script + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 64 ] +} + +@test "collect-signals: malformed REPO causes usage error" { + export REPO="not-a-slug" + build_happy_script + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 64 ] +} + +# --------------------------------------------------------------------------- +# Truncation warnings +# --------------------------------------------------------------------------- + +@test "collect-signals: emits truncation warning when discussions hasNextPage=true" { + script="${TT_TMP}/gh-script.tsv" + : >"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-open.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-closed.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-categories.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-discussions-truncated.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/release-list.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/pr-list-merged.json" >>"$script" + export GH_STUB_SCRIPT="$script" + rm -f "${TT_TMP}/.gh-stub-counter" + + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 0 ] + warn_count=$(jq '.truncation_warnings | length' "$SIGNALS_OUTPUT") + [ "$warn_count" -ge 1 ] + jq -e '.truncation_warnings[] | select(.source == "ideas_discussions")' "$SIGNALS_OUTPUT" >/dev/null +} + +# --------------------------------------------------------------------------- +# No "Ideas" category — graceful skip, not a hard failure +# --------------------------------------------------------------------------- + +@test "collect-signals: skips discussions when Ideas category absent" { + script="${TT_TMP}/gh-script.tsv" + : >"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-open.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/issue-list-closed.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/graphql-no-ideas-category.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/release-list.json" >>"$script" + printf '0\t%s\t-\n' "${TT_FIXTURES_DIR}/gh-responses/pr-list-merged.json" >>"$script" + export GH_STUB_SCRIPT="$script" + rm -f "${TT_TMP}/.gh-stub-counter" + + run bash "${TT_SCRIPTS_DIR}/collect-signals.sh" + [ "$status" -eq 0 ] + idc=$(jq '.ideas_discussions.count' "$SIGNALS_OUTPUT") + [ "$idc" = "0" ] +} diff --git a/test/workflows/feature-ideation/compose-signals.bats b/test/workflows/feature-ideation/compose-signals.bats new file mode 100644 index 00000000..4dced2c8 --- /dev/null +++ b/test/workflows/feature-ideation/compose-signals.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# Tests for .github/scripts/feature-ideation/lib/compose-signals.sh +# +# Kills R4 (jq --argjson crash on empty inputs) and R3 (no contract for +# the signals payload). Schema validation lives in signals-schema.bats; +# this file pins the composition logic itself. + +load 'helpers/setup' + +setup() { + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/lib/compose-signals.sh" +} + +# Helper: invoke compose_signals with all-empty buckets and a fixed scan_date. +compose_empty() { + compose_signals \ + '[]' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' \ + '2026-04-07T00:00:00Z' \ + '1.0.0' \ + '[]' +} + +# --------------------------------------------------------------------------- +# Argument validation (R4) +# --------------------------------------------------------------------------- + +@test "compose: rejects wrong arg count" { + run compose_signals '[]' '[]' + [ "$status" -ne 0 ] +} + +@test "compose: rejects empty string for any JSON arg" { + run compose_signals \ + '' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' '2026-04-07T00:00:00Z' '1.0.0' '[]' + [ "$status" -ne 0 ] +} + +@test "compose: rejects non-JSON for any JSON arg" { + run compose_signals \ + 'not json' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' '2026-04-07T00:00:00Z' '1.0.0' '[]' + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# Output shape (R3) +# --------------------------------------------------------------------------- + +@test "compose: produces all required top-level fields with empty inputs" { + run compose_empty + [ "$status" -eq 0 ] + for field in schema_version scan_date repo open_issues closed_issues_30d \ + ideas_discussions releases merged_prs_30d feature_requests \ + bug_reports truncation_warnings; do + printf '%s' "$output" | jq -e "has(\"$field\")" >/dev/null + done +} + +@test "compose: count fields equal items length" { + open='[{"number":1,"title":"a","labels":[]},{"number":2,"title":"b","labels":[]}]' + run compose_signals \ + "$open" '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' '2026-04-07T00:00:00Z' '1.0.0' '[]' + [ "$status" -eq 0 ] + count=$(printf '%s' "$output" | jq '.open_issues.count') + items_len=$(printf '%s' "$output" | jq '.open_issues.items | length') + [ "$count" = "2" ] + [ "$items_len" = "2" ] +} + +@test "compose: schema_version is preserved verbatim" { + run compose_signals \ + '[]' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' '2026-04-07T00:00:00Z' '2.5.1' '[]' + [ "$status" -eq 0 ] + v=$(printf '%s' "$output" | jq -r '.schema_version') + [ "$v" = "2.5.1" ] +} + +@test "compose: truncation_warnings round-trip" { + warnings='[{"source":"open_issues","limit":50,"message":"truncated"}]' + run compose_signals \ + '[]' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'foo/bar' '2026-04-07T00:00:00Z' '1.0.0' "$warnings" + [ "$status" -eq 0 ] + src=$(printf '%s' "$output" | jq -r '.truncation_warnings[0].source') + [ "$src" = "open_issues" ] +} + +@test "compose: scan_date and repo round-trip exactly" { + run compose_signals \ + '[]' '[]' '[]' '[]' '[]' '[]' '[]' \ + 'octocat/hello-world' '2030-01-15T12:34:56Z' '1.0.0' '[]' + [ "$status" -eq 0 ] + d=$(printf '%s' "$output" | jq -r '.scan_date') + r=$(printf '%s' "$output" | jq -r '.repo') + [ "$d" = "2030-01-15T12:34:56Z" ] + [ "$r" = "octocat/hello-world" ] +} diff --git a/test/workflows/feature-ideation/date-utils.bats b/test/workflows/feature-ideation/date-utils.bats new file mode 100644 index 00000000..dfb132b2 --- /dev/null +++ b/test/workflows/feature-ideation/date-utils.bats @@ -0,0 +1,50 @@ +#!/usr/bin/env bats +# Tests for .github/scripts/feature-ideation/lib/date-utils.sh + +load 'helpers/setup' + +setup() { + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/lib/date-utils.sh" +} + +@test "date_days_ago: returns ISO date format" { + run date_days_ago 30 + [ "$status" -eq 0 ] + [[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] +} + +@test "date_days_ago: 0 returns today" { + today=$(date -u +%Y-%m-%d) + run date_days_ago 0 + [ "$status" -eq 0 ] + [ "$output" = "$today" ] +} + +@test "date_days_ago: rejects non-integer input" { + run date_days_ago "abc" + [ "$status" -ne 0 ] +} + +@test "date_days_ago: rejects empty input" { + run date_days_ago "" + [ "$status" -ne 0 ] +} + +@test "date_days_ago: 30 days ago is earlier than today" { + today=$(date_today) + past=$(date_days_ago 30) + [ "$past" \< "$today" ] +} + +@test "date_now_iso: returns ISO-8601 Zulu" { + run date_now_iso + [ "$status" -eq 0 ] + [[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] +} + +@test "date_today: returns YYYY-MM-DD" { + run date_today + [ "$status" -eq 0 ] + [[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] +} diff --git a/test/workflows/feature-ideation/discussion-mutations.bats b/test/workflows/feature-ideation/discussion-mutations.bats new file mode 100644 index 00000000..b42dabe6 --- /dev/null +++ b/test/workflows/feature-ideation/discussion-mutations.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# Tests for discussion-mutations.sh — verifies DRY_RUN logging contract +# and live-mode delegation to gh-safe. + +bats_require_minimum_version 1.5.0 + +load 'helpers/setup' + +setup() { + tt_make_tmpdir + tt_install_gh_stub + export DRY_RUN_LOG="${TT_TMP}/dry-run.jsonl" + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/discussion-mutations.sh" +} + +teardown() { + tt_cleanup_tmpdir +} + +# --------------------------------------------------------------------------- +# DRY_RUN mode +# --------------------------------------------------------------------------- + +@test "dry-run: create_discussion logs entry and returns it on stdout" { + DRY_RUN=1 run create_discussion "R_1" "C_ideas" "💡 New idea" "Body text" + [ "$status" -eq 0 ] + [[ "$output" == *'"create_discussion"'* ]] + [[ "$output" == *'"R_1"'* ]] + [[ "$output" == *'"💡 New idea"'* ]] + [ -f "$DRY_RUN_LOG" ] + [ "$(wc -l <"$DRY_RUN_LOG")" -eq 1 ] +} + +@test "dry-run: comment_on_discussion logs entry" { + DRY_RUN=1 run comment_on_discussion "D_1" "Update text" + [ "$status" -eq 0 ] + [[ "$output" == *'"comment_on_discussion"'* ]] + [[ "$output" == *'"D_1"'* ]] + [[ "$output" == *'"Update text"'* ]] +} + +@test "dry-run: add_label_to_discussion logs entry" { + DRY_RUN=1 run add_label_to_discussion "D_1" "L_enhancement" + [ "$status" -eq 0 ] + [[ "$output" == *'"add_label_to_discussion"'* ]] + [[ "$output" == *'"L_enhancement"'* ]] +} + +@test "dry-run: log file is JSONL (one valid object per line)" { + DRY_RUN=1 create_discussion "R_1" "C_1" "title 1" "body 1" + DRY_RUN=1 comment_on_discussion "D_1" "comment 1" + DRY_RUN=1 add_label_to_discussion "D_1" "L_1" + [ "$(wc -l <"$DRY_RUN_LOG")" -eq 3 ] + while IFS= read -r line; do + printf '%s' "$line" | jq -e . >/dev/null + done <"$DRY_RUN_LOG" +} + +@test "dry-run: never invokes gh" { + log="${TT_TMP}/gh-invocations.log" + GH_STUB_LOG="$log" + DRY_RUN=1 create_discussion "R_1" "C_1" "t" "b" + [ ! -f "$log" ] +} + +# --------------------------------------------------------------------------- +# Argument validation +# --------------------------------------------------------------------------- + +@test "create_discussion: rejects wrong arg count" { + DRY_RUN=1 run create_discussion "R_1" "C_1" "title" + [ "$status" -eq 64 ] +} + +@test "comment_on_discussion: rejects wrong arg count" { + DRY_RUN=1 run comment_on_discussion "D_1" + [ "$status" -eq 64 ] +} + +@test "add_label_to_discussion: rejects wrong arg count" { + DRY_RUN=1 run add_label_to_discussion "D_1" + [ "$status" -eq 64 ] +} + +# --------------------------------------------------------------------------- +# Live mode (gh stub returns success) +# --------------------------------------------------------------------------- + +@test "live: create_discussion calls gh and returns its output" { + GH_STUB_STDOUT='{"data":{"createDiscussion":{"discussion":{"id":"D_new","number":42,"url":"https://x"}}}}' \ + run create_discussion "R_1" "C_ideas" "title" "body" + [ "$status" -eq 0 ] + [[ "$output" == *'"D_new"'* ]] + [ ! -f "$DRY_RUN_LOG" ] +} + +@test "live: comment_on_discussion fails loudly when gh returns errors envelope" { + GH_STUB_STDOUT='{"errors":[{"message":"forbidden"}],"data":null}' \ + run comment_on_discussion "D_1" "body" + [ "$status" -ne 0 ] +} diff --git a/test/workflows/feature-ideation/filter-bots.bats b/test/workflows/feature-ideation/filter-bots.bats new file mode 100644 index 00000000..6f4e3ff3 --- /dev/null +++ b/test/workflows/feature-ideation/filter-bots.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats +# Tests for .github/scripts/feature-ideation/lib/filter-bots.sh +# +# Pins R10: bot filter must catch all known automation accounts and be +# extensible via env var. + +load 'helpers/setup' + +setup() { + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/lib/filter-bots.sh" +} + +input_with_bots() { + cat <<'JSON' +[ + {"number":1,"title":"real issue","author":{"login":"alice"}}, + {"number":2,"title":"dependabot bump","author":{"login":"dependabot[bot]"}}, + {"number":3,"title":"renovate bump","author":{"login":"renovate[bot]"}}, + {"number":4,"title":"copilot suggestion","author":{"login":"copilot[bot]"}}, + {"number":5,"title":"coderabbit","author":{"login":"coderabbitai[bot]"}}, + {"number":6,"title":"another real","author":{"login":"bob"}}, + {"number":7,"title":"github actions","author":{"login":"github-actions[bot]"}} +] +JSON +} + +@test "filter-bots: removes default bot authors" { + result=$(input_with_bots | filter_bots_apply) + count=$(printf '%s' "$result" | jq 'length') + [ "$count" = "2" ] + printf '%s' "$result" | jq -e '.[] | select(.author.login == "alice")' >/dev/null + printf '%s' "$result" | jq -e '.[] | select(.author.login == "bob")' >/dev/null +} + +@test "filter-bots: leaves human authors untouched" { + result=$(input_with_bots | filter_bots_apply) + for user in alice bob; do + printf '%s' "$result" | jq -e --arg u "$user" '.[] | select(.author.login == $u)' >/dev/null + done +} + +@test "filter-bots: handles items without author field" { + echo '[{"number":1,"title":"orphan"}]' | filter_bots_apply | jq -e '.[0].number == 1' >/dev/null +} + +@test "filter-bots: env extension adds custom bot logins" { + input='[{"number":1,"title":"x","author":{"login":"my-custom-bot"}},{"number":2,"title":"y","author":{"login":"alice"}}]' + result=$(FEATURE_IDEATION_BOT_AUTHORS="my-custom-bot" sh -c " + . '${TT_SCRIPTS_DIR}/lib/filter-bots.sh' + printf '%s' '$input' | filter_bots_apply + ") + count=$(printf '%s' "$result" | jq 'length') + [ "$count" = "1" ] + login=$(printf '%s' "$result" | jq -r '.[0].author.login') + [ "$login" = "alice" ] +} + +@test "filter-bots: empty input array round-trips" { + result=$(echo '[]' | filter_bots_apply) + [ "$result" = "[]" ] +} diff --git a/test/workflows/feature-ideation/fixtures/expected/INVALID-bad-repo.signals.json b/test/workflows/feature-ideation/fixtures/expected/INVALID-bad-repo.signals.json new file mode 100644 index 00000000..697af833 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/expected/INVALID-bad-repo.signals.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T07:00:00Z", + "repo": "not-a-valid-slug", + "open_issues": { "count": 0, "items": [] }, + "closed_issues_30d": { "count": 0, "items": [] }, + "ideas_discussions": { "count": 0, "items": [] }, + "releases": [], + "merged_prs_30d": { "count": 0, "items": [] }, + "feature_requests": { "count": 0, "items": [] }, + "bug_reports": { "count": 0, "items": [] }, + "truncation_warnings": [] +} diff --git a/test/workflows/feature-ideation/fixtures/expected/INVALID-missing-field.signals.json b/test/workflows/feature-ideation/fixtures/expected/INVALID-missing-field.signals.json new file mode 100644 index 00000000..897f5a28 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/expected/INVALID-missing-field.signals.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T07:00:00Z", + "repo": "petry-projects/talkterm", + "open_issues": { "count": 0, "items": [] } +} diff --git a/test/workflows/feature-ideation/fixtures/expected/empty-repo.signals.json b/test/workflows/feature-ideation/fixtures/expected/empty-repo.signals.json new file mode 100644 index 00000000..4101afb6 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/expected/empty-repo.signals.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T07:00:00Z", + "repo": "petry-projects/talkterm", + "open_issues": { "count": 0, "items": [] }, + "closed_issues_30d": { "count": 0, "items": [] }, + "ideas_discussions": { "count": 0, "items": [] }, + "releases": [], + "merged_prs_30d": { "count": 0, "items": [] }, + "feature_requests": { "count": 0, "items": [] }, + "bug_reports": { "count": 0, "items": [] }, + "truncation_warnings": [] +} diff --git a/test/workflows/feature-ideation/fixtures/expected/populated.signals.json b/test/workflows/feature-ideation/fixtures/expected/populated.signals.json new file mode 100644 index 00000000..4c929219 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/expected/populated.signals.json @@ -0,0 +1,93 @@ +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T07:00:00Z", + "repo": "petry-projects/talkterm", + "open_issues": { + "count": 2, + "items": [ + { + "number": 101, + "title": "Add dark mode", + "labels": [{ "name": "enhancement" }], + "createdAt": "2026-03-15T10:00:00Z", + "author": { "login": "alice" } + }, + { + "number": 102, + "title": "Crash on startup", + "labels": [{ "name": "bug" }], + "createdAt": "2026-03-20T11:00:00Z", + "author": { "login": "bob" } + } + ] + }, + "closed_issues_30d": { + "count": 1, + "items": [ + { + "number": 95, + "title": "Old issue", + "labels": [], + "closedAt": "2026-03-25T12:00:00Z" + } + ] + }, + "ideas_discussions": { + "count": 1, + "items": [ + { + "id": "D_1", + "number": 7, + "title": "💡 Streaming voice responses", + "createdAt": "2026-03-01T00:00:00Z", + "updatedAt": "2026-04-01T00:00:00Z", + "labels": { "nodes": [{ "name": "enhancement" }] }, + "comments": { "totalCount": 3 } + } + ] + }, + "releases": [ + { + "tagName": "v0.1.0", + "name": "Initial preview", + "publishedAt": "2026-03-30T00:00:00Z", + "isPrerelease": true + } + ], + "merged_prs_30d": { + "count": 1, + "items": [ + { + "number": 60, + "title": "feat: weekly ideation workflow", + "labels": [], + "mergedAt": "2026-04-01T00:00:00Z" + } + ] + }, + "feature_requests": { + "count": 1, + "items": [ + { + "number": 101, + "title": "Add dark mode", + "labels": [{ "name": "enhancement" }], + "createdAt": "2026-03-15T10:00:00Z", + "author": { "login": "alice" } + } + ] + }, + "bug_reports": { + "count": 1, + "items": [ + { + "number": 102, + "title": "Crash on startup", + "labels": [{ "name": "bug" }], + "createdAt": "2026-03-20T11:00:00Z", + "author": { "login": "bob" } + } + ] + }, + "truncation_warnings": [] +} diff --git a/test/workflows/feature-ideation/fixtures/expected/truncated.signals.json b/test/workflows/feature-ideation/fixtures/expected/truncated.signals.json new file mode 100644 index 00000000..845db66f --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/expected/truncated.signals.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T07:00:00Z", + "repo": "petry-projects/talkterm", + "open_issues": { "count": 0, "items": [] }, + "closed_issues_30d": { "count": 0, "items": [] }, + "ideas_discussions": { "count": 0, "items": [] }, + "releases": [], + "merged_prs_30d": { "count": 0, "items": [] }, + "feature_requests": { "count": 0, "items": [] }, + "bug_reports": { "count": 0, "items": [] }, + "truncation_warnings": [ + { + "source": "open_issues", + "limit": 50, + "message": "result count equals limit; possible truncation" + }, + { + "source": "ideas_discussions", + "limit": 100, + "message": "hasNextPage=true; results truncated" + } + ] +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/graphql-categories.json b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-categories.json new file mode 100644 index 00000000..98250399 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-categories.json @@ -0,0 +1,13 @@ +{ + "data": { + "repository": { + "discussionCategories": { + "nodes": [ + {"id": "DC_general", "name": "General"}, + {"id": "DC_ideas", "name": "Ideas"}, + {"id": "DC_qna", "name": "Q&A"} + ] + } + } + } +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions-truncated.json b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions-truncated.json new file mode 100644 index 00000000..6b09dd74 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions-truncated.json @@ -0,0 +1,20 @@ +{ + "data": { + "repository": { + "discussions": { + "pageInfo": {"hasNextPage": true}, + "nodes": [ + { + "id": "D_1", + "number": 7, + "title": "💡 First idea", + "createdAt": "2026-03-01T00:00:00Z", + "updatedAt": "2026-04-01T00:00:00Z", + "labels": {"nodes": []}, + "comments": {"totalCount": 0} + } + ] + } + } + } +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions.json b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions.json new file mode 100644 index 00000000..d86cb95c --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-discussions.json @@ -0,0 +1,20 @@ +{ + "data": { + "repository": { + "discussions": { + "pageInfo": {"hasNextPage": false}, + "nodes": [ + { + "id": "D_1", + "number": 7, + "title": "💡 Streaming voice responses", + "createdAt": "2026-03-01T00:00:00Z", + "updatedAt": "2026-04-01T00:00:00Z", + "labels": {"nodes": [{"name": "enhancement"}]}, + "comments": {"totalCount": 3} + } + ] + } + } + } +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/graphql-errors-envelope.json b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-errors-envelope.json new file mode 100644 index 00000000..e444dae2 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-errors-envelope.json @@ -0,0 +1,10 @@ +{ + "errors": [ + { + "type": "FORBIDDEN", + "message": "Resource not accessible by integration", + "path": ["repository", "discussions"] + } + ], + "data": null +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/graphql-no-ideas-category.json b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-no-ideas-category.json new file mode 100644 index 00000000..b87ffefa --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/graphql-no-ideas-category.json @@ -0,0 +1,11 @@ +{ + "data": { + "repository": { + "discussionCategories": { + "nodes": [ + {"id": "DC_general", "name": "General"} + ] + } + } + } +} diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-closed.json b/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-closed.json new file mode 100644 index 00000000..3c344f2b --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-closed.json @@ -0,0 +1,14 @@ +[ + { + "number": 95, + "title": "Old recently closed", + "labels": [], + "closedAt": "2099-01-01T00:00:00Z" + }, + { + "number": 50, + "title": "Ancient closed", + "labels": [], + "closedAt": "2020-01-01T00:00:00Z" + } +] diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-open.json b/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-open.json new file mode 100644 index 00000000..006d1ea6 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/issue-list-open.json @@ -0,0 +1,23 @@ +[ + { + "number": 101, + "title": "Add dark mode", + "labels": [{"name": "enhancement"}], + "createdAt": "2026-03-15T10:00:00Z", + "author": {"login": "alice"} + }, + { + "number": 102, + "title": "Crash on startup", + "labels": [{"name": "bug"}], + "createdAt": "2026-03-20T11:00:00Z", + "author": {"login": "bob"} + }, + { + "number": 103, + "title": "dependabot bump", + "labels": [], + "createdAt": "2026-03-22T11:00:00Z", + "author": {"login": "dependabot[bot]"} + } +] diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/pr-list-merged.json b/test/workflows/feature-ideation/fixtures/gh-responses/pr-list-merged.json new file mode 100644 index 00000000..8dee1048 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/pr-list-merged.json @@ -0,0 +1,14 @@ +[ + { + "number": 60, + "title": "feat: weekly ideation workflow", + "labels": [], + "mergedAt": "2099-04-01T00:00:00Z" + }, + { + "number": 10, + "title": "old merged PR", + "labels": [], + "mergedAt": "2020-01-01T00:00:00Z" + } +] diff --git a/test/workflows/feature-ideation/fixtures/gh-responses/release-list.json b/test/workflows/feature-ideation/fixtures/gh-responses/release-list.json new file mode 100644 index 00000000..4d6f5bf8 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/gh-responses/release-list.json @@ -0,0 +1,8 @@ +[ + { + "tagName": "v0.1.0", + "name": "Initial preview", + "publishedAt": "2026-03-30T00:00:00Z", + "isPrerelease": true + } +] diff --git a/test/workflows/feature-ideation/gh-safe.bats b/test/workflows/feature-ideation/gh-safe.bats new file mode 100644 index 00000000..9194d538 --- /dev/null +++ b/test/workflows/feature-ideation/gh-safe.bats @@ -0,0 +1,163 @@ +#!/usr/bin/env bats +# Tests for .github/scripts/feature-ideation/lib/gh-safe.sh +# +# These tests kill R1: the original "2>/dev/null || echo '[]'" pattern that +# silently swallowed every kind of failure. Each test pins one failure mode. + +load 'helpers/setup' + +setup() { + tt_make_tmpdir + tt_install_gh_stub + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/lib/gh-safe.sh" +} + +teardown() { + tt_cleanup_tmpdir +} + +# --------------------------------------------------------------------------- +# gh_safe_is_json +# --------------------------------------------------------------------------- + +@test "is_json: accepts valid JSON" { + run gh_safe_is_json '[]' + [ "$status" -eq 0 ] + run gh_safe_is_json '{"a":1}' + [ "$status" -eq 0 ] +} + +@test "is_json: rejects empty string" { + run gh_safe_is_json '' + [ "$status" -ne 0 ] +} + +@test "is_json: rejects garbage" { + run gh_safe_is_json 'not json' + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# gh_safe_rest — happy paths +# --------------------------------------------------------------------------- + +@test "rest: returns JSON array on 200 OK + populated result" { + GH_STUB_STDOUT='[{"number":1,"title":"hello"}]' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -eq 0 ] + [ "$output" = '[{"number":1,"title":"hello"}]' ] +} + +@test "rest: returns [] when gh prints empty array" { + GH_STUB_STDOUT='[]' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -eq 0 ] + [ "$output" = '[]' ] +} + +@test "rest: normalizes empty stdout to []" { + GH_STUB_STDOUT='' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -eq 0 ] + [ "$output" = '[]' ] +} + +# --------------------------------------------------------------------------- +# gh_safe_rest — failure modes (R1 kill list) +# --------------------------------------------------------------------------- + +@test "rest: EXITS NON-ZERO on auth failure (gh exit 4)" { + GH_STUB_EXIT=4 \ + GH_STUB_STDERR='HTTP 401: Bad credentials' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -ne 0 ] + [[ "$output" == *"rest-failure"* ]] || [[ "$stderr" == *"rest-failure"* ]] || true +} + +@test "rest: EXITS NON-ZERO on rate limit (gh exit 1 + 403)" { + GH_STUB_EXIT=1 \ + GH_STUB_STDERR='API rate limit exceeded' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -ne 0 ] +} + +@test "rest: EXITS NON-ZERO on 5xx" { + GH_STUB_EXIT=1 \ + GH_STUB_STDERR='HTTP 502: Bad gateway' \ + run gh_safe_rest pr list --repo foo/bar + [ "$status" -ne 0 ] +} + +@test "rest: EXITS NON-ZERO on network failure" { + GH_STUB_EXIT=2 \ + GH_STUB_STDERR='dial tcp: lookup api.github.com: connection refused' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -ne 0 ] +} + +@test "rest: EXITS NON-ZERO on non-JSON success output" { + GH_STUB_STDOUT='this is not json at all' \ + run gh_safe_rest issue list --repo foo/bar + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# gh_safe_graphql — happy paths +# --------------------------------------------------------------------------- + +@test "graphql: returns full envelope when called without --jq" { + GH_STUB_STDOUT='{"data":{"repository":{"id":"R_1"}}}' \ + run gh_safe_graphql -f query='q' + [ "$status" -eq 0 ] + [[ "$output" == *'"R_1"'* ]] +} + +@test "graphql: applies --jq filter and returns its result" { + GH_STUB_STDOUT='{"data":{"repository":{"discussionCategories":{"nodes":[{"id":"C1","name":"Ideas"}]}}}}' \ + run gh_safe_graphql -f query='q' --jq '.data.repository.discussionCategories.nodes' + [ "$status" -eq 0 ] + [[ "$output" == *'"Ideas"'* ]] +} + +@test "graphql: --jq returning null normalizes to []" { + GH_STUB_STDOUT='{"data":{"repository":{"discussionCategories":{"nodes":null}}}}' \ + run gh_safe_graphql -f query='q' --jq '.data.repository.discussionCategories.nodes' + [ "$status" -eq 0 ] + [ "$output" = '[]' ] +} + +# --------------------------------------------------------------------------- +# gh_safe_graphql — failure modes (R1 + R7 kill list) +# --------------------------------------------------------------------------- + +@test "graphql: EXITS NON-ZERO on errors[] field present (HTTP 200)" { + GH_STUB_STDOUT='{"errors":[{"type":"FORBIDDEN","message":"Resource not accessible"}],"data":null}' \ + run gh_safe_graphql -f query='q' + [ "$status" -ne 0 ] +} + +@test "graphql: EXITS NON-ZERO on errors[] even when caller used --jq" { + GH_STUB_STDOUT='{"errors":[{"message":"bad"}],"data":null}' \ + run gh_safe_graphql -f query='q' --jq '.data.repository.id' + [ "$status" -ne 0 ] +} + +@test "graphql: EXITS NON-ZERO on data:null (no errors field)" { + GH_STUB_STDOUT='{"data":null}' \ + run gh_safe_graphql -f query='q' + [ "$status" -ne 0 ] +} + +@test "graphql: EXITS NON-ZERO on gh hard failure" { + GH_STUB_EXIT=1 \ + GH_STUB_STDERR='HTTP 401: Bad credentials' \ + run gh_safe_graphql -f query='q' + [ "$status" -ne 0 ] +} + +@test "graphql: EXITS NON-ZERO on non-JSON success output" { + GH_STUB_STDOUT='not json' \ + run gh_safe_graphql -f query='q' + [ "$status" -ne 0 ] +} diff --git a/test/workflows/feature-ideation/helpers/setup.bash b/test/workflows/feature-ideation/helpers/setup.bash new file mode 100644 index 00000000..7d8f92e7 --- /dev/null +++ b/test/workflows/feature-ideation/helpers/setup.bash @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Common test helpers for feature-ideation bats suites. + +# Repo root, regardless of where bats is invoked from. +TT_REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)" +export TT_REPO_ROOT + +TT_SCRIPTS_DIR="${TT_REPO_ROOT}/.github/scripts/feature-ideation" +export TT_SCRIPTS_DIR + +TT_FIXTURES_DIR="${TT_REPO_ROOT}/test/workflows/feature-ideation/fixtures" +export TT_FIXTURES_DIR + +TT_STUBS_DIR="${TT_REPO_ROOT}/test/workflows/feature-ideation/stubs" +export TT_STUBS_DIR + +# Per-test scratch dir, auto-cleaned by bats. +tt_make_tmpdir() { + TT_TMP="$(mktemp -d)" + export TT_TMP +} + +tt_cleanup_tmpdir() { + if [ -n "${TT_TMP:-}" ] && [ -d "${TT_TMP}" ]; then + rm -rf "${TT_TMP}" + fi +} + +# Install a fake `gh` binary on PATH for the duration of a test. +# Behavior is driven by env vars set per-test: +# GH_STUB_STDOUT — what the stub prints on stdout +# GH_STUB_STDERR — what the stub prints on stderr +# GH_STUB_EXIT — exit code (default 0) +# GH_STUB_LOG — file path to append the invocation argv to (optional) +tt_install_gh_stub() { + local stub_dir="${TT_TMP}/bin" + mkdir -p "$stub_dir" + cp "${TT_STUBS_DIR}/gh" "$stub_dir/gh" + chmod +x "$stub_dir/gh" + PATH="${stub_dir}:${PATH}" + export PATH +} + +# Convenience: load a fixture file by relative path. +tt_fixture() { + cat "${TT_FIXTURES_DIR}/$1" +} diff --git a/test/workflows/feature-ideation/lint-prompt.bats b/test/workflows/feature-ideation/lint-prompt.bats new file mode 100644 index 00000000..8ca83bf5 --- /dev/null +++ b/test/workflows/feature-ideation/lint-prompt.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats +# Tests for lint-prompt.sh — kills R2 (unescaped shell expansions in +# direct_prompt blocks that YAML/claude-code-action will not interpolate). + +load 'helpers/setup' + +setup() { + tt_make_tmpdir +} + +teardown() { + tt_cleanup_tmpdir +} + +LINTER="${TT_REPO_ROOT}/.github/scripts/feature-ideation/lint-prompt.sh" + +write_yml() { + local path="$1" + cat >"$path" +} + +@test "lint-prompt: clean prompt passes" { + write_yml "${TT_TMP}/clean.yml" <<'YML' +jobs: + analyze: + steps: + - uses: anthropics/claude-code-action@v1 + with: + direct_prompt: | + You are Mary. + The date is ${{ env.RUN_DATE }}. + Repo is ${{ github.repository }}. +YML + run bash "$LINTER" "${TT_TMP}/clean.yml" + [ "$status" -eq 0 ] +} + +@test "lint-prompt: FAILS on unescaped \$(date)" { + write_yml "${TT_TMP}/bad.yml" <<'YML' +jobs: + analyze: + steps: + - uses: anthropics/claude-code-action@v1 + with: + direct_prompt: | + You are Mary. + Date: $(date -u +%Y-%m-%d) +YML + run bash "$LINTER" "${TT_TMP}/bad.yml" + [ "$status" -eq 1 ] +} + +@test "lint-prompt: FAILS on bare \${VAR}" { + write_yml "${TT_TMP}/bad2.yml" <<'YML' +jobs: + analyze: + steps: + - uses: anthropics/claude-code-action@v1 + with: + direct_prompt: | + You are Mary. + Focus area: ${FOCUS_AREA} +YML + run bash "$LINTER" "${TT_TMP}/bad2.yml" + [ "$status" -eq 1 ] +} + +@test "lint-prompt: ALLOWS GitHub Actions expressions \${{ }}" { + write_yml "${TT_TMP}/gh-expr.yml" <<'YML' +jobs: + analyze: + steps: + - uses: anthropics/claude-code-action@v1 + with: + direct_prompt: | + Repo: ${{ github.repository }} + Run: ${{ github.run_id }} + Inputs: ${{ inputs.focus_area || 'open' }} +YML + run bash "$LINTER" "${TT_TMP}/gh-expr.yml" + [ "$status" -eq 0 ] +} + +@test "lint-prompt: detects expansions only inside direct_prompt block" { + write_yml "${TT_TMP}/scoped.yml" <<'YML' +jobs: + build: + steps: + - run: | + echo "$(date)" # this is fine — it's a real shell + analyze: + steps: + - uses: anthropics/claude-code-action@v1 + with: + direct_prompt: | + This is $(unsafe). +YML + run bash "$LINTER" "${TT_TMP}/scoped.yml" + [ "$status" -eq 1 ] +} + +@test "lint-prompt: live feature-ideation.yml lints clean" { + # Contract: the live workflow must never reintroduce unescaped shell + # expansions in the direct_prompt block (kills R2). + workflow="${TT_REPO_ROOT}/.github/workflows/feature-ideation.yml" + [ -f "$workflow" ] + run bash "$LINTER" "$workflow" + [ "$status" -eq 0 ] +} + +@test "lint-prompt: scans every workflow file by default" { + run bash "$LINTER" + [ "$status" -eq 0 ] +} diff --git a/test/workflows/feature-ideation/match-discussions.bats b/test/workflows/feature-ideation/match-discussions.bats new file mode 100644 index 00000000..792fdd5c --- /dev/null +++ b/test/workflows/feature-ideation/match-discussions.bats @@ -0,0 +1,181 @@ +#!/usr/bin/env bats +# Tests for match-discussions.sh — deterministic matching of proposals +# against existing Ideas Discussions. Kills R5 (fuzzy in-prompt matching) +# and R6 (idempotency hole on retry). + +bats_require_minimum_version 1.5.0 + +load 'helpers/setup' + +MATCH="${TT_REPO_ROOT}/.github/scripts/feature-ideation/match-discussions.sh" + +setup() { + tt_make_tmpdir +} + +teardown() { + tt_cleanup_tmpdir +} + +# Helper: build a signals.json with the given discussions array. +build_signals() { + local discussions="$1" + cat >"${TT_TMP}/signals.json" <<JSON +{ + "schema_version": "1.0.0", + "scan_date": "2026-04-07T00:00:00Z", + "repo": "petry-projects/talkterm", + "open_issues": { "count": 0, "items": [] }, + "closed_issues_30d": { "count": 0, "items": [] }, + "ideas_discussions": { "count": 0, "items": ${discussions} }, + "releases": [], + "merged_prs_30d": { "count": 0, "items": [] }, + "feature_requests": { "count": 0, "items": [] }, + "bug_reports": { "count": 0, "items": [] }, + "truncation_warnings": [] +} +JSON +} + +build_proposals() { + printf '%s' "$1" >"${TT_TMP}/proposals.json" +} + +@test "match: identical title produces a match" { + build_signals '[{"id":"D_1","number":1,"title":"💡 Streaming voice responses"}]' + build_proposals '[{"title":"💡 Streaming voice responses","summary":"x"}]' + + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + [ "$matched" = "1" ] + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$new" = "0" ] +} + +@test "match: emoji difference does not break a match" { + build_signals '[{"id":"D_1","number":1,"title":"🎤 Streaming voice responses"}]' + build_proposals '[{"title":"💡 Streaming voice responses"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + [ "$matched" = "1" ] +} + +@test "match: case and stopword differences still match" { + build_signals '[{"id":"D_1","number":1,"title":"Add support for streaming voice responses"}]' + build_proposals '[{"title":"Streaming Voice Responses"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + [ "$matched" = "1" ] +} + +@test "match: unrelated proposal is a new candidate" { + build_signals '[{"id":"D_1","number":1,"title":"💡 Streaming voice responses"}]' + build_proposals '[{"title":"Dark mode toggle in settings"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$matched" = "0" ] + [ "$new" = "1" ] +} + +@test "match: empty existing discussions yields all candidates as new" { + build_signals '[]' + build_proposals '[{"title":"A"},{"title":"B"},{"title":"C"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$new" = "3" ] +} + +@test "match: each existing discussion only matches one proposal" { + # Two near-identical proposals against one existing discussion. + # Only the FIRST should match; the second goes to new_candidates. + build_signals '[{"id":"D_1","number":1,"title":"Streaming voice responses"}]' + build_proposals '[{"title":"Streaming voice responses"},{"title":"Streaming voice responses"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$matched" = "1" ] + [ "$new" = "1" ] +} + +@test "match: result includes similarity score per match" { + build_signals '[{"id":"D_1","number":1,"title":"Streaming voice responses"}]' + build_proposals '[{"title":"Streaming voice responses"}]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + sim=$(printf '%s' "$output" | jq '.matched[0].similarity') + # Identical normalized titles → similarity 1.0 + awk -v s="$sim" 'BEGIN{exit !(s>=0.99)}' +} + +@test "match: threshold env var is respected" { + build_signals '[{"id":"D_1","number":1,"title":"voice"}]' + build_proposals '[{"title":"voice streaming responses"}]' + # At default threshold 0.6 these have Jaccard ~0.33, so no match. + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + [ "$matched" = "0" ] + # Lower the threshold and they should match. + MATCH_THRESHOLD=0.2 run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + [ "$matched" = "1" ] +} + +@test "match: missing signals file causes usage error" { + build_proposals '[{"title":"x"}]' + run bash "$MATCH" "${TT_TMP}/no-such-file.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 64 ] +} + +@test "match: missing proposals file causes usage error" { + build_signals '[]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/no-such-file.json" + [ "$status" -eq 64 ] +} + +@test "match: malformed proposal entries are skipped, valid ones processed" { + build_signals '[]' + build_proposals '[{"title":"valid"},"garbage",{"no_title":"x"},{"title":"also valid"}]' + run --separate-stderr bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$new" = "2" ] +} + +@test "match: proposals as empty array yields empty result" { + build_signals '[{"id":"D_1","number":1,"title":"x"}]' + build_proposals '[]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$matched" = "0" ] + [ "$new" = "0" ] +} + +@test "match: idempotent re-run of same proposals against existing matches yields all-matched" { + # Simulates the R6 idempotency case: run 1 created Discussions, run 2 + # finds them and should NOT propose duplicates. + build_signals '[ + {"id":"D_1","number":1,"title":"💡 Streaming voice responses"}, + {"id":"D_2","number":2,"title":"💡 Dark mode"} + ]' + build_proposals '[ + {"title":"💡 Streaming voice responses"}, + {"title":"💡 Dark mode"} + ]' + run bash "$MATCH" "${TT_TMP}/signals.json" "${TT_TMP}/proposals.json" + [ "$status" -eq 0 ] + matched=$(printf '%s' "$output" | jq '.matched | length') + new=$(printf '%s' "$output" | jq '.new_candidates | length') + [ "$matched" = "2" ] + [ "$new" = "0" ] +} diff --git a/test/workflows/feature-ideation/signals-schema.bats b/test/workflows/feature-ideation/signals-schema.bats new file mode 100644 index 00000000..a416b8f7 --- /dev/null +++ b/test/workflows/feature-ideation/signals-schema.bats @@ -0,0 +1,52 @@ +#!/usr/bin/env bats +# Validates fixture signals.json files against the schema. +# +# Kills R3: ensures the producer/consumer contract is enforced in CI +# rather than discovered when Mary's prompt parses an unexpected shape. + +load 'helpers/setup' + +VALIDATOR="${TT_REPO_ROOT}/.github/scripts/feature-ideation/validate-signals.py" +SCHEMA="${TT_REPO_ROOT}/.github/schemas/signals.schema.json" +FIX="${TT_FIXTURES_DIR}/expected" + +@test "schema: validator script exists and is executable" { + [ -f "$VALIDATOR" ] + [ -r "$VALIDATOR" ] +} + +@test "schema: schema file is valid JSON" { + jq -e . "$SCHEMA" >/dev/null +} + +@test "schema: empty-repo fixture passes" { + run python3 "$VALIDATOR" "${FIX}/empty-repo.signals.json" "$SCHEMA" + [ "$status" -eq 0 ] +} + +@test "schema: populated fixture passes" { + run python3 "$VALIDATOR" "${FIX}/populated.signals.json" "$SCHEMA" + [ "$status" -eq 0 ] +} + +@test "schema: truncated fixture passes" { + run python3 "$VALIDATOR" "${FIX}/truncated.signals.json" "$SCHEMA" + [ "$status" -eq 0 ] +} + +@test "schema: missing required field FAILS validation" { + run python3 "$VALIDATOR" "${FIX}/INVALID-missing-field.signals.json" "$SCHEMA" + [ "$status" -eq 1 ] +} + +@test "schema: malformed repo string FAILS validation" { + run python3 "$VALIDATOR" "${FIX}/INVALID-bad-repo.signals.json" "$SCHEMA" + [ "$status" -eq 1 ] +} + +@test "schema: extra top-level field FAILS validation" { + bad_file="${BATS_TEST_TMPDIR}/extra-field.json" + jq '. + {unexpected: "value"}' "${FIX}/empty-repo.signals.json" >"$bad_file" + run python3 "$VALIDATOR" "$bad_file" "$SCHEMA" + [ "$status" -eq 1 ] +} diff --git a/test/workflows/feature-ideation/stubs/gh b/test/workflows/feature-ideation/stubs/gh new file mode 100755 index 00000000..158b6655 --- /dev/null +++ b/test/workflows/feature-ideation/stubs/gh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Fake `gh` binary used by feature-ideation bats tests. +# +# Behavior is fully driven by env vars so individual tests can script +# arbitrary success / failure / paginated scenarios without per-fixture +# stub variants. +# +# Recognized vars: +# GH_STUB_STDOUT — literal text or @file:/abs/path to load from disk +# GH_STUB_STDERR — literal text or @file:/abs/path +# GH_STUB_EXIT — exit code (default 0) +# GH_STUB_LOG — append-only log of argv invocations (one TSV line each) +# +# For multi-call tests (where the same stub is invoked multiple times with +# different responses), set GH_STUB_SCRIPT to a file containing one +# `EXIT\tSTDOUT_PATH\tSTDERR_PATH` line per expected call. The stub +# advances a counter file in $TT_TMP/.gh-stub-counter on each invocation. + +set -euo pipefail + +if [ -n "${GH_STUB_LOG:-}" ]; then + printf '%s\n' "$*" >>"$GH_STUB_LOG" +fi + +# Multi-call mode. +if [ -n "${GH_STUB_SCRIPT:-}" ] && [ -f "${GH_STUB_SCRIPT}" ]; then + counter_file="${TT_TMP:-/tmp}/.gh-stub-counter" + count=0 + if [ -f "$counter_file" ]; then + count=$(cat "$counter_file") + fi + next=$((count + 1)) + printf '%s' "$next" >"$counter_file" + + line=$(sed -n "${next}p" "$GH_STUB_SCRIPT") + if [ -z "$line" ]; then + printf '[gh-stub] no script entry for call #%d\n' "$next" >&2 + exit 99 + fi + exit_code=$(printf '%s' "$line" | awk -F'\t' '{print $1}') + stdout_path=$(printf '%s' "$line" | awk -F'\t' '{print $2}') + stderr_path=$(printf '%s' "$line" | awk -F'\t' '{print $3}') + + if [ -n "$stdout_path" ] && [ "$stdout_path" != "-" ]; then + cat "$stdout_path" + fi + if [ -n "$stderr_path" ] && [ "$stderr_path" != "-" ]; then + cat "$stderr_path" >&2 + fi + exit "$exit_code" +fi + +# Single-call mode. +emit() { + local var="$1" + local stream="$2" + local val="${!var:-}" + if [ -z "$val" ]; then + return 0 + fi + if [[ "$val" == @file:* ]]; then + cat "${val#@file:}" >&"$stream" + else + printf '%s' "$val" >&"$stream" + fi +} + +emit GH_STUB_STDOUT 1 +emit GH_STUB_STDERR 2 + +exit "${GH_STUB_EXIT:-0}"