Merge branch 'master' into auto-issue-4291-1965bd

This commit is contained in:
JJ Liebig
2026-11-03 12:36:13 +01:00
committed by GitHub
10 changed files with 289 additions and 19 deletions
+233
View File
@@ -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 = '<!-- herdr:stale-close -->';
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);
});
}
+46
View File
@@ -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();
@@ -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.
+2 -2
View File
@@ -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
@@ -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 ソケットと安全に通信できることを意味します。
@@ -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 のコンセプト、セットアップ、設定、よくある問題の解決方法を教えるので、エージェントの回答が即興ではなく正確なままになります。
エージェントは即興ではなく、実際のソースとドキュメントを読んで答えます。
## コアガイド
@@ -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 通信。
@@ -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 的概念、安装、配置和常见问题的修复方法,让它的回答保持准确,而不是即兴发挥。
你的智能体会阅读真实的源码和文档来回答,而不是即兴发挥。
## 核心指南
+3 -5
View File
@@ -46,12 +46,10 @@ const TERMINAL_SESSION_CONTROL_USAGE: &str =
"usage: herdr terminal session control <target> [--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>), String> {
+1 -2
View File
@@ -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),