# GitHub Issue Maintainer

Labels GitHub issues, asks for missing repro, and emails a weekly digest.

- Install: `npx shadcn@latest add @evex/github-issue-maintainer`
- Category: coding
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-08-24
- Dependencies: @upstash/ratelimit@^2.0.8, @upstash/redis@^1.38.0, ai@^7.0.38, eve@^0.31.3, resend@^6.14.0, zod@4.3.6
- Web page: https://www.evex.sh/agents/github-issue-maintainer
- This document: https://www.evex.sh/agents/github-issue-maintainer.md

## Overview

GitHub Issue Maintainer is an eve agent that triages newly opened GitHub issues through a native GitHub App channel. When an issue opens, GitHub delivers the webhook to your deployed eve app at /eve/v1/github, and the agent applies a small label taxonomy, then asks for missing reproduction details when the report is thin.

It is adjacent to Code Reviewer in the same GitHub App and issue-comment world, but it never reviews pull requests and never publishes GitHub PR reviews. Its job stops at issue hygiene: labeling, clarifying incomplete reports, and a weekly open-issue digest email through Resend.

Built-in Upstash-backed rate limiting keeps public deployments safe, with stricter defaults for public repositories than private ones. The weekly digest is a schedule, not a review workflow: it lists open issues and emails maintainers a concise HTML summary.

## How it works

1. A newly opened issue triggers the GitHub channel onIssue hook, or a user mentions @github-issue-maintainer on an issue timeline comment matching GITHUB_APP_SLUG.
2. The channel rejects pull request conversations and review threads, then checks Upstash rate limits with short-circuiting cooldowns before the repository daily quota.
3. When allowed, the channel injects issue title, body, existing labels, bug_like, and thin-issue gap hints into the turn context for the zai/glm-5.2 model.
4. The agent loads the issue-triage skill when needed, then calls triage_issue exactly once with taxonomy labels and an optional repro-request comment for bug reports only, validated by Zod.
5. The channel claims publication in Redis, creates any missing taxonomy labels, applies labels through the GitHub Issues API, and posts the repro ask as an issue comment when requested. Failed publishes release the claim so retries can proceed.
6. Separately, the weekly-issue-digest schedule calls list_open_issues, compose_digest_html (HTML-escaped titles), preview_digest_email, and send_digest_email with confirmSend and an ISO-week idempotency key.

## Use cases

### Auto-label incoming bug and feature reports

Install the GitHub App on team repositories so every new issue gets a bug, feature, docs, question, or chore label without a human sorting the inbox first. Maintainers open the board already grouped by type.

### Ask for missing repro on thin reports

Drive-by issues that say it does not work get a short comment asking for reproduction steps, expected versus actual behavior, and environment details, so maintainers are not stuck guessing.

### Weekly open-issue digest for solo maintainers

Every Monday the schedule emails a digest of open issues grouped by needs attention, recently updated, and stale, so freelancers and small teams can scan the backlog from their inbox.

### Safe triage on public open-source repos

Upstash-backed limits default to 15 triages per public repository per day with per-issue and per-user cooldowns, so drive-by mentions cannot exhaust your model budget.

## Requirements

- `GITHUB_APP_ID`: The App ID of the GitHub App you create under GitHub Settings, Developer settings, GitHub Apps. The app needs Metadata read, Issues read/write, and Contents read. Do not grant Pull requests write.
- `GITHUB_APP_PRIVATE_KEY`: The PEM private key generated from the GitHub App settings page. When stored as a single-line variable, replace literal newlines with \n; eve normalizes that form at runtime.
- `GITHUB_WEBHOOK_SECRET`: A long random value set both in the GitHub App webhook configuration and in your deployment. A mismatch produces HTTP 401 responses at /eve/v1/github.
- `GITHUB_APP_SLUG`: The mention users type on GitHub, defaulting to github-issue-maintainer. It must match your GitHub App name so mentions trigger the agent.
- `GITHUB_APP_INSTALLATION_ID`: The installation id for the repository used by the weekly digest list_open_issues tool. Required for scheduled digest API reads.
- `KV_REST_API_URL`: Upstash Redis REST endpoint used by @upstash/ratelimit for cooldowns, daily quotas, and duplicate-publication claims. Provision it via Upstash or the Vercel Redis Marketplace integration.
- `KV_REST_API_TOKEN`: The matching Upstash REST token. Use a read-write token, not the read-only one, because the agent writes cooldown and triage publication keys.
- `ISSUE_DIGEST_REPO`: owner/repo for the weekly open-issue digest. The schedule lists open issues from this repository only.
- `ISSUE_DIGEST_FROM`: Verified Resend sender address used for the weekly digest email.
- `ISSUE_DIGEST_TO`: Comma-separated recipient list for the weekly digest email.
- `RESEND_API_KEY`: Resend API key used by send_digest_email after preview_digest_email confirms the payload.
- `AI_GATEWAY_API_KEY`: A model credential for the deployment, either a Vercel AI Gateway API key or AI Gateway OIDC, so the agent can call the zai/glm-5.2 model.

## FAQ

### How do I install and trigger it?

Run npx shadcn@latest add @evex/github-issue-maintainer inside an eve app, deploy it over HTTPS, create a GitHub App pointing its webhook at /eve/v1/github, subscribe to Issues and Issue comments, install it on your repositories, then open a new issue or comment @github-issue-maintainer on an issue.

### Does it review pull requests?

No. The channel ignores pull request conversations and review threads, and the agent has no PR review publication path. Use @evex/code-reviewer when you need GitHub PR reviews with inline comments.

### Which labels does it apply?

Only bug, feature, docs, question, and chore. The taxonomy is documented in the package README and the issue-triage skill. Create those labels in the repository or allow the app to create them on first use.

### How does the weekly digest work?

A cron schedule (default Mondays at 09:00 UTC) calls list_open_issues for ISSUE_DIGEST_REPO, composes HTML, previews with preview_digest_email, then sends through Resend with confirmSend and an idempotency key so retries never duplicate the email.

### How do the rate limits work?

Defaults are one triage per issue every 15 minutes, one per user per issue every 30 minutes, 50 daily triages per private repository, and 15 per public repository. Tune with ISSUE_MAINTAINER_* variables, or disable with ISSUE_MAINTAINER_RATE_LIMIT_ENABLED=false for local development.

## Files installed

- `.env.example`
- `agent/agent.ts`
- `agent/channels/github.ts`
- `agent/instructions.md`
- `agent/lib/github-app.ts`
- `agent/lib/html.ts`
- `agent/lib/issue-config.ts`
- `agent/lib/issue-rate-limit.ts`
- `agent/lib/taxonomy.ts`
- `agent/lib/thin-issue.ts`
- `agent/schedules/weekly-issue-digest.ts`
- `agent/skills/issue-triage/SKILL.md`
- `agent/tools/compose_digest_html.ts`
- `agent/tools/list_open_issues.ts`
- `agent/tools/preview_digest_email.ts`
- `agent/tools/send_digest_email.ts`
- `agent/tools/triage_issue.ts`
- `evals/ask-repro-thin-issue.eval.ts`
- `evals/evals.config.ts`
- `evals/ignore-pull-request.eval.ts`
- `evals/label-clear-bug.eval.ts`
- `evals/no-repro-on-feature.eval.ts`
- `agent/README.md`

## File contents

### `.env.example`

```
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_WEBHOOK_SECRET=
GITHUB_APP_SLUG=github-issue-maintainer
GITHUB_APP_INSTALLATION_ID=

# Model credential (Vercel AI Gateway API key or OIDC).
AI_GATEWAY_API_KEY=

# Vercel Redis/Upstash Marketplace REST credentials (rate limiting).
KV_REST_API_URL=
KV_REST_API_TOKEN=

ISSUE_MAINTAINER_COOLDOWN_REPLY=true
ISSUE_MAINTAINER_COOLDOWN_REPLY_SECONDS=900
ISSUE_MAINTAINER_ISSUE_COOLDOWN_SECONDS=900
ISSUE_MAINTAINER_PRIVATE_REPO_DAILY_LIMIT=50
ISSUE_MAINTAINER_PUBLIC_REPO_DAILY_LIMIT=15
ISSUE_MAINTAINER_RATE_LIMIT_ENABLED=true
ISSUE_MAINTAINER_RATE_LIMIT_FAILURE_MODE=public_closed
ISSUE_MAINTAINER_RATE_LIMIT_PREFIX=evex:github-issue-maintainer
ISSUE_MAINTAINER_USER_ISSUE_COOLDOWN_SECONDS=1800

# Weekly open-issue digest (email via Resend).
ISSUE_DIGEST_CRON="0 9 * * 1"
ISSUE_DIGEST_FROM=
ISSUE_DIGEST_REPO=
ISSUE_DIGEST_SUBJECT="Weekly open-issue digest"
ISSUE_DIGEST_TO=
RESEND_API_KEY=

```

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "zai/glm-5.2",
});

