@paperclipai_dld/plugin-github 2026.319.0-canary.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/constants.d.ts +38 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +30 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/github-types.d.ts +86 -0
  7. package/dist/github-types.d.ts.map +1 -0
  8. package/dist/github-types.js +6 -0
  9. package/dist/github-types.js.map +1 -0
  10. package/dist/github.d.ts +55 -0
  11. package/dist/github.d.ts.map +1 -0
  12. package/dist/github.js +80 -0
  13. package/dist/github.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +6 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/manifest.d.ts +4 -0
  19. package/dist/manifest.d.ts.map +1 -0
  20. package/dist/manifest.js +96 -0
  21. package/dist/manifest.js.map +1 -0
  22. package/dist/sync.d.ts +44 -0
  23. package/dist/sync.d.ts.map +1 -0
  24. package/dist/sync.js +107 -0
  25. package/dist/sync.js.map +1 -0
  26. package/dist/tools.d.ts +9 -0
  27. package/dist/tools.d.ts.map +1 -0
  28. package/dist/tools.js +141 -0
  29. package/dist/tools.js.map +1 -0
  30. package/dist/verify-signature.d.ts +10 -0
  31. package/dist/verify-signature.d.ts.map +1 -0
  32. package/dist/verify-signature.js +21 -0
  33. package/dist/verify-signature.js.map +1 -0
  34. package/dist/worker.d.ts +3 -0
  35. package/dist/worker.d.ts.map +1 -0
  36. package/dist/worker.js +368 -0
  37. package/dist/worker.js.map +1 -0
  38. package/package.json +29 -0
  39. package/src/constants.ts +48 -0
  40. package/src/github-types.ts +61 -0
  41. package/src/github.ts +152 -0
  42. package/src/index.ts +6 -0
  43. package/src/manifest.ts +105 -0
  44. package/src/sync.ts +193 -0
  45. package/src/tools.ts +172 -0
  46. package/src/verify-signature.ts +26 -0
  47. package/src/worker.ts +488 -0
  48. package/tsconfig.json +9 -0
