From ab826fc3c000c58cd2d0216f1970d4b8aff0f13c Mon Sep 17 00:00:00 2001 From: Odilitime Date: Tue, 3 Mar 2026 06:05:08 +0000 Subject: [PATCH] feat: My PR Status providers, enricher registry, barrel removal - Add MY_OPEN_PRS and MY_PR_STATUS dynamic providers (myPrStatus.ts) - Add GITHUB_PR_STATUS_ORG_FILTER for org-scoped PR status - Add Data Enricher registry on GitHubService (registerEnricher, enrichItems) - Register built-in pr-merge-review-status enricher for github:pull_request - Add MyOpenPrItem, DataEnricher types; state-helpers, serialize utils - Remove provider/utils index barrels; pullRequests imports from concrete utils - PR URL regex accepts /pull/ and /pulls/; use getPullRequestFromState/getRepositoryFromState - .gitignore: .turbo/, *.tsbuildinfo; README and CHANGELOG updates - Tests: enricher-registry.test, myPrStatus.test Made-with: Cursor --- .gitignore | 4 + CHANGELOG.md | 25 +++ README.md | 17 ++ package.json | 5 + src/__tests__/enricher-registry.test.ts | 153 ++++++++++++++ src/__tests__/myPrStatus.test.ts | 260 ++++++++++++++++++++++++ src/actions/activity.ts | 34 +++- src/actions/branches.ts | 48 ++++- src/actions/issues.ts | 112 +++++++--- src/actions/pullRequests.ts | 108 +++++++--- src/actions/repository.ts | 87 +++++--- src/actions/search.ts | 49 ++++- src/actions/stats.ts | 30 ++- src/actions/users.ts | 36 +++- src/actions/webhooks.ts | 16 +- src/index.ts | 42 ++++ src/providers/github.ts | 2 +- src/providers/index.ts | 27 --- src/providers/myPrStatus.ts | 166 +++++++++++++++ src/services/github.ts | 157 +++++++++++++- src/types.ts | 35 ++++ src/utils/serialize.ts | 250 +++++++++++++++++++++++ src/utils/state-helpers.ts | 153 ++++++++++++++ src/utils/state-persistence.ts | 41 +++- 24 files changed, 1711 insertions(+), 146 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/__tests__/enricher-registry.test.ts create mode 100644 src/__tests__/myPrStatus.test.ts delete mode 100644 src/providers/index.ts create mode 100644 src/providers/myPrStatus.ts create mode 100644 src/utils/serialize.ts create mode 100644 src/utils/state-helpers.ts diff --git a/.gitignore b/.gitignore index 1b7523c..6b24b04 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ dist/ node_modules/ +# Turbo cache (monorepo) +.turbo/ + # Environment files .env .env.local @@ -40,6 +43,7 @@ coverage/ .cache/ .npm/ .eslintcache +*.tsbuildinfo # Temporary folders tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b86d8e --- /dev/null +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index 9b8777a..0bf37e8 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 @@ -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 @@ -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 diff --git a/package.json b/package.json index 73b8bc3..2288cf2 100644 --- a/package.json +++ b/package.json @@ -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 } } }, diff --git a/src/__tests__/enricher-registry.test.ts b/src/__tests__/enricher-registry.test.ts new file mode 100644 index 0000000..f1e0ef3 --- /dev/null +++ b/src/__tests__/enricher-registry.test.ts @@ -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" }); + }); +}); diff --git a/src/__tests__/myPrStatus.test.ts b/src/__tests__/myPrStatus.test.ts new file mode 100644 index 0000000..78954f7 --- /dev/null +++ b/src/__tests__/myPrStatus.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; +import { GitHubService } from "../services/github"; +import type { IAgentRuntime } from "@elizaos/core"; +import { myOpenPrsProvider, myPrStatusProvider } from "../providers/myPrStatus"; +import type { MyOpenPrItem } from "../types"; + +function assertJsonSerializable(obj: unknown): void { + expect(() => JSON.parse(JSON.stringify(obj))).not.toThrow(); +} + +describe("getMyOpenPRs", () => { + let mockRuntime: IAgentRuntime; + + beforeEach(() => { + mockRuntime = { + getSetting: mock((key: string) => { + if (key === "GITHUB_TOKEN") return "ghp_test123"; + return undefined; + }), + } as unknown as IAgentRuntime; + }); + + it("returns items and totalCount from search", async () => { + const service = new GitHubService(mockRuntime); + const searchPullRequests = service.searchPullRequests.bind(service); + (service as any).searchPullRequests = mock(async (query: string, opts: { per_page?: number }) => { + expect(query).toContain("is:pr"); + expect(query).toContain("is:open"); + return { + total_count: 2, + items: [ + { + number: 1, + title: "First PR", + html_url: "https://github.com/owner/repo/pull/1", + draft: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + }, + { + number: 2, + title: "Second PR", + html_url: "https://github.com/owner/repo/pull/2", + draft: true, + created_at: "2024-01-03T00:00:00Z", + updated_at: "2024-01-04T00:00:00Z", + }, + ], + }; + }); + (service as any).getAuthenticatedUser = mock(async () => ({ login: "me" })); + + const result = await service.getMyOpenPRs({ maxResults: 10 }); + expect(result.totalCount).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ + owner: "owner", + repo: "repo", + number: 1, + title: "First PR", + full_name: "owner/repo", + draft: false, + }); + expect(result.items[1].number).toBe(2); + expect(result.items[1].draft).toBe(true); + }); + + it("applies org filter case-insensitively", async () => { + const service = new GitHubService(mockRuntime); + (service as any).searchPullRequests = mock(async () => ({ + total_count: 3, + items: [ + { number: 1, title: "A", html_url: "https://github.com/MyOrg/repo1/pull/1", draft: false, created_at: "", updated_at: "" }, + { number: 2, title: "B", html_url: "https://github.com/other/repo2/pull/2", draft: false, created_at: "", updated_at: "" }, + { number: 3, title: "C", html_url: "https://github.com/myorg/repo3/pull/3", draft: false, created_at: "", updated_at: "" }, + ], + })); + (service as any).getAuthenticatedUser = mock(async () => ({ login: "me" })); + + const result = await service.getMyOpenPRs({ orgFilter: "myorg", maxResults: 10 }); + expect(result.items).toHaveLength(2); + expect(result.items.map((p: MyOpenPrItem) => p.full_name)).toEqual(["MyOrg/repo1", "myorg/repo3"]); + }); +}); + +describe("MY_OPEN_PRS provider", () => { + it("returns empty when unauthenticated", async () => { + const mockRuntime = { + getSetting: mock(() => undefined), + getService: mock(() => ({ + authMode: "unauthenticated", + })), + } as unknown as IAgentRuntime; + + const result = await myOpenPrsProvider.get(mockRuntime, {} as any, undefined); + expect(result.text).toBe(""); + expect(result.values).toMatchObject({ openPrCount: 0 }); + expect(result.data?.prs).toEqual([]); + }); + + it("returns list text when authenticated and PRs exist", async () => { + const items: MyOpenPrItem[] = [ + { + owner: "o", + repo: "r", + number: 1, + title: "Test PR", + html_url: "https://github.com/o/r/pull/1", + full_name: "o/r", + draft: false, + created_at: "", + updated_at: "", + }, + ]; + const mockRuntime = { + getSetting: mock((key: string) => (key === "GITHUB_PR_STATUS_ORG_FILTER" ? undefined : undefined)), + getService: mock(() => ({ + authMode: "authenticated", + getMyOpenPRs: mock(async () => ({ items, totalCount: 1 })), + })), + } as unknown as IAgentRuntime; + + const result = await myOpenPrsProvider.get(mockRuntime, {} as any, undefined); + expect(result.text).toContain("o/r#1"); + expect(result.text).toContain("Test PR"); + expect(result.values).toMatchObject({ openPrCount: 1 }); + expect((result.data?.prs as MyOpenPrItem[]).length).toBe(1); + }); +}); + +describe("built-in PR enricher (merge/review status)", () => { + it("returns plain JSON-serializable fields and correct merge/review state", async () => { + const mockRuntime = { + getSetting: mock(() => "ghp_test"), + getService: mock(() => undefined as unknown), + } as unknown as IAgentRuntime; + + const service = new GitHubService(mockRuntime); + (mockRuntime.getService as ReturnType).mockImplementation( + () => service, + ); + + (service as any).getPullRequest = mock(async () => ({ + comments: 3, + review_comments: 2, + mergeable: true, + mergeable_state: "clean", + })); + (service as any).getPullRequestReviews = mock(async () => [ + { state: "APPROVED" }, + ]); + + service.registerEnricher({ + name: "pr-merge-review-status", + dataType: "github:pull_request", + enrich: async (pr: MyOpenPrItem, rt: IAgentRuntime) => { + const svc = rt.getService("github"); + if (!svc) return {}; + const [fullPr, reviews] = await Promise.all([ + svc.getPullRequest(pr.owner, pr.repo, pr.number), + svc.getPullRequestReviews(pr.owner, pr.repo, pr.number), + ]); + const approved = (reviews as { state: string }[]).some( + (r) => r.state === "APPROVED", + ); + const changesRequested = (reviews as { state: string }[]).some( + (r) => r.state === "CHANGES_REQUESTED", + ); + return { + comments: fullPr.comments, + review_comments: fullPr.review_comments, + mergeable: fullPr.mergeable, + mergeable_state: fullPr.mergeable_state, + approved, + changesRequested, + clearToMerge: fullPr.mergeable === true && !changesRequested, + }; + }, + }); + + const item: MyOpenPrItem = { + owner: "o", + repo: "r", + number: 1, + title: "PR", + html_url: "https://github.com/o/r/pull/1", + full_name: "o/r", + draft: false, + created_at: "", + updated_at: "", + }; + const enriched = await service.enrichItems( + "github:pull_request", + [item], + { concurrency: 5 }, + ); + expect(enriched).toHaveLength(1); + expect(enriched[0]).toMatchObject({ + ...item, + comments: 3, + review_comments: 2, + mergeable: true, + mergeable_state: "clean", + approved: true, + changesRequested: false, + clearToMerge: true, + }); + assertJsonSerializable(enriched); + }); +}); + +describe("MY_PR_STATUS provider", () => { + it("returns empty when unauthenticated", async () => { + const mockRuntime = { + getSetting: mock(() => undefined), + getService: mock(() => ({ + authMode: "unauthenticated", + })), + } as unknown as IAgentRuntime; + + const result = await myPrStatusProvider.get(mockRuntime, {} as any, undefined); + expect(result.text).toBe(""); + expect(result.values).toMatchObject({ openPrCount: 0, clearToMergeCount: 0 }); + }); + + it("returns enriched status text when authenticated", async () => { + const items: MyOpenPrItem[] = [ + { + owner: "o", + repo: "r", + number: 1, + title: "Ready PR", + html_url: "https://github.com/o/r/pull/1", + full_name: "o/r", + draft: false, + created_at: "", + updated_at: "", + }, + ]; + const enriched = [ + { ...items[0], comments: 0, review_comments: 0, clearToMerge: true, approved: true, changesRequested: false }, + ]; + const mockRuntime = { + getSetting: mock(() => undefined), + getService: mock(() => ({ + authMode: "authenticated", + getMyOpenPRs: mock(async () => ({ items, totalCount: 1 })), + enrichItems: mock(async () => enriched), + })), + } as unknown as IAgentRuntime; + + const result = await myPrStatusProvider.get(mockRuntime, {} as any, undefined); + expect(result.text).toContain("clear to merge"); + expect(result.text).toContain("o/r#1"); + expect(result.values).toMatchObject({ openPrCount: 1, clearToMergeCount: 1 }); + expect((result.data?.prs as unknown[]).length).toBe(1); + expect((result.data?.prs as unknown[])[0]).not.toHaveProperty("request"); + expect((result.data?.prs as unknown[])[0]).not.toHaveProperty("headers"); + }); +}); diff --git a/src/actions/activity.ts b/src/actions/activity.ts index 210560a..417f858 100644 --- a/src/actions/activity.ts +++ b/src/actions/activity.ts @@ -18,11 +18,19 @@ export const getGitHubActivityAction: Action = { validate: async ( runtime: IAgentRuntime, - _message: Memory, + message: Memory, _state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for activity/log-related keywords + const hasActivityIntent = /\b(activity|log|history|what.*did|recent|action)\b/.test(text); + const hasGitHubContext = /\b(github)\b/.test(text) || hasActivityIntent; + + return hasActivityIntent && hasGitHubContext; }, handler: async ( @@ -229,7 +237,16 @@ export const clearGitHubActivityAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for clear/reset intent + const hasClearIntent = /\b(clear|reset|delete|remove|erase)\b/.test(text); + const hasActivityContext = /\b(activity|log|history)\b/.test(text); + const hasGitHubContext = /\b(github)\b/.test(text) || hasActivityContext; + + return hasClearIntent && hasActivityContext && hasGitHubContext; }, handler: async ( @@ -355,7 +372,16 @@ export const getGitHubRateLimitAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for rate limit intent + const hasRateLimitIntent = /\b(rate.*limit|api.*limit|quota|remaining|usage)\b/.test(text); + const hasCheckIntent = /\b(check|show|get|what|how.*many)\b/.test(text); + const hasGitHubContext = /\b(github|api)\b/.test(text) || hasRateLimitIntent; + + return (hasRateLimitIntent || (hasCheckIntent && hasGitHubContext)); }, handler: async ( diff --git a/src/actions/branches.ts b/src/actions/branches.ts index 00b929a..d5b2567 100644 --- a/src/actions/branches.ts +++ b/src/actions/branches.ts @@ -31,7 +31,19 @@ export const listBranchesAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for branch-related keywords + const hasBranchIntent = /\b(branch|branches)\b/.test(text); + const hasListIntent = /\b(list|show|get|display)\b/.test(text); + + // Check for repository context + const hasRepoContext = /github\.com\/[^\/\s]+\/[^\/\s]+|[^\/\s]+\/[^\/\s]+/.test(message.content.text || "") || + /\b(repo|repository)\b/.test(text); + + return hasBranchIntent && hasListIntent && hasRepoContext; }, handler: async ( @@ -130,8 +142,18 @@ export const listBranchesAction: Action = { }), ); + // Optional enrichment when enrichers are registered; guard for mocks. + const branchesToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:branch", + branchesWithDetails, + { concurrency: 5 }, + ) + : branchesWithDetails; + // Sort by commit date (most recent first) - branchesWithDetails.sort( + branchesToUse.sort( (a, b) => new Date(b.commit.date).getTime() - new Date(a.commit.date).getTime(), ); @@ -139,10 +161,10 @@ export const listBranchesAction: Action = { // Filter by protected status if requested const filteredBranches = options?.protected !== undefined - ? branchesWithDetails.filter( - (b) => b.protected === options?.protected, - ) - : branchesWithDetails; + ? branchesToUse.filter( + (b) => b.protected === options?.protected, + ) + : branchesToUse; const branchList = filteredBranches .map((branch) => { @@ -428,7 +450,19 @@ export const getBranchProtectionAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for protection-related keywords + const hasProtectionIntent = /\b(protect|protection|rule|check|status)\b/.test(text); + const hasBranchContext = /\b(branch)\b/.test(text); + + // Check for repository context + const hasRepoContext = /github\.com\/[^\/\s]+\/[^\/\s]+|[^\/\s]+\/[^\/\s]+/.test(message.content.text || "") || + /\b(repo|repository)\b/.test(text); + + return hasProtectionIntent && hasBranchContext && hasRepoContext; }, handler: async ( diff --git a/src/actions/issues.ts b/src/actions/issues.ts index 3ead75a..d2f82e1 100644 --- a/src/actions/issues.ts +++ b/src/actions/issues.ts @@ -14,7 +14,9 @@ import { type GitHubIssue, type CreateIssueOptions, } from "../index"; -import { mergeAndSaveGitHubState } from "../utils/state-persistence"; +import { mergeAndSaveGitHubState, loadGitHubState } from "../utils/state-persistence"; +import { getRepositoryFromState } from "../utils/state-helpers"; +import { sanitizeIssue } from "../utils/serialize"; /** * Check if the GitHub service is available and authenticated. @@ -62,24 +64,28 @@ export const getIssueAction: Action = { // Extract owner, repo, and issue number from message text or options const text = message.content.text || ""; const issueMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/issues\/(\d+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)\/issues\/(\d+)/, ); const ownerRepoMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)/, ); const issueNumMatch = text.match(/(?:issue\s*#?|#)(\d+)/i); + // Load persisted state for repository context fallback + const persistedState = await loadGitHubState(runtime, message.roomId); + const repoContext = getRepositoryFromState(state, persistedState); + const owner = options.owner || issueMatch?.[1] || ownerRepoMatch?.[1] || - state?.github?.lastRepository?.owner?.login || + repoContext?.owner || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || issueMatch?.[2] || ownerRepoMatch?.[2] || - state?.github?.lastRepository?.name; + repoContext?.repo; const issue_number = options.issue_number || parseInt(issueMatch?.[3] || issueNumMatch?.[1] || "0", 10); @@ -264,18 +270,22 @@ export const listIssuesAction: Action = { // Extract owner and repo from message text or options const text = message.content.text || ""; const ownerRepoMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)/, ); + // Load persisted state for repository context fallback + const persistedState = await loadGitHubState(runtime, message.roomId); + const repoContext = getRepositoryFromState(state, persistedState); + const owner = options.owner || ownerRepoMatch?.[1] || - state?.github?.lastRepository?.owner?.login || + repoContext?.owner || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || ownerRepoMatch?.[2] || - state?.github?.lastRepository?.name; + repoContext?.repo; if (!owner || !repo) { throw new Error( @@ -298,8 +308,17 @@ export const listIssuesAction: Action = { labels: options.labels, per_page: options.limit || 10, }); + // Optional enrichment when enrichers are registered; guard for mocks without enrichItems. + const issuesToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:issue", + issues, + { concurrency: 5 }, + ) + : issues; - const issueList = issues + const issueList = issuesToUse .map((issue: any) => { const labels = issue.labels @@ -312,13 +331,13 @@ export const listIssuesAction: Action = { .join("\n"); const responseContent: Content = { - text: `${issueState.charAt(0).toUpperCase() + issueState.slice(1)} issues for ${owner}/${repo} (${issues.length} shown):\n${issueList}`, + text: `${issueState.charAt(0).toUpperCase() + issueState.slice(1)} issues for ${owner}/${repo} (${issuesToUse.length} shown):\n${issueList}`, actions: ["LIST_GITHUB_ISSUES"], source: message.content.source, // Include data for callbacks - issues, + issues: issuesToUse, repository: `${owner}/${repo}`, - issueCount: issues.length, + issueCount: issuesToUse.length, }; if (callback) { @@ -328,23 +347,23 @@ export const listIssuesAction: Action = { return { text: responseContent.text, values: { - issues, + issues: issuesToUse, repository: `${owner}/${repo}`, - issueCount: issues.length, + issueCount: issuesToUse.length, }, data: { - issues, + issues: issuesToUse, github: { ...state?.data?.github, // Preserve previous github state from data ...state?.github, // Also check root-level github state - lastIssues: issues, + lastIssues: issuesToUse, lastRepository: state?.data?.github?.lastRepository || state?.github?.lastRepository, // Preserve lastRepository issues: { ...state?.data?.github?.issues, ...state?.github?.issues, - ...issues.reduce( + ...issuesToUse.reduce( (acc: any, issue: any) => { acc[`${owner}/${repo}#${issue.number}`] = issue; return acc; @@ -437,18 +456,22 @@ export const createIssueAction: Action = { // Extract owner and repo from message text or options const text = message.content.text || ""; const ownerRepoMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)/, ); + // Load persisted state for repository context fallback + const persistedState = await loadGitHubState(runtime, message.roomId); + const repoContext = getRepositoryFromState(state, persistedState); + const owner = options.owner || ownerRepoMatch?.[1] || - state?.github?.lastRepository?.owner?.login || + repoContext?.owner || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || ownerRepoMatch?.[2] || - state?.github?.lastRepository?.name; + repoContext?.repo; if (!owner || !repo) { throw new Error( @@ -523,6 +546,7 @@ URL: ${issue.html_url}`, // Persist state for daemon restarts const newGitHubState = { + lastRepository: { owner: { login: owner }, name: repo, full_name: `${owner}/${repo}` }, lastIssue: issue, lastCreatedIssue: issue, issues: { @@ -542,8 +566,10 @@ URL: ${issue.html_url}`, issueUrl: issue.html_url, }, data: { + ...state?.data, issue, github: { + ...state?.data?.github, ...state?.github, ...newGitHubState, }, @@ -645,7 +671,25 @@ export const searchIssuesAction: Action = { /(?:search|find|look for)\s+(?:issues?\s+)?(?:for\s+)?["\']?([^"'\n]+?)["\']?(?:\s|$)/i, ); - const query = options.query || queryMatch?.[1]; + let query = options.query || queryMatch?.[1]; + + // Fallback: try to extract query from after "issues" keyword + if (!query) { + const issuesMatch = text.match(/issues?\s+(?:about|with|related to|matching)\s+["\']?([^"'\n]+?)["\']?(?:\s|$)/i); + if (issuesMatch?.[1]) { + query = issuesMatch[1]; + } + } + + // Last resort: use message text minus Discord metadata + if (!query) { + // Strip "Referencing MessageID..." suffix + const cleanText = text.replace(/\s*Referencing MessageID.*$/i, '').trim(); + // Only use if it's not too short and doesn't look like a command + if (cleanText.length > 3 && !cleanText.match(/^(search|find|list|show)\s*$/i)) { + query = cleanText; + } + } if (!query) { throw new Error( @@ -658,8 +702,18 @@ export const searchIssuesAction: Action = { sort: options.sort || "updated", per_page: options.limit || 10, }); + const rawItems = searchResult.items || []; + // Optional enrichment; guard for mocks. + const issuesToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:issue", + rawItems, + { concurrency: 5 }, + ) + : rawItems; - const issueList = (searchResult.items || []) + const issueList = issuesToUse .map((issue: any) => { const labels = issue.labels @@ -676,11 +730,11 @@ export const searchIssuesAction: Action = { .join("\n"); const responseContent: Content = { - text: `Found ${searchResult.total_count || 0} issues for "${query}" (showing ${searchResult.items?.length || 0}):\n${issueList}`, + text: `Found ${searchResult.total_count || 0} issues for "${query}" (showing ${issuesToUse.length}):\n${issueList}`, actions: ["SEARCH_GITHUB_ISSUES"], source: message.content.source, - // Include data for callbacks - issues: searchResult.items || [], + // Include data for callbacks (sanitized to prevent circular refs) + issues: issuesToUse.map(sanitizeIssue), totalCount: searchResult.total_count || 0, query, }; @@ -693,16 +747,16 @@ export const searchIssuesAction: Action = { return { text: responseContent.text, values: { - issues: searchResult.items, + issues: issuesToUse, totalCount: searchResult.total_count, query, }, data: { - issues: searchResult.items, + issues: issuesToUse, github: { ...state?.data?.github, // Preserve previous github state from data ...state?.github, // Also check root-level github state - lastIssueSearchResults: searchResult, + lastIssueSearchResults: { ...searchResult, items: issuesToUse }, lastIssueSearchQuery: query, lastRateLimit: state?.data?.github?.lastRateLimit || @@ -710,7 +764,7 @@ export const searchIssuesAction: Action = { issues: { ...state?.data?.github?.issues, ...state?.github?.issues, - ...(searchResult.items || []).reduce( + ...issuesToUse.reduce( (acc: any, issue: any) => { const repoName = issue.html_url ? issue.html_url.match(/github\.com\/([^\/]+\/[^\/]+)/)?.[1] diff --git a/src/actions/pullRequests.ts b/src/actions/pullRequests.ts index 15d3699..ebc9617 100644 --- a/src/actions/pullRequests.ts +++ b/src/actions/pullRequests.ts @@ -11,7 +11,17 @@ import { } from "@elizaos/core"; import { GitHubService } from "../services/github"; import { CreatePullRequestOptions, GitHubPullRequest } from "../types"; -import { mergeAndSaveGitHubState, loadGitHubState } from "../utils/state-persistence"; +import { + mergeAndSaveGitHubState, + loadGitHubState, +} from "../utils/state-persistence"; +import { + getRepositoryFromState, + getPullRequestFromState, + createRepositoryContext, + mergeGitHubStateForReturn, +} from "../utils/state-helpers"; +import { sanitizePullRequest } from "../utils/serialize"; /** * Check if the GitHub service is available and authenticated. @@ -54,7 +64,8 @@ export const getPullRequestAction: Action = { if (wantsAggregate) return false; // Check if message contains a GitHub PR URL (language-agnostic) - const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content.text || ""); + // Match both /pull/123 and /pulls/123 (GitHub accepts both) + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pulls?\/\d+/.test(message.content.text || ""); if (hasPrUrl) return true; // Check if asking about comments/reviews on an existing PR context @@ -87,32 +98,34 @@ export const getPullRequestAction: Action = { } // Extract owner, repo, and PR number from message text + // Match both /pull/123 and /pulls/123 (GitHub accepts both) const text = message.content.text || ""; const prMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pulls?\/(\d+)/, ); const ownerRepoMatch = text.match( /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, ); const prNumMatch = text.match(/(?:pr\s*#?|pull\s*request\s*#?|#)(\d+)/i); - // Try to get lastPullRequest from persisted state for context + // Try to get context from persisted state const persistedState = await loadGitHubState(runtime, message.roomId); - const lastPr = persistedState?.lastPullRequest || state?.github?.lastPullRequest; + const lastPr = getPullRequestFromState(state, persistedState); const lastPrOwner = lastPr?.base?.repo?.owner?.login || lastPr?.head?.repo?.owner?.login; const lastPrRepo = lastPr?.base?.repo?.name || lastPr?.head?.repo?.name; + const repoContext = getRepositoryFromState(state, persistedState); const owner = prMatch?.[1] || ownerRepoMatch?.[1] || lastPrOwner || - state?.github?.lastRepository?.owner?.login || + repoContext?.owner || runtime.getSetting("GITHUB_OWNER"); const repo = prMatch?.[2] || ownerRepoMatch?.[2] || lastPrRepo || - state?.github?.lastRepository?.name; + repoContext?.repo; const pull_number = parseInt(prMatch?.[3] || prNumMatch?.[1] || "", 10) || lastPr?.number; @@ -411,15 +424,21 @@ export const listPullRequestsAction: Action = { // Don't match if asking about comments on a specific PR const isCommentQuestion = /comments?|reviews?|feedback|how many/.test(text); - const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(message.content.text || ""); + // Match both /pull/123 and /pulls/123 + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pulls?\/\d+/.test(message.content.text || ""); if (isCommentQuestion && hasPrUrl) { return false; // Let GET_GITHUB_PULL_REQUEST handle this } + // If the URL points to a specific PR, let GET_GITHUB_PULL_REQUEST handle it + if (hasPrUrl) { + return false; + } + // Has list intent OR has repo URL without specific PR number - const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+(?!\/pull)/.test(message.content.text || "") && - !/\/pull\/\d+/.test(message.content.text || ""); + const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+/.test(message.content.text || "") && + !/\/pulls?\/\d+/.test(message.content.text || ""); return hasListIntent || hasRepoUrl; }, @@ -440,16 +459,19 @@ export const listPullRequestsAction: Action = { // Extract owner and repo from message text const text = message.content.text || ""; const ownerRepoMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)/, ); + // Load persisted state for repository context fallback + const persistedState = await loadGitHubState(runtime, message.roomId); + const repoContext = getRepositoryFromState(state, persistedState); const owner = ownerRepoMatch?.[1] || - state?.github?.lastRepository?.owner?.login || + repoContext?.owner || runtime.getSetting("GITHUB_OWNER"); const repo = ownerRepoMatch?.[2] || - state?.github?.lastRepository?.name; + repoContext?.repo; if (!owner || !repo) { throw new Error( @@ -473,8 +495,17 @@ export const listPullRequestsAction: Action = { state: prState, per_page: limit, }); - - const prList = prs + // Optional enrichment: merge/review metadata when enrichers are registered. Guard so mocks without enrichItems still work. + const prsToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:pull_request", + prs, + { concurrency: 5 }, + ) + : prs; + + const prList = prsToUse .map((pr: any) => { const labels = pr.labels ? pr.labels.map((label: any) => label.name).join(", ") @@ -490,13 +521,13 @@ export const listPullRequestsAction: Action = { .join("\n"); const responseContent: Content = { - text: `${prState.charAt(0).toUpperCase() + prState.slice(1)} pull requests for ${owner}/${repo} (${prs.length} shown):\n${prList}`, + text: `${prState.charAt(0).toUpperCase() + prState.slice(1)} pull requests for ${owner}/${repo} (${prsToUse.length} shown):\n${prList}`, actions: ["LIST_GITHUB_PULL_REQUESTS"], source: message.content.source, - // Include data for callbacks - pullRequests: prs, + // Include data for callbacks (sanitized to prevent circular refs) + pullRequests: prsToUse.map(sanitizePullRequest), repository: `${owner}/${repo}`, - pullRequestCount: prs.length, + pullRequestCount: prsToUse.length, }; if (callback) { @@ -505,10 +536,11 @@ export const listPullRequestsAction: Action = { // Persist state for daemon restarts const newGitHubState = { - lastPullRequests: prs, + lastRepository: createRepositoryContext(owner, repo), + lastPullRequests: prsToUse, pullRequests: { ...state?.github?.pullRequests, - ...prs.reduce( + ...prsToUse.reduce( (acc: any, pr: any) => { acc[`${owner}/${repo}#${pr.number}`] = pr; return acc; @@ -524,16 +556,13 @@ export const listPullRequestsAction: Action = { success: true, text: responseContent.text, values: { - pullRequests: prs, + pullRequests: prsToUse, repository: `${owner}/${repo}`, - pullRequestCount: prs.length, + pullRequestCount: prsToUse.length, }, data: { - pullRequests: prs, - github: { - ...state?.github, - ...newGitHubState, - }, + ...mergeGitHubStateForReturn(state, newGitHubState), + pullRequests: prsToUse, }, } as ActionResult; } catch (error) { @@ -627,11 +656,13 @@ export const createPullRequestAction: Action = { const owner = options.owner || ownerRepoMatch?.[1] || + state?.data?.github?.lastRepository?.owner?.login || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || ownerRepoMatch?.[2] || + state?.data?.github?.lastRepository?.name || state?.github?.lastRepository?.name; if (!owner || !repo) { @@ -713,6 +744,7 @@ URL: ${pr.html_url}`, // Persist state for daemon restarts const newGitHubState = { + lastRepository: { owner: { login: owner }, name: repo, full_name: `${owner}/${repo}` }, lastPullRequest: pr, lastCreatedPullRequest: pr, pullRequests: { @@ -733,8 +765,10 @@ URL: ${pr.html_url}`, pullRequestUrl: pr.html_url, }, data: { + ...state?.data, pullRequest: pr, github: { + ...state?.data?.github, ...state?.github, ...newGitHubState, }, @@ -822,9 +856,10 @@ export const mergePullRequestAction: Action = { } // Extract owner, repo, and PR number from message text + // Match both /pull/123 and /pulls/123 const text = message.content.text || ""; const prMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pulls?\/(\d+)/, ); const ownerRepoMatch = text.match( /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, @@ -834,16 +869,21 @@ export const mergePullRequestAction: Action = { const owner = prMatch?.[1] || ownerRepoMatch?.[1] || + state?.data?.github?.lastPullRequest?.base?.repo?.owner?.login || state?.github?.lastPullRequest?.base?.repo?.owner?.login || + state?.data?.github?.lastRepository?.owner?.login || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = prMatch?.[2] || ownerRepoMatch?.[2] || + state?.data?.github?.lastPullRequest?.base?.repo?.name || state?.github?.lastPullRequest?.base?.repo?.name || + state?.data?.github?.lastRepository?.name || state?.github?.lastRepository?.name; const pull_number = parseInt(prMatch?.[3] || prNumMatch?.[1] || "0", 10) || + state?.data?.github?.lastPullRequest?.number || state?.github?.lastPullRequest?.number; if (!owner || !repo || !pull_number) { @@ -1000,7 +1040,7 @@ export const summarizePrFeedbackAction: Action = { const wantsAggregate = /aggregat|mega.?prompt|combine.*comment|all.*bot|bot.*feedback|task.*prompt|single.*prompt|prompt.*cursor/i.test(text); if (wantsAggregate) return false; - const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(text); + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pulls?\/\d+/.test(text); // Match: summary, analyze, issues, findings, comments, feedback, addressed, reviews, what did X find/say const wantsFeedback = /summar|analyz|issues?|findings?|comments?|feedback|address|review|what.*(?:found|say|wrong|find)/i.test(text); @@ -1021,9 +1061,10 @@ export const summarizePrFeedbackAction: Action = { } // Extract owner, repo, and PR number + // Match both /pull/123 and /pulls/123 const text = message.content.text || ""; const prMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pulls?\/(\d+)/, ); const owner = prMatch?.[1]; @@ -1215,7 +1256,7 @@ export const aggregatePrContextAction: Action = { if (!githubService) return false; const text = message.content.text || ""; - const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/\d+/.test(text); + const hasPrUrl = /github\.com\/[^\/\s]+\/[^\/\s]+\/pulls?\/\d+/.test(text); const wantsAggregate = /aggregat|mega.?prompt|combine|all.*comment|bot.*comment|bot.*feedback|coderabbit|copilot|greptile|cursor.*bot|review.*comment|task.*prompt/i.test(text); return hasPrUrl && wantsAggregate; @@ -1234,9 +1275,10 @@ export const aggregatePrContextAction: Action = { throw new Error("GitHub service not available"); } + // Match both /pull/123 and /pulls/123 const text = message.content.text || ""; const prMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pull\/(\d+)/, + /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)\/pulls?\/(\d+)/, ); const owner = prMatch?.[1]; diff --git a/src/actions/repository.ts b/src/actions/repository.ts index 55077d7..512e754 100644 --- a/src/actions/repository.ts +++ b/src/actions/repository.ts @@ -19,6 +19,7 @@ import { sanitizeBranchName, validatePath } from "../utils/shell-exec"; +import { sanitizeRepository } from "../utils/serialize"; /** * Check if the GitHub service is available and authenticated. @@ -66,9 +67,11 @@ export const getRepositoryAction: Action = { } // Extract owner and repo from message text or options + // Use positive character class to only capture valid GitHub identifier chars + // (letters, numbers, hyphens, underscores, dots) and avoid trailing quotes/punctuation const text = message.content.text || ""; const ownerRepoMatch = text.match( - /(?:github\.com\/)?([^\/\s]+)\/([^\/\s]+)/, + /(?:github\.com\/)?([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)/, ); const owner = @@ -133,9 +136,11 @@ URL: ${repository.html_url}`, language: repository.language, }, data: { + ...state?.data, actionName: "GET_GITHUB_REPOSITORY", repository, github: { + ...state?.data?.github, ...state?.github, ...newGitHubState, }, @@ -240,8 +245,17 @@ export const listRepositoriesAction: Action = { sort: options.sort || "updated", per_page: options.limit || 10, }); - - const repoList = repositories + // Optional enrichment when enrichers are registered; guard for mocks. + const repositoriesToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:repository", + repositories, + { concurrency: 5 }, + ) + : repositories; + + const repoList = repositoriesToUse .map( (repo: any) => `• ${repo.full_name} (${repo.language || "Unknown"}) - ⭐ ${repo.stargazers_count}`, @@ -249,12 +263,12 @@ export const listRepositoriesAction: Action = { .join("\n"); const responseContent: Content = { - text: `Your repositories (${repositories.length} shown):\n${repoList}`, + text: `Your repositories (${repositoriesToUse.length} shown):\n${repoList}`, actions: ["LIST_GITHUB_REPOSITORIES"], source: message.content.source, - // Include data for callbacks - repositories, - repositoryCount: repositories.length, + // Include data for callbacks (sanitized to prevent circular refs) + repositories: repositoriesToUse.map(sanitizeRepository), + repositoryCount: repositoriesToUse.length, }; if (callback) { @@ -267,19 +281,19 @@ export const listRepositoriesAction: Action = { text: responseContent.text, values: { success: true, - repositories, - repositoryCount: repositories.length, - repositoryNames: repositories.map((repo: any) => repo.full_name), + repositories: repositoriesToUse, + repositoryCount: repositoriesToUse.length, + repositoryNames: repositoriesToUse.map((repo: any) => repo.full_name), }, data: { actionName: "LIST_GITHUB_REPOSITORIES", - repositories, + repositories: repositoriesToUse, github: { ...state?.github, - lastRepositories: repositories, + lastRepositories: repositoriesToUse, repositories: { ...state?.github?.repositories, - ...repositories.reduce( + ...repositoriesToUse.reduce( (acc: any, repo: any) => { acc[repo.full_name] = repo; return acc; @@ -547,7 +561,14 @@ export const searchRepositoriesAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + // Check if message contains search intent for repositories + const text = message.content.text?.toLowerCase() || ""; + const hasSearchIntent = /\b(search|find|look\s+for|discover|explore)\b/.test(text); + const hasRepoContext = /\b(repo|repository|repositories|github)\b/.test(text); + + return hasSearchIntent && hasRepoContext; }, handler: async ( @@ -586,8 +607,18 @@ export const searchRepositoriesAction: Action = { sort: options.sort || "stars", per_page: options.limit || 10, }); - - const repoList = searchResult.items + const rawItems = searchResult.items || []; + // Optional enrichment; guard for mocks. + const repositoriesToUse = + typeof githubService.enrichItems === "function" + ? await githubService.enrichItems( + "github:repository", + rawItems, + { concurrency: 5 }, + ) + : rawItems; + + const repoList = repositoriesToUse .map( (repo: any) => `• ${repo.full_name} (${repo.language || "Unknown"}) - ⭐ ${repo.stargazers_count}\n ${repo.description || "No description"}`, @@ -595,11 +626,11 @@ export const searchRepositoriesAction: Action = { .join("\n"); const responseContent: Content = { - text: `Found ${searchResult.total_count} repositories for "${query}" (showing ${searchResult.items.length}):\n${repoList}`, + text: `Found ${searchResult.total_count} repositories for "${query}" (showing ${repositoriesToUse.length}):\n${repoList}`, actions: ["SEARCH_GITHUB_REPOSITORIES"], source: message.content.source, - // Include data for callbacks - repositories: searchResult.items, + // Include data for callbacks (sanitized to prevent circular refs) + repositories: repositoriesToUse.map(sanitizeRepository), query, totalCount: searchResult.total_count, }; @@ -614,23 +645,23 @@ export const searchRepositoriesAction: Action = { text: responseContent.text, values: { success: true, - repositories: searchResult.items, + repositories: repositoriesToUse, query, totalCount: searchResult.total_count, - repositoryNames: searchResult.items.map( + repositoryNames: repositoriesToUse.map( (repo: any) => repo.full_name, ), }, data: { actionName: "SEARCH_GITHUB_REPOSITORIES", - repositories: searchResult.items, + repositories: repositoriesToUse, github: { ...state?.github, - lastSearchResults: searchResult, + lastSearchResults: { ...searchResult, items: repositoriesToUse }, lastSearchQuery: query, repositories: { ...state?.github?.repositories, - ...searchResult.items.reduce( + ...repositoriesToUse.reduce( (acc: any, repo: any) => { acc[repo.full_name] = repo; return acc; @@ -724,7 +755,7 @@ export const cloneRepositoryAction: Action = { if (!githubService) return false; const text = message.content.text || ""; - const hasRepoUrl = /github\.com\/[^\/\s]+\/[^\/\s]+/.test(text); + const hasRepoUrl = /github\.com\/[a-zA-Z0-9][\w.\-]*\/[a-zA-Z0-9][\w.\-]*/.test(text); const wantsClone = /\b(clone|checkout|download|pull)\b.*\b(repo|repository)\b|\b(repo|repository)\b.*\b(clone|checkout|download|pull)\b|git\s+clone/i.test(text); return hasRepoUrl || wantsClone; @@ -747,11 +778,13 @@ export const cloneRepositoryAction: Action = { // Extract owner/repo and optionally branch from URL // Supports: github.com/owner/repo, github.com/owner/repo/tree/branch, github.com/owner/repo/blob/branch/... + // Use positive character classes to only capture valid GitHub identifier chars + // and avoid trailing quotes/punctuation from LLM-generated text const fullUrlMatch = text.match( - /github\.com\/([^\/\s]+)\/([^\/\s]+?)(?:\/(?:tree|blob)\/([^\/\s]+))?(?:\/|\s|$)/, + /github\.com\/([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)(?:\/(?:tree|blob)\/([^\s\/]+))?/, ); const simpleMatch = text.match( - /\b([^\/\s]+)\/([^\/\s]+)\b/, + /\b([a-zA-Z0-9][\w.\-]*)\/([a-zA-Z0-9][\w.\-]*)\b/, ); let owner = options.owner || fullUrlMatch?.[1] || simpleMatch?.[1]; diff --git a/src/actions/search.ts b/src/actions/search.ts index c99b319..a877675 100644 --- a/src/actions/search.ts +++ b/src/actions/search.ts @@ -23,7 +23,31 @@ export const searchGitHubAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) { + return false; + } + + // Only validate if we can extract a search query + const text = message.content.text || ""; + + // Check if message contains search-related keywords + const hasSearchKeywords = /(?:search|find|look\s*(?:up|for))\s+(?:for\s+)?/i.test(text); + + // Check if there's a quoted string (explicit query) + const hasQuotedQuery = /["\']([^"']+)["\']/.test(text); + + // Check if there's enough content after cleaning + const cleanText = text + .replace(/^(?:hey\s+)?(?:avie|@\w+)[,:]?\s*/i, "") + .replace(/^(?:can\s+you|please|could\s+you|would\s+you)\s+/i, "") + .replace(/(?:search|find|look\s*(?:up|for))\s+(?:for\s+)?(?:on\s+)?(?:github\s+)?/i, "") + .replace(/\s+(?:on|in)\s+github\s*$/i, "") + .trim(); + + const hasContent = cleanText.length > 2 && cleanText.length < 200; + + // Validate if we have search keywords with content, or a quoted query + return (hasSearchKeywords && hasContent) || hasQuotedQuery; }, handler: async ( @@ -66,23 +90,40 @@ export const searchGitHubAction: Action = { if (!query) { // Pattern 3: After common prefixes (avie, hey, can you, etc.) - const cleanText = text + let cleanText = text .replace(/^(?:hey\s+)?(?:avie|@\w+)[,:]?\s*/i, "") - .replace(/^(?:can\s+you|please|could\s+you)\s+/i, "") + .replace(/^(?:can\s+you|please|could\s+you|would\s+you)\s+/i, "") .replace(/(?:search|find|look\s*(?:up|for))\s+(?:for\s+)?(?:on\s+)?(?:github\s+)?/i, "") .replace(/\s+(?:on|in)\s+github\s*$/i, "") .trim(); + + // If still too generic, try to extract meaningful content + if (cleanText.length <= 2) { + // Try to get content after action keywords + const afterKeyword = text.match(/(?:search|find|look)\s+(?:for\s+)?(.+)/i); + cleanText = afterKeyword?.[1]?.trim() || ""; + } + if (cleanText.length > 2 && cleanText.length < 200) { query = cleanText; } } if (!query) { - throw new Error("Search query is required. Try: 'search for [topic]' or 'find [topic] on github'"); + logger.warn(`Failed to extract search query from message: "${text}"`); + + // Provide helpful error with context + const errorMsg = text.length > 0 + ? `I couldn't extract a search query from your message. Please try:\n- "search for machine learning"\n- "find React repositories"\n- "look up TypeScript issues"\n\nYour message: "${text.substring(0, 100)}${text.length > 100 ? '...' : ''}"` + : "Search query is required. Try: 'search for [topic]' or 'find [topic] on github'"; + + throw new Error(errorMsg); } // Clean up the query query = query.trim().replace(/\s+/g, " "); + + logger.info(`Extracted search query: "${query}" from message: "${text}"`); // Determine search type from context const searchType = diff --git a/src/actions/stats.ts b/src/actions/stats.ts index e4653ef..b6820c4 100644 --- a/src/actions/stats.ts +++ b/src/actions/stats.ts @@ -23,7 +23,18 @@ export const getRepositoryStatsAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for stats-related keywords + const hasStatsIntent = /\b(stat|statistic|metric|analytic|contributor|commit|activity|health)\b/.test(text); + + // Check for repository context (URL or owner/repo pattern) + const hasRepoContext = /github\.com\/[^\/\s]+\/[^\/\s]+|[^\/\s]+\/[^\/\s]+/.test(message.content.text || "") || + /\b(repo|repository)\b/.test(text); + + return hasStatsIntent && hasRepoContext; }, handler: async ( @@ -48,11 +59,13 @@ export const getRepositoryStatsAction: Action = { const owner = options.owner || ownerRepoMatch?.[1] || + state?.data?.github?.lastRepository?.owner?.login || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || ownerRepoMatch?.[2] || + state?.data?.github?.lastRepository?.name || state?.github?.lastRepository?.name; if (!owner || !repo) { @@ -275,7 +288,18 @@ export const getRepositoryTrafficAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for traffic-related keywords + const hasTrafficIntent = /\b(traffic|visitor|view|clone|referr|popular|analytics)\b/.test(text); + + // Check for repository context (URL or owner/repo pattern) + const hasRepoContext = /github\.com\/[^\/\s]+\/[^\/\s]+|[^\/\s]+\/[^\/\s]+/.test(message.content.text || "") || + /\b(repo|repository)\b/.test(text); + + return hasTrafficIntent && hasRepoContext; }, handler: async ( @@ -300,11 +324,13 @@ export const getRepositoryTrafficAction: Action = { const owner = options.owner || ownerRepoMatch?.[1] || + state?.data?.github?.lastRepository?.owner?.login || state?.github?.lastRepository?.owner?.login || runtime.getSetting("GITHUB_OWNER"); const repo = options.repo || ownerRepoMatch?.[2] || + state?.data?.github?.lastRepository?.name || state?.github?.lastRepository?.name; if (!owner || !repo) { diff --git a/src/actions/users.ts b/src/actions/users.ts index e11ae44..772178e 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -9,6 +9,7 @@ import { logger, } from "@elizaos/core"; import { GitHubService } from "../services/github"; +import { sanitizeRepository } from "../utils/serialize"; // Get User Profile Action export const getUserProfileAction: Action = { @@ -22,7 +23,15 @@ export const getUserProfileAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for user profile intent keywords + const hasUserIntent = /\b(user|profile|info|about|who)\b/.test(text); + const hasGitHubContext = /\b(github|@[a-zA-Z0-9_-]+)\b/.test(text) || /@/.test(message.content.text || ""); + + return hasUserIntent && hasGitHubContext; }, handler: async ( @@ -215,7 +224,15 @@ export const getUserStatsAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for stats-related keywords + const hasStatsIntent = /\b(stat|statistic|activity|contribution|language|breakdown)\b/.test(text); + const hasUserContext = /\b(user|@[a-zA-Z0-9_-]+|github)\b/.test(text) || /@/.test(message.content.text || ""); + + return hasStatsIntent && hasUserContext; }, handler: async ( @@ -437,7 +454,16 @@ export const listUserRepositoriesAction: Action = { state: State | undefined, ): Promise => { const githubService = runtime.getService("github"); - return !!githubService; + if (!githubService) return false; + + const text = message.content.text?.toLowerCase() || ""; + + // Check for list repos intent + const hasListIntent = /\b(list|show|get|display)\b/.test(text); + const hasRepoIntent = /\b(repo|repository|repositories|project)\b/.test(text); + const hasUserContext = /\b(user|@[a-zA-Z0-9_-]+|for)\b/.test(text) || /@/.test(message.content.text || ""); + + return hasListIntent && hasRepoIntent && hasUserContext; }, handler: async ( @@ -492,8 +518,8 @@ export const listUserRepositoriesAction: Action = { text: `Repositories for @${username} (${repos.length} shown):\n${repoList}`, actions: ["LIST_USER_REPOSITORIES"], source: message.content.source, - // Include data for callbacks - repositories: repos, + // Include data for callbacks (sanitized to prevent circular refs) + repositories: repos.map(sanitizeRepository), username, }; diff --git a/src/actions/webhooks.ts b/src/actions/webhooks.ts index 9a57c08..7ea5796 100644 --- a/src/actions/webhooks.ts +++ b/src/actions/webhooks.ts @@ -168,6 +168,9 @@ export const createWebhookAction: Action = { if (state.data?.github?.lastRepository) { owner = owner || state.data.github.lastRepository.owner?.login; repo = repo || state.data.github.lastRepository.name; + } else if (state?.github?.lastRepository) { + owner = owner || state.github.lastRepository.owner?.login; + repo = repo || state.github.lastRepository.name; } } @@ -324,6 +327,9 @@ export const listWebhooksAction: Action = { if (state?.data?.github?.lastRepository) { owner = owner || state.data.github.lastRepository.owner?.login; repo = repo || state.data.github.lastRepository.name; + } else if (state?.github?.lastRepository) { + owner = owner || state.github.lastRepository.owner?.login; + repo = repo || state.github.lastRepository.name; } if (!owner || !repo) { @@ -455,7 +461,12 @@ export const deleteWebhookAction: Action = { if (state.data?.github?.lastRepository) { owner = owner || state.data.github.lastRepository.owner?.login; repo = repo || state.data.github.lastRepository.name; - } else { + } else if (state?.github?.lastRepository) { + owner = owner || state.github.lastRepository.owner?.login; + repo = repo || state.github.lastRepository.name; + } + + if (!owner || !repo) { await callback({ text: "I need to know which repository the webhook belongs to. Please specify it in the format: owner/repo", thought: "Missing repository information", @@ -575,6 +586,9 @@ export const pingWebhookAction: Action = { if (state?.data?.github?.lastRepository) { owner = owner || state.data.github.lastRepository.owner?.login; repo = repo || state.data.github.lastRepository.name; + } else if (state?.github?.lastRepository) { + owner = owner || state.github.lastRepository.owner?.login; + repo = repo || state.github.lastRepository.name; } if (!owner || !repo) { diff --git a/src/index.ts b/src/index.ts index 97606cf..ff70c43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,6 +256,8 @@ import { githubHelpProvider, githubSettingsProvider, } from "./providers/github"; +import { myOpenPrsProvider, myPrStatusProvider } from "./providers/myPrStatus"; +import type { MyOpenPrItem } from "./types"; /** * Token requirement level for actions/providers. @@ -602,6 +604,8 @@ const githubProviders: Provider[] = [ prCommentsProvider, // PR comments with smart context management githubHelpProvider, // Usage instructions githubSettingsProvider, // Current settings (non-sensitive) + myOpenPrsProvider, // Dynamic: list my open PRs (cheap) + myPrStatusProvider, // Dynamic: my PR status with merge/review info ]; export const githubPlugin: Plugin = { @@ -759,6 +763,44 @@ export const githubPlugin: Plugin = { }, 1000); // Delay to allow Ngrok service to initialize } + // Register built-in enricher for PR merge/review status (used by MY_PR_STATUS and list PR actions). + // WHY: Return only plain fields (comments, mergeable, approved, etc.). Raw fullPr/reviews contain + // circular refs (request, headers) and would break JSON.stringify in state/callbacks. + if (runtime) { + const githubService = runtime.getService("github"); + if (githubService?.registerEnricher) { + githubService.registerEnricher({ + name: "pr-merge-review-status", + dataType: "github:pull_request", + enrich: async (item: Record, rt: IAgentRuntime) => { + const svc = rt.getService("github"); + if (!svc) return {}; + const pr = item as unknown as MyOpenPrItem; + const [fullPr, reviews] = await Promise.all([ + svc.getPullRequest(pr.owner, pr.repo, pr.number), + svc.getPullRequestReviews(pr.owner, pr.repo, pr.number), + ]); + const approved = (reviews as { state: string }[]).some( + (r) => r.state === "APPROVED", + ); + const changesRequested = (reviews as { state: string }[]).some( + (r) => r.state === "CHANGES_REQUESTED", + ); + return { + comments: fullPr.comments, + review_comments: fullPr.review_comments, + mergeable: fullPr.mergeable, + mergeable_state: fullPr.mergeable_state, + approved, + changesRequested, + clearToMerge: + fullPr.mergeable === true && !changesRequested, + }; + }, + }); + } + } + // Ensure we return void } catch (error) { logger.error({ error }, "GitHub plugin configuration validation failed"); diff --git a/src/providers/github.ts b/src/providers/github.ts index fd5eb35..fbb5408 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -21,4 +21,4 @@ export { getFilteredComments, } from "./prComments"; export { githubHelpProvider } from "./help"; -export { githubSettingsProvider } from "./settings"; +export { githubSettingsProvider } from "./settings"; \ No newline at end of file diff --git a/src/providers/index.ts b/src/providers/index.ts deleted file mode 100644 index a3f70e6..0000000 --- a/src/providers/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -// GitHub Providers - Re-exports -export { githubRepositoryProvider } from "./repository"; -export { githubIssuesProvider } from "./issues"; -export { githubPullRequestsProvider } from "./pullRequests"; -export { githubActivityProvider } from "./activity"; -export { githubUserProvider } from "./user"; -export { githubRateLimitProvider, githubStatusProvider } from "./rateLimit"; -export { githubBranchProtectionProvider } from "./branches"; -export { githubWebhooksProvider } from "./webhooks"; -export { - githubWorkingCopiesProvider, - // Helper functions for other plugins to locate working copies - getWorkingCopiesBaseDir, - getReposDir, - getWorkingCopyPath, -} from "./workingCopies"; -export { prSplitPlanProvider } from "./prSplitPlan"; -export { - prCommentsProvider, - getSingleComment, - getFilteredComments, -} from "./prComments"; - -// Help and settings -export { githubHelpProvider } from "./help"; -export { githubSettingsProvider } from "./settings"; - diff --git a/src/providers/myPrStatus.ts b/src/providers/myPrStatus.ts new file mode 100644 index 0000000..7a111e1 --- /dev/null +++ b/src/providers/myPrStatus.ts @@ -0,0 +1,166 @@ +/** + * Dynamic providers for "my open PRs" and "my PR status". + * WHY two providers: So the LLM can pick list-only (MY_OPEN_PRS, 1 API call) when the user + * just wants a list, or full status (MY_PR_STATUS, list + enrichment) when they ask "what's + * my PR status?" / "what's ready to merge?" No multi-step; only the selected provider runs. + * We cap detailed lines at 15 and add "...and N more" to avoid dumping too much into context. + */ +import { + type IAgentRuntime, + type Memory, + type Provider, + type ProviderResult, + type State, +} from "@elizaos/core"; +import { GitHubService } from "../services/github"; +import type { MyOpenPrItem } from "../types"; + +export const myOpenPrsProvider: Provider = { + name: "MY_OPEN_PRS", + description: + "Lists the authenticated user's open pull requests across all repos. Use when the user asks to list or see their open PRs.", + dynamic: true, + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + ): Promise => { + const githubService = runtime.getService("github"); + if (!githubService || githubService.authMode !== "authenticated") { + return { text: "", values: { openPrCount: 0 }, data: { prs: [] } }; + } + + const orgFilter = (runtime.getSetting("GITHUB_PR_STATUS_ORG_FILTER") as string)?.trim() || undefined; + try { + const { items, totalCount } = await githubService.getMyOpenPRs({ + orgFilter, + maxResults: 25, + }); + + if (items.length === 0) { + const text = + totalCount > 0 + ? `You have ${totalCount} open PR(s) but none match the current filter.` + : "You have no open pull requests."; + return { + text, + values: { openPrCount: 0, totalCount }, + data: { prs: [] }, + }; + } + + const lines = items.map( + (pr: MyOpenPrItem) => + `• ${pr.full_name}#${pr.number}: ${pr.title}${pr.draft ? " (draft)" : ""}`, + ); + const totalNote = + totalCount > items.length ? ` (showing ${items.length} of ${totalCount})` : ""; + const text = `Your open pull requests${totalNote}:\n${lines.join("\n")}`; + + return { + text, + values: { openPrCount: items.length, totalCount }, + data: { prs: items }, + }; + } catch (err) { + return { + text: "Unable to fetch your open PRs. Ensure GITHUB_TOKEN is set and valid.", + values: { openPrCount: 0 }, + data: { prs: [], error: String(err) }, + }; + } + }, +}; + +export const myPrStatusProvider: Provider = { + name: "MY_PR_STATUS", + description: + "Shows the authenticated user's open pull requests with comment counts, review status, and merge readiness. Use when the user asks about their PR status, what needs review, or what is ready to merge.", + dynamic: true, + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + ): Promise => { + const githubService = runtime.getService("github"); + if (!githubService || githubService.authMode !== "authenticated") { + return { + text: "", + values: { openPrCount: 0, clearToMergeCount: 0 }, + data: { prs: [] }, + }; + } + + const orgFilter = (runtime.getSetting("GITHUB_PR_STATUS_ORG_FILTER") as string)?.trim() || undefined; + try { + const { items, totalCount } = await githubService.getMyOpenPRs({ + orgFilter, + maxResults: 25, + }); + + if (items.length === 0) { + const text = + totalCount > 0 + ? `You have ${totalCount} open PR(s) but none match the current filter.` + : "You have no open pull requests."; + return { + text, + values: { openPrCount: 0, clearToMergeCount: 0, totalCount }, + data: { prs: [] }, + }; + } + + const enriched = await githubService.enrichItems( + "github:pull_request", + items, + { concurrency: 5 }, + ); + + const clearToMergeCount = enriched.filter( + (pr: MyOpenPrItem & Record) => pr.clearToMerge === true, + ).length; + const summary = + totalCount > enriched.length + ? `${enriched.length} of ${totalCount} open PRs` + : `${enriched.length} open PR(s)`; + const summaryLine = `${summary}: ${clearToMergeCount} clear to merge.`; + + const detailCap = 15; + const detailLines = enriched.slice(0, detailCap).map((pr: MyOpenPrItem & Record) => { + const comments = Number(pr.comments) || 0; + const reviewComments = Number(pr.review_comments) || 0; + const commentStr = [comments > 0 && `${comments} comments`, reviewComments > 0 && `${reviewComments} review`].filter(Boolean).join(", ") || "no comments"; + const status = + pr.clearToMerge === true + ? "clear to merge" + : pr.changesRequested === true + ? "changes requested" + : pr.approved === true + ? "approved" + : "pending review"; + return `• ${pr.full_name}#${pr.number}: ${pr.title} — ${commentStr}, ${status}`; + }); + const moreNote = + enriched.length > detailCap + ? `\n... and ${enriched.length - detailCap} more` + : ""; + const text = `${summaryLine}\n${detailLines.join("\n")}${moreNote}`; + + return { + text, + values: { + openPrCount: enriched.length, + clearToMergeCount, + totalCount, + }, + data: { prs: enriched }, + }; + } catch (err) { + return { + text: "Unable to fetch your PR status. Ensure GITHUB_TOKEN is set and valid.", + values: { openPrCount: 0, clearToMergeCount: 0 }, + data: { prs: [], error: String(err) }, + }; + } + }, +}; diff --git a/src/services/github.ts b/src/services/github.ts index fa672fd..fe51912 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,5 +1,6 @@ import { logger, Service, type IAgentRuntime } from "@elizaos/core"; import { Octokit } from "@octokit/rest"; +import type { DataEnricher, MyOpenPrItem } from "../types"; export interface GitHubConfig { GITHUB_TOKEN?: string; @@ -68,6 +69,8 @@ export class GitHubService extends Service { private rateLimitReset: number = 0; private activityLog: GitHubActivityItem[] = []; private githubConfig: GitHubConfig; + /** Registry of enrichers by dataType (e.g. github:pull_request). Cleared on stop(). WHY: Lets other plugins (or us) attach metadata to list items without coupling list fetching to enrichment. */ + private enricherRegistry: Map = new Map(); /** * Whether the service is authenticated with a token. @@ -147,9 +150,72 @@ export class GitHubService extends Service { async stop(): Promise { this.activityLog = []; + this.enricherRegistry.clear(); logger.info("GitHub service stopped"); } + /** + * Register an enricher for a data type. Other plugins can register for + * github:pull_request, github:issue, github:repository, github:branch. + * WHY: Called from plugin init(); dependent plugins must list @elizaos/plugin-github + * in dependencies so this service exists before they register. + */ + registerEnricher(enricher: DataEnricher): void { + const list = this.enricherRegistry.get(enricher.dataType) ?? []; + list.push(enricher); + this.enricherRegistry.set(enricher.dataType, list); + } + + /** + * Run all registered enrichers for the given data type on each item. + * Returns new objects (never mutates input). Uses concurrency limit. + * On per-item or per-enricher failure, logs and returns item without that enrichment. + * WHY: No mutation so callers can reuse/cache the original list. Concurrency limit + * avoids GitHub secondary rate limits (~100 concurrent). Per-enricher try/catch + * so one bad enricher or one bad item does not drop the whole list. + */ + async enrichItems>( + dataType: string, + items: T[], + options: { concurrency?: number } = {}, + ): Promise<(T & Record)[]> { + const enrichers = this.enricherRegistry.get(dataType) ?? []; + if (enrichers.length === 0) { + return items.map((item) => ({ ...item })); + } + + const concurrency = Math.max(1, options.concurrency ?? 5); + const runtime = this.runtime as IAgentRuntime; + + const processItem = async ( + item: T, + ): Promise> => { + let merged: Record = { ...item }; + for (const enricher of enrichers) { + try { + const result = await enricher.enrich(item, runtime); + if (result && typeof result === "object") { + merged = { ...merged, ...result }; + } + } catch (err) { + logger.warn( + { enricher: enricher.name, dataType, item: merged?.number ?? merged?.id }, + "Enricher failed for item, skipping", + ); + } + } + return merged as T & Record; + }; + + const results: (T & Record)[] = []; + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(processItem)); + results.push(...batchResults); + } + return results; + } + /** * Validate authentication by checking user permissions. * Returns false in unauthenticated mode. @@ -363,12 +429,42 @@ export class GitHubService extends Service { try { await this.checkRateLimit(); + // GitHub API constraint: Cannot use 'type' with 'visibility' or 'affiliation' + // Use modern parameters (visibility + affiliation) instead of legacy 'type' + // Map legacy 'type' values to appropriate visibility/affiliation + let visibility: "all" | "public" | "private" = options.visibility || "all"; + let affiliation = options.affiliation || "owner,collaborator,organization_member"; + + // Handle legacy 'type' parameter by mapping to visibility/affiliation + if (options.type && !options.visibility && !options.affiliation) { + switch (options.type) { + case "owner": + affiliation = "owner"; + break; + case "public": + visibility = "public"; + affiliation = "owner,collaborator,organization_member"; + break; + case "private": + visibility = "private"; + affiliation = "owner,collaborator,organization_member"; + break; + case "member": + affiliation = "collaborator,organization_member"; + break; + case "all": + default: + visibility = "all"; + affiliation = "owner,collaborator,organization_member"; + break; + } + } + const { data, headers } = await this.octokit.repos.listForAuthenticatedUser({ - visibility: options.visibility || "all", - affiliation: - options.affiliation || "owner,collaborator,organization_member", - type: options.type || "all", + visibility, + affiliation, + // DO NOT pass 'type' - conflicts with visibility/affiliation sort: options.sort || "updated", direction: options.direction || "desc", per_page: options.per_page || 30, @@ -1783,6 +1879,59 @@ export class GitHubService extends Service { return this.searchIssues(prQuery, options); } + /** + * Get the authenticated user's open PRs across all repos. + * Requires authentication. Optional org filter (case-insensitive) limits to repos whose full_name contains the string. + * Returns items and totalCount from search so the provider can show "Showing 25 of 47". + * WHY: Uses searchPullRequests (not searchIssues) so is:pr is guaranteed. owner/repo parsed from html_url + * as fallback when repository object is missing. Plain MyOpenPrItem[] so enrichers and state never see Octokit refs. + */ + async getMyOpenPRs(options: { + orgFilter?: string; + maxResults?: number; + } = {}): Promise<{ items: MyOpenPrItem[]; totalCount: number }> { + this.requireAuth("getMyOpenPRs"); + + const login = (await this.getAuthenticatedUser()).login; + const query = `author:${login} is:pr is:open`; + const searchResult = await this.searchPullRequests(query, { + per_page: 50, + sort: "updated", + order: "desc", + }); + + const totalCount = Number(searchResult?.total_count) || 0; + const items = searchResult?.items ?? []; + const orgFilter = options.orgFilter?.trim(); + const maxResults = Math.max(1, options.maxResults ?? 25); + + const parsed: MyOpenPrItem[] = []; + for (const item of items) { + if (parsed.length >= maxResults) break; + const htmlUrl = item.html_url as string | undefined; + const repoMatch = htmlUrl?.match(/github\.com\/([^/]+)\/([^/]+)\//); + const owner = repoMatch?.[1]; + const repo = repoMatch?.[2]; + if (!owner || !repo) continue; + const full_name = `${owner}/${repo}`; + if (orgFilter && !full_name.toLowerCase().includes(orgFilter.toLowerCase())) { + continue; + } + parsed.push({ + owner, + repo, + number: item.number as number, + title: (item.title as string) ?? "", + html_url: htmlUrl ?? `https://github.com/${full_name}/pull/${item.number}`, + full_name, + draft: Boolean(item.draft), + created_at: (item.created_at as string) ?? "", + updated_at: (item.updated_at as string) ?? "", + }); + } + return { items: parsed, totalCount }; + } + /** * List pull requests */ diff --git a/src/types.ts b/src/types.ts index fb49b0a..abfacea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { IAgentRuntime } from "@elizaos/core"; import { z } from "zod"; // GitHub Configuration Schema (Production) @@ -254,6 +255,40 @@ export interface GitHubRateLimit { resource: string; } +/** + * Enricher for attaching additional metadata to list items (e.g. PRs, issues). + * Register via GitHubService.registerEnricher(). Other plugins can register + * for data types like github:pull_request, github:issue, github:repository, github:branch. + * + * WHY: Decouples "get a list" from "attach extra data per row". The core plugin (or others) + * can add merge status, CI, Jira links, etc. without changing list logic. dataType is a + * string so new types need no code changes—just a convention (e.g. "github:branch"). + * Return only plain scalars/objects: raw API responses often have circular refs and break + * JSON.stringify when stored in state or callbacks. + */ +export interface DataEnricher> { + name: string; + dataType: string; + enrich: (item: T, runtime: IAgentRuntime) => Promise>; +} + +/** + * Minimal PR identity from search (e.g. getMyOpenPRs). Plain object, no Octokit refs. + * WHY: Search API returns full Octokit items; we normalize to this shape so callers and + * enrichers never depend on request/headers or other non-serializable fields. + */ +export interface MyOpenPrItem { + owner: string; + repo: string; + number: number; + title: string; + html_url: string; + full_name: string; + draft: boolean; + created_at: string; + updated_at: string; +} + // Error types export class GitHubAPIError extends Error { constructor( diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts new file mode 100644 index 0000000..b76564b --- /dev/null +++ b/src/utils/serialize.ts @@ -0,0 +1,250 @@ +/** + * Utilities for serializing GitHub API objects to prevent circular reference errors + * when passing data through callbacks and state persistence. + */ + +/** + * Sanitizes a GitHub pull request object by extracting only serializable fields + * and removing circular references from nested objects like user, head, base, etc. + */ +export function sanitizePullRequest(pr: any): any { + if (!pr) return pr; + + return { + id: pr.id, + number: pr.number, + state: pr.state, + title: pr.title, + body: pr.body, + html_url: pr.html_url, + created_at: pr.created_at, + updated_at: pr.updated_at, + closed_at: pr.closed_at, + merged_at: pr.merged_at, + merged: pr.merged, + draft: pr.draft, + comments: pr.comments, + review_comments: pr.review_comments, + commits: pr.commits, + additions: pr.additions, + deletions: pr.deletions, + changed_files: pr.changed_files, + mergeable: pr.mergeable, + mergeable_state: pr.mergeable_state, + user: pr.user ? { + login: pr.user.login, + id: pr.user.id, + avatar_url: pr.user.avatar_url, + html_url: pr.user.html_url, + } : undefined, + head: pr.head ? { + ref: pr.head.ref, + sha: pr.head.sha, + label: pr.head.label, + repo: pr.head.repo ? { + name: pr.head.repo.name, + full_name: pr.head.repo.full_name, + owner: pr.head.repo.owner ? { + login: pr.head.repo.owner.login, + } : undefined, + } : undefined, + } : undefined, + base: pr.base ? { + ref: pr.base.ref, + sha: pr.base.sha, + label: pr.base.label, + repo: pr.base.repo ? { + name: pr.base.repo.name, + full_name: pr.base.repo.full_name, + owner: pr.base.repo.owner ? { + login: pr.base.repo.owner.login, + } : undefined, + } : undefined, + } : undefined, + labels: pr.labels ? pr.labels.map((label: any) => ({ + id: label.id, + name: label.name, + color: label.color, + description: label.description, + })) : undefined, + milestone: pr.milestone ? { + id: pr.milestone.id, + number: pr.milestone.number, + title: pr.milestone.title, + state: pr.milestone.state, + } : undefined, + requested_reviewers: pr.requested_reviewers ? pr.requested_reviewers.map((reviewer: any) => ({ + login: reviewer.login, + id: reviewer.id, + })) : undefined, + }; +} + +/** + * Sanitizes a GitHub issue object + */ +export function sanitizeIssue(issue: any): any { + if (!issue) return issue; + + return { + id: issue.id, + number: issue.number, + state: issue.state, + title: issue.title, + body: issue.body, + html_url: issue.html_url, + created_at: issue.created_at, + updated_at: issue.updated_at, + closed_at: issue.closed_at, + comments: issue.comments, + user: issue.user ? { + login: issue.user.login, + id: issue.user.id, + avatar_url: issue.user.avatar_url, + html_url: issue.user.html_url, + } : undefined, + labels: issue.labels ? issue.labels.map((label: any) => + typeof label === 'string' ? label : { + id: label.id, + name: label.name, + color: label.color, + description: label.description, + } + ) : undefined, + milestone: issue.milestone ? { + id: issue.milestone.id, + number: issue.milestone.number, + title: issue.milestone.title, + state: issue.milestone.state, + } : undefined, + assignees: issue.assignees ? issue.assignees.map((assignee: any) => ({ + login: assignee.login, + id: assignee.id, + })) : undefined, + }; +} + +/** + * Sanitizes a GitHub repository object + */ +export function sanitizeRepository(repo: any): any { + if (!repo) return repo; + + return { + id: repo.id, + name: repo.name, + full_name: repo.full_name, + description: repo.description, + html_url: repo.html_url, + private: repo.private, + fork: repo.fork, + created_at: repo.created_at, + updated_at: repo.updated_at, + pushed_at: repo.pushed_at, + size: repo.size, + stargazers_count: repo.stargazers_count, + watchers_count: repo.watchers_count, + forks_count: repo.forks_count, + open_issues_count: repo.open_issues_count, + language: repo.language, + default_branch: repo.default_branch, + owner: repo.owner ? { + login: repo.owner.login, + id: repo.owner.id, + avatar_url: repo.owner.avatar_url, + html_url: repo.owner.html_url, + } : undefined, + license: repo.license ? { + key: repo.license.key, + name: repo.license.name, + spdx_id: repo.license.spdx_id, + } : undefined, + topics: repo.topics, + }; +} + +/** + * Sanitizes a GitHub comment object + */ +export function sanitizeComment(comment: any): any { + if (!comment) return comment; + + return { + id: comment.id, + body: comment.body, + html_url: comment.html_url, + created_at: comment.created_at, + updated_at: comment.updated_at, + user: comment.user ? { + login: comment.user.login, + id: comment.user.id, + avatar_url: comment.user.avatar_url, + html_url: comment.user.html_url, + } : undefined, + reactions: comment.reactions ? { + total_count: comment.reactions.total_count, + plus_one: comment.reactions['+1'], + minus_one: comment.reactions['-1'], + laugh: comment.reactions.laugh, + hooray: comment.reactions.hooray, + confused: comment.reactions.confused, + heart: comment.reactions.heart, + rocket: comment.reactions.rocket, + eyes: comment.reactions.eyes, + } : undefined, + }; +} + +/** + * Sanitizes an array of GitHub API objects based on their type + */ +export function sanitizeArray(items: T[], sanitizeFn: (item: T) => any): any[] { + if (!Array.isArray(items)) return []; + return items.map(sanitizeFn); +} + +/** + * Deeply sanitizes GitHub API responses to prevent circular references + * Uses JSON.stringify with a replacer function as a fallback + */ +export function sanitizeGitHubObject(obj: any, maxDepth = 10): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + + // Use a WeakSet to track visited objects and prevent infinite loops + const visited = new WeakSet(); + + function sanitize(value: any, depth: number): any { + // Primitives and null + if (value === null || value === undefined) return value; + if (typeof value !== 'object') return value; + + // Max depth reached + if (depth >= maxDepth) return '[Max Depth Reached]'; + + // Check for circular reference + if (visited.has(value)) return '[Circular Reference]'; + visited.add(value); + + // Arrays + if (Array.isArray(value)) { + return value.map(item => sanitize(item, depth + 1)); + } + + // Objects + const sanitized: any = {}; + for (const key in value) { + if (value.hasOwnProperty(key)) { + try { + sanitized[key] = sanitize(value[key], depth + 1); + } catch (err) { + sanitized[key] = '[Serialization Error]'; + } + } + } + + return sanitized; + } + + return sanitize(obj, 0); +} diff --git a/src/utils/state-helpers.ts b/src/utils/state-helpers.ts new file mode 100644 index 0000000..aeb6320 --- /dev/null +++ b/src/utils/state-helpers.ts @@ -0,0 +1,153 @@ +import { State } from "@elizaos/core"; +import { PersistedGitHubState } from "./state-persistence"; + +/** + * Repository context information + */ +export interface RepositoryContext { + owner: string; + repo: string; + full_name?: string; +} + +/** + * Get repository context from state with proper fallback handling. + * + * This utility checks both the correct location (state.data.github) and + * the legacy location (state.github) for backward compatibility. + * + * @param state - The current state object + * @param persistedState - Optional persisted state from cache + * @returns Repository context if found, undefined otherwise + */ +export function getRepositoryFromState( + state: State | undefined, + persistedState?: PersistedGitHubState | null +): RepositoryContext | undefined { + // Check persisted state first + if (persistedState?.lastRepository) { + const repo = persistedState.lastRepository; + return { + owner: repo.owner?.login || repo.owner, + repo: repo.name, + full_name: repo.full_name, + }; + } + + // Check state.data.github (correct location) + if (state?.data?.github?.lastRepository) { + const repo = state.data.github.lastRepository; + return { + owner: repo.owner?.login || repo.owner, + repo: repo.name, + full_name: repo.full_name, + }; + } + + // Check state.github (legacy location for backward compatibility) + if (state?.github?.lastRepository) { + const repo = state.github.lastRepository; + return { + owner: repo.owner?.login || repo.owner, + repo: repo.name, + full_name: repo.full_name, + }; + } + + return undefined; +} + +/** + * Get pull request context from state with proper fallback handling. + * + * @param state - The current state object + * @param persistedState - Optional persisted state from cache + * @returns Pull request context if found, undefined otherwise + */ +export function getPullRequestFromState( + state: State | undefined, + persistedState?: PersistedGitHubState | null +): any | undefined { + // Check persisted state first + if (persistedState?.lastPullRequest) { + return persistedState.lastPullRequest; + } + + // Check state.data.github (correct location) + if (state?.data?.github?.lastPullRequest) { + return state.data.github.lastPullRequest; + } + + // Check state.github (legacy location) + if (state?.github?.lastPullRequest) { + return state.github.lastPullRequest; + } + + return undefined; +} + +/** + * Get issue context from state with proper fallback handling. + * + * @param state - The current state object + * @param persistedState - Optional persisted state from cache + * @returns Issue context if found, undefined otherwise + */ +export function getIssueFromState( + state: State | undefined, + persistedState?: PersistedGitHubState | null +): any | undefined { + // Check persisted state first + if (persistedState?.lastIssue) { + return persistedState.lastIssue; + } + + // Check state.data.github (correct location) + if (state?.data?.github?.lastIssue) { + return state.data.github.lastIssue; + } + + // Check state.github (legacy location) + if (state?.github?.lastIssue) { + return state.github.lastIssue; + } + + return undefined; +} + +/** + * Create a repository context object for saving to state. + * + * @param owner - Repository owner + * @param repo - Repository name + * @returns Repository context object + */ +export function createRepositoryContext(owner: string, repo: string): any { + return { + owner: { login: owner }, + name: repo, + full_name: `${owner}/${repo}`, + }; +} + +/** + * Merge GitHub state into the current state data structure. + * This ensures proper state accumulation across action chains. + * + * @param state - The current state object + * @param newGitHubState - New GitHub state to merge + * @returns Merged data object for ActionResult + */ +export function mergeGitHubStateForReturn( + state: State | undefined, + newGitHubState: Partial +): any { + return { + ...state?.data, + github: { + ...state?.data?.github, + ...state?.github, // Backward compatibility + ...newGitHubState, + }, + }; +} diff --git a/src/utils/state-persistence.ts b/src/utils/state-persistence.ts index ecfb3bc..ea5625e 100644 --- a/src/utils/state-persistence.ts +++ b/src/utils/state-persistence.ts @@ -126,14 +126,51 @@ function getGitHubStateCacheKey(agentId: UUID, roomId?: UUID): string { } /** - * Load persisted GitHub state from cache + * In-flight promise cache to deduplicate concurrent loadGitHubState calls. + * When multiple GitHub providers run in parallel (via composeState's + * Promise.all), they all call loadGitHubState simultaneously. Without + * dedup, each triggers independent cache reads for the same key. + * This map ensures only one read happens per key and shares the result. + */ +const inFlightLoads = new Map>(); + +/** + * Load persisted GitHub state from cache. + * Concurrent calls for the same key are deduplicated -- only one cache + * read is performed and the result is shared across all callers. */ export async function loadGitHubState( runtime: IAgentRuntime, roomId?: UUID +): Promise { + const cacheKey = getGitHubStateCacheKey(runtime.agentId, roomId); + + // If there's already an in-flight load for this key, reuse it + const inFlight = inFlightLoads.get(cacheKey); + if (inFlight) { + return inFlight; + } + + // Create the load promise and register it + const loadPromise = loadGitHubStateImpl(runtime, roomId, cacheKey); + inFlightLoads.set(cacheKey, loadPromise); + + try { + return await loadPromise; + } finally { + inFlightLoads.delete(cacheKey); + } +} + +/** + * Internal implementation of GitHub state loading. + */ +async function loadGitHubStateImpl( + runtime: IAgentRuntime, + roomId: UUID | undefined, + cacheKey: string ): Promise { try { - const cacheKey = getGitHubStateCacheKey(runtime.agentId, roomId); const cached = await runtime.getCache(cacheKey); if (cached) {