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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
dist/
node_modules/

# Turbo cache (monorepo)
.turbo/

# Environment files
.env
.env.local
Expand Down Expand Up @@ -40,6 +43,7 @@ coverage/
.cache/
.npm/
.eslintcache
*.tsbuildinfo

# Temporary folders
tmp/
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

All notable changes to the GitHub plugin will be documented in this file.

## Unreleased

### Added

- **My PR Status (dynamic providers)**
- **MY_OPEN_PRS**: Dynamic provider that lists the authenticated user's open PRs across all repos (single search API call). Invoked when the user asks to list or see their open PRs.
- **MY_PR_STATUS**: Dynamic provider that returns the same list with per-PR metadata (comment counts, review state, merge readiness). Invoked when the user asks "what's my PR status?" or "what's ready to merge?"
- **Why two providers?** So the model can choose the right one: list-only when a simple list is enough (minimal API cost), or full status when the user wants merge/review details. No multi-step or extra LLM round-trips—only the selected dynamic provider runs before REPLY composes the answer.

- **Data Enricher Registry**
- `GitHubService.registerEnricher(enricher)` and `enrichItems(dataType, items, options?)` allow attaching extra metadata to list items (PRs, issues, repos, branches).
- **Why a registry?** To keep "fetch a list" and "attach extra data per row" composable: this plugin (or others) can register enrichers for `github:pull_request`, `github:issue`, `github:repository`, `github:branch` without changing core list logic. Built-in enricher adds merge/review status for PRs; list PR actions and MY_PR_STATUS both use it.
- List actions (list PRs, list/search issues, list/search repos, list branches) call `enrichItems` when the service supports it; when no enrichers are registered, behavior is unchanged (zero overhead).
- Enrichers run with a concurrency limit and per-enricher error isolation so one failure does not drop the whole list.

- **Setting: GITHUB_PR_STATUS_ORG_FILTER**
- Optional string. When set, "my PR status" / MY_OPEN_PRS only include PRs from repositories whose full name (`owner/repo`) contains this value (case-insensitive). Use to limit to work orgs and hide personal repos.

### Changed

- List pull requests, list/search issues, list/search repositories, and list branches now pass their results through the enricher registry when available. Existing behavior is preserved when no enrichers are registered or when the service does not expose `enrichItems` (e.g. in tests with mocks).
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A comprehensive GitHub integration plugin for elizaos that provides repository m
| `SERVER_PORT` | No | Server port (used with SERVER_HOST) |
| `SERVER_PROTOCOL` | No | Protocol for webhooks (http/https, defaults to https) |
| `GITHUB_WEBHOOK_SECRET` | No | Secret for webhook signature verification |
| `GITHUB_PR_STATUS_ORG_FILTER` | No | When asking "what's my PR status", only include PRs from repos whose full name (owner/repo) contains this string (case-insensitive). Use to limit to work orgs, e.g. `mycompany`. |

### Example `.env`

Expand Down Expand Up @@ -107,6 +108,12 @@ npm install @elizaos/plugin-github
- **Create Pull Requests**: Open new PRs from feature branches
- **Merge Pull Requests**: Merge approved PRs with different strategies

### 📋 My PR Status (dynamic providers)
- **MY_OPEN_PRS**: Lists your open PRs across all repos (one API call). Use when the user asks to "list my open PRs."
- **MY_PR_STATUS**: Same list plus per-PR metadata (comment counts, review state, merge readiness). Use when the user asks "what's my PR status?" or "what's ready to merge?"
**Why two providers?** So the LLM can choose: sometimes only the list is needed (cheap); sometimes full status is needed (more API calls). No extra LLM round-trips—the framework runs only the selected dynamic provider before REPLY composes the answer.
- **Org filter**: Set `GITHUB_PR_STATUS_ORG_FILTER` to restrict "my PR status" to repos whose name contains that string (e.g. your work org), so personal repos can be excluded.

