paperclip-github-plugin 0.8.9 → 0.8.10

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/dist/worker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/worker.ts
2
+ import { Buffer } from "node:buffer";
2
3
  import { realpathSync } from "node:fs";
3
4
  import { readFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
@@ -532,6 +533,58 @@ var GITHUB_AGENT_TOOLS = [
532
533
  }
533
534
  }
534
535
  },
536
+ {
537
+ name: "upload_pull_request_asset",
538
+ displayName: "Upload Pull Request Asset",
539
+ description: "Upload a PR-visible asset such as an image, PDF, log, archive, or report to a non-merge artifact branch and return durable markdown that can be embedded in the PR body.",
540
+ parametersSchema: {
541
+ type: "object",
542
+ additionalProperties: false,
543
+ required: ["fileName"],
544
+ allOf: [pullRequestTargetSchema],
545
+ anyOf: [
546
+ { required: ["contentBase64"] },
547
+ { required: ["dataUrl"] }
548
+ ],
549
+ properties: {
550
+ repository: repositoryProperty,
551
+ pullRequestNumber: pullRequestNumberProperty,
552
+ paperclipIssueId: paperclipIssueIdProperty,
553
+ fileName: {
554
+ type: "string",
555
+ description: "Asset filename. The plugin sanitizes it and preserves a safe extension."
556
+ },
557
+ label: {
558
+ type: "string",
559
+ description: "Human-readable link text for the returned Markdown. Defaults to the sanitized filename."
560
+ },
561
+ alt: {
562
+ type: "string",
563
+ description: "Backward-compatible alias for label, useful as image alt text."
564
+ },
565
+ caption: {
566
+ type: "string",
567
+ description: "Optional human-facing caption returned with the uploaded asset metadata."
568
+ },
569
+ contentBase64: {
570
+ type: "string",
571
+ description: "Base64-encoded asset bytes. Assets are limited to 10 MiB."
572
+ },
573
+ dataUrl: {
574
+ type: "string",
575
+ description: "Alternative base64 data URL input such as data:application/pdf;base64,... or data:image/png;base64,... ."
576
+ },
577
+ mimeType: {
578
+ type: "string",
579
+ description: "Optional MIME type such as application/pdf or image/png. If omitted, the plugin infers common types from fileName and otherwise uses application/octet-stream."
580
+ },
581
+ artifactBranch: {
582
+ type: "string",
583
+ description: "Optional artifact branch name. Defaults to paperclip-artifacts-pr-<pullRequestNumber>."
584
+ }
585
+ }
586
+ }
587
+ },
535
588
  {
536
589
  name: "link_github_item",
537
590
  displayName: "Link GitHub Item",
@@ -637,6 +690,9 @@ var COMPANY_METRIC_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/a
637
690
  var ISSUE_LINK_API_ROUTE_KEY = "link-github-item";
638
691
  var ISSUE_LINK_API_ROUTE_PATH = "/issue-link";
639
692
  var ISSUE_LINK_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${ISSUE_LINK_API_ROUTE_PATH}`;
693
+ var PULL_REQUEST_ASSET_API_ROUTE_KEY = "upload-pull-request-asset";
694
+ var PULL_REQUEST_ASSET_API_ROUTE_PATH = "/pull-request-assets";
695
+ var PULL_REQUEST_ASSET_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${PULL_REQUEST_ASSET_API_ROUTE_PATH}`;
640
696
 
641
697
  // src/paperclip-health.ts
642
698
  function normalizeOptionalString(value) {
@@ -739,6 +795,40 @@ var AI_AUTHORED_MARKDOWN_FOOTER_PATTERN = /\n\n---\n###### ✨ This (?:comment|i
739
795
  var HIDDEN_GITHUB_IMPORT_MARKER_PREFIX = "<!-- paperclip-github-plugin-imported-from: ";
740
796
  var HIDDEN_GITHUB_IMPORT_MARKER_SUFFIX = " -->";
741
797
  var EMPTY_GITHUB_ISSUE_DESCRIPTION_PLACEHOLDER = "_No description provided on GitHub._";
798
+ var MAX_PULL_REQUEST_ASSET_BYTES = 10 * 1024 * 1024;
799
+ var DEFAULT_PULL_REQUEST_ASSET_MIME_TYPE = "application/octet-stream";
800
+ var PULL_REQUEST_ASSET_MIME_TYPE_BY_EXTENSION = {
801
+ png: "image/png",
802
+ jpg: "image/jpeg",
803
+ jpeg: "image/jpeg",
804
+ webp: "image/webp",
805
+ gif: "image/gif",
806
+ pdf: "application/pdf",
807
+ txt: "text/plain",
808
+ md: "text/markdown",
809
+ markdown: "text/markdown",
810
+ json: "application/json",
811
+ csv: "text/csv",
812
+ xml: "application/xml",
813
+ zip: "application/zip",
814
+ gz: "application/gzip",
815
+ tgz: "application/gzip"
816
+ };
817
+ var PULL_REQUEST_ASSET_EXTENSION_BY_MIME_TYPE = {
818
+ "image/png": "png",
819
+ "image/jpeg": "jpg",
820
+ "image/webp": "webp",
821
+ "image/gif": "gif",
822
+ "application/pdf": "pdf",
823
+ "text/plain": "txt",
824
+ "text/markdown": "md",
825
+ "application/json": "json",
826
+ "text/csv": "csv",
827
+ "application/xml": "xml",
828
+ "application/zip": "zip",
829
+ "application/gzip": "gz",
830
+ "application/octet-stream": "bin"
831
+ };
742
832
  var pluginRuntimeContext = null;
743
833
  function normalizeCompanyId(value) {
744
834
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
@@ -10003,6 +10093,187 @@ function buildToolSuccessResult(content, data) {
10003
10093
  data
10004
10094
  };
10005
10095
  }
10096
+ function normalizePullRequestAssetMimeType(value) {
10097
+ if (typeof value !== "string") {
10098
+ return void 0;
10099
+ }
10100
+ const normalized = value.trim().toLowerCase();
10101
+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:;\s*[a-z0-9!#$&^_.+-]+=[a-z0-9!#$&^_.+-]+)*$/.test(normalized)) {
10102
+ return void 0;
10103
+ }
10104
+ return normalized.split(";", 1)[0];
10105
+ }
10106
+ function getPullRequestAssetMimeTypeFromFileName(fileName) {
10107
+ const extensionMatch = fileName.trim().toLowerCase().match(/\.([a-z0-9]+)$/);
10108
+ if (!extensionMatch) {
10109
+ return void 0;
10110
+ }
10111
+ return PULL_REQUEST_ASSET_MIME_TYPE_BY_EXTENSION[extensionMatch[1]];
10112
+ }
10113
+ function getPullRequestAssetExtension(fileName) {
10114
+ const extensionMatch = fileName.trim().toLowerCase().match(/\.([a-z0-9]+)$/);
10115
+ return extensionMatch?.[1];
10116
+ }
10117
+ function sanitizePullRequestAssetFileName(fileNameInput, mimeType) {
10118
+ const inferredExtension = PULL_REQUEST_ASSET_EXTENSION_BY_MIME_TYPE[mimeType] ?? "bin";
10119
+ const fallbackBaseName = `asset.${inferredExtension}`;
10120
+ const rawFileName = normalizeOptionalString2(fileNameInput) ?? fallbackBaseName;
10121
+ const lastSegment = rawFileName.split(/[\\/]+/).filter(Boolean).at(-1) ?? fallbackBaseName;
10122
+ const extension = getPullRequestAssetExtension(lastSegment) ?? inferredExtension;
10123
+ const withoutExtension = lastSegment.replace(/\.[A-Za-z0-9]+$/, "");
10124
+ const sanitizedBaseName = withoutExtension.normalize("NFKD").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/[-_.]+$/g, "").replace(/^[-_.]+/g, "").slice(0, 80) || "asset";
10125
+ return `${sanitizedBaseName}.${extension}`;
10126
+ }
10127
+ function sanitizePullRequestAssetLabel(value, fallbackFileName) {
10128
+ const normalized = normalizeOptionalString2(value);
10129
+ return (normalized ?? fallbackFileName.replace(/[-_.]+/g, " ")).slice(0, 200);
10130
+ }
10131
+ function sanitizePullRequestAssetArtifactBranch(value, pullRequestNumber) {
10132
+ const normalized = normalizeOptionalString2(value);
10133
+ const candidate = normalized ?? `paperclip-artifacts-pr-${pullRequestNumber}`;
10134
+ const sanitized = candidate.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/\.\.+/g, ".").replace(/\/{2,}/g, "/").replace(/^[-/.]+|[-/.]+$/g, "");
10135
+ if (!sanitized || sanitized.includes("..") || sanitized.startsWith("/") || sanitized.endsWith(".lock")) {
10136
+ throw new Error("artifactBranch must be a safe Git branch name.");
10137
+ }
10138
+ return sanitized;
10139
+ }
10140
+ function decodePullRequestAssetContent(payload) {
10141
+ const dataUrl = normalizeOptionalString2(payload.dataUrl);
10142
+ let contentBase64 = normalizeOptionalString2(payload.contentBase64);
10143
+ let mimeType = normalizePullRequestAssetMimeType(payload.mimeType);
10144
+ if (dataUrl) {
10145
+ const match = dataUrl.match(/^data:([^;,]+(?:;[^,]+)?);base64,(.+)$/is);
10146
+ if (!match) {
10147
+ throw new Error("dataUrl must be a base64 data URL.");
10148
+ }
10149
+ const dataUrlMimeType = normalizePullRequestAssetMimeType(match[1]);
10150
+ if (!dataUrlMimeType) {
10151
+ throw new Error("dataUrl MIME type must be a valid MIME type such as image/png or application/pdf.");
10152
+ }
10153
+ mimeType = dataUrlMimeType;
10154
+ contentBase64 = match[2].replace(/\s+/g, "");
10155
+ }
10156
+ if (!contentBase64) {
10157
+ throw new Error("contentBase64 or dataUrl is required.");
10158
+ }
10159
+ mimeType = mimeType ?? getPullRequestAssetMimeTypeFromFileName(normalizeOptionalString2(payload.fileName) ?? "") ?? DEFAULT_PULL_REQUEST_ASSET_MIME_TYPE;
10160
+ const normalizedBase64 = contentBase64.replace(/\s+/g, "");
10161
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalizedBase64) || normalizedBase64.length % 4 === 1) {
10162
+ throw new Error("Asset content must be valid base64.");
10163
+ }
10164
+ const bytes = Buffer.from(normalizedBase64, "base64");
10165
+ if (bytes.length === 0) {
10166
+ throw new Error("Asset content must not be empty.");
10167
+ }
10168
+ if (bytes.length > MAX_PULL_REQUEST_ASSET_BYTES) {
10169
+ throw new Error(`Asset content exceeds the ${MAX_PULL_REQUEST_ASSET_BYTES} byte limit.`);
10170
+ }
10171
+ return {
10172
+ bytes,
10173
+ mimeType
10174
+ };
10175
+ }
10176
+ function buildPullRequestAssetMarkdown(label, rawUrl, mimeType) {
10177
+ const normalizedLabel = label.replace(/[\]\n\r]/g, " ").trim();
10178
+ if (mimeType.startsWith("image/")) {
10179
+ return `![${normalizedLabel}](${rawUrl})`;
10180
+ }
10181
+ return `[${normalizedLabel}](${rawUrl})`;
10182
+ }
10183
+ async function uploadPullRequestAssetArtifact(params) {
10184
+ const { bytes, mimeType } = decodePullRequestAssetContent(params.payload);
10185
+ const fileName = sanitizePullRequestAssetFileName(params.payload.fileName, mimeType);
10186
+ const label = sanitizePullRequestAssetLabel(params.payload.label ?? params.payload.alt, fileName);
10187
+ const caption = normalizeOptionalString2(params.payload.caption);
10188
+ const artifactBranch = sanitizePullRequestAssetArtifactBranch(params.payload.artifactBranch, params.pullRequestNumber);
10189
+ const pullRequestResponse = await params.octokit.rest.pulls.get({
10190
+ owner: params.repository.owner,
10191
+ repo: params.repository.repo,
10192
+ pull_number: params.pullRequestNumber,
10193
+ headers: {
10194
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
10195
+ }
10196
+ });
10197
+ const headSha = pullRequestResponse.data.head.sha;
10198
+ const shortHeadSha = headSha.slice(0, 12);
10199
+ const contentPath = `assets/pr-${params.pullRequestNumber}/${shortHeadSha}/${fileName}`;
10200
+ let branchSha;
10201
+ try {
10202
+ const branchRefResponse = await params.octokit.rest.git.getRef({
10203
+ owner: params.repository.owner,
10204
+ repo: params.repository.repo,
10205
+ ref: `heads/${artifactBranch}`,
10206
+ headers: {
10207
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
10208
+ }
10209
+ });
10210
+ branchSha = branchRefResponse.data.object.sha;
10211
+ } catch (error) {
10212
+ if (getErrorStatus(error) !== 404) {
10213
+ throw error;
10214
+ }
10215
+ }
10216
+ if (!branchSha) {
10217
+ await params.octokit.rest.git.createRef({
10218
+ owner: params.repository.owner,
10219
+ repo: params.repository.repo,
10220
+ ref: `refs/heads/${artifactBranch}`,
10221
+ sha: pullRequestResponse.data.base.sha,
10222
+ headers: {
10223
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
10224
+ }
10225
+ });
10226
+ }
10227
+ let existingFileSha;
10228
+ try {
10229
+ const existingContentResponse = await params.octokit.rest.repos.getContent({
10230
+ owner: params.repository.owner,
10231
+ repo: params.repository.repo,
10232
+ path: contentPath,
10233
+ ref: artifactBranch,
10234
+ headers: {
10235
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
10236
+ }
10237
+ });
10238
+ if (!Array.isArray(existingContentResponse.data) && existingContentResponse.data.type === "file") {
10239
+ existingFileSha = existingContentResponse.data.sha;
10240
+ }
10241
+ } catch (error) {
10242
+ if (getErrorStatus(error) !== 404) {
10243
+ throw error;
10244
+ }
10245
+ }
10246
+ const updateResponse = await params.octokit.rest.repos.createOrUpdateFileContents({
10247
+ owner: params.repository.owner,
10248
+ repo: params.repository.repo,
10249
+ path: contentPath,
10250
+ message: `Add asset for PR #${params.pullRequestNumber}`,
10251
+ content: bytes.toString("base64"),
10252
+ branch: artifactBranch,
10253
+ ...existingFileSha ? { sha: existingFileSha } : {},
10254
+ headers: {
10255
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
10256
+ }
10257
+ });
10258
+ const commitSha = updateResponse.data.commit.sha;
10259
+ const rawUrl = `https://raw.githubusercontent.com/${params.repository.owner}/${params.repository.repo}/${commitSha}/${contentPath}`;
10260
+ const markdown = buildPullRequestAssetMarkdown(label, rawUrl, mimeType);
10261
+ return {
10262
+ repository: params.repository.url,
10263
+ pullRequestNumber: params.pullRequestNumber,
10264
+ artifactBranch,
10265
+ path: contentPath,
10266
+ fileName,
10267
+ mimeType,
10268
+ sizeBytes: bytes.length,
10269
+ commitSha,
10270
+ rawUrl,
10271
+ markdown,
10272
+ label,
10273
+ ...mimeType.startsWith("image/") ? { alt: label } : {},
10274
+ ...caption ? { caption } : {}
10275
+ };
10276
+ }
10006
10277
  function buildToolErrorResult(error) {
10007
10278
  const rateLimitPause = getGitHubRateLimitPauseDetails(error);
10008
10279
  if (rateLimitPause) {
@@ -10183,12 +10454,15 @@ async function handleCompanyMetricApiRoute(ctx, input) {
10183
10454
  }
10184
10455
  };
10185
10456
  }
