Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions .github/workflows/idea-enhancer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ on:
- cron: '0 7 * * 3' # Wednesday 07:00 UTC — safety net between ideation (Fri) and triage (Mon)
workflow_dispatch:
inputs:
target_repo:
description: 'Target repo (owner/repo) to enhance FOR — read its Ideas Discussions and post the enhancement comments there. Empty => this repo (the dogfood/self path).'
required: false
default: ''
type: string
dry_run:
description: 'Log intended enhancement comments to an artifact instead of posting them'
required: false
Expand All @@ -44,7 +49,10 @@ on:
permissions: {}

concurrency:
group: idea-enhancer
# One enhancement pass per target repo at a time; never cancel an in-flight run.
# Keying on the target repo stops two fleet repos from colliding on a single
# global lock (same convention as initiative-planner.yml).
group: idea-enhancer-${{ github.event.inputs.target_repo || github.repository }}
cancel-in-progress: false

jobs:
Expand All @@ -56,7 +64,15 @@ jobs:
discussions: write # read the Ideas backlog + post enhancement comments
id-token: write # claude-code-action OIDC
env:
REPO: ${{ github.repository }}
# REPO is the TARGET repo to enhance for; empty target_repo => this repo (self).
# The `discussion` trigger carries no inputs, so it always resolves to self.
REPO: ${{ github.event.inputs.target_repo || github.repository }}
# Token for every WRITE to the TARGET repo (gather reads + the enhancement
# comment). GITHUB_TOKEN is scoped only to .github-private, so it is valid for
# the self path only; cross-repo runs use GH_PAT_WORKFLOWS (same pattern as
# initiative-planner.yml). S1 (#818) will replace this with a minted GitHub
# App token for tighter scoping.
TARGET_GH_TOKEN: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_WORKFLOWS }}
DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
# Empty for schedule/dispatch (scan all); the created discussion's number
# for the `discussion` trigger (enhance just that one, immediately).
Expand All @@ -75,24 +91,29 @@ jobs:
- name: Gather enhancement candidates
id: gather
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Reads the TARGET repo's Ideas Discussions.
GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}
run: |
bash scripts/idea-enhancer/gather-candidates.sh
echo "count=$(jq '.candidates | length' "$CONTEXT_PATH")" >> "$GITHUB_OUTPUT"

- name: Enhance each candidate idea
if: ${{ steps.gather.outputs.count != '0' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}
uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# CRITICAL (same gotcha as initiative-planner): pass the target-repo
# write token explicitly so the enhancement comment posts on the TARGET
# repo. The action's own app token lacks them and the write would fail
# silently.
github_token: ${{ env.TARGET_GH_TOKEN }}
claude_args: |
--model ${{ inputs.model || 'claude-sonnet-4-6' }}
--allowedTools Bash,Read,Glob,Grep
prompt: |
You are the **idea-enhancer analyst** for the ${{ github.repository }} repo.
You are the **idea-enhancer analyst** for the ${{ env.REPO }} repo.
Your job: for each candidate Idea Discussion, post exactly ONE comment that
makes the idea more actionable. You do NOT approve, label, or promote
anything — that stays with a human and the triage/planner pipeline.
Expand Down Expand Up @@ -132,7 +153,7 @@ jobs:
`$DRY_RUN`):

```bash
REPO="${{ github.repository }}" DISCUSSION_NUMBER="<number>" \
REPO="$REPO" DISCUSSION_NUMBER="<number>" \
BODY_PATH="<your-draft-file>" \
bash scripts/idea-enhancer/post-enhancement.sh
```
Expand Down
34 changes: 27 additions & 7 deletions .github/workflows/idea-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ on:
- cron: '0 13 * * 1' # Monday 13:00 UTC (after the Fri ideation run lands)
workflow_dispatch:
inputs:
target_repo:
description: 'Target repo (owner/repo) to triage FOR — read its Ideas backlog and refresh its Idea Promotion Queue issue. Empty => this repo (the dogfood/self path).'
required: false
default: ''
type: string
dry_run:
description: 'Log the intended queue update to an artifact instead of writing the tracking issue'
required: false
Expand All @@ -36,7 +41,10 @@ on:
permissions: {}

concurrency:
group: idea-triage
# One triage pass per target repo at a time; never cancel an in-flight run.
# Keying on the target repo stops two fleet repos from colliding on a single
# global lock (same convention as initiative-planner.yml).
group: idea-triage-${{ github.event.inputs.target_repo || github.repository }}
cancel-in-progress: false