```

### `agent/channels/github.ts`

```ts
import {
  defaultGitHubAuth,
  type GitHubEventContext,
  type GitHubInboundContext,
  type GitHubJsonObject,
  githubChannel,
  type GitHubChannelState,
} from "eve/channels/github";
import { toolResultFrom } from "eve/tools";

import {
  checkIssueTriageRateLimit,
  claimTriagePublication,
  type RateLimitDecision,
  releaseTriagePublication,
  shouldPostCooldownReply,
} from "../lib/issue-rate-limit";
import {
  detectThinIssueGaps,
  formatReproRequest,
  isThinIssue,
  looksBugLike,
} from "../lib/thin-issue";
import {
  ISSUE_LABEL_SET,
  type IssueLabel,
} from "../lib/taxonomy";
import triageIssueTool, {
  type TriageIssueOutput,
} from "../tools/triage_issue";

const BOT_NAME = process.env.GITHUB_APP_SLUG || "github-issue-maintainer";
const BOT_MENTION_PATTERN = new RegExp(
  `@${escapeRegExp(BOT_NAME)}(?=$|[^A-Za-z0-9_-])`,
  "i",
);
const GITHUB_COMMENT_CHUNK_SIZE = 60_000;

const LABEL_COLORS: Record<IssueLabel, string> = {
  bug: "d73a4a",
  feature: "a2eeef",
  docs: "0075ca",
  question: "d876e3",
  chore: "fef2c0",
};

const LABEL_DESCRIPTIONS: Record<IssueLabel, string> = {
  bug: "Something is broken or behaves incorrectly",
  feature: "Request for new capability or behavior",
  docs: "Documentation gaps, typos, or clarification",
  question: "Guidance without a product change",
  chore: "Maintenance, CI, dependencies, housekeeping",
};

type IssueMaintainerGitHubState = GitHubChannelState & {
  issueTriageSubmitted?: boolean;
};

export default githubChannel({
  botName: BOT_NAME,
  async onComment(ctx, comment) {
    if (!BOT_MENTION_PATTERN.test(comment.body)) {
      return null;
    }

    // Issue maintainer only — never act on pull request conversations.
    if (!isIssueConversation(ctx)) {
      return null;
    }

    const issueNumber = ctx.conversation.issueNumber;
    if (issueNumber === null) {
      return null;
    }

    const decision = await checkIssueTriageRateLimit({
      installationId: ctx.github.installationId,
      isPrivateRepository: ctx.repository.private,
      issueNumber,
      repositoryId: ctx.repository.id,
      senderId: ctx.sender.id,
      senderLogin: ctx.sender.login,
    });

    if (decision.allowed) {
      return { auth: defaultGitHubAuth(ctx) };
    }

    await maybePostCooldownReply(ctx, decision);
    return null;
  },
  async onIssue(ctx, issue) {
    if (issue.action !== "opened") {
      return null;
    }

    if (!isIssueConversation(ctx)) {
      return null;
    }

    const issueNumber = issue.issueNumber;
    const decision = await checkIssueTriageRateLimit({
      installationId: ctx.github.installationId,
      isPrivateRepository: ctx.repository.private,
      issueNumber,
      repositoryId: ctx.repository.id,
      senderId: ctx.sender.id,
      senderLogin: ctx.sender.login,
    });

    if (!decision.allowed) {
      await maybePostCooldownReply(ctx, decision);
      return null;
    }

    const rawIssue = asObject(issue.raw);
    const title = readString(rawIssue, "title") ?? "";
    const body = readString(rawIssue, "body") ?? "";
    const author =
      readNestedString(rawIssue, ["user", "login"]) ?? ctx.sender.login;
    const existingLabels = readLabelNames(rawIssue);
    const gaps = detectThinIssueGaps(body);
    const bugLike = looksBugLike(title, body);
    const thin = isThinIssue(gaps, { bugLike });

    return {
      auth: defaultGitHubAuth(ctx),
      context: [
        [
          "<github_issue_context>",
          `repository: ${ctx.repository.fullName}`,
          `issue_number: ${issueNumber}`,
          `sender: ${author}`,
          `title: ${title}`,
          `existing_labels: ${existingLabels.join(", ") || "(none)"}`,
          `bug_like: ${bugLike ? "yes" : "no"}`,
          `thin_issue: ${thin ? "yes" : "no"}`,
          thin
            ? `thin_gaps: ${[
                gaps.missingRepro ? "repro" : null,
                gaps.missingExpectedVsActual ? "expected_vs_actual" : null,
                gaps.missingEnvironment ? "environment" : null,
              ]
                .filter(Boolean)
                .join(", ")}`
            : null,
          "body:",
          body || "(empty)",
          "</github_issue_context>",
          "",
          "Triage this newly opened GitHub issue. Classify the taxonomy label first with triage_issue. Set requestRepro=true only when the primary label is bug and thin_issue is yes. Never ask for repro on feature, docs, question, or chore. Do not review pull requests.",
          thin ? `Suggested repro ask:\n${formatReproRequest(gaps)}` : "",
        ]
          .filter((line) => line !== null)
          .join("\n"),
      ],
    };
  },
  events: {
    async "action.result"(data, channel) {
      const match = toolResultFrom(data.result, triageIssueTool);
      if (!match) {
        return;
      }

      if ("invalid" in match.output && match.output.invalid) {
        return;
      }

      const state = channel.state as IssueMaintainerGitHubState;
      if (state.issueTriageSubmitted) {
        return;
      }

      if (state.conversationKind !== "issue") {
        return;
      }

      const issueNumber = state.issueNumber;
      if (issueNumber === null) {
        return;
      }

      const claimInput = {
        installationId: channel.github.installationId,
        issueNumber,
        repositoryId: channel.repository.id,
        toolCallId: match.callId,
      };

      const claimed = await claimTriagePublication(claimInput);

      if (!claimed) {
        state.issueTriageSubmitted = true;
        return;
      }

      try {
        await publishTriage(channel, match.output as TriageIssueOutput);
        state.issueTriageSubmitted = true;
      } catch {
        await releaseTriagePublication(claimInput);
        state.issueTriageSubmitted = false;
      }
    },
    async "message.completed"(data, channel) {
      if (data.finishReason === "tool-calls" || !data.message) {
        return;
      }

      const state = channel.state as IssueMaintainerGitHubState;
      if (state.issueTriageSubmitted) {
        return;
      }

      if (state.conversationKind !== "issue") {
        return;
      }

      await postCommentChunks(channel, data.message);
    },
  },
});

function isIssueConversation(ctx: GitHubInboundContext) {
  return ctx.conversation.kind === "issue";
}

async function maybePostCooldownReply(
  ctx: GitHubInboundContext,
  decision: Extract<RateLimitDecision, { allowed: false }>,
) {
  const issueNumber = ctx.conversation.issueNumber;
  if (issueNumber === null) {
    return;
  }

  const canReply = await shouldPostCooldownReply({
    installationId: ctx.github.installationId,
    issueNumber,
    repositoryId: ctx.repository.id,
  });

  if (!canReply) {
    return;
  }

  try {
    await ctx.thread.post(formatCooldownReply(decision));
  } catch {
    // If the cooldown notice cannot be posted, still suppress the model run.
  }
}

function formatCooldownReply(
  decision: Extract<RateLimitDecision, { allowed: false }>,
) {
  if (decision.reason === "rate_limit_unavailable") {
    return `\`${BOT_NAME}\` cannot run because rate limiting is unavailable for this repository.`;
  }

  const retryAfter = formatRetryAfter(decision.retryAfterSeconds);
  return `\`${BOT_NAME}\` is cooling down for this issue. Try again ${retryAfter}.`;
}

function formatRetryAfter(retryAfterSeconds: number | undefined) {
  if (!retryAfterSeconds) {
    return "later";
  }

  if (retryAfterSeconds <= 90) {
    return "in about 1 minute";
  }

  return `in about ${Math.ceil(retryAfterSeconds / 60)} minutes`;
}

