Skip to content

Recovered: feat(agent): add Gemini CLI install target (#57 by @merlinsantiago982-cmd) #535

Recovered: feat(agent): add Gemini CLI install target (#57 by @merlinsantiago982-cmd)

Recovered: feat(agent): add Gemini CLI install target (#57 by @merlinsantiago982-cmd) #535

Workflow file for this run

# P1-2 issue auto-assign + 3-slot-cap bot (InsForge `agent-zhang-beihai` pattern).
# Posts as the `testsprite-hob` GitHub App ⇒ `testsprite-hob[bot]`.
#
# Setup (one-time, by an org admin):
# 1. Create a GitHub App named `testsprite-hob` (org Settings → Developer settings →
# GitHub Apps → New). Permissions: Issues = Read & write, Metadata = Read. No webhook.
# Generate a private key; install the App on the public testsprite-cli repo.
# 2. Add two repo (or org) secrets:
# TESTSPRITE_HOB_APP_ID = the App's numeric App ID
# TESTSPRITE_HOB_PRIVATE_KEY = the App's .pem private key (full contents)
# (The ALFHEIM_AGENT_* fallbacks are this App's original secret names from
# before it was renamed — same App ID + key; either naming works.)
# Until those exist, the bot gracefully falls back to github-actions[bot] (still works).
#
# Fires on each new issue comment; assigns the commenter when they claim an issue
# (e.g. "/assign" or "I'd like to work on this") and reports their open-assignment count.
name: Issue triage (auto-assign + slot cap)
on:
issue_comment:
types: [created]
permissions:
issues: write # assign/unassign + comment + (re)label; nothing else
env:
SLOT_CAP: '3'
# D2: the cap ships ARMED, NOT ENFORCED. report-only by default — assigns anyway,
# just surfaces slot usage. Flip to 'true' if an AI-PR flood appears.
ENFORCE_CAP: 'false'
jobs:
triage:
# issues only (issue_comment also fires on PRs), and never react to a bot's own
# comment — incl. our own testsprite-hob[bot], which (unlike GITHUB_TOKEN) would
# otherwise re-trigger this workflow. `type == 'Bot'` covers both bot identities.
if: ${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' }}
runs-on: ubuntu-latest
steps:
# Mint a token for the testsprite-hob App so comments post as testsprite-hob[bot].
# continue-on-error: until the App + secrets exist this no-ops and we fall back below.
- id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
# Scope the minted token to what this job uses (issues API only) instead
# of inheriting the App's full installation permissions.
permission-issues: write
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
with:
# App token when available (→ testsprite-hob[bot]); else the default
# GITHUB_TOKEN (→ github-actions[bot]). Either way the logic is identical.
github-token: ${{ steps.app-token.outputs.token || github.token }}
script: |
const cap = parseInt(process.env.SLOT_CAP, 10);
const enforce = process.env.ENFORCE_CAP === 'true';
const { owner, repo } = context.repo;
const issue = context.payload.issue.number;
const user = context.payload.comment.user.login;
const body = (context.payload.comment.body || '').toLowerCase();
const wantsAssign = /(^|\s)\/assign\b/.test(body)
|| /\bi('?d| would)? ?(like|want) to (work on|take|tackle) this\b/.test(body)
|| /\bcan i (work on|take|have|grab) this\b/.test(body)
|| /\bi'?ll (work on|take|tackle) this\b/.test(body);
const wantsUnassign = /(^|\s)\/unassign\b/.test(body)
|| /\bi (can'?t|cannot) (continue|work on this)\b/.test(body);
if (!wantsAssign && !wantsUnassign) return; // nothing to do
// List THIS user's currently-open assigned issues (exclude PRs).
const assigned = (await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'open', assignee: user, per_page: 100,
})).filter(i => !i.pull_request);
const list = assigned.length
? assigned.map(i => `- ${owner}/${repo}#${i.number} — ${i.title}`).join('\n')
: '_(none)_';
if (wantsUnassign) {
await github.rest.issues.removeAssignees({ owner, repo, issue_number: issue, assignees: [user] });
await github.rest.issues.createComment({ owner, repo, issue_number: issue,
body: `Unassigned @${user}. Thanks for the update — freeing this up for someone else.` });
return;
}
const already = assigned.some(i => i.number === issue);
const atCap = assigned.length >= cap;
if (atCap && enforce && !already) {
await github.rest.issues.createComment({ owner, repo, issue_number: issue,
body: `Thanks @${user}! You're at the ${cap}-issue limit, so I haven't assigned this one. `
+ `Please finish or \`/unassign\` one first, then comment again here.\n\n`
+ `Your open assigned issues (${assigned.length}/${cap}):\n${list}` });
return;
}
if (!already) {
await github.rest.issues.addAssignees({ owner, repo, issue_number: issue, assignees: [user] });
// label hygiene: move needs-triage → in-progress (best-effort)
try { await github.rest.issues.removeLabel({ owner, repo, issue_number: issue, name: 'needs-triage' }); } catch {}
try { await github.rest.issues.addLabels({ owner, repo, issue_number: issue, labels: ['in-progress'] }); } catch {}
}
const countNow = already ? assigned.length : assigned.length + 1;
const remaining = Math.max(0, cap - countNow);
const overCap = countNow > cap; // only possible in report-only mode
const slots = overCap
? `⚠️ You're now over the soft ${cap}-issue limit (${countNow}/${cap}). Consider finishing one before taking more.`
: `(${remaining} more slot${remaining === 1 ? '' : 's'} available.)`;
await github.rest.issues.createComment({ owner, repo, issue_number: issue,
body: `Assigned to @${user}. Thanks for taking this on. ${slots}\n\n`
+ `Your open assigned issues (${countNow}/${cap}):\n${list}` });