### 📊 Activity Tracking
- **Activity Dashboard**: Real-time tracking of all GitHub operations
- **Rate Limit Monitoring**: Track GitHub API usage and remaining quota
Expand Down Expand Up @@ -191,6 +198,8 @@ GITHUB_WEBHOOK_URL=https://abc123.ngrok.io/api/github/webhook
"Create an issue about authentication bugs"
"Show me recent GitHub activity"
"What's my current GitHub rate limit?"
"What's my PR status?"
"List my open PRs"
```

### Programmatic
Expand Down Expand Up @@ -280,6 +289,14 @@ The plugin provides context through these providers:
- `GITHUB_PULL_REQUESTS_CONTEXT` - PR information and merge status
- `GITHUB_ACTIVITY_CONTEXT` - Activity statistics and recent operations
- `GITHUB_USER_CONTEXT` - Authenticated user information
- `MY_OPEN_PRS` (dynamic) - List of your open PRs (lightweight). Selected when the user asks to list their open PRs.
- `MY_PR_STATUS` (dynamic) - Your open PRs with merge/review status. Selected when the user asks about PR status or what's ready to merge.

### Data Enricher Registry

List actions (PRs, issues, repos, branches) can attach extra metadata to each row via a **registry** on `GitHubService`. Other plugins (or this one) register enrichers for data types like `github:pull_request`, `github:issue`, `github:repository`, `github:branch`. When a list is fetched, all registered enrichers for that type run per item and their results are merged onto the row.

**Why a registry?** So "get a list" and "attach extra data per row" stay decoupled: the plugin (or another plugin) can add merge status, CI status, Jira links, etc. without changing core list logic. The built-in PR enricher adds merge/review state for `github:pull_request`; list PR actions and MY_PR_STATUS both use it. Register enrichers in your plugin's `init()` and declare `@elizaos/plugin-github` as a dependency so the GitHub service exists first.

## Testing

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
"type": "string",
"description": "Secret for verifying GitHub webhook signatures",
"required": false
},
"GITHUB_PR_STATUS_ORG_FILTER": {
"type": "string",
"description": "When set, only PRs from repositories whose full name (owner/repo) contains this string are included in 'my PR status'. Use to limit to work orgs (e.g. mycompany). Case-insensitive.",
"required": false
}
}
},
Expand Down
153 changes: 153 additions & 0 deletions src/__tests__/enricher-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import { GitHubService } from "../services/github";
import type { IAgentRuntime } from "@elizaos/core";
import type { DataEnricher } from "../types";

describe("Enricher Registry", () => {
let mockRuntime: IAgentRuntime;

beforeEach(() => {
mockRuntime = {
getSetting: mock((key: string) => {
if (key === "GITHUB_TOKEN") return "ghp_test123";
return undefined;
}),
} as unknown as IAgentRuntime;
});

it("enrichItems with empty registry returns new array with same items", async () => {
const service = new GitHubService(mockRuntime);
const items = [
{ id: 1, title: "PR one" },
{ id: 2, title: "PR two" },
];
const result = await service.enrichItems("github:pull_request", items);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 1, title: "PR one" });
expect(result[1]).toEqual({ id: 2, title: "PR two" });
expect(result).not.toBe(items);
expect(result[0]).not.toBe(items[0]);
});

it("enrichItems with one enricher merges fields correctly", async () => {
const service = new GitHubService(mockRuntime);
const enricher: DataEnricher = {
name: "test-enricher",
dataType: "github:pull_request",
enrich: async (item) => ({
extra: `enriched-${(item as { id: number }).id}`,
}),
};
service.registerEnricher(enricher);
const items = [{ id: 1, title: "A" }, { id: 2, title: "B" }];
const result = await service.enrichItems("github:pull_request", items);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ id: 1, title: "A", extra: "enriched-1" });
expect(result[1]).toMatchObject({ id: 2, title: "B", extra: "enriched-2" });
});

it("enrichItems with multiple enrichers for same dataType runs all", async () => {
const service = new GitHubService(mockRuntime);
service.registerEnricher({
name: "enricher-a",
dataType: "github:pull_request",
enrich: async () => ({ fromA: true }),
});
service.registerEnricher({
name: "enricher-b",
dataType: "github:pull_request",
enrich: async () => ({ fromB: true }),
});
const items = [{ id: 1 }];
const result = await service.enrichItems("github:pull_request", items);
expect(result[0]).toMatchObject({ id: 1, fromA: true, fromB: true });
});

it("failed enricher on one item does not block others", async () => {
const service = new GitHubService(mockRuntime);
let callCount = 0;
service.registerEnricher({
name: "failing-enricher",
dataType: "github:pull_request",
enrich: async (item) => {
callCount++;
if ((item as { id: number }).id === 2) {
throw new Error("Enricher failed for item 2");
}
return { ok: true };
},
});
const items = [
{ id: 1, title: "A" },
{ id: 2, title: "B" },
{ id: 3, title: "C" },
];
const result = await service.enrichItems("github:pull_request", items, {
concurrency: 5,
});
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({ id: 1, title: "A", ok: true });
expect(result[1]).toMatchObject({ id: 2, title: "B" });
expect(result[1]).not.toHaveProperty("ok");
expect(result[2]).toMatchObject({ id: 3, title: "C", ok: true });
expect(callCount).toBe(3);
});

it("enrichItems does not mutate input items", async () => {
const service = new GitHubService(mockRuntime);
service.registerEnricher({
name: "mutating-lookalike",
dataType: "github:pull_request",
enrich: async (item) => ({ added: (item as { id: number }).id + 100 }),
});
const items = [{ id: 1 }, { id: 2 }];
const result = await service.enrichItems("github:pull_request", items);
expect(items[0]).toEqual({ id: 1 });
expect(items[1]).toEqual({ id: 2 });
expect(result[0]).toMatchObject({ id: 1, added: 101 });
expect(result[1]).toMatchObject({ id: 2, added: 102 });
});

it("enricher return value is JSON-serializable (no circular refs)", async () => {
const service = new GitHubService(mockRuntime);
service.registerEnricher({
name: "plain-result",
dataType: "github:pull_request",
enrich: async () => ({
comments: 3,
mergeable: true,
clearToMerge: true,
}),
});
const items = [{ number: 1 }];
const result = await service.enrichItems("github:pull_request", items);
expect(() => JSON.parse(JSON.stringify(result[0]))).not.toThrow();
const roundTrip = JSON.parse(JSON.stringify(result[0]));
expect(roundTrip).toMatchObject({
number: 1,
comments: 3,
mergeable: true,
clearToMerge: true,
});
});

it("different dataTypes have separate enricher lists", async () => {
const service = new GitHubService(mockRuntime);
service.registerEnricher({
name: "pr-enricher",
dataType: "github:pull_request",
enrich: async (item) => ({ type: "pr", id: (item as { id: number }).id }),
});
service.registerEnricher({
name: "issue-enricher",
dataType: "github:issue",
enrich: async (item) => ({ type: "issue", id: (item as { id: number }).id }),
});
const prItems = [{ id: 1 }];
const issueItems = [{ id: 2 }];
const prResult = await service.enrichItems("github:pull_request", prItems);
const issueResult = await service.enrichItems("github:issue", issueItems);
expect(prResult[0]).toMatchObject({ id: 1, type: "pr" });
expect(issueResult[0]).toMatchObject({ id: 2, type: "issue" });
});
});
Loading