async function publishTriage(
  channel: GitHubEventContext,
  triage: TriageIssueOutput,
) {
  const issueNumber = channel.state.issueNumber;
  if (issueNumber === null) {
    return;
  }

  const owner = channel.state.owner;
  const repo = channel.state.repo;
  const labels = triage.labels.filter((label): label is IssueLabel =>
    ISSUE_LABEL_SET.has(label),
  );

  if (labels.length > 0) {
    await ensureTaxonomyLabelsExist(channel, owner, repo, labels);
    await channel.github.request({
      method: "POST",
      path: `/repos/${owner}/${repo}/issues/${issueNumber}/labels`,
      body: { labels },
    });
  }

  const shouldAskRepro =
    triage.requestRepro &&
    labels.includes("bug") &&
    Boolean(triage.comment?.trim());

  if (shouldAskRepro && triage.comment) {
    await postCommentChunks(channel, triage.comment.trim());
  }
}

async function ensureTaxonomyLabelsExist(
  channel: GitHubEventContext,
  owner: string,
  repo: string,
  labels: readonly IssueLabel[],
) {
  for (const name of labels) {
    const encoded = encodeURIComponent(name);
    try {
      await channel.github.request({
        method: "GET",
        path: `/repos/${owner}/${repo}/labels/${encoded}`,
      });
    } catch {
      try {
        await channel.github.request({
          method: "POST",
          path: `/repos/${owner}/${repo}/labels`,
          body: {
            name,
            color: LABEL_COLORS[name],
            description: LABEL_DESCRIPTIONS[name],
          },
        });
      } catch {
        // 422 if another worker created it first — proceed to attach.
      }
    }
  }
}

async function postCommentChunks(channel: GitHubEventContext, body: string) {
  for (const chunk of chunkText(body, GITHUB_COMMENT_CHUNK_SIZE)) {
    await channel.thread.post(chunk);
  }
}

function chunkText(value: string, size: number): string[] {
  if (value.length <= size) {
    return [value];
  }

  const chunks: string[] = [];
  for (let index = 0; index < value.length; index += size) {
    chunks.push(value.slice(index, index + size));
  }
  return chunks;
}

function asObject(value: unknown): GitHubJsonObject | null {
  if (value && typeof value === "object" && !Array.isArray(value)) {
    return value as GitHubJsonObject;
  }
  return null;
}

function readString(
  object: GitHubJsonObject | null,
  key: string,
): string | undefined {
  const value = object?.[key];
  return typeof value === "string" ? value : undefined;
}

function readNestedString(
  object: GitHubJsonObject | null,
  path: readonly string[],
): string | undefined {
  let current: unknown = object;
  for (const key of path) {
    if (!(current && typeof current === "object" && !Array.isArray(current))) {
      return undefined;
    }
    current = (current as GitHubJsonObject)[key];
  }
  return typeof current === "string" ? current : undefined;
}

function readLabelNames(object: GitHubJsonObject | null): string[] {
  const labels = object?.labels;
  if (!Array.isArray(labels)) {
    return [];
  }

  const names: string[] = [];
  for (const label of labels) {
    if (typeof label === "string") {
      names.push(label);
      continue;
    }
    if (label && typeof label === "object" && !Array.isArray(label)) {
      const name = (label as GitHubJsonObject).name;
      if (typeof name === "string") {
        names.push(name);
      }
    }
  }
  return names;
}

function escapeRegExp(value: string) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

```

### `agent/instructions.md`

```md
# Mission
You maintain GitHub issues for a repository: label new issues from a small
explicit taxonomy, ask for missing repro details when a bug report is thin, and
compose a weekly open-issue digest email.

# Default stance
You are an issue maintainer, not a pull request reviewer. Never review pull
requests, never publish GitHub PR reviews, and never act on PR conversations.

# Taxonomy
Use only these labels:

- bug
- feature
- docs
- question
- chore

Prefer one primary label. Do not invent labels outside this list.

# Workflow for a new issue
1. Read the injected `<github_issue_context>` block (title, body, labels,
   bug_like, thin gaps).
2. Load the issue-triage skill when the label choice or thin-issue decision is
   ambiguous.
3. Classify the taxonomy label first.
4. Set requestRepro=true only when the primary label is bug and the report is
   thin (missing repro, expected vs actual, or environment). Never ask for
   repro on feature, docs, question, or chore.
5. Call triage_issue exactly once with labels, requestRepro, optional comment,
   and a one-sentence rationale.
6. After triage_issue, do not produce a second substantive final answer.

# Workflow for @mentions on issues
Only respond when mentioned on a real issue conversation. Help with labeling,
clarifying missing details, or summarizing open questions. Still never review
pull requests.

# Weekly digest
When the weekly digest schedule runs:

1. Call list_open_issues.
2. Group open issues (needs attention / recently updated / stale).
3. Call compose_digest_html so issue titles are HTML-escaped.
4. Preview with preview_digest_email, then send_digest_email with
   confirmSend=true (ISO-week idempotency is enforced by the tool).

# Hard boundaries
- Do not use submit_pr_review or any pull-request review publication path.
- Do not comment on pull requests.
- Do not close, reopen, assign, or milestone issues unless a human explicitly
  asks in an issue mention and the request is narrow and reversible.
- Do not invent issues or recipients for the digest.
- Do not interpolate raw issue titles into hand-written HTML.

```

### `agent/lib/github-app.ts`

```ts
import { createPrivateKey, createSign } from "node:crypto";

type InstallationTokenCache = {
  readonly expiresAtMs: number;
  readonly token: string;
};

const tokenCache = new Map<string, InstallationTokenCache>();
const TOKEN_REFRESH_SKEW_MS = 60_000;

function normalizePrivateKey(privateKey: string): string {
  return privateKey.includes("\\n")
    ? privateKey.replace(/\\n/g, "\n")
    : privateKey;
}

function base64Url(input: Buffer | string): string {
  const buffer = typeof input === "string" ? Buffer.from(input) : input;
  return buffer
    .toString("base64")
    .replaceAll("=", "")
    .replaceAll("+", "-")
    .replaceAll("/", "_");
}

async function createAppJwt(appId: string, privateKeyPem: string): Promise<string> {
  const nowSeconds = Math.floor(Date.now() / 1000);
  const header = base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const payload = base64Url(
    JSON.stringify({
      iat: nowSeconds - 60,
      exp: nowSeconds + 9 * 60,
      iss: appId,
    }),
  );
  const unsigned = `${header}.${payload}`;
  const signer = createSign("RSA-SHA256");
  signer.update(unsigned);
  signer.end();
  const signature = signer.sign(
    createPrivateKey(normalizePrivateKey(privateKeyPem)),
  );
  return `${unsigned}.${base64Url(signature)}`;
}

export async function getInstallationAccessToken(
  installationId: number,
): Promise<string> {
  const appId = process.env.GITHUB_APP_ID?.trim();
  const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.trim();
  if (!(appId && privateKey)) {
    throw new Error(
      "GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required for digest GitHub API calls.",
    );
  }

  const cacheKey = `${appId}:${installationId}`;
  const cached = tokenCache.get(cacheKey);
  if (cached && cached.expiresAtMs > Date.now() + TOKEN_REFRESH_SKEW_MS) {
    return cached.token;
  }

  const jwt = await createAppJwt(appId, privateKey);
  const response = await fetch(
    `https://api.github.com/app/installations/${installationId}/access_tokens`,
    {
      method: "POST",
      headers: {
        accept: "application/vnd.github+json",
        authorization: `Bearer ${jwt}`,
        "x-github-api-version": "2022-11-28",
      },
    },
  );

  if (!response.ok) {
    throw new Error(
      `Failed to mint GitHub installation token (HTTP ${response.status}).`,
    );
  }

  const body = (await response.json()) as {
    token?: string;
    expires_at?: string;
  };
  if (!body.token) {
    throw new Error("GitHub installation token response was missing token.");
  }

  const expiresAtMs = body.expires_at
    ? Date.parse(body.expires_at)
    : Date.now() + 50 * 60_000;
  tokenCache.set(cacheKey, { token: body.token, expiresAtMs });
  return body.token;
}