jobs:
Expand All @@ -49,7 +57,14 @@ jobs:
discussions: read # read the Ideas backlog
id-token: write # claude-code-action OIDC
env:
REPO: ${{ github.repository }}
# REPO is the TARGET repo to triage for; empty target_repo => this repo (self).
REPO: ${{ github.event.inputs.target_repo || github.repository }}
# Token for every WRITE to the TARGET repo (gather reads + the queue-issue
# upsert). GITHUB_TOKEN is scoped only to .github-private, so it is valid for
# the self path only; cross-repo runs use GH_PAT_WORKFLOWS (same pattern as
# initiative-planner.yml). S1 (#818) will replace this with a minted GitHub
# App token for tighter scoping.
TARGET_GH_TOKEN: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_WORKFLOWS }}
DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
steps:
- name: Checkout agent repo
Expand All @@ -65,21 +80,26 @@ jobs:

- name: Gather Ideas backlog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Reads the TARGET repo's Ideas backlog + open epics.
GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}
run: bash scripts/idea-triage/gather-ideas.sh

- name: Rank ripeness and refresh the queue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}
uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# CRITICAL (same gotcha as initiative-planner): pass the target-repo
# write token explicitly so the queue-issue upsert applies on the TARGET
# repo. The action's own app token lacks them and the write would fail
# silently.
github_token: ${{ env.TARGET_GH_TOKEN }}
claude_args: |
--model ${{ inputs.model || 'claude-sonnet-4-6' }}
--allowedTools Bash,Read,Glob,Grep
prompt: |
You are an **idea-triage analyst** for the ${{ github.repository }} repo.
You are an **idea-triage analyst** for the ${{ env.REPO }} repo.
Your job: decide which open Ideas are RIPE to promote into a tracked
initiative, and refresh the promotion-queue tracking issue. You do NOT
approve anything — a human reads your queue and adds `idea:approved`.
Expand Down Expand Up @@ -131,7 +151,7 @@ jobs:
Upsert the single tracking issue (idempotent; honors `$DRY_RUN`):

```bash
REPO="${{ github.repository }}" QUEUE_BODY_PATH="$QUEUE_BODY_PATH" \
REPO="$REPO" QUEUE_BODY_PATH="$QUEUE_BODY_PATH" \
bash scripts/idea-triage/upsert-queue.sh
```

Expand Down
71 changes: 71 additions & 0 deletions tests/test_idea_enhancer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,74 @@ EOF
[ "$status" -ne 0 ]
[[ "$output" == *"owner/repo"* ]]
}

# ── cross-repo target_repo parameterization (#821) ────────────────────────────
# The central enhancer workflow can run for a NON-self target repo: candidates
# are gathered FROM, and the enhancement comment is posted IN, that target repo.
# The scripts already take REPO; these assert REPO threads through to every
# gh/GraphQL call for a target repo that is NOT .github-private. gh is stubbed so
# no network is touched.

# A logging `gh` mock that records every invocation and serves the bulk-scan
# discussions query (one fresh human-authored Ideas candidate).
_install_logging_gh_mock() {
cat > "$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
if [[ "$*" == *"addDiscussionComment"* ]]; then
echo 'https://github.com/acme/widgets/discussions/101#comment'
elif [[ "$*" == *"discussion(number:"* ]]; then
echo '{"data":{"repository":{"discussion":{"id":"D_target"}}}}'
else
cat <<'JSON'
{
"data": { "repository": { "discussions": {
"pageInfo": { "hasNextPage": false, "endCursor": "" },
"nodes": [
{ "number": 101, "title": "Human idea A", "url": "u/101",
"createdAt": "2026-06-01T00:00:00Z", "updatedAt": "2026-06-01T00:00:00Z",
"closed": false, "category": { "name": "Ideas" },
"author": { "login": "alice", "__typename": "User" },
"labels": { "nodes": [] },
"comments": { "totalCount": 0, "nodes": [] },
"bodyText": "A plain human idea." }
]
} } }
}
JSON
fi
EOF
chmod +x "$MOCK_BIN/gh"
}