@@ -0,0 +1,48 @@
1
+ export const PLUGIN_ID = "paperclip-github";
2
+ export const PLUGIN_VERSION = "0.1.0";
3
+
4
+ export const WEBHOOK_KEYS = {
5
+ github: "github-events",
6
+ } as const;
7
+
8
+ export const SUPPORTED_GITHUB_EVENTS = [
9
+ "workflow_run",
10
+ "check_run",
11
+ "issues",
12
+ ] as const;
13
+
14
+ export type SupportedGitHubEvent = (typeof SUPPORTED_GITHUB_EVENTS)[number];
15
+
16
+ export const TOOL_NAMES = {
17
+ searchIssues: "github_search_issues",
18
+ linkIssue: "github_link_issue",
19
+ unlinkIssue: "github_unlink_issue",
20
+ } as const;
21
+
22
+ export const JOB_KEYS = {
23
+ syncLinkedIssues: "sync-linked-issues",
24
+ } as const;
25
+
26
+ export const DEFAULT_CONFIG = {
27
+ webhookSecret: "",
28
+ companyId: "",
29
+ goalId: "",
30
+ defaultAssigneeAgentId: "",
31
+ defaultRepo: "",
32
+ githubTokenRef: "",
33
+ syncDirection: "bidirectional" as const,
34
+ syncComments: false,
35
+ skipSignatureVerification: false,
36
+ } as const;
37
+
38
+ export type PluginConfig = {
39
+ webhookSecret?: string;
40
+ companyId?: string;
41
+ goalId?: string;
42
+ defaultAssigneeAgentId?: string;
43
+ defaultRepo?: string;
44
+ githubTokenRef?: string;
45
+ syncDirection?: "bidirectional" | "github-to-paperclip" | "paperclip-to-github";
46
+ syncComments?: boolean;
47
+ skipSignatureVerification?: boolean;
48
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Minimal type definitions for the GitHub webhook payloads we care about.
3
+ * Only the fields we actually read are typed; the rest passes through as `unknown`.
4
+ */
5
+
6
+ export interface GitHubWorkflowRunEvent {
7
+ action: "completed" | "requested" | "in_progress" | string;
8
+ workflow_run: {
9
+ id: number;
10
+ name: string;
11
+ head_branch: string;
12
+ head_sha: string;
13
+ status: string;
14
+ conclusion: "success" | "failure" | "cancelled" | "timed_out" | "action_required" | string | null;
15
+ html_url: string;
16
+ run_number: number;
17
+ run_attempt: number;
18
+ actor: { login: string } | null;
19
+ head_commit: {
20
+ id: string;
21
+ message: string;
22
+ author: { name: string; email: string } | null;
23
+ } | null;
24
+ pull_requests: Array<{
25
+ number: number;
26
+ head: { ref: string; sha: string };
27
+ base: { ref: string };
28
+ }>;
29
+ repository?: { full_name: string; html_url: string };
30
+ };
31
+ repository: { full_name: string; html_url: string };
32
+ }
33
+
34
+ export interface GitHubCheckRunEvent {
35
+ action: "completed" | "created" | "rerequested" | "requested_action" | string;
36
+ check_run: {
37
+ id: number;
38
+ name: string;
39
+ status: string;
40
+ conclusion: "success" | "failure" | "cancelled" | "timed_out" | "action_required" | "skipped" | string | null;
41
+ html_url: string;
42
+ head_sha: string;
43
+ output: {
44
+ title: string | null;
45
+ summary: string | null;
46
+ };
47
+ check_suite: {
48
+ id: number;
49
+ head_branch: string;
50
+ pull_requests: Array<{
51
+ number: number;
52
+ head: { ref: string; sha: string };
53
+ base: { ref: string };
54
+ }>;
55
+ } | null;
56
+ app: { slug: string; name: string } | null;
57
+ };
58
+ repository: { full_name: string; html_url: string };
59
+ }
60
+
61
+ export type GitHubWebhookPayload = GitHubWorkflowRunEvent | GitHubCheckRunEvent;
package/src/github.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * GitHub REST API client. Uses the plugin SDK's http.fetch for outbound calls
3
+ * so all requests go through the capability-gated host proxy.
4
+ */
5
+
6
+ const GITHUB_API = "https://api.github.com";
7
+
8
+ interface GitHubFetch {
9
+ (url: string, init?: RequestInit): Promise<Response>;
10
+ }
11
+
12
+ export interface GitHubIssue {
13
+ number: number;
14
+ title: string;
15
+ body: string | null;
16
+ state: "open" | "closed";
17
+ html_url: string;
18
+ labels: Array<{ name: string; color: string }>;
19
+ assignees: Array<{ login: string }>;
20
+ created_at: string;
21
+ updated_at: string;
22
+ closed_at: string | null;
23
+ }
24
+
25
+ export interface GitHubComment {
26
+ id: number;
27
+ body: string;
28
+ user: { login: string };
29
+ created_at: string;
30
+ html_url: string;
31
+ }
32
+
33
+ export interface GitHubSearchResult {
34
+ total_count: number;
35
+ items: GitHubIssue[];
36
+ }
37
+
38
+ function headers(token: string): Record<string, string> {
39
+ return {
40
+ Authorization: `Bearer ${token}`,
41
+ Accept: "application/vnd.github+json",
42
+ "X-GitHub-Api-Version": "2022-11-28",
43
+ };
44
+ }
45
+
46
+ export async function searchIssues(
47
+ fetch: GitHubFetch,
48
+ token: string,
49
+ repo: string,
50
+ query: string,
51
+ ): Promise<GitHubSearchResult> {
52
+ const q = encodeURIComponent(`repo:${repo} is:issue ${query}`);
53
+ const res = await fetch(`${GITHUB_API}/search/issues?q=${q}&per_page=10`, {
54
+ headers: headers(token),
55
+ });
56
+ if (!res.ok) throw new Error(`GitHub search failed: ${res.status} ${res.statusText}`);
57
+ return res.json() as Promise<GitHubSearchResult>;
58
+ }
59
+
60
+ export async function getIssue(
61
+ fetch: GitHubFetch,
62
+ token: string,
63
+ owner: string,
64
+ repo: string,
65
+ number: number,
66
+ ): Promise<GitHubIssue> {
67
+ const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
68
+ headers: headers(token),
69
+ });
70
+ if (!res.ok) throw new Error(`GitHub get issue failed: ${res.status} ${res.statusText}`);
71
+ return res.json() as Promise<GitHubIssue>;
72
+ }
73
+
74
+ export async function updateIssueState(
75
+ fetch: GitHubFetch,
76
+ token: string,
77
+ owner: string,
78
+ repo: string,
79
+ number: number,
80
+ state: "open" | "closed",
81
+ ): Promise<GitHubIssue> {
82
+ const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
83
+ method: "PATCH",
84
+ headers: { ...headers(token), "Content-Type": "application/json" },
85
+ body: JSON.stringify({ state }),
86
+ });
87
+ if (!res.ok) throw new Error(`GitHub update issue failed: ${res.status} ${res.statusText}`);
88
+ return res.json() as Promise<GitHubIssue>;
89
+ }
90
+
91
+ export async function listComments(
92
+ fetch: GitHubFetch,
93
+ token: string,
94
+ owner: string,
95
+ repo: string,
96
+ number: number,
97
+ since?: string,
98
+ ): Promise<GitHubComment[]> {
99
+ const params = since ? `?since=${encodeURIComponent(since)}` : "";
100
+ const res = await fetch(
101
+ `${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/comments${params}`,
102
+ { headers: headers(token) },
103
+ );
104
+ if (!res.ok) throw new Error(`GitHub list comments failed: ${res.status} ${res.statusText}`);
105
+ return res.json() as Promise<GitHubComment[]>;
106
+ }
107
+
108
+ export async function createComment(
109
+ fetch: GitHubFetch,
110
+ token: string,
111
+ owner: string,
112
+ repo: string,
113
+ number: number,
114
+ body: string,
115
+ ): Promise<GitHubComment> {
116
+ const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/comments`, {
117
+ method: "POST",
118
+ headers: { ...headers(token), "Content-Type": "application/json" },
119
+ body: JSON.stringify({ body }),
120
+ });
121
+ if (!res.ok) throw new Error(`GitHub create comment failed: ${res.status} ${res.statusText}`);
122
+ return res.json() as Promise<GitHubComment>;
123
+ }
124
+
125
+ /**
126
+ * Parse a GitHub issue reference from various formats:
127
+ * - https://github.com/owner/repo/issues/123
128
+ * - owner/repo#123
129
+ * - #123 (requires default repo)
130
+ */
131
+ export function parseGitHubIssueRef(
132
+ ref: string,
133
+ defaultRepo?: string,
134
+ ): { owner: string; repo: string; number: number } | null {
135
+ const urlMatch = ref.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
136
+ if (urlMatch) {
137
+ return { owner: urlMatch[1], repo: urlMatch[2], number: parseInt(urlMatch[3], 10) };
138
+ }
139
+
140
+ const refMatch = ref.match(/^([^/]+)\/([^#]+)#(\d+)$/);
141
+ if (refMatch) {
142
+ return { owner: refMatch[1], repo: refMatch[2], number: parseInt(refMatch[3], 10) };
143
+ }
144
+
145
+ const numMatch = ref.match(/^#?(\d+)$/);
146
+ if (numMatch && defaultRepo) {
147
+ const [owner, repo] = defaultRepo.split("/");
148
+ if (owner && repo) return { owner, repo, number: parseInt(numMatch[1], 10) };
149
+ }
150
+
151
+ return null;
152
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { default as manifest } from "./manifest.js";
2
+ export * from "./constants.js";
3
+ export * from "./verify-signature.js";
4
+ export type * from "./github-types.js";
5
+ export * as sync from "./sync.js";
6
+ export * as githubApi from "./github.js";
@@ -0,0 +1,105 @@
1
+ import type { PaperclipPluginManifestV1 } from "@paperclipai_dld/plugin-sdk";
2
+ import { DEFAULT_CONFIG, PLUGIN_ID, PLUGIN_VERSION, WEBHOOK_KEYS } from "./constants.js";
3
+
4
+ const manifest: PaperclipPluginManifestV1 = {
5
+ id: PLUGIN_ID,
6
+ apiVersion: 1,
7
+ version: PLUGIN_VERSION,
8
+ displayName: "GitHub",
9
+ description:
10
+ "GitHub integration for Paperclip. Phase 1: receives GitHub Actions webhook events (workflow_run, check_run) and creates Paperclip issues on CI failures. Phase 2: bidirectional issue sync, status bridging, and agent tools for searching and linking GitHub issues.",
11
+ author: "Paperclip",
12
+ categories: ["connector", "automation"],
13
+ capabilities: [
14
+ "webhooks.receive",
15
+ "issues.read",
16
+ "issues.create",
17
+ "issues.update",
18
+ "issue.comments.read",
19
+ "issue.comments.create",
20
+ "agents.read",
21
+ "agents.invoke",
22
+ "plugin.state.read",
23
+ "plugin.state.write",
24
+ "http.outbound",
25
+ "secrets.read-ref",
26
+ ],
27
+ entrypoints: {
28
+ worker: "./dist/worker.js",
29
+ },
30
+ instanceConfigSchema: {
31
+ type: "object",
32
+ properties: {
33
+ webhookSecret: {
34
+ type: "string",
35
+ title: "GitHub Webhook Secret",
36
+ description:
37
+ "Shared secret for HMAC-SHA256 signature verification of incoming GitHub webhooks.",
38
+ default: DEFAULT_CONFIG.webhookSecret,
39
+ },
40
+ companyId: {
41
+ type: "string",
42
+ title: "Company ID",
43
+ description: "Paperclip company ID where failure issues are created.",
44
+ default: DEFAULT_CONFIG.companyId,
45
+ },
46
+ goalId: {
47
+ type: "string",
48
+ title: "Goal ID",
49
+ description: "Goal to associate CI failure issues with.",
50
+ default: DEFAULT_CONFIG.goalId,
51
+ },
52
+ defaultAssigneeAgentId: {
53
+ type: "string",
54
+ title: "Default Assignee Agent ID",
55
+ description:
56
+ "Agent to assign CI failure issues to when the committing agent cannot be determined.",
57
+ default: DEFAULT_CONFIG.defaultAssigneeAgentId,
58
+ },
59
+ defaultRepo: {
60
+ type: "string",
61
+ title: "Default Repository",
62
+ description:
63
+ "Default GitHub repository in owner/repo format for agent tools and issue references.",
64
+ default: DEFAULT_CONFIG.defaultRepo,
65
+ },
66
+ githubTokenRef: {
67
+ type: "string",
68
+ title: "GitHub Token Secret Ref",
69
+ description:
70
+ "Paperclip secret reference name (or ID) for a GitHub Personal Access Token with repo scope. Required for GitHub API calls (search, sync, agent tools).",
71
+ default: DEFAULT_CONFIG.githubTokenRef,
72
+ },
73
+ syncDirection: {
74
+ type: "string",
75
+ title: "Sync Direction",
76
+ enum: ["bidirectional", "github-to-paperclip", "paperclip-to-github"],
77
+ description:
78
+ "Direction of issue state synchronisation when a GitHub link is active.",
79
+ default: DEFAULT_CONFIG.syncDirection,
80
+ },
81
+ syncComments: {
82
+ type: "boolean",
83
+ title: "Sync Comments",
84
+ description: "When enabled, new GitHub comments are mirrored to linked Paperclip issues.",
85
+ default: DEFAULT_CONFIG.syncComments,
86
+ },
87
+ skipSignatureVerification: {
88
+ type: "boolean",
89
+ title: "Skip Signature Verification",
90
+ description: "Development only — skip GitHub webhook HMAC verification.",
91
+ default: DEFAULT_CONFIG.skipSignatureVerification,
92
+ },
93
+ },
94
+ },
95
+ webhooks: [
96
+ {
97
+ endpointKey: WEBHOOK_KEYS.github,
98
+ displayName: "GitHub Events",
99
+ description:
100
+ "Receives workflow_run and check_run events from GitHub. Configure your GitHub repo webhook to POST to this endpoint.",
101
+ },
102
+ ],
103
+ };
104
+
105
+ export default manifest;
package/src/sync.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Sync logic between GitHub Issues and Paperclip issues.
3
+ * Manages link state in plugin state storage and handles
4
+ * bidirectional status + comment syncing.
5
+ *
6
+ * SDK fix applied: removed `scopeId: "default"` from all `{ scopeKind: "instance" }` state
7
+ * calls — `scopeId` must be omitted for instance-scoped keys per current SDK contract.
8
+ */
9
+
10
+ import type { PluginContext } from "@paperclipai_dld/plugin-sdk";
11
+ import * as github from "./github.js";
12
+
13
+ const LINK_PREFIX = "link:";
14
+ const GH_PREFIX = "gh:";
15
+
16
+ export interface IssueLink {
17
+ paperclipIssueId: string;
18
+ paperclipCompanyId: string;
19
+ ghOwner: string;
20
+ ghRepo: string;
21
+ ghNumber: number;
22
+ ghHtmlUrl: string;
23
+ syncDirection: "bidirectional" | "github-to-paperclip" | "paperclip-to-github";
24
+ lastSyncAt: string;
25
+ lastGhState: "open" | "closed";
26
+ lastCommentSyncAt: string | null;
27
+ }
28
+
29
+ function linkStateKey(paperclipIssueId: string): string {
30
+ return `${LINK_PREFIX}${paperclipIssueId}`;
31
+ }
32
+
33
+ function ghStateKey(owner: string, repo: string, number: number): string {
34
+ return `${GH_PREFIX}${owner}/${repo}#${number}`;
35
+ }
36
+
37
+ export async function getLink(
38
+ ctx: PluginContext,
39
+ paperclipIssueId: string,
40
+ ): Promise<IssueLink | null> {
41
+ const raw = await ctx.state.get({
42
+ scopeKind: "instance",
43
+ stateKey: linkStateKey(paperclipIssueId),
44
+ });
45
+ if (!raw) return null;
46
+ return JSON.parse(String(raw)) as IssueLink;
47
+ }
48
+
49
+ export async function getLinkByGitHub(
50
+ ctx: PluginContext,
51
+ owner: string,
52
+ repo: string,
53
+ number: number,
54
+ ): Promise<IssueLink | null> {
55
+ const raw = await ctx.state.get({
56
+ scopeKind: "instance",
57
+ stateKey: ghStateKey(owner, repo, number),
58
+ });
59
+ if (!raw) return null;
60
+ const paperclipIssueId = String(raw);
61
+ return getLink(ctx, paperclipIssueId);
62
+ }
63
+
64
+ export async function createLink(
65
+ ctx: PluginContext,
66
+ params: {
67
+ paperclipIssueId: string;
68
+ paperclipCompanyId: string;
69
+ ghOwner: string;
70
+ ghRepo: string;
71
+ ghNumber: number;
72
+ ghHtmlUrl: string;
73
+ ghState: "open" | "closed";
74
+ syncDirection: IssueLink["syncDirection"];
75
+ },
76
+ ): Promise<IssueLink> {
77
+ const link: IssueLink = {
78
+ paperclipIssueId: params.paperclipIssueId,
79
+ paperclipCompanyId: params.paperclipCompanyId,
80
+ ghOwner: params.ghOwner,
81
+ ghRepo: params.ghRepo,
82
+ ghNumber: params.ghNumber,
83
+ ghHtmlUrl: params.ghHtmlUrl,
84
+ syncDirection: params.syncDirection,
85
+ lastSyncAt: new Date().toISOString(),
86
+ lastGhState: params.ghState,
87
+ lastCommentSyncAt: null,
88
+ };
89
+
90
+ await ctx.state.set(
91
+ { scopeKind: "instance", stateKey: linkStateKey(params.paperclipIssueId) },
92
+ JSON.stringify(link),
93
+ );
94
+
95
+ await ctx.state.set(
96
+ { scopeKind: "instance", stateKey: ghStateKey(params.ghOwner, params.ghRepo, params.ghNumber) },
97
+ params.paperclipIssueId,
98
+ );
99
+
100
+ return link;
101
+ }
102
+
103
+ export async function updateLink(
104
+ ctx: PluginContext,
105
+ paperclipIssueId: string,
106
+ patch: Partial<Pick<IssueLink, "lastSyncAt" | "lastGhState" | "lastCommentSyncAt" | "syncDirection">>,
107
+ ): Promise<IssueLink | null> {
108
+ const existing = await getLink(ctx, paperclipIssueId);
109
+ if (!existing) return null;
110
+
111
+ const updated: IssueLink = { ...existing, ...patch };
112
+
113
+ await ctx.state.set(
114
+ { scopeKind: "instance", stateKey: linkStateKey(paperclipIssueId) },
115
+ JSON.stringify(updated),
116
+ );
117
+
118
+ return updated;
119
+ }
120
+
121
+ export async function deleteLink(
122
+ ctx: PluginContext,
123
+ paperclipIssueId: string,
124
+ ): Promise<void> {
125
+ const existing = await getLink(ctx, paperclipIssueId);
126
+ if (!existing) return;
127
+
128
+ await ctx.state.delete({
129
+ scopeKind: "instance",
130
+ stateKey: linkStateKey(paperclipIssueId),
131
+ });
132
+
133
+ await ctx.state.delete({
134
+ scopeKind: "instance",
135
+ stateKey: ghStateKey(existing.ghOwner, existing.ghRepo, existing.ghNumber),
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Sync a GitHub issue state change to the linked Paperclip issue.
141
+ */
142
+ export async function syncGitHubStateToPaperclip(
143
+ ctx: PluginContext,
144
+ link: IssueLink,
145
+ ghState: "open" | "closed",
146
+ ): Promise<void> {
147
+ if (link.syncDirection === "paperclip-to-github") return;
148
+ if (link.lastGhState === ghState) return;
149
+
150
+ const paperclipStatus = ghState === "closed" ? "done" : "in_progress";
151
+ await ctx.issues.update(
152
+ link.paperclipIssueId,
153
+ { status: paperclipStatus },
154
+ link.paperclipCompanyId,
155
+ );
156
+
157
+ await updateLink(ctx, link.paperclipIssueId, {
158
+ lastSyncAt: new Date().toISOString(),
159
+ lastGhState: ghState,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Sync new GitHub comments to the linked Paperclip issue.
165
+ */
166
+ export async function syncGitHubCommentsToPaperclip(
167
+ ctx: PluginContext,
168
+ link: IssueLink,
169
+ fetch: PluginContext["http"]["fetch"],
170
+ token: string,
171
+ ): Promise<void> {
172
+ if (link.syncDirection === "paperclip-to-github") return;
173
+
174
+ const comments = await github.listComments(
175
+ fetch,
176
+ token,
177
+ link.ghOwner,
178
+ link.ghRepo,
179
+ link.ghNumber,
180
+ link.lastCommentSyncAt ?? undefined,
181
+ );
182
+
183
+ for (const comment of comments) {
184
+ const body = `**[${comment.user.login} on GitHub](${comment.html_url}):**\n\n${comment.body}`;
185
+ await ctx.issues.createComment(link.paperclipIssueId, body, link.paperclipCompanyId);
186
+ }
187
+
188
+ if (comments.length > 0) {
189
+ await updateLink(ctx, link.paperclipIssueId, {
190
+ lastCommentSyncAt: new Date().toISOString(),
191
+ });
192
+ }
193
+ }