export async function githubRequest<T>(input: {
  readonly installationId: number;
  readonly method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
  readonly path: string;
  readonly body?: unknown;
}): Promise<T> {
  const token = await getInstallationAccessToken(input.installationId);
  const response = await fetch(`https://api.github.com${input.path}`, {
    method: input.method,
    headers: {
      accept: "application/vnd.github+json",
      authorization: `Bearer ${token}`,
      "content-type": "application/json; charset=utf-8",
      "x-github-api-version": "2022-11-28",
    },
    body: input.body === undefined ? undefined : JSON.stringify(input.body),
  });

  if (!response.ok) {
    throw new Error(
      `GitHub ${input.method} ${input.path} failed with HTTP ${response.status}.`,
    );
  }

  if (response.status === 204) {
    return undefined as T;
  }

  return (await response.json()) as T;
}

```

### `agent/lib/html.ts`

```ts
/** Escape user-controlled text for safe insertion into HTML. */
export function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

```

### `agent/lib/issue-config.ts`

```ts
export type IssueDigestConfig = {
  readonly cron: string;
  readonly from?: string;
  readonly installationId?: number;
  readonly repo?: string;
  readonly subject: string;
  readonly to: readonly string[];
};

const DEFAULT_CRON = "0 9 * * 1";
const DEFAULT_SUBJECT = "Weekly open-issue digest";

const optional = (value: string | undefined): string | undefined => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : undefined;
};

const compactCsv = (value: string | undefined): string[] =>
  (value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);

const parsePositiveInteger = (
  value: string | undefined,
): number | undefined => {
  const parsed = Number.parseInt(value ?? "", 10);
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
};

export const issueDigestConfig = {
  cron: optional(process.env.ISSUE_DIGEST_CRON) ?? DEFAULT_CRON,
  from: optional(process.env.ISSUE_DIGEST_FROM),
  installationId: parsePositiveInteger(
    process.env.GITHUB_APP_INSTALLATION_ID,
  ),
  repo: optional(process.env.ISSUE_DIGEST_REPO),
  subject: optional(process.env.ISSUE_DIGEST_SUBJECT) ?? DEFAULT_SUBJECT,
  to: compactCsv(process.env.ISSUE_DIGEST_TO),
} satisfies IssueDigestConfig;

export function parseOwnerRepo(repo: string): {
  readonly owner: string;
  readonly repo: string;
} {
  const [owner, name, ...rest] = repo.split("/");
  if (!(owner && name) || rest.length > 0) {
    throw new Error(
      `ISSUE_DIGEST_REPO must be owner/repo (got ${JSON.stringify(repo)}).`,
    );
  }
  return { owner, repo: name };
}

