@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.
- package/LICENSE +21 -0
- package/dist/constants.d.ts +38 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +30 -0
- package/dist/constants.js.map +1 -0
- package/dist/github-types.d.ts +86 -0
- package/dist/github-types.d.ts.map +1 -0
- package/dist/github-types.js +6 -0
- package/dist/github-types.js.map +1 -0
- package/dist/github.d.ts +55 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +80 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +96 -0
- package/dist/manifest.js.map +1 -0
- package/dist/sync.d.ts +44 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +107 -0
- package/dist/sync.js.map +1 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +141 -0
- package/dist/tools.js.map +1 -0
- package/dist/verify-signature.d.ts +10 -0
- package/dist/verify-signature.d.ts.map +1 -0
- package/dist/verify-signature.js +21 -0
- package/dist/verify-signature.js.map +1 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +368 -0
- package/dist/worker.js.map +1 -0
- package/package.json +29 -0
- package/src/constants.ts +48 -0
- package/src/github-types.ts +61 -0
- package/src/github.ts +152 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +105 -0
- package/src/sync.ts +193 -0
- package/src/tools.ts +172 -0
- package/src/verify-signature.ts +26 -0
- package/src/worker.ts +488 -0
- package/tsconfig.json +9 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools for GitHub issue search, link, and unlink.
|
|
3
|
+
*
|
|
4
|
+
* SDK fix applied: ctx.tools.register requires 3 args (name, declaration, fn).
|
|
5
|
+
* The original mvanhorn plugin used 2-arg form which no longer matches the SDK.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginContext } from "@paperclipai_dld/plugin-sdk";
|
|
9
|
+
import { TOOL_NAMES } from "./constants.js";
|
|
10
|
+
import * as github from "./github.js";
|
|
11
|
+
import * as sync from "./sync.js";
|
|
12
|
+
|
|
13
|
+
export function registerTools(ctx: PluginContext): void {
|
|
14
|
+
// -----------------------------------------------------------------------
|
|
15
|
+
// github_search_issues
|
|
16
|
+
// -----------------------------------------------------------------------
|
|
17
|
+
ctx.tools.register(
|
|
18
|
+
TOOL_NAMES.searchIssues,
|
|
19
|
+
{
|
|
20
|
+
displayName: "Search GitHub Issues",
|
|
21
|
+
description: "Search GitHub issues in a repository. Returns matching issue titles, states, URLs, and labels.",
|
|
22
|
+
parametersSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
query: { type: "string", description: "Search query string" },
|
|
26
|
+
repo: { type: "string", description: "Repository in owner/repo format (optional, uses default if configured)" },
|
|
27
|
+
},
|
|
28
|
+
required: ["query"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
async (params, runCtx) => {
|
|
32
|
+
const p = params as Record<string, unknown>;
|
|
33
|
+
const config = (await ctx.config.get()) as Record<string, unknown>;
|
|
34
|
+
const tokenRef = config.githubTokenRef as string | undefined;
|
|
35
|
+
if (!tokenRef) return { error: "githubTokenRef not configured" };
|
|
36
|
+
|
|
37
|
+
const token = await ctx.secrets.resolve(tokenRef);
|
|
38
|
+
const defaultRepo = config.defaultRepo as string | undefined;
|
|
39
|
+
const repo = (p.repo as string | undefined) || defaultRepo || "";
|
|
40
|
+
if (!repo) {
|
|
41
|
+
return {
|
|
42
|
+
error: "No repository specified. Pass repo parameter or configure a default repository.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const results = await github.searchIssues(
|
|
48
|
+
ctx.http.fetch.bind(ctx.http),
|
|
49
|
+
token,
|
|
50
|
+
repo,
|
|
51
|
+
p.query as string,
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
content: `Found ${results.total_count} issues`,
|
|
55
|
+
data: {
|
|
56
|
+
total_count: results.total_count,
|
|
57
|
+
issues: results.items.map((issue) => ({
|
|
58
|
+
number: issue.number,
|
|
59
|
+
title: issue.title,
|
|
60
|
+
state: issue.state,
|
|
61
|
+
url: issue.html_url,
|
|
62
|
+
labels: issue.labels.map((l) => l.name),
|
|
63
|
+
assignees: issue.assignees.map((a) => a.login),
|
|
64
|
+
updated_at: issue.updated_at,
|
|
65
|
+
})),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { error: String(err) };
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// -----------------------------------------------------------------------
|
|
75
|
+
// github_link_issue
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
ctx.tools.register(
|
|
78
|
+
TOOL_NAMES.linkIssue,
|
|
79
|
+
{
|
|
80
|
+
displayName: "Link GitHub Issue",
|
|
81
|
+
description: "Link a GitHub issue to the current Paperclip issue for bidirectional sync.",
|
|
82
|
+
parametersSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
ghIssueUrl: {
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "GitHub issue URL, owner/repo#number, or #number (with a configured default repo)",
|
|
88
|
+
},
|
|
89
|
+
syncDirection: {
|
|
90
|
+
type: "string",
|
|
91
|
+
enum: ["bidirectional", "github-to-paperclip", "paperclip-to-github"],
|
|
92
|
+
description: "Sync direction (defaults to bidirectional)",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ["ghIssueUrl"],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
async (params, runCtx) => {
|
|
99
|
+
const p = params as Record<string, unknown>;
|
|
100
|
+
const config = (await ctx.config.get()) as Record<string, unknown>;
|
|
101
|
+
const tokenRef = config.githubTokenRef as string | undefined;
|
|
102
|
+
if (!tokenRef) return { error: "githubTokenRef not configured" };
|
|
103
|
+
|
|
104
|
+
const token = await ctx.secrets.resolve(tokenRef);
|
|
105
|
+
const defaultRepo = config.defaultRepo as string | undefined;
|
|
106
|
+
|
|
107
|
+
const ref = github.parseGitHubIssueRef(p.ghIssueUrl as string, defaultRepo);
|
|
108
|
+
if (!ref) return { error: "Could not parse GitHub issue reference." };
|
|
109
|
+
|
|
110
|
+
const issueId = runCtx.projectId; // using projectId as proxy; real impl uses context issue
|
|
111
|
+
const companyId = runCtx.companyId;
|
|
112
|
+
if (!issueId || !companyId) return { error: "No issue context available." };
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const ghIssue = await github.getIssue(
|
|
116
|
+
ctx.http.fetch.bind(ctx.http),
|
|
117
|
+
token,
|
|
118
|
+
ref.owner,
|
|
119
|
+
ref.repo,
|
|
120
|
+
ref.number,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const syncDirection =
|
|
124
|
+
(p.syncDirection as sync.IssueLink["syncDirection"] | undefined) ?? "bidirectional";
|
|
125
|
+
|
|
126
|
+
const link = await sync.createLink(ctx, {
|
|
127
|
+
paperclipIssueId: issueId,
|
|
128
|
+
paperclipCompanyId: companyId,
|
|
129
|
+
ghOwner: ref.owner,
|
|
130
|
+
ghRepo: ref.repo,
|
|
131
|
+
ghNumber: ref.number,
|
|
132
|
+
ghHtmlUrl: ghIssue.html_url,
|
|
133
|
+
ghState: ghIssue.state,
|
|
134
|
+
syncDirection,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
content: `Linked to GitHub issue #${ref.number} in ${ref.owner}/${ref.repo}`,
|
|
139
|
+
data: { link },
|
|
140
|
+
};
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { error: String(err) };
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
// github_unlink_issue
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
ctx.tools.register(
|
|
151
|
+
TOOL_NAMES.unlinkIssue,
|
|
152
|
+
{
|
|
153
|
+
displayName: "Unlink GitHub Issue",
|
|
154
|
+
description: "Remove the GitHub issue link from the current Paperclip issue.",
|
|
155
|
+
parametersSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
async (_params, runCtx) => {
|
|
161
|
+
const issueId = runCtx.projectId;
|
|
162
|
+
const companyId = runCtx.companyId;
|
|
163
|
+
if (!issueId || !companyId) return { error: "No issue context available." };
|
|
164
|
+
|
|
165
|
+
const existing = await sync.getLink(ctx, issueId);
|
|
166
|
+
if (!existing) return { error: "No GitHub link found for this issue." };
|
|
167
|
+
|
|
168
|
+
await sync.deleteLink(ctx, issueId);
|
|
169
|
+
return { content: `Unlinked GitHub issue #${existing.ghNumber} from ${existing.ghOwner}/${existing.ghRepo}` };
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify GitHub webhook HMAC-SHA256 signature.
|
|
5
|
+
*
|
|
6
|
+
* @param rawBody The raw request body string (must match what GitHub signed).
|
|
7
|
+
* @param signature The value of the `x-hub-signature-256` header (`sha256=<hex>`).
|
|
8
|
+
* @param secret The shared webhook secret.
|
|
9
|
+
* @returns `true` if the signature is valid.
|
|
10
|
+
*/
|
|
11
|
+
export function verifyGitHubSignature(
|
|
12
|
+
rawBody: string,
|
|
13
|
+
signature: string | undefined,
|
|
14
|
+
secret: string,
|
|
15
|
+
): boolean {
|
|
16
|
+
if (!signature || !secret) return false;
|
|
17
|
+
|
|
18
|
+
const [algo, hex] = signature.split("=", 2);
|
|
19
|
+
if (algo !== "sha256" || !hex) return false;
|
|
20
|
+
|
|
21
|
+
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
|
|
22
|
+
|
|
23
|
+
if (expected.length !== hex.length) return false;
|
|
24
|
+
|
|
25
|
+
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(hex, "hex"));
|
|
26
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
runWorker,
|
|
4
|
+
type PluginContext,
|
|
5
|
+
type PluginWebhookInput,
|
|
6
|
+
} from "@paperclipai_dld/plugin-sdk";
|
|
7
|
+
import type { Agent } from "@paperclipai_dld/shared";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_CONFIG,
|
|
10
|
+
SUPPORTED_GITHUB_EVENTS,
|
|
11
|
+
WEBHOOK_KEYS,
|
|
12
|
+
type PluginConfig,
|
|
13
|
+
type SupportedGitHubEvent,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
import type {
|
|
16
|
+
GitHubCheckRunEvent,
|
|
17
|
+
GitHubWorkflowRunEvent,
|
|
18
|
+
} from "./github-types.js";
|
|
19
|
+
import * as sync from "./sync.js";
|
|
20
|
+
import { registerTools } from "./tools.js";
|
|
21
|
+
import { verifyGitHubSignature } from "./verify-signature.js";
|
|
22
|
+
|
|
23
|
+
interface GitHubIssueEvent {
|
|
24
|
+
action: "opened" | "closed" | "reopened" | "edited" | "assigned" | string;
|
|
25
|
+
issue: {
|
|
26
|
+
number: number;
|
|
27
|
+
title: string;
|
|
28
|
+
body: string | null;
|
|
29
|
+
state: "open" | "closed";
|
|
30
|
+
html_url: string;
|
|
31
|
+
labels: Array<{ name: string }>;
|
|
32
|
+
};
|
|
33
|
+
repository: { full_name: string; html_url: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
let ctx: PluginContext | null = null;
|
|
41
|
+
|
|
42
|
+
async function getConfig(): Promise<Required<PluginConfig>> {
|
|
43
|
+
if (!ctx) throw new Error("Plugin not initialized");
|
|
44
|
+
const raw = (await ctx.config.get()) as PluginConfig;
|
|
45
|
+
return { ...DEFAULT_CONFIG, ...raw } as Required<PluginConfig>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Normalise header access — GitHub sends lowercase, SDK may preserve casing. */
|
|
49
|
+
function getHeader(
|
|
50
|
+
headers: Record<string, string | string[]>,
|
|
51
|
+
key: string,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
const lower = key.toLowerCase();
|
|
54
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
55
|
+
if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Try to resolve a Paperclip agent from the Git committer email or login.
|
|
62
|
+
* This is a best-effort lookup — the agent list is searched for a name match.
|
|
63
|
+
*/
|
|
64
|
+
async function resolveAgent(
|
|
65
|
+
companyId: string,
|
|
66
|
+
login: string | undefined,
|
|
67
|
+
email: string | undefined,
|
|
68
|
+
): Promise<Agent | null> {
|
|
69
|
+
if (!ctx || (!login && !email)) return null;
|
|
70
|
+
try {
|
|
71
|
+
const agents = await ctx.agents.list({ companyId });
|
|
72
|
+
const needle = (login ?? email ?? "").toLowerCase();
|
|
73
|
+
return (
|
|
74
|
+
agents.find(
|
|
75
|
+
(a) =>
|
|
76
|
+
a.name.toLowerCase() === needle ||
|
|
77
|
+
a.urlKey?.toLowerCase() === needle,
|
|
78
|
+
) ?? null
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Store the last processed delivery ID to avoid duplicate processing
|
|
87
|
+
* on retries from GitHub.
|
|
88
|
+
*/
|
|
89
|
+
async function isDuplicate(deliveryId: string): Promise<boolean> {
|
|
90
|
+
if (!ctx) return false;
|
|
91
|
+
try {
|
|
92
|
+
const last = (await ctx.state.get({
|
|
93
|
+
scopeKind: "instance",
|
|
94
|
+
stateKey: `last-delivery-${deliveryId}`,
|
|
95
|
+
})) as string | null;
|
|
96
|
+
return last != null;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function markDelivery(deliveryId: string): Promise<void> {
|
|
103
|
+
if (!ctx) return;
|
|
104
|
+
try {
|
|
105
|
+
await ctx.state.set(
|
|
106
|
+
{ scopeKind: "instance", stateKey: `last-delivery-${deliveryId}` },
|
|
107
|
+
new Date().toISOString(),
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Event handlers
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
async function handleWorkflowRun(payload: GitHubWorkflowRunEvent): Promise<void> {
|
|
119
|
+
const run = payload.workflow_run;
|
|
120
|
+
|
|
121
|
+
if (payload.action !== "completed") return;
|
|
122
|
+
if (run.conclusion !== "failure" && run.conclusion !== "timed_out") return;
|
|
123
|
+
|
|
124
|
+
const config = await getConfig();
|
|
125
|
+
if (!config.companyId) {
|
|
126
|
+
ctx?.logger.warn("No companyId configured — skipping issue creation");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const repo = payload.repository.full_name;
|
|
131
|
+
const commitAuthor = run.head_commit?.author;
|
|
132
|
+
const prNumbers = run.pull_requests.map((pr) => pr.number);
|
|
133
|
+
|
|
134
|
+
const agent = await resolveAgent(
|
|
135
|
+
config.companyId,
|
|
136
|
+
run.actor?.login,
|
|
137
|
+
commitAuthor?.email,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const assigneeAgentId = agent?.id ?? (config.defaultAssigneeAgentId || undefined);
|
|
141
|
+
|
|
142
|
+
if (prNumbers.length > 0) {
|
|
143
|
+
const commented = await commentOnLinkedIssues(config.companyId, repo, prNumbers, run);
|
|
144
|
+
if (commented) return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const title = `CI failure: ${run.name} #${run.run_number} on ${repo}`;
|
|
148
|
+
const description = buildWorkflowRunDescription(payload);
|
|
149
|
+
|
|
150
|
+
ctx?.logger.info(`Creating issue: ${title}`);
|
|
151
|
+
|
|
152
|
+
await ctx!.issues.create({
|
|
153
|
+
companyId: config.companyId,
|
|
154
|
+
goalId: config.goalId || undefined,
|
|
155
|
+
title,
|
|
156
|
+
description,
|
|
157
|
+
priority: "high",
|
|
158
|
+
assigneeAgentId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (assigneeAgentId) {
|
|
162
|
+
try {
|
|
163
|
+
await ctx!.agents.invoke(assigneeAgentId, config.companyId, {
|
|
164
|
+
prompt: `CI failure on ${repo}: workflow "${run.name}" #${run.run_number} failed (${run.conclusion}). Details: ${run.html_url}`,
|
|
165
|
+
reason: "github-ci-failure",
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
ctx?.logger.warn(`Could not invoke agent ${assigneeAgentId}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function handleCheckRun(payload: GitHubCheckRunEvent): Promise<void> {
|
|
174
|
+
const check = payload.check_run;
|
|
175
|
+
|
|
176
|
+
if (payload.action !== "completed") return;
|
|
177
|
+
if (check.conclusion !== "failure" && check.conclusion !== "timed_out") return;
|
|
178
|
+
|
|
179
|
+
const config = await getConfig();
|
|
180
|
+
if (!config.companyId) {
|
|
181
|
+
ctx?.logger.warn("No companyId configured — skipping");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const repo = payload.repository.full_name;
|
|
186
|
+
const prNumbers = check.check_suite?.pull_requests.map((pr) => pr.number) ?? [];
|
|
187
|
+
|
|
188
|
+
if (prNumbers.length > 0) {
|
|
189
|
+
const commented = await commentOnLinkedIssues(config.companyId, repo, prNumbers, check);
|
|
190
|
+
if (commented) return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const title = `PR gate failure: ${check.name} on ${repo}`;
|
|
194
|
+
const description = buildCheckRunDescription(payload);
|
|
195
|
+
|
|
196
|
+
ctx?.logger.info(`Creating issue: ${title}`);
|
|
197
|
+
|
|
198
|
+
const assigneeAgentId = config.defaultAssigneeAgentId || undefined;
|
|
199
|
+
|
|
200
|
+
await ctx!.issues.create({
|
|
201
|
+
companyId: config.companyId,
|
|
202
|
+
goalId: config.goalId || undefined,
|
|
203
|
+
title,
|
|
204
|
+
description,
|
|
205
|
+
priority: "high",
|
|
206
|
+
assigneeAgentId,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (assigneeAgentId) {
|
|
210
|
+
try {
|
|
211
|
+
await ctx!.agents.invoke(assigneeAgentId, config.companyId, {
|
|
212
|
+
prompt: `PR gate failure on ${repo}: check "${check.name}" failed (${check.conclusion}). Details: ${check.html_url}`,
|
|
213
|
+
reason: "github-pr-gate-failure",
|
|
214
|
+
});
|
|
215
|
+
} catch {
|
|
216
|
+
ctx?.logger.warn(`Could not invoke agent ${assigneeAgentId}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// PR-linked issue comment logic
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async function commentOnLinkedIssues(
|
|
226
|
+
companyId: string,
|
|
227
|
+
repo: string,
|
|
228
|
+
prNumbers: number[],
|
|
229
|
+
failureContext: GitHubWorkflowRunEvent["workflow_run"] | GitHubCheckRunEvent["check_run"],
|
|
230
|
+
): Promise<boolean> {
|
|
231
|
+
if (!ctx) return false;
|
|
232
|
+
|
|
233
|
+
const issues = await ctx.issues.list({
|
|
234
|
+
companyId,
|
|
235
|
+
status: "in_progress",
|
|
236
|
+
limit: 50,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let commented = false;
|
|
240
|
+
|
|
241
|
+
for (const issue of issues) {
|
|
242
|
+
const haystack = `${issue.title} ${issue.description ?? ""}`.toLowerCase();
|
|
243
|
+
const repoLower = repo.toLowerCase();
|
|
244
|
+
|
|
245
|
+
const matchesPR = prNumbers.some(
|
|
246
|
+
(n) =>
|
|
247
|
+
haystack.includes(`#${n}`) ||
|
|
248
|
+
haystack.includes(`pr ${n}`) ||
|
|
249
|
+
haystack.includes(`pull/${n}`),
|
|
250
|
+
);
|
|
251
|
+
const matchesRepo = haystack.includes(repoLower);
|
|
252
|
+
|
|
253
|
+
if (matchesPR || matchesRepo) {
|
|
254
|
+
const commentBody = buildFailureComment(repo, failureContext);
|
|
255
|
+
await ctx.issues.createComment(issue.id, commentBody, companyId);
|
|
256
|
+
commented = true;
|
|
257
|
+
|
|
258
|
+
ctx.logger.info(`Commented on issue ${issue.identifier ?? issue.id} about CI failure`);
|
|
259
|
+
|
|
260
|
+
if (issue.assigneeAgentId) {
|
|
261
|
+
try {
|
|
262
|
+
const name = "name" in failureContext ? failureContext.name : "CI check";
|
|
263
|
+
await ctx.agents.invoke(issue.assigneeAgentId, companyId, {
|
|
264
|
+
prompt: `CI/PR gate failure on ${repo}: "${name}" failed. See issue ${issue.identifier ?? issue.id} for details.`,
|
|
265
|
+
reason: "github-ci-failure-on-linked-issue",
|
|
266
|
+
});
|
|
267
|
+
} catch {
|
|
268
|
+
ctx.logger.warn(`Could not invoke agent ${issue.assigneeAgentId}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return commented;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Description builders
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
function buildWorkflowRunDescription(event: GitHubWorkflowRunEvent): string {
|
|
282
|
+
const run = event.workflow_run;
|
|
283
|
+
const repo = event.repository;
|
|
284
|
+
const commit = run.head_commit;
|
|
285
|
+
|
|
286
|
+
const lines: string[] = [
|
|
287
|
+
`## CI Failure: ${run.name}`,
|
|
288
|
+
"",
|
|
289
|
+
`| Field | Value |`,
|
|
290
|
+
`|-------|-------|`,
|
|
291
|
+
`| Repository | [${repo.full_name}](${repo.html_url}) |`,
|
|
292
|
+
`| Workflow | ${run.name} |`,
|
|
293
|
+
`| Run | [#${run.run_number}](${run.html_url}) (attempt ${run.run_attempt}) |`,
|
|
294
|
+
`| Branch | \`${run.head_branch}\` |`,
|
|
295
|
+
`| Conclusion | \`${run.conclusion}\` |`,
|
|
296
|
+
`| Commit | \`${run.head_sha.slice(0, 8)}\` |`,
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
if (commit?.author) {
|
|
300
|
+
lines.push(`| Author | ${commit.author.name} (${commit.author.email}) |`);
|
|
301
|
+
}
|
|
302
|
+
if (run.actor) {
|
|
303
|
+
lines.push(`| Actor | ${run.actor.login} |`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (run.pull_requests.length > 0) {
|
|
307
|
+
const prLinks = run.pull_requests
|
|
308
|
+
.map((pr) => `[#${pr.number}](${repo.html_url}/pull/${pr.number})`)
|
|
309
|
+
.join(", ");
|
|
310
|
+
lines.push(`| Pull Requests | ${prLinks} |`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (commit?.message) {
|
|
314
|
+
lines.push("", "### Commit Message", "", `> ${commit.message.split("\n")[0]}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lines.push("", "---", `*Created by GitHub plugin*`);
|
|
318
|
+
|
|
319
|
+
return lines.join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildCheckRunDescription(event: GitHubCheckRunEvent): string {
|
|
323
|
+
const check = event.check_run;
|
|
324
|
+
const repo = event.repository;
|
|
325
|
+
|
|
326
|
+
const lines: string[] = [
|
|
327
|
+
`## PR Gate Failure: ${check.name}`,
|
|
328
|
+
"",
|
|
329
|
+
`| Field | Value |`,
|
|
330
|
+
`|-------|-------|`,
|
|
331
|
+
`| Repository | [${repo.full_name}](${repo.html_url}) |`,
|
|
332
|
+
`| Check | [${check.name}](${check.html_url}) |`,
|
|
333
|
+
`| Conclusion | \`${check.conclusion}\` |`,
|
|
334
|
+
`| Commit | \`${check.head_sha.slice(0, 8)}\` |`,
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
if (check.app) {
|
|
338
|
+
lines.push(`| App | ${check.app.name} (\`${check.app.slug}\`) |`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (check.check_suite?.head_branch) {
|
|
342
|
+
lines.push(`| Branch | \`${check.check_suite.head_branch}\` |`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const prNumbers = check.check_suite?.pull_requests ?? [];
|
|
346
|
+
if (prNumbers.length > 0) {
|
|
347
|
+
const prLinks = prNumbers
|
|
348
|
+
.map((pr) => `[#${pr.number}](${repo.html_url}/pull/${pr.number})`)
|
|
349
|
+
.join(", ");
|
|
350
|
+
lines.push(`| Pull Requests | ${prLinks} |`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (check.output.summary) {
|
|
354
|
+
lines.push("", "### Summary", "", check.output.summary);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
lines.push("", "---", `*Created by GitHub plugin*`);
|
|
358
|
+
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildFailureComment(
|
|
363
|
+
repo: string,
|
|
364
|
+
failureContext: GitHubWorkflowRunEvent["workflow_run"] | GitHubCheckRunEvent["check_run"],
|
|
365
|
+
): string {
|
|
366
|
+
const name = failureContext.name;
|
|
367
|
+
const conclusion = failureContext.conclusion;
|
|
368
|
+
const url = failureContext.html_url;
|
|
369
|
+
const sha = failureContext.head_sha.slice(0, 8);
|
|
370
|
+
|
|
371
|
+
return [
|
|
372
|
+
`## CI Failure Detected`,
|
|
373
|
+
"",
|
|
374
|
+
`**${name}** failed on \`${repo}\` at commit \`${sha}\`.`,
|
|
375
|
+
"",
|
|
376
|
+
`- Conclusion: \`${conclusion}\``,
|
|
377
|
+
`- Details: [View on GitHub](${url})`,
|
|
378
|
+
"",
|
|
379
|
+
`*Reported by GitHub plugin*`,
|
|
380
|
+
].join("\n");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// GitHub issues event handler (Phase 2 — bidirectional sync)
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
async function handleIssueEvent(payload: GitHubIssueEvent): Promise<void> {
|
|
388
|
+
if (!ctx) return;
|
|
389
|
+
if (payload.action !== "closed" && payload.action !== "reopened") return;
|
|
390
|
+
|
|
391
|
+
const [owner, repo] = payload.repository.full_name.split("/");
|
|
392
|
+
if (!owner || !repo) return;
|
|
393
|
+
|
|
394
|
+
const link = await sync.getLinkByGitHub(ctx, owner, repo, payload.issue.number);
|
|
395
|
+
if (!link) {
|
|
396
|
+
ctx.logger.info(
|
|
397
|
+
`No linked Paperclip issue for ${payload.repository.full_name}#${payload.issue.number}`,
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const ghState = payload.issue.state;
|
|
403
|
+
ctx.logger.info(
|
|
404
|
+
`Syncing GitHub issue state (${ghState}) to Paperclip issue ${link.paperclipIssueId}`,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
await sync.syncGitHubStateToPaperclip(ctx, link, ghState);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Plugin definition
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
const plugin = definePlugin({
|
|
415
|
+
async setup(pluginCtx) {
|
|
416
|
+
ctx = pluginCtx;
|
|
417
|
+
registerTools(ctx);
|
|
418
|
+
ctx.logger.info("GitHub plugin initialized");
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async onHealth() {
|
|
422
|
+
return { status: "ok", message: "GitHub plugin ready" };
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
async onWebhook(input: PluginWebhookInput) {
|
|
426
|
+
if (!ctx) throw new Error("Plugin not initialized");
|
|
427
|
+
|
|
428
|
+
if (input.endpointKey !== WEBHOOK_KEYS.github) {
|
|
429
|
+
throw new Error(`Unsupported webhook endpoint "${input.endpointKey}"`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const config = await getConfig();
|
|
433
|
+
|
|
434
|
+
const deliveryId = getHeader(input.headers, "x-github-delivery");
|
|
435
|
+
if (deliveryId && (await isDuplicate(deliveryId))) {
|
|
436
|
+
ctx.logger.info(`Skipping duplicate delivery ${deliveryId}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!config.skipSignatureVerification) {
|
|
441
|
+
const signature = getHeader(input.headers, "x-hub-signature-256");
|
|
442
|
+
if (!config.webhookSecret) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
"webhookSecret not configured — cannot verify GitHub signature. " +
|
|
445
|
+
"Set the webhook secret in plugin config or enable skipSignatureVerification for development.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (!verifyGitHubSignature(input.rawBody, signature, config.webhookSecret)) {
|
|
449
|
+
throw new Error("Invalid GitHub webhook signature");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const eventType = getHeader(input.headers, "x-github-event");
|
|
454
|
+
if (!eventType || !SUPPORTED_GITHUB_EVENTS.includes(eventType as SupportedGitHubEvent)) {
|
|
455
|
+
ctx.logger.info(`Ignoring unsupported event type: ${eventType}`);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const payload =
|
|
460
|
+
typeof input.parsedBody === "object" && input.parsedBody !== null
|
|
461
|
+
? input.parsedBody
|
|
462
|
+
: JSON.parse(input.rawBody);
|
|
463
|
+
|
|
464
|
+
if (deliveryId) await markDelivery(deliveryId);
|
|
465
|
+
|
|
466
|
+
switch (eventType as SupportedGitHubEvent) {
|
|
467
|
+
case "workflow_run":
|
|
468
|
+
await handleWorkflowRun(payload as GitHubWorkflowRunEvent);
|
|
469
|
+
break;
|
|
470
|
+
case "check_run":
|
|
471
|
+
await handleCheckRun(payload as GitHubCheckRunEvent);
|
|
472
|
+
break;
|
|
473
|
+
case "issues":
|
|
474
|
+
await handleIssueEvent(payload as GitHubIssueEvent);
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
ctx.logger.info(`Processed ${eventType} event`);
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
async onShutdown() {
|
|
482
|
+
ctx?.logger.info("GitHub plugin shutting down");
|
|
483
|
+
ctx = null;
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
export default plugin;
|
|
488
|
+
runWorker(plugin, import.meta.url);
|