10186
- function parseIssueLinkApiRouteBody(input) {
10457
+ function parsePluginApiRouteJsonObjectBody(input, routeLabel) {
10187
10458
  if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) {
10188
- throw new Error("Issue link route body must be a JSON object.");
10459
+ throw new Error(`${routeLabel} body must be a JSON object.`);
10189
10460
  }
10190
10461
  return input.body;
10191
10462
  }
10463
+ function parseIssueLinkApiRouteBody(input) {
10464
+ return parsePluginApiRouteJsonObjectBody(input, "Issue link route");
10465
+ }
10192
10466
  function normalizeIssueLinkApiRouteKind(payload) {
10193
10467
  const explicitKind = normalizeIssueGitHubLinkKind(payload.kind);
10194
10468
  if (explicitKind) {
@@ -10203,6 +10477,47 @@ function normalizeIssueLinkApiRouteKind(payload) {
10203
10477
  }
10204
10478
  return null;
10205
10479
  }
10480
+ async function handlePullRequestAssetApiRoute(ctx, input) {
10481
+ if (input.actor.actorType !== "agent") {
10482
+ throw new Error("Pull request assets must be uploaded by an authenticated Paperclip agent.");
10483
+ }
10484
+ const payload = parsePluginApiRouteJsonObjectBody(input, "Pull request asset route");
10485
+ const rawPullRequestUrl = normalizeOptionalString2(payload.pullRequestUrl);
10486
+ const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(rawPullRequestUrl);
10487
+ if (rawPullRequestUrl && !pullRequestUrl) {
10488
+ throw new Error("pullRequestUrl must be a valid GitHub pull request URL.");
10489
+ }
10490
+ const parsedPullRequestUrl = pullRequestUrl ? parseGitHubPullRequestHtmlUrl(pullRequestUrl) : void 0;
10491
+ const repositoryInput = normalizeOptionalString2(payload.repository) ?? parsedPullRequestUrl?.repositoryUrl;
10492
+ if (!repositoryInput) {
10493
+ throw new Error("repository is required unless pullRequestUrl is provided.");
10494
+ }
10495
+ const repository = requireRepositoryReference(repositoryInput);
10496
+ const pullRequestNumber = normalizeToolPositiveInteger(payload.pullRequestNumber) ?? parsedPullRequestUrl?.pullRequestNumber;
10497
+ if (!pullRequestNumber) {
10498
+ throw new Error("pullRequestNumber is required unless pullRequestUrl is provided.");
10499
+ }
10500
+ if (parsedPullRequestUrl && !areRepositoriesEqual(repository, requireRepositoryReference(parsedPullRequestUrl.repositoryUrl))) {
10501
+ throw new Error("repository must match pullRequestUrl.");
10502
+ }
10503
+ const octokit = await createGitHubToolOctokit(ctx, input.companyId, {
10504
+ toolName: PULL_REQUEST_ASSET_API_ROUTE_KEY,
10505
+ repositoryUrl: repository.url
10506
+ });
10507
+ const asset = await uploadPullRequestAssetArtifact({
10508
+ octokit,
10509
+ repository,
10510
+ pullRequestNumber,
10511
+ payload
10512
+ });
10513
+ return {
10514
+ status: 201,
10515
+ body: {
10516
+ status: "uploaded",
10517
+ asset
10518
+ }
10519
+ };
10520
+ }
10206
10521
  async function handleIssueLinkApiRoute(ctx, input) {
10207
10522
  if (input.actor.actorType !== "agent") {
10208
10523
  throw new Error("GitHub issue links must be recorded by an authenticated Paperclip agent.");
@@ -15085,6 +15400,27 @@ function registerGitHubAgentTools(ctx) {
15085
15400
  );
15086
15401
  })
15087
15402
  );