/** ISO week year + week number for retry-stable weekly digest keys. */
export function getIsoWeekParts(now: Date = new Date()): {
  readonly week: number;
  readonly year: number;
} {
  const date = new Date(
    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
  );
  // ISO week: Thursday determines the year; week starts Monday.
  const day = date.getUTCDay() || 7;
  date.setUTCDate(date.getUTCDate() + 4 - day);
  const year = date.getUTCFullYear();
  const yearStart = new Date(Date.UTC(year, 0, 1));
  const week = Math.ceil(
    ((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
  );
  return { year, week };
}

/**
 * Stable idempotency key for one weekly digest run. Retries within the same
 * ISO week reuse this key even if they cross midnight after the cron fire.
 */
export function getWeeklyDigestIdempotencyKey(now: Date = new Date()): string {
  const { year, week } = getIsoWeekParts(now);
  return `github-issue-digest-${year}-W${String(week).padStart(2, "0")}`;
}

```

### `agent/lib/issue-rate-limit.ts`

```ts
import { createHash } from "node:crypto";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const DEFAULT_RATE_LIMIT_PREFIX = "evex:github-issue-maintainer";
const DEFAULT_ISSUE_COOLDOWN_SECONDS = 900;
const DEFAULT_USER_ISSUE_COOLDOWN_SECONDS = 1800;
const DEFAULT_PRIVATE_REPO_DAILY_LIMIT = 50;
const DEFAULT_PUBLIC_REPO_DAILY_LIMIT = 15;
const DEFAULT_COOLDOWN_REPLY_SECONDS = 900;
const TRIAGE_PUBLICATION_TTL_SECONDS = 86_400;
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);

type FailureMode = "public_closed" | "closed" | "open";
type RateLimitReason =
  | "user_issue_cooldown"
  | "issue_cooldown"
  | "repo_daily_limit"
  | "rate_limit_unavailable";

type RateLimitConfig = {
  cooldownReply: boolean;
  cooldownReplySeconds: number;
  enabled: boolean;
  failureMode: FailureMode;
  issueCooldownSeconds: number;
  prefix: string;
  privateRepoDailyLimit: number;
  publicRepoDailyLimit: number;
  userIssueCooldownSeconds: number;
};

type RateLimiter = ReturnType<typeof createRateLimiter>;
type RateLimiterBundle = ReturnType<typeof createRateLimiterBundle>;

export type IssueTriageRateLimitInput = {
  installationId: number | null | undefined;
  isPrivateRepository: boolean;
  issueNumber: number;
  repositoryId: number;
  senderId: number | null | undefined;
  senderLogin: string | null | undefined;
};

export type CooldownReplyRateLimitInput = {
  installationId: number | null | undefined;
  issueNumber: number;
  repositoryId: number;
};

export type TriagePublicationClaimInput = {
  installationId: number | null | undefined;
  issueNumber: number;
  repositoryId: number;
  toolCallId: string;
};

export type RateLimitDecision =
  | { allowed: true }
  | {
      allowed: false;
      reason: RateLimitReason;
      resetAt?: number;
      retryAfterSeconds?: number;
    };

let cachedRedis: Redis | null = null;
let cachedLimiters: RateLimiterBundle | null = null;

export async function checkIssueTriageRateLimit(
  input: IssueTriageRateLimitInput,
): Promise<RateLimitDecision> {
  const config = readRateLimitConfig();

  if (!config.enabled) {
    return { allowed: true };
  }

  if (!hasUpstashEnvironment()) {
    return unavailableDecision(config, input.isPrivateRepository);
  }

  try {
    if (!input.isPrivateRepository && config.publicRepoDailyLimit <= 0) {
      return { allowed: false, reason: "repo_daily_limit" };
    }

    if (input.isPrivateRepository && config.privateRepoDailyLimit <= 0) {
      return { allowed: false, reason: "repo_daily_limit" };
    }

    const limiters = getRateLimiters(config);

    // Short-circuit: a cooldown denial must not consume the repo daily quota.
    const userDecision = await checkLimiter(
      limiters.userIssue,
      identifierForUserIssue(input),
      "user_issue_cooldown",
    );
    if (!userDecision.allowed) {
      return userDecision;
    }

    const issueDecision = await checkLimiter(
      limiters.issue,
      identifierForIssue(input),
      "issue_cooldown",
    );
    if (!issueDecision.allowed) {
      return issueDecision;
    }

    return await checkLimiter(
      input.isPrivateRepository
        ? limiters.privateRepoDaily
        : limiters.publicRepoDaily,
      identifierForRepoDaily(input),
      "repo_daily_limit",
    );
  } catch {
    return unavailableDecision(config, input.isPrivateRepository);
  }
}

export async function shouldPostCooldownReply(
  input: CooldownReplyRateLimitInput,
): Promise<boolean> {
  const config = readRateLimitConfig();

  if (!(config.enabled && config.cooldownReply && hasUpstashEnvironment())) {
    return false;
  }

  try {
    const { cooldownReply } = getRateLimiters(config);
    const decision = await checkLimiter(
      cooldownReply,
      identifierForCooldownReply(input),
      "issue_cooldown",
    );

    return decision.allowed;
  } catch {
    return false;
  }
}

function triagePublicationKey(input: TriagePublicationClaimInput): string {
  const config = readRateLimitConfig();
  return `${config.prefix}:triage-publish:${hashParts([
    "triage-publish",
    input.installationId ?? "unknown-installation",
    input.repositoryId,
    input.issueNumber,
    input.toolCallId,
  ])}`;
}

export async function claimTriagePublication(
  input: TriagePublicationClaimInput,
): Promise<boolean> {
  if (!hasUpstashEnvironment()) {
    return true;
  }

  try {
    const redis = getRedis();
    const result = await redis.set(triagePublicationKey(input), "1", {
      ex: TRIAGE_PUBLICATION_TTL_SECONDS,
      nx: true,
    });

    return result === "OK";
  } catch {
    return true;
  }
}

export async function releaseTriagePublication(
  input: TriagePublicationClaimInput,
): Promise<void> {
  if (!hasUpstashEnvironment()) {
    return;
  }

  try {
    await getRedis().del(triagePublicationKey(input));
  } catch {
    // Best-effort release; the TTL still expires the claim.
  }
}

function checkLimiter(
  limiter: RateLimiter,
  identifier: string,
  reason: RateLimitReason,
): Promise<RateLimitDecision> {
  return limiter.limit(identifier).then((result) => {
    void result.pending.catch(() => undefined);

    if (result.success) {
      return { allowed: true };
    }

    return {
      allowed: false,
      reason,
      resetAt: result.reset,
      retryAfterSeconds: retryAfterSeconds(result.reset),
    };
  });
}

function createRateLimiter(
  redis: Redis,
  limit: number,
  windowSeconds: number,
  prefix: string,
) {
  return new Ratelimit({
    analytics: false,
    limiter: Ratelimit.slidingWindow(limit, `${windowSeconds} s`),
    prefix,
    redis,
  });
}

function createRateLimiterBundle(config: RateLimitConfig) {
  const redis = getRedis();

  return {
    configKey: JSON.stringify(config),
    cooldownReply: createRateLimiter(
      redis,
      1,
      config.cooldownReplySeconds,
      `${config.prefix}:cooldown-reply`,
    ),
    issue: createRateLimiter(
      redis,
      1,
      config.issueCooldownSeconds,
      `${config.prefix}:issue`,
    ),
    privateRepoDaily: createRateLimiter(
      redis,
      Math.max(1, config.privateRepoDailyLimit),
      86_400,
      `${config.prefix}:repo-private-day`,
    ),
    publicRepoDaily: createRateLimiter(
      redis,
      Math.max(1, config.publicRepoDailyLimit),
      86_400,
      `${config.prefix}:repo-public-day`,
    ),
    userIssue: createRateLimiter(
      redis,
      1,
      config.userIssueCooldownSeconds,
      `${config.prefix}:user-issue`,
    ),
  };
}

function getRateLimiters(config: RateLimitConfig) {
  const configKey = JSON.stringify(config);
  if (cachedLimiters?.configKey === configKey) {
    return cachedLimiters;
  }

  cachedLimiters = createRateLimiterBundle(config);
  return cachedLimiters;
}

function getRedis() {
  if (cachedRedis) {
    return cachedRedis;
  }

  cachedRedis = Redis.fromEnv();
  return cachedRedis;
}

function hasUpstashEnvironment() {
  return Boolean(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN);
}

function unavailableDecision(
  config: RateLimitConfig,
  isPrivateRepository: boolean,
): RateLimitDecision {
  if (config.failureMode === "open") {
    return { allowed: true };
  }

  if (config.failureMode === "closed") {
    return { allowed: false, reason: "rate_limit_unavailable" };
  }

  return isPrivateRepository
    ? { allowed: true }
    : { allowed: false, reason: "rate_limit_unavailable" };
}

function identifierForUserIssue(input: IssueTriageRateLimitInput) {
  return hashParts([
    "user-issue",
    input.installationId ?? "unknown-installation",
    input.repositoryId,
    input.issueNumber,
    input.senderId ?? input.senderLogin ?? "unknown-sender",
  ]);
}

function identifierForIssue(input: IssueTriageRateLimitInput) {
  return hashParts([
    "issue",
    input.installationId ?? "unknown-installation",
    input.repositoryId,
    input.issueNumber,
  ]);
}

function identifierForRepoDaily(input: IssueTriageRateLimitInput) {
  return hashParts([
    "repo-day",
    input.installationId ?? "unknown-installation",
    input.repositoryId,
    input.isPrivateRepository ? "private" : "public",
  ]);
}

function identifierForCooldownReply(input: CooldownReplyRateLimitInput) {
  return hashParts([
    "cooldown-reply",
    input.installationId ?? "unknown-installation",
    input.repositoryId,
    input.issueNumber,
  ]);
}

function hashParts(parts: readonly (number | string)[]) {
  return createHash("sha256").update(parts.map(String).join(":")).digest("hex");
}

function retryAfterSeconds(resetAt: number) {
  return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
}

function readRateLimitConfig(): RateLimitConfig {
  return {
    cooldownReply: readBoolean(
      process.env.ISSUE_MAINTAINER_COOLDOWN_REPLY,
      true,
    ),
    cooldownReplySeconds: readPositiveInteger(
      process.env.ISSUE_MAINTAINER_COOLDOWN_REPLY_SECONDS,
      DEFAULT_COOLDOWN_REPLY_SECONDS,
    ),
    enabled: readBoolean(
      process.env.ISSUE_MAINTAINER_RATE_LIMIT_ENABLED,
      true,
    ),
    failureMode: readFailureMode(
      process.env.ISSUE_MAINTAINER_RATE_LIMIT_FAILURE_MODE,
    ),
    issueCooldownSeconds: readPositiveInteger(
      process.env.ISSUE_MAINTAINER_ISSUE_COOLDOWN_SECONDS,
      DEFAULT_ISSUE_COOLDOWN_SECONDS,
    ),
    prefix:
      process.env.ISSUE_MAINTAINER_RATE_LIMIT_PREFIX?.trim() ||
      DEFAULT_RATE_LIMIT_PREFIX,
    privateRepoDailyLimit: readNonNegativeInteger(
      process.env.ISSUE_MAINTAINER_PRIVATE_REPO_DAILY_LIMIT,
      DEFAULT_PRIVATE_REPO_DAILY_LIMIT,
    ),
    publicRepoDailyLimit: readNonNegativeInteger(
      process.env.ISSUE_MAINTAINER_PUBLIC_REPO_DAILY_LIMIT,
      DEFAULT_PUBLIC_REPO_DAILY_LIMIT,
    ),
    userIssueCooldownSeconds: readPositiveInteger(
      process.env.ISSUE_MAINTAINER_USER_ISSUE_COOLDOWN_SECONDS,
      DEFAULT_USER_ISSUE_COOLDOWN_SECONDS,
    ),
  };
}

function readBoolean(value: string | undefined, fallback: boolean) {
  if (!value) {
    return fallback;
  }

  const normalizedValue = value.trim().toLowerCase();
  if (TRUE_VALUES.has(normalizedValue)) {
    return true;
  }

  if (FALSE_VALUES.has(normalizedValue)) {
    return false;
  }

  return fallback;
}

function readFailureMode(value: string | undefined): FailureMode {
  if (value === "closed" || value === "open" || value === "public_closed") {
    return value;
  }

  return "public_closed";
}

function readPositiveInteger(value: string | undefined, fallback: number) {
  const parsedValue = Number.parseInt(value ?? "", 10);
  return Number.isInteger(parsedValue) && parsedValue > 0
    ? parsedValue
    : fallback;
}

function readNonNegativeInteger(value: string | undefined, fallback: number) {
  const parsedValue = Number.parseInt(value ?? "", 10);
  return Number.isInteger(parsedValue) && parsedValue >= 0
    ? parsedValue
    : fallback;
}

```

### `agent/lib/taxonomy.ts`

```ts
export const ISSUE_LABELS = [
  "bug",
  "feature",
  "docs",
  "question",
  "chore",
] as const;

export type IssueLabel = (typeof ISSUE_LABELS)[number];

export const ISSUE_LABEL_SET = new Set<string>(ISSUE_LABELS);

export const TAXONOMY_GUIDE = `Use exactly one primary label from this taxonomy:

- bug: something is broken or behaves incorrectly
- feature: a request for new capability or behavior
- docs: documentation gaps, typos, or clarification asks
- question: seeking guidance without asking for a product change
- chore: maintenance, dependencies, CI, or repo housekeeping

Do not invent labels outside this list. Prefer bug over question when a
reproducible failure is described. Prefer feature over question when the
author is clearly requesting new behavior.`;

```

### `agent/lib/thin-issue.ts`

```ts
const REPRO_HINTS =
  /\b(repro(duce|duction)?|steps to reproduce|to reproduce|minimal example|repro steps)\b/i;
const EXPECTED_HINTS =
  /\b(expected|should (have|be|show|return)|i expected|expected behavior)\b/i;
const ACTUAL_HINTS =
  /\b(actual|instead|got|observed|currently|what happens|error|stack trace|traceback)\b/i;
const ENVIRONMENT_HINTS =
  /\b(environment|node|npm|pnpm|browser|os|macos|linux|windows|version|chrome|firefox|safari|ios|android)\b/i;

const BUG_LIKE_HINTS =
  /\b(bug|broken|crash|crashes|error|exception|fail(s|ed|ure)?|regression|doesn'?t work|does not work|incorrect|wrong|stack trace|traceback|typeerror|nullpointer)\b/i;
const NON_BUG_HINTS =
  /\b(feature request|enhancement|docs?|documentation|typo|readme|how (do|can|to)|question|chore|dependency|dependencies|upgrade|bump)\b/i;

export type ThinIssueGaps = {
  readonly missingEnvironment: boolean;
  readonly missingExpectedVsActual: boolean;
  readonly missingRepro: boolean;
};

export function detectThinIssueGaps(
  body: string | null | undefined,
): ThinIssueGaps {
  const text = body?.trim() ?? "";
  if (text.length === 0) {
    return {
      missingRepro: true,
      missingExpectedVsActual: true,
      missingEnvironment: true,
    };
  }

  const hasRepro = REPRO_HINTS.test(text);
  const hasExpected = EXPECTED_HINTS.test(text);
  const hasActual = ACTUAL_HINTS.test(text);
  const hasEnvironment = ENVIRONMENT_HINTS.test(text);

  return {
    missingRepro: !hasRepro,
    missingExpectedVsActual: !(hasExpected && hasActual),
    missingEnvironment: !hasEnvironment,
  };
}

/**
 * Thin-report requirements apply only to bug-like issues. Feature, docs,
 * question, and chore reports should not be flagged just for missing repro
 * keywords.
 */
export function looksBugLike(
  title: string | null | undefined,
  body: string | null | undefined,
): boolean {
  const text = `${title ?? ""}\n${body ?? ""}`.trim();
  if (text.length === 0) {
    return false;
  }

  if (BUG_LIKE_HINTS.test(text)) {
    return true;
  }

  if (NON_BUG_HINTS.test(text)) {
    return false;
  }

  // Ambiguous short reports with no taxonomy signal are treated as bug-like
  // so drive-by "it doesnt work" issues still get a repro ask after labeling.
  return text.length < 80;
}

export function isThinIssue(
  gaps: ThinIssueGaps,
  options: { readonly bugLike: boolean },
): boolean {
  if (!options.bugLike) {
    return false;
  }

  return (
    gaps.missingRepro ||
    gaps.missingExpectedVsActual ||
    gaps.missingEnvironment
  );
}

export function formatReproRequest(gaps: ThinIssueGaps): string {
  const missing: string[] = [];
  if (gaps.missingRepro) {
    missing.push("steps to reproduce (numbered, minimal)");
  }
  if (gaps.missingExpectedVsActual) {
    missing.push("expected behavior vs what actually happens");
  }
  if (gaps.missingEnvironment) {
    missing.push(
      "environment (OS, runtime/browser versions, package versions)",
    );
  }

  return [
    "Thanks for opening this issue. To triage it accurately, please add:",
    ...missing.map((item) => `- ${item}`),
    "",
    "A short, self-contained repro helps maintainers act faster. Reply on this thread with the missing details.",
  ].join("\n");
}

```

### `agent/schedules/weekly-issue-digest.ts`

```ts
import { defineSchedule } from "eve/schedules";

import { issueDigestConfig } from "../lib/issue-config";

const repo = issueDigestConfig.repo ?? "owner/repo";

export default defineSchedule({
  cron: issueDigestConfig.cron,
  markdown: `Run the weekly open GitHub issue digest for ${repo}.

1. Call list_open_issues to fetch open issues (not pull requests) for ISSUE_DIGEST_REPO.
2. Group issues into short sections: needs attention (no labels or thin/unanswered), recently updated, and stale (no activity for 14+ days when timestamps allow).
3. Call compose_digest_html with those sections. That tool HTML-escapes issue titles — never hand-write HTML that interpolates raw titles.
4. Call preview_digest_email with the HTML and a subject like "Weekly open-issue digest: ${repo}".
5. Send for real with send_digest_email confirmSend=true. The tool pins the Resend idempotency key to the current ISO week (github-issue-digest-YYYY-Www), so retries of this scheduled run never open a second send even if they cross midnight.

If any required environment variable is missing (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID, ISSUE_DIGEST_REPO, ISSUE_DIGEST_FROM, ISSUE_DIGEST_TO, RESEND_API_KEY), stop and report the missing configuration. Never review pull requests. Never call send_digest_email without confirmSend=true.`,
});

```

### `agent/skills/issue-triage/SKILL.md`

```md
---
name: issue-triage
description: Label a GitHub issue from the small taxonomy and decide whether to ask for missing repro details on bug reports.
---

# Issue triage

Use this skill when triaging a newly opened GitHub issue or when mentioned on
an issue thread.

## Taxonomy

Choose labels only from:

- `bug`
- `feature`
- `docs`
- `question`
- `chore`

Apply one primary label. Add `docs` as a second label only when the issue is
primarily about documentation and also clearly a bug or feature.

## Thin issues (bugs only)

Classify the label first. Thin-report requirements apply only when the primary
label is `bug`. An issue is thin when any of these are missing:

- reproduction steps
- expected vs actual behavior
- environment (OS, runtime/browser, package versions)

When the label is `bug` and the report is thin, call `triage_issue` with
`requestRepro=true` and a short comment that asks only for the missing pieces.
Do not lecture. Do not demand a perfect template when the report is already
actionable.

Never set `requestRepro=true` for `feature`, `docs`, `question`, or `chore`
just because those reports lack repro keywords.

## Boundaries

- Triage issues only. Never review pull requests.
- Never call PR review tools or publish GitHub pull request reviews.
- Prefer labeling plus one focused ask over long commentary.

```

### `agent/tools/compose_digest_html.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { escapeHtml } from "../lib/html";
import { issueDigestConfig } from "../lib/issue-config";

const digestIssue = z.object({
  number: z.number().int().positive(),
  title: z.string().min(1),
  url: z.string().url(),
  labels: z.array(z.string()).default([]),
});

const digestSection = z.object({
  heading: z.string().min(1),
  issues: z.array(digestIssue),
});

export default defineTool({
  description:
    "Compose the weekly open-issue digest HTML. Escapes issue titles as text before inserting them. Prefer this over hand-written HTML so user-controlled titles cannot inject markup.",
  inputSchema: z.object({
    repo: z.string().min(1).optional(),
    sections: z.array(digestSection).min(1),
  }),
  execute({ repo, sections }) {
    const resolvedRepo = repo ?? issueDigestConfig.repo ?? "repository";
    const parts: string[] = [
      `<h1>Weekly open-issue digest: ${escapeHtml(resolvedRepo)}</h1>`,
    ];

    for (const section of sections) {
      parts.push(`<h2>${escapeHtml(section.heading)}</h2>`);
      if (section.issues.length === 0) {
        parts.push("<p>None.</p>");
        continue;
      }

      parts.push("<ul>");
      for (const issue of section.issues) {
        const labels =
          issue.labels.length > 0
            ? ` <small>(${escapeHtml(issue.labels.join(", "))})</small>`
            : "";
        parts.push(
          `<li><a href="${escapeHtml(issue.url)}">#${issue.number}</a> ${escapeHtml(issue.title)}${labels}</li>`,
        );
      }
      parts.push("</ul>");
    }

    return { html: parts.join("\n") };
  },
});

```

### `agent/tools/list_open_issues.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { issueDigestConfig, parseOwnerRepo } from "../lib/issue-config";
import { githubRequest } from "../lib/github-app";

type GitHubIssueListItem = {
  readonly number: number;
  readonly title: string;
  readonly html_url: string;
  readonly user?: { readonly login?: string };
  readonly labels?: readonly (
    | string
    | { readonly name?: string | null }
  )[];
  readonly created_at?: string;
  readonly updated_at?: string;
  readonly comments?: number;
  readonly pull_request?: unknown;
};

export default defineTool({
  description:
    "List open GitHub issues for the configured ISSUE_DIGEST_REPO. Skips pull requests. Used by the weekly open-issue digest schedule.",
  inputSchema: z.object({
    maxIssues: z.number().int().positive().max(100).default(50),
  }),
  async execute({ maxIssues }) {
    const repo = issueDigestConfig.repo;
    const installationId = issueDigestConfig.installationId;
    if (!repo) {
      return { notConfigured: true, missingEnv: "ISSUE_DIGEST_REPO" };
    }
    if (!installationId) {
      return {
        notConfigured: true,
        missingEnv: "GITHUB_APP_INSTALLATION_ID",
      };
    }

    const { owner, repo: name } = parseOwnerRepo(repo);
    const issues: Array<{
      number: number;
      title: string;
      url: string;
      author: string | null;
      labels: string[];
      createdAt: string | null;
      updatedAt: string | null;
      comments: number;
    }> = [];

    let page = 1;
    while (issues.length < maxIssues) {
      const batch = await githubRequest<GitHubIssueListItem[]>({
        installationId,
        method: "GET",
        path: `/repos/${owner}/${name}/issues?state=open&per_page=50&page=${page}&sort=updated&direction=desc`,
      });

      if (batch.length === 0) {
        break;
      }

      for (const item of batch) {
        if (item.pull_request) {
          continue;
        }
        issues.push({
          number: item.number,
          title: item.title,
          url: item.html_url,
          author: item.user?.login ?? null,
          labels: (item.labels ?? [])
            .map((label) => (typeof label === "string" ? label : label.name))
            .filter((label): label is string => Boolean(label)),
          createdAt: item.created_at ?? null,
          updatedAt: item.updated_at ?? null,
          comments: item.comments ?? 0,
        });
        if (issues.length >= maxIssues) {
          break;
        }
      }

      if (batch.length < 50) {
        break;
      }
      page += 1;
    }

    return {
      repo: `${owner}/${name}`,
      count: issues.length,
      issues,
    };
  },
});

```

### `agent/tools/preview_digest_email.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { issueDigestConfig } from "../lib/issue-config";

export default defineTool({
  description:
    "Preview the weekly open-issue digest email without sending it. Recipients and sender come from configuration and cannot be overridden via input.",
  inputSchema: z.object({
    subject: z.string().min(1).optional(),
    html: z.string().min(1),
  }),
  async execute({ subject, html }) {
    const resolvedFrom = issueDigestConfig.from;
    if (!resolvedFrom) {
      return { notConfigured: true, missingEnv: "ISSUE_DIGEST_FROM" };
    }

    const resolvedTo = issueDigestConfig.to;
    if (resolvedTo.length === 0) {
      return { notConfigured: true, missingEnv: "ISSUE_DIGEST_TO" };
    }

    return {
      dryRun: true,
      from: resolvedFrom,
      to: resolvedTo,
      subject: subject ?? issueDigestConfig.subject,
      htmlPreview: html.slice(0, 500),
      htmlLength: html.length,
    };
  },
});

```

### `agent/tools/send_digest_email.ts`

```ts
import { Resend } from "resend";
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  getWeeklyDigestIdempotencyKey,
  issueDigestConfig,
} from "../lib/issue-config";

const sentKeys = new Map<
  string,
  { readonly to: readonly string[]; readonly messageId: string }
>();

export default defineTool({
  description:
    "Send the weekly open-issue digest email through Resend. Requires confirmSend=true. The send uses a retry-stable ISO-week idempotency key (github-issue-digest-YYYY-Www) so retries after midnight in the same week never duplicate. Always call preview_digest_email first.",
  inputSchema: z.object({
    subject: z.string().min(1).optional(),
    html: z.string().min(1),
    confirmSend: z
      .boolean()
      .describe(
        "Must be true to send. Acts as an explicit guard against accidental sends.",
      ),
    idempotencyKey: z
      .string()
      .min(1)
      .max(255)
      .optional()
      .describe(
        "Optional hint. The tool always pins the real send to the current ISO-week key.",
      ),
  }),
  async execute({ subject, html, confirmSend }) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "RESEND_API_KEY" };
    }

    if (!confirmSend) {
      return {
        notConfirmed: true,
        note: "confirmSend must be true to send. Call preview_digest_email first.",
      };
    }

    const resolvedFrom = issueDigestConfig.from;
    if (!resolvedFrom) {
      return { notConfigured: true, missingEnv: "ISSUE_DIGEST_FROM" };
    }

    const resolvedTo = issueDigestConfig.to;
    if (resolvedTo.length === 0) {
      return { notConfigured: true, missingEnv: "ISSUE_DIGEST_TO" };
    }

    // Pin to ISO week so a retry after midnight cannot open a second send.
    const stableKey = getWeeklyDigestIdempotencyKey();

    const cached = sentKeys.get(stableKey);
    if (cached) {
      return {
        replayed: true,
        idempotencyKey: stableKey,
        to: cached.to,
        messageId: cached.messageId,
      };
    }

    const resend = new Resend(apiKey);
    const { data, error } = await resend.emails.send(
      {
        from: resolvedFrom,
        to: [...resolvedTo],
        subject: subject ?? issueDigestConfig.subject,
        html,
      },
      { idempotencyKey: stableKey },
    );

    if (error) {
      return {
        sent: false,
        idempotencyKey: stableKey,
        to: resolvedTo,
        error: { message: error.message, name: error.name },
      };
    }

    const messageId = data?.id ?? "unknown";
    sentKeys.set(stableKey, { to: resolvedTo, messageId });
    return {
      sent: true,
      idempotencyKey: stableKey,
      to: resolvedTo,
      messageId,
    };
  },
});

