diff --git a/.github/scripts/stale-close.cjs b/.github/scripts/stale-close.cjs new file mode 100644 index 000000000..f906a59d5 --- /dev/null +++ b/.github/scripts/stale-close.cjs @@ -0,0 +1,233 @@ +// Closes stale issues that never received outside engagement. +// +// Used by .github/workflows/stale-close.yml through actions/github-script, and +// runnable locally as a read-only dry run: +// +// node .github/scripts/stale-close.cjs [--repo owner/name] [--out report.md] [--min-age-days 30] +// +// Locally the token comes from GITHUB_TOKEN or `gh auth token`. The local +// runner never writes to GitHub. + +const MARKER = ''; +const EXTRA_TEAM = ['akbash-bot', 'kangal-bot']; +const PAGE_SIZE = 25; +const CLOSE_COMMENT = [ + MARKER, + "We weren't able to reproduce this, and it hasn't had further reports or activity, so it may already be fixed or no longer relevant. Closing as stale.", + '', + 'If you still see this on the latest release, please open a new issue with current details.', +].join('\n'); + +const SKIP_REASONS = { + reactions: 'has reactions', + outside_comment: 'outside comment', + linked_pr: 'linked open or merged PR', + too_many_comments: 'too many comments to evaluate (>100)', +}; + +const ISSUES_QUERY = ` +query($owner: String!, $repo: String!, $cursor: String, $pageSize: Int!) { + repository(owner: $owner, name: $repo) { + issues(states: OPEN, first: $pageSize, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + id + number + title + url + createdAt + author { login __typename } + labels(first: 20) { nodes { name } } + reactions { totalCount } + comments(first: 100) { + totalCount + nodes { author { login __typename } } + } + closedByPullRequestsReferences(first: 10, includeClosedPrs: false) { + nodes { number state } + } + timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + nodes { + __typename + ... on CrossReferencedEvent { source { __typename ... on PullRequest { number state } } } + ... on ConnectedEvent { subject { __typename ... on PullRequest { number state } } } + } + } + } + } + } + rateLimit { cost remaining } +}`; + +const MAINTAINERS_QUERY = ` +query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + object(expression: "HEAD:.github/MAINTAINERS") { ... on Blob { text } } + } +}`; + +const CLOSE_MUTATION = ` +mutation($id: ID!, $body: String!) { + addComment(input: {subjectId: $id, body: $body}) { clientMutationId } + closeIssue(input: {issueId: $id, stateReason: NOT_PLANNED}) { issue { number state } } +}`; + +function parseUserList(content) { + return content + .split('\n') + .map(line => line.trim().toLowerCase()) + .filter(line => line && !line.startsWith('#')); +} + +function isTeam(team, author) { + if (!author) return false; + const login = author.login.toLowerCase(); + return author.__typename === 'Bot' || login.endsWith('[bot]') || team.has(login); +} + +function classify(issue, team) { + if (issue.reactions.totalCount > 0) return 'reactions'; + if (issue.comments.totalCount > issue.comments.nodes.length) return 'too_many_comments'; + const authorLogin = issue.author?.login.toLowerCase(); + const outside = issue.comments.nodes.some(comment => + !isTeam(team, comment.author) && + !(comment.author && comment.author.login.toLowerCase() === authorLogin)); + if (outside) return 'outside_comment'; + const prs = [ + ...issue.closedByPullRequestsReferences.nodes, + ...issue.timelineItems.nodes.map(item => item.source ?? item.subject), + ].filter(pr => pr && pr.state); + if (prs.some(pr => pr.state === 'OPEN' || pr.state === 'MERGED')) return 'linked_pr'; + return null; +} + +function escapeCell(text) { + return String(text).replace(/\|/g, '\\|').replace(/\n/g, ' '); +} + +function renderReport({ repo, dryRun, minAgeDays, candidates, wouldClose, skipped, team }) { + const verb = dryRun ? 'Would close' : 'Closed'; + const lines = [ + `# Stale issue close (${dryRun ? 'dry run' : 'live'})`, + '', + `Repository: \`${repo}\` · minimum age: ${minAgeDays} days · team: ${[...team].sort().join(', ')} + bots`, + '', + `- Candidates (open issues older than ${minAgeDays} days): ${candidates}`, + `- ${verb}: ${wouldClose.length}`, + ]; + for (const [key, label] of Object.entries(SKIP_REASONS)) { + lines.push(`- Skipped, ${label}: ${skipped[key]}`); + } + lines.push('', `## ${verb}`, ''); + if (wouldClose.length === 0) { + lines.push('None.'); + } else { + lines.push('| # | Title | Age (days) | Labels | Author |', '|---|---|---|---|---|'); + for (const issue of wouldClose) { + lines.push(`| [#${issue.number}](${issue.url}) | ${escapeCell(issue.title)} | ${issue.ageDays} | ${escapeCell(issue.labels.join(', '))} | ${escapeCell(issue.author)} |`); + } + } + return lines.join('\n') + '\n'; +} + +async function run({ graphql, owner, repo, dryRun, minAgeDays = 30, log = console.log, now = Date.now() }) { + const maintainers = await graphql(MAINTAINERS_QUERY, { owner, repo }); + const text = maintainers.repository.object?.text; + if (typeof text !== 'string') throw new Error('Could not read .github/MAINTAINERS'); + const team = new Set([...parseUserList(text), ...EXTRA_TEAM]); + + const cutoff = now - minAgeDays * 24 * 60 * 60 * 1000; + const wouldClose = []; + const skipped = Object.fromEntries(Object.keys(SKIP_REASONS).map(key => [key, 0])); + let candidates = 0; + let cursor = null; + let done = false; + + while (!done) { + const data = await graphql(ISSUES_QUERY, { owner, repo, cursor, pageSize: PAGE_SIZE }); + const { nodes, pageInfo } = data.repository.issues; + for (const issue of nodes) { + const created = Date.parse(issue.createdAt); + if (created >= cutoff) { + done = true; + break; + } + candidates += 1; + const reason = classify(issue, team); + if (reason) { + skipped[reason] += 1; + continue; + } + wouldClose.push({ + id: issue.id, + number: issue.number, + title: issue.title, + url: issue.url, + ageDays: Math.floor((now - created) / (24 * 60 * 60 * 1000)), + labels: issue.labels.nodes.map(label => label.name), + author: issue.author?.login ?? 'ghost', + }); + } + if (!pageInfo.hasNextPage) done = true; + cursor = pageInfo.endCursor; + } + + if (!dryRun) { + for (const issue of wouldClose) { + await graphql(CLOSE_MUTATION, { id: issue.id, body: CLOSE_COMMENT }); + log(`Closed #${issue.number}`); + } + } + + log(`candidates=${candidates} close=${wouldClose.length} skipped=${JSON.stringify(skipped)} dryRun=${dryRun}`); + const report = renderReport({ + repo: `${owner}/${repo}`, dryRun, minAgeDays, candidates, wouldClose, skipped, team, + }); + return { candidates, wouldClose, skipped, report }; +} + +module.exports = { run, classify, parseUserList, CLOSE_COMMENT, MARKER }; + +async function main() { + const { execFileSync } = require('node:child_process'); + const fs = require('node:fs'); + const args = process.argv.slice(2); + const option = (name, fallback) => { + const index = args.indexOf(name); + return index === -1 ? fallback : args[index + 1]; + }; + const [owner, repo] = option('--repo', 'herdrdev/herdr').split('/'); + const out = option('--out', null); + const minAgeDays = Number(option('--min-age-days', '30')); + const token = process.env.GITHUB_TOKEN || + execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); + + const graphql = async (query, variables) => { + if (query.trimStart().startsWith('mutation')) { + throw new Error('Local runner is read-only'); + } + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + const body = await response.json(); + if (!response.ok || body.errors) { + throw new Error(`GraphQL error: ${JSON.stringify(body.errors ?? body)}`); + } + return body.data; + }; + + const result = await run({ + graphql, owner, repo, dryRun: true, minAgeDays, log: msg => console.error(msg), + }); + if (out) fs.writeFileSync(out, result.report); + else process.stdout.write(result.report); +} + +if (require.main === module) { + main().catch(error => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/workflows/stale-close.yml b/.github/workflows/stale-close.yml new file mode 100644 index 000000000..13ce50e4a --- /dev/null +++ b/.github/workflows/stale-close.yml @@ -0,0 +1,46 @@ +name: Stale Issue Close + +on: + schedule: + - cron: '0 6 1 * *' + workflow_dispatch: + inputs: + dry_run: + description: Report what would close without commenting or closing + type: boolean + default: true + +concurrency: + group: stale-close + cancel-in-progress: false + +jobs: + stale-close: + if: github.repository == 'herdrdev/herdr' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Close stale issues without outside engagement + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + # Scheduled runs close for real. Manual runs close only when dry_run is unchecked. + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + with: + github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }} + script: | + const { run } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/stale-close.cjs`); + const result = await run({ + graphql: github.graphql, + owner: context.repo.owner, + repo: context.repo.repo, + dryRun: process.env.DRY_RUN !== 'false', + log: message => core.info(message), + }); + await core.summary.addRaw(result.report).write(); diff --git a/docs/next/website/src/content/docs/agent-skill.mdx b/docs/next/website/src/content/docs/agent-skill.mdx index d73832ac2..44393feac 100644 --- a/docs/next/website/src/content/docs/agent-skill.mdx +++ b/docs/next/website/src/content/docs/agent-skill.mdx @@ -7,8 +7,6 @@ Herdr ships a reusable agent skill file at [`skills/herdr/SKILL.md`](https://git Install that file into any coding agent that supports reusable skills or custom instructions. The skill teaches the agent how to control Herdr from inside a Herdr pane. -Herdr also serves a separate guide at [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md) for a different job: an agent helping a human learn, set up, or troubleshoot Herdr. The skill is for an agent operating Herdr; the guide is for an agent teaching a human. - ## What the skill does The skill tells an agent to use the `herdr` CLI when `HERDR_ENV=1` is set. That means the agent is running inside a Herdr-managed pane and can safely talk to the local Herdr socket. diff --git a/docs/next/website/src/content/docs/index.mdx b/docs/next/website/src/content/docs/index.mdx index 09159fa3b..0aa98fabd 100644 --- a/docs/next/website/src/content/docs/index.mdx +++ b/docs/next/website/src/content/docs/index.mdx @@ -36,10 +36,10 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; If you already run an AI coding agent, let it handle the onboarding. Paste this prompt: ```text -Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +Help me understand and set up Herdr. Its source and docs are at https://github.com/herdrdev/herdr. Read what you need there, then walk me through it step by step. ``` -The guide covers Herdr's concepts, setup, configuration, and common fixes, so your agent can answer from the docs instead of improvising. +Your agent answers from the real source and docs instead of improvising. ## Core guides diff --git a/docs/next/website/src/content/docs/ja/agent-skill.mdx b/docs/next/website/src/content/docs/ja/agent-skill.mdx index 15120dfaa..a6e0dd472 100644 --- a/docs/next/website/src/content/docs/ja/agent-skill.mdx +++ b/docs/next/website/src/content/docs/ja/agent-skill.mdx @@ -7,8 +7,6 @@ Herdr は再利用可能なエージェントスキルファイルを [`skills/h このファイルを、再利用可能なスキルやカスタム指示に対応した任意のコーディングエージェントにインストールしてください。このスキルは、Herdr のペイン内から Herdr を制御する方法をエージェントに教えます。 -Herdr は別の用途向けに [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md) というガイドも提供しています。こちらは、人間が Herdr を学習・セットアップ・トラブルシューティングするのをエージェントが手伝うためのものです。スキルは Herdr を操作するエージェントのため、ガイドは人間に教えるエージェントのためのものです。 - ## スキルがすること このスキルは、`HERDR_ENV=1` が設定されているときに `herdr` CLI を使うようエージェントに指示します。これは、エージェントが Herdr 管理下のペイン内で動作しており、ローカルの Herdr ソケットと安全に通信できることを意味します。 diff --git a/docs/next/website/src/content/docs/ja/index.mdx b/docs/next/website/src/content/docs/ja/index.mdx index ea50b0d73..dbe3615d1 100644 --- a/docs/next/website/src/content/docs/ja/index.mdx +++ b/docs/next/website/src/content/docs/ja/index.mdx @@ -36,10 +36,10 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; すでに AI コーディングエージェントを使っていますか? オンボーディングはエージェントに任せましょう。このプロンプトを貼り付けてください: ```text -Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +Help me understand and set up Herdr. Its source and docs are at https://github.com/herdrdev/herdr. Read what you need there, then walk me through it step by step. ``` -このガイドはエージェントに Herdr のコンセプト、セットアップ、設定、よくある問題の解決方法を教えるので、エージェントの回答が即興ではなく正確なままになります。 +エージェントは即興ではなく、実際のソースとドキュメントを読んで答えます。 ## コアガイド diff --git a/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx b/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx index 6ce065f4c..416aa2bbc 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx @@ -7,8 +7,6 @@ Herdr 提供了一个可复用的智能体技能文件: [`skills/herdr/SKILL.md` 把这个文件安装到任何支持可复用技能或自定义指令的编程智能体中。这份技能教智能体如何在 Herdr 窗格内部控制 Herdr。 -Herdr 还提供了另一份用途不同的指南: [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md),用于让智能体帮助人类学习、安装或排查 Herdr。技能是给操作 Herdr 的智能体用的;指南是给教人类的智能体用的。 - ## 这份技能做什么 这份技能告诉智能体: 当 `HERDR_ENV=1` 已设置时,使用 `herdr` CLI。这意味着智能体运行在 Herdr 管理的窗格内,可以安全地与本地 Herdr socket 通信。 diff --git a/docs/next/website/src/content/docs/zh-cn/index.mdx b/docs/next/website/src/content/docs/zh-cn/index.mdx index 8e5e1b0f9..a442e7d2e 100644 --- a/docs/next/website/src/content/docs/zh-cn/index.mdx +++ b/docs/next/website/src/content/docs/zh-cn/index.mdx @@ -36,10 +36,10 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; 已经在用 AI 编程智能体?让它来帮你完成入门。粘贴这个提示词: ```text -Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +Help me understand and set up Herdr. Its source and docs are at https://github.com/herdrdev/herdr. Read what you need there, then walk me through it step by step. ``` -这份指南会教你的智能体 Herdr 的概念、安装、配置和常见问题的修复方法,让它的回答保持准确,而不是即兴发挥。 +你的智能体会阅读真实的源码和文档来回答,而不是即兴发挥。 ## 核心指南 diff --git a/src/cli.rs b/src/cli.rs index 0efac7e35..1bb33c520 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -46,12 +46,10 @@ const TERMINAL_SESSION_CONTROL_USAGE: &str = "usage: herdr terminal session control [--takeover] [--cols N] [--rows N]"; pub(crate) const AGENT_HELP_FOOTER: &str = concat!( "Are you an AI? Use these resources ONLY IF your task specifically asks you to:\n", - " Help a human understand or set up Herdr for the first time:\n", - " https://herdr.dev/agent-guide.md\n", - " Debug or investigate a problem with Herdr:\n", - " https://herdr.dev/llms.txt\n", " Control Herdr panes, agents, or workspaces:\n", - " SKIP if a Herdr skill is already in your context. Otherwise run: herdr --skill", + " SKIP if a Herdr skill is already in your context. Otherwise run: herdr --skill\n", + " Help a human understand or set up Herdr, or debug a problem with it:\n", + " read the source and docs: https://github.com/herdrdev/herdr", ); pub(crate) fn parse_token_assignment(raw: &str) -> Result<(String, Option), String> { diff --git a/tests/cli/surface.rs b/tests/cli/surface.rs index 1c7f617a3..9c87ce271 100644 --- a/tests/cli/surface.rs +++ b/tests/cli/surface.rs @@ -287,9 +287,8 @@ fn root_and_command_group_help_point_agents_to_plain_text_docs() { let stdout = String::from_utf8_lossy(&output.stdout); for expected in [ "Are you an AI? Use these resources ONLY IF your task specifically asks you to:", - "https://herdr.dev/agent-guide.md", - "https://herdr.dev/llms.txt", "herdr --skill", + "https://github.com/herdrdev/herdr", ] { assert!( stdout.contains(expected),