15403
+ ctx.tools.register(
15404
+ "upload_pull_request_asset",
15405
+ getGitHubAgentToolDeclaration("upload_pull_request_asset"),
15406
+ async (params, runCtx) => executeGitHubTool(async () => {
15407
+ const input = getToolInputRecord(params);
15408
+ const target = await resolveGitHubPullRequestToolTarget(ctx, runCtx, input);
15409
+ const octokit = await createAgentToolOctokit(runCtx, "upload_pull_request_asset", target.repository);
15410
+ const asset = await uploadPullRequestAssetArtifact({
15411
+ octokit,
15412
+ repository: target.repository,
15413
+ pullRequestNumber: target.pullRequestNumber,
15414
+ payload: input
15415
+ });
15416
+ return buildToolSuccessResult(
15417
+ `Uploaded asset ${asset.fileName} for pull request #${target.pullRequestNumber}.`,
15418
+ {
15419
+ asset
15420
+ }
15421
+ );
15422
+ })
15423
+ );
15088
15424
  ctx.tools.register(
15089
15425
  "link_github_item",
15090
15426
  getGitHubAgentToolDeclaration("link_github_item"),
@@ -15559,6 +15895,9 @@ var plugin = definePlugin({
15559
15895
  if (input.routeKey === ISSUE_LINK_API_ROUTE_KEY) {
15560
15896
  return handleIssueLinkApiRoute(pluginRuntimeContext, input);
15561
15897
  }
15898
+ if (input.routeKey === PULL_REQUEST_ASSET_API_ROUTE_KEY) {
15899
+ return handlePullRequestAssetApiRoute(pluginRuntimeContext, input);
15900
+ }
15562
15901
  return {
15563
15902
  status: 404,
15564
15903
  body: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-github-plugin",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "description": "Paperclip plugin for synchronizing GitHub issues into Paperclip projects.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "dependencies": {
43
43
  "@octokit/rest": "^22.0.1",
44
44
  "@paperclipai/plugin-sdk": "^2026.428.0",
45
- "react": "^19.2.5",
45
+ "react": "^19.2.6",
46
46
  "react-markdown": "^10.1.0",
47
47
  "rehype-raw": "^7.0.0",
48
48
  "rehype-sanitize": "^6.0.0",