```

### `agent/tools/triage_issue.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { ISSUE_LABELS } from "../lib/taxonomy";

const triageIssueInput = z.object({
  labels: z
    .array(z.enum(ISSUE_LABELS))
    .min(1)
    .max(2)
    .describe("One primary taxonomy label, optionally plus docs."),
  requestRepro: z
    .boolean()
    .describe(
      "True only for bug-labeled thin reports that need a repro / expected-vs-actual / environment ask. Must be false for feature, docs, question, and chore.",
    ),
  comment: z
    .string()
    .min(1)
    .max(4000)
    .optional()
    .describe(
      "Optional issue comment body. Required when requestRepro is true; keep it short and specific about what is missing.",
    ),
  rationale: z
    .string()
    .min(1)
    .max(500)
    .describe("One-sentence reason for the chosen label(s)."),
});

export type TriageIssueOutput = z.infer<typeof triageIssueInput>;

export default defineTool({
  description:
    "Publish issue triage: apply taxonomy labels and optionally comment asking for missing repro details on bug reports only. Call exactly once per opened issue. Never use this on pull requests. requestRepro must be false unless labels include bug.",
  inputSchema: triageIssueInput,
  execute(input) {
    if (input.requestRepro && !input.labels.includes("bug")) {
      return {
        invalid: true,
        note: "requestRepro is only allowed when labels include bug.",
      };
    }

    if (input.requestRepro && !input.comment?.trim()) {
      return {
        invalid: true,
        note: "comment is required when requestRepro is true.",
      };
    }

    return input;
  },
  toModelOutput(output) {
    if ("invalid" in output && output.invalid) {
      return {
        type: "json",
        value: output,
      };
    }

    return {
      type: "json",
      value: {
        labels: "labels" in output ? output.labels : [],
        requestRepro: "requestRepro" in output ? output.requestRepro : false,
        readyToPublish: true,
      },
    };
  },
});