@test "gather-candidates gathers candidates FROM the target repo (non-self)" {
GH_LOG="$TMP/gh.log"
export GH_LOG
_install_logging_gh_mock
REPO="acme/widgets" CONTEXT_PATH="$CONTEXT" run bash "$ENH_DIR/gather-candidates.sh"
[ "$status" -eq 0 ]
# GraphQL discussions query targets the target repo's owner/name.
grep -qF -- "owner=acme" "$GH_LOG"
grep -qF -- "name=widgets" "$GH_LOG"
! grep -qF -- ".github-private" "$GH_LOG"
# The assembled context records the target repo and keeps the one candidate.
[ "$(jq -r '.repo' "$CONTEXT")" = "acme/widgets" ]
[ "$(jq '.candidates | length' "$CONTEXT")" -eq 1 ]
}

@test "post-enhancement comments on the discussion IN the target repo (non-self, live)" {
GH_LOG="$TMP/gh.log"
export GH_LOG
_install_logging_gh_mock
BODY="$TMP/body.md"
printf 'A concrete enhancement to your idea.' > "$BODY"
# Live path (no DRY_RUN): resolve the discussion id then post the comment, both
# against the TARGET repo's owner/name.
REPO="acme/widgets" DISCUSSION_NUMBER="101" BODY_PATH="$BODY" \
run bash "$ENH_DIR/post-enhancement.sh"
[ "$status" -eq 0 ]
grep -qF -- "owner=acme" "$GH_LOG"
grep -qF -- "name=widgets" "$GH_LOG"
grep -qF -- "addDiscussionComment" "$GH_LOG"
! grep -qF -- ".github-private" "$GH_LOG"
}
64 changes: 64 additions & 0 deletions tests/test_idea_triage.bats
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,67 @@ teardown() { rm -rf "$TMP"; }
[ "$status" -ne 0 ]
[[ "$output" == *"missing or empty"* ]]
}

# ── cross-repo target_repo parameterization (#821) ────────────────────────────
# The central triage workflow can run for a NON-self target repo: the Ideas
# backlog is gathered FROM, and the "Idea Promotion Queue" tracking issue is
# created IN, that target repo. The scripts already take REPO; these assert REPO
# threads through to every gh/GraphQL call for a target repo that is NOT
# .github-private. gh is stubbed so no network is touched.

@test "gather-ideas gathers the backlog FROM the target repo (non-self)" {
MOCK_BIN="$TMP/mock_bin_gather"
mkdir -p "$MOCK_BIN"
GH_LOG="$TMP/gh.log"
export GH_LOG
export PATH="$MOCK_BIN:$PATH"
cat >"$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
case "$*" in
*graphql*) echo '{"data":{"repository":{"discussions":{"pageInfo":{"hasNextPage":false,"endCursor":""},"nodes":[]}}}}' ;;
*"issue list"*) echo '[]' ;;
*) echo '{}' ;;
esac
EOF
chmod +x "$MOCK_BIN/gh"

CTX="$TMP/context.json"
REPO="acme/widgets" CONTEXT_PATH="$CTX" GH_TOKEN=x \
run bash "$TRIAGE_DIR/gather-ideas.sh"
[ "$status" -eq 0 ]
# GraphQL discussions query targets the target repo's owner/name.
grep -qF -- "owner=acme" "$GH_LOG"
grep -qF -- "name=widgets" "$GH_LOG"
# Open-epics lookup runs against the target repo, not .github-private.
grep -qF -- "--repo acme/widgets" "$GH_LOG"
! grep -qF -- ".github-private" "$GH_LOG"
# The assembled context records the target repo.
[ "$(jq -r '.repo' "$CTX")" = "acme/widgets" ]
}

@test "upsert-queue creates the queue issue IN the target repo (non-self, live)" {
MOCK_BIN="$TMP/mock_bin_upsert"
mkdir -p "$MOCK_BIN"
GH_LOG="$TMP/gh.log"
export GH_LOG
export PATH="$MOCK_BIN:$PATH"
cat >"$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
case "$*" in
*"issue list"*) echo '' ;;
*"issue create"*) echo 'https://github.com/acme/widgets/issues/7' ;;
*) echo '' ;;
esac
EOF
chmod +x "$MOCK_BIN/gh"

# Live path (no DRY_RUN): create the queue issue + label in the TARGET repo.
REPO="acme/widgets" QUEUE_BODY_PATH="$BODY" GH_TOKEN=x \
run bash "$TRIAGE_DIR/upsert-queue.sh"
[ "$status" -eq 0 ]
grep -qF -- "issue create --repo acme/widgets" "$GH_LOG"
grep -qF -- "label create idea-triage --repo acme/widgets" "$GH_LOG"
! grep -qF -- ".github-private" "$GH_LOG"
}
Loading