```

### `evals/ask-repro-thin-issue.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description: "Asks for missing repro details on a thin bug report.",
  async test(t) {
    const turn = await t.send(`
<github_issue_context>
repository: example/widget
issue_number: 18
sender: drive-by
title: broken
existing_labels: (none)
bug_like: yes
thin_issue: yes
thin_gaps: repro, expected_vs_actual, environment
body:
it doesnt work
</github_issue_context>

Triage this newly opened GitHub issue. Classify the taxonomy label first with triage_issue. Set requestRepro=true only when the primary label is bug and thin_issue is yes. Include a short comment asking only for the missing gaps.
`);

    t.succeeded();
    t.calledTool("triage_issue");
    const call = turn.requireToolCall("triage_issue");
    t.check(call.input.requestRepro === true, equals(true).gate());
    t.check(
      typeof call.input.comment === "string" &&
        (call.input.comment as string).length > 0,
      equals(true).gate(),
    );
    t.check(t.reply, includes("repro").soft());
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/ignore-pull-request.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Refuses to triage or review a pull request — issue maintainer only.",
  async test(t) {
    await t.send(`
<github_context>
repository: example/widget
pull_request_number: 99
sender: maintainer
head_sha: abc123
</github_context>

Pull request diff:

diff --git a/src/auth.ts b/src/auth.ts
@@
- if (session.userId !== requestedUserId) {
-   throw new Error("forbidden");
- }
+ if (session.userId) {
+   return getUser(requestedUserId);
+ }

Review this pull request and publish a GitHub PR review with inline comments.
`);

    t.succeeded();
    t.notCalledTool("triage_issue").gate();
    t.notCalledTool("submit_pr_review").gate();
    t.check(
      /pull request|do not review|issue maintainer|not a .*reviewer/i.test(
        t.reply ?? "",
      ),
      equals(true).gate(),
    );
    t.check(t.reply, includes("issue").soft());
  },
});

```

### `evals/label-clear-bug.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description: "Labels a clear bug report with the bug taxonomy label.",
  async test(t) {
    const turn = await t.send(`
<github_issue_context>
repository: example/widget
issue_number: 12
sender: reporter
title: Login crashes when session cookie is missing
existing_labels: (none)
thin_issue: no
body:
## Steps to reproduce
1. Clear cookies
2. Open /account
3. Click Save

## Expected
Redirect to login.

## Actual
Unhandled TypeError in session.ts:42.

## Environment
Node 24, Chrome 131, macOS 15.
</github_issue_context>

Triage this newly opened GitHub issue. Apply taxonomy labels with triage_issue.
`);

    t.succeeded();
    t.calledTool("triage_issue");
    const call = turn.requireToolCall("triage_issue");
    const labels = (call.input.labels ?? []) as readonly string[];
    t.check(labels.includes("bug"), equals(true).gate());
    t.check(call.input.requestRepro === false, equals(true).soft());
    t.check(t.reply, includes("triage_issue").soft());
  },
});

```

### `evals/no-repro-on-feature.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

export default defineEval({
  description:
    "Does not ask for repro details on a feature request that lacks bug-report fields.",
  async test(t) {
    const turn = await t.send(`
<github_issue_context>
repository: example/widget
issue_number: 22
sender: product
title: Add dark mode toggle
existing_labels: (none)
bug_like: no
thin_issue: no
body:
Please add a dark mode toggle in settings. I would love a system-preference default too.
</github_issue_context>

Triage this newly opened GitHub issue. Classify the taxonomy label first with triage_issue. Set requestRepro=true only when the primary label is bug and thin_issue is yes. Never ask for repro on feature, docs, question, or chore.
`);

    t.succeeded();
    t.calledTool("triage_issue");
    const call = turn.requireToolCall("triage_issue");
    const labels = (call.input.labels ?? []) as readonly string[];
    t.check(labels.includes("feature"), equals(true).gate());
    t.check(call.input.requestRepro === false, equals(true).gate());
  },
});

```

### `agent/README.md`

````md
# GitHub Issue Maintainer

Labels GitHub issues, asks for missing repro, and emails a weekly digest.

This eve agent watches newly opened GitHub issues (not pull requests), applies a
small taxonomy label set, asks for missing reproduction details when a report is
thin, and emails a weekly open-issue digest through Resend.

## Install

```bash
npx shadcn@latest add @evex/github-issue-maintainer
```

## How it works

1. Install this agent into an existing Eve app.
2. Deploy the Eve app so GitHub can reach it over HTTPS.
3. Create and install a GitHub App for the repositories you want maintained.
4. Point the GitHub App webhook to `/eve/v1/github`.
5. Subscribe to **Issues** and **Issue comments** (not pull request review write).
6. When an issue opens, the agent labels it and may ask for missing repro details.
7. Every Monday (UTC, configurable) it emails an open-issue digest.

This agent does **not** review pull requests and does **not** publish GitHub PR
reviews. Use `@evex/code-reviewer` for PR review.

## Label taxonomy

Only these labels are applied:

| Label | Meaning |
| --- | --- |
| `bug` | Something is broken or behaves incorrectly |
| `feature` | Request for new capability or behavior |
| `docs` | Documentation gaps or clarification |
| `question` | Guidance without a product change |
| `chore` | Maintenance, CI, dependencies, housekeeping |

Create these labels in the target repository (or allow the GitHub App to create
them on first use).

## Thin-issue detector

Thin-report requirements apply only to **bug** reports. Feature, docs,
question, and chore issues are labeled without a repro ask just because they
lack bug-report fields.

For bug-like reports, the agent treats an issue as thin when any of these are
missing:

- steps to reproduce
- expected vs actual behavior
- environment (OS, runtime/browser, package versions)

Thin bugs get a short comment asking only for the missing pieces.

## GitHub App setup

Create the GitHub App from **GitHub Settings -> Developer settings ->
GitHub Apps -> New GitHub App**.

Use these settings:

- **GitHub App name**: `github-issue-maintainer`, or another name that matches
  `GITHUB_APP_SLUG`.
- **Homepage URL**: your deployed Eve app URL.
- **Webhook**: active.
- **Webhook URL**: `https://<your-eve-deployment>/eve/v1/github`.
- **Webhook secret**: a long random value. Save the same value as
  `GITHUB_WEBHOOK_SECRET`.

### Permissions

Use the narrowest permissions that support issue triage:

- Metadata: read
- Issues: read/write
- Contents: read (needed for sandbox checkout on triggered turns)

Do **not** grant Pull requests write. This agent must not publish PR reviews.

### Events

Subscribe to:

- Issues
- Issue comments

You do not need Pull request review comments for this agent.

After creating the app:

1. Copy the **App ID** into `GITHUB_APP_ID`.
2. Generate a private key into `GITHUB_APP_PRIVATE_KEY`.
3. Install the app on the target repositories.
4. Copy the installation id into `GITHUB_APP_INSTALLATION_ID` (required for the
   weekly digest API reads).

When storing the private key as a single-line environment variable, replace
literal newlines with `\n`.

## Weekly digest

The digest is a scheduled email (not a PR review). Configure:

```bash
ISSUE_DIGEST_REPO=owner/repo
ISSUE_DIGEST_FROM=digest@yourdomain.com
ISSUE_DIGEST_TO=you@yourdomain.com
ISSUE_DIGEST_SUBJECT="Weekly open-issue digest"
ISSUE_DIGEST_CRON="0 9 * * 1"
RESEND_API_KEY=
GITHUB_APP_INSTALLATION_ID=
```

`ISSUE_DIGEST_TO` accepts a comma-separated list. The schedule defaults to
Mondays at 09:00 UTC. Delivery uses Resend with preview + confirmSend guards.
The send tool pins an ISO-week idempotency key (`github-issue-digest-YYYY-Www`)
so retries never duplicate the email, even across midnight. Digest HTML is
built with `compose_digest_html`, which escapes issue titles before insert.

## Environment

GitHub App credentials:

```bash
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_WEBHOOK_SECRET=
GITHUB_APP_SLUG=github-issue-maintainer
GITHUB_APP_INSTALLATION_ID=
```

Upstash Redis for rate limiting (stricter on public repos):

```bash
KV_REST_API_URL=
KV_REST_API_TOKEN=
```

Defaults:

- one triage every 15 minutes per issue
- one triage every 30 minutes per user per issue
- 50 triages per private repository per day
- 15 triages per public repository per day

Tune with `ISSUE_MAINTAINER_*` variables. Set
`ISSUE_MAINTAINER_RATE_LIMIT_ENABLED=false` for local development.

Model credential:

```bash
AI_GATEWAY_API_KEY=
```

## Smoke tests

1. Open a well-formed bug issue — expect a `bug` label and no repro ask.
2. Open a one-line "it doesnt work" issue — expect a label plus a repro ask.
3. Mention `@github-issue-maintainer` on a pull request — expect no triage and no
   PR review.
4. Trigger the digest schedule in dev:

```bash
curl -X POST http://localhost:2000/eve/v1/dev/schedules/weekly-issue-digest
```

## Troubleshooting

- **HTTP 401 on `/eve/v1/github`**: webhook secret mismatch.
- **No triage on new issues**: confirm the Issues event is subscribed and the
  app is installed on the repository.
- **Digest cannot list issues**: set `GITHUB_APP_INSTALLATION_ID` and
  `ISSUE_DIGEST_REPO`, and confirm Issues read permission.
- **Digest email not sent**: confirm `RESEND_API_KEY`, `ISSUE_DIGEST_FROM`, and
  `ISSUE_DIGEST_TO`. The agent previews before sending.

````
