paperclip-github-plugin 0.3.2 → 0.3.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/README.md CHANGED
@@ -118,6 +118,8 @@ When a token is saved, the settings page audits the mapped repositories for the
118
118
 
119
119
  Imported issues keep the original GitHub title and use the normalized GitHub body as the Paperclip description. The worker also normalizes GitHub HTML that Paperclip descriptions do not render cleanly, including elements such as `<br>`, `<hr>`, `<details>`, `<summary>`, and inline images.
120
120
 
121
+ To keep imported issues recognizable without cluttering the visible description, the plugin appends a hidden HTML comment footer with the source GitHub issue URL. Agents and repair flows use that marker when the plugin-owned link entity or import registry is missing.
122
+
121
123
  Repeated syncs keep existing imports current instead of creating duplicates again. If the plugin's import registry is stale, the worker can repair deduplication by reusing existing Paperclip issues when durable GitHub link metadata is already present.
122
124
 
123
125
  When the local Paperclip API is available, the plugin also syncs labels by name, prefers exact color matches when multiple Paperclip labels share the same name, and creates missing Paperclip labels when needed.
@@ -136,6 +138,7 @@ When the local Paperclip API is available, the plugin also syncs labels by name,
136
138
  Additional behavior:
137
139
 
138
140
  - Open imported issues that are already in `backlog` stay in `backlog` until someone changes them in Paperclip.
141
+ - If an imported issue is `done` or `cancelled` and GitHub shows it open again with no linked pull request, sync moves it to `todo` so agents can pick it up again.
139
142
  - Trusted new GitHub comments from the original issue author or a verified maintainer/admin can move an open imported issue back to `todo`.
140
143
  - When the sync changes a Paperclip issue status, it adds a Paperclip comment explaining what changed and why.
141
144
 
package/dist/manifest.js CHANGED
@@ -435,7 +435,7 @@ var require2 = createRequire(import.meta.url);
435
435
  var packageJson = require2("../package.json");
436
436
  var DASHBOARD_WIDGET_CAPABILITY = "ui.dashboardWidget.register";
437
437
  var SCHEDULE_TICK_CRON = "* * * * *";
438
- var MANIFEST_VERSION = "0.3.2"?.trim() || typeof packageJson.version === "string" && packageJson.version.trim() || process.env.npm_package_version?.trim() || "0.0.0-dev";
438
+ var MANIFEST_VERSION = "0.3.4"?.trim() || typeof packageJson.version === "string" && packageJson.version.trim() || process.env.npm_package_version?.trim() || "0.0.0-dev";
439
439
  var manifest = {
440
440
  id: "paperclip-github-plugin",
441
441
  apiVersion: 1,
package/dist/worker.js CHANGED
@@ -560,6 +560,8 @@ var ISSUE_LINK_ENTITY_TYPE = "paperclip-github-plugin.issue-link";
560
560
  var PULL_REQUEST_LINK_ENTITY_TYPE = "paperclip-github-plugin.pull-request-link";
561
561
  var COMMENT_ANNOTATION_ENTITY_TYPE = "paperclip-github-plugin.comment-annotation";
562
562
  var AI_AUTHORED_COMMENT_FOOTER_PREFIX = "Created by a Paperclip AI agent using ";
563
+ var HIDDEN_GITHUB_IMPORT_MARKER_PREFIX = "<!-- paperclip-github-plugin-imported-from: ";
564
+ var HIDDEN_GITHUB_IMPORT_MARKER_SUFFIX = " -->";
563
565
  function normalizeCompanyId(value) {
564
566
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
565
567
  }
@@ -1157,6 +1159,9 @@ function createMappingId(index) {
1157
1159
  function normalizeOptionalString2(value) {
1158
1160
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
1159
1161
  }
1162
+ function stripNullBytes(value) {
1163
+ return value.replace(/\u0000/g, "");
1164
+ }
1160
1165
  function getErrorStatus(error) {
1161
1166
  if (!error || typeof error !== "object" || !("status" in error)) {
1162
1167
  return void 0;
@@ -1419,7 +1424,11 @@ function normalizeSecretRef(value) {
1419
1424
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
1420
1425
  }
1421
1426
  function normalizeGitHubUserLogin(value) {
1422
- return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : void 0;
1427
+ if (typeof value !== "string") {
1428
+ return void 0;
1429
+ }
1430
+ const trimmed = stripNullBytes(value).trim();
1431
+ return trimmed ? trimmed.toLowerCase() : void 0;
1423
1432
  }
1424
1433
  function normalizeGitHubTokenRef(value) {
1425
1434
  return normalizeSecretRef(value);
@@ -1983,8 +1992,8 @@ function doesImportedIssueMatchTarget(issue, target) {
1983
1992
  }
1984
1993
  return target.issueId !== void 0 && issue.paperclipIssueId === target.issueId || target.githubIssueId !== void 0 && issue.githubIssueId === target.githubIssueId || target.githubIssueNumber !== void 0 && issue.githubIssueNumber === target.githubIssueNumber;
1985
1994
  }
1986
- async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId) {
1987
- const linkRecords = await listGitHubIssueLinkRecords(ctx, {
1995
+ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId, options = {}) {
1996
+ const linkRecords = options.linkRecords ?? await listGitHubIssueLinkRecords(ctx, {
1988
1997
  paperclipIssueId: issueId
1989
1998
  });
1990
1999
  const entityMatch = linkRecords.find((record) => !record.data.companyId || record.data.companyId === companyId);
@@ -1997,7 +2006,8 @@ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId) {
1997
2006
  githubIssueId: entityMatch.data.githubIssueId,
1998
2007
  githubIssueNumber: entityMatch.data.githubIssueNumber,
1999
2008
  githubIssueUrl: entityMatch.data.githubIssueUrl,
2000
- linkedPullRequestNumbers: entityMatch.data.linkedPullRequestNumbers
2009
+ linkedPullRequestNumbers: entityMatch.data.linkedPullRequestNumbers,
2010
+ entityRecord: entityMatch
2001
2011
  };
2002
2012
  }
2003
2013
  const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
@@ -2010,7 +2020,7 @@ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId) {
2010
2020
  registryMatch.githubIssueNumber
2011
2021
  );
2012
2022
  if (githubIssueUrl2) {
2013
- return {
2023
+ const fallbackLink2 = {
2014
2024
  source: "import_registry",
2015
2025
  companyId: registryMatch.companyId,
2016
2026
  paperclipProjectId: registryMatch.paperclipProjectId,
@@ -2020,15 +2030,16 @@ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId) {
2020
2030
  githubIssueUrl: githubIssueUrl2,
2021
2031
  linkedPullRequestNumbers: []
2022
2032
  };
2033
+ return await hydrateRecoveredPaperclipIssueGitHubLink(ctx, issueId, fallbackLink2) ?? fallbackLink2;
2023
2034
  }
2024
2035
  }
2025
- const issue = await ctx.issues.get(issueId, companyId);
2036
+ const issue = options.paperclipIssue ?? await ctx.issues.get(issueId, companyId);
2026
2037
  const githubIssueUrl = extractImportedGitHubIssueUrlFromDescription(issue?.description);
2027
2038
  const githubIssueReference = githubIssueUrl ? parseGitHubIssueHtmlUrl(githubIssueUrl) : null;
2028
2039
  if (!githubIssueReference) {
2029
2040
  return null;
2030
2041
  }
2031
- return {
2042
+ const fallbackLink = {
2032
2043
  source: "description",
2033
2044
  companyId,
2034
2045
  paperclipProjectId: issue?.projectId ?? void 0,
@@ -2037,6 +2048,73 @@ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId) {
2037
2048
  githubIssueUrl: githubIssueReference.issueUrl,
2038
2049
  linkedPullRequestNumbers: []
2039
2050
  };
2051
+ return await hydrateRecoveredPaperclipIssueGitHubLink(ctx, issueId, fallbackLink) ?? fallbackLink;
2052
+ }
2053
+ async function hydrateRecoveredPaperclipIssueGitHubLink(ctx, issueId, fallbackLink) {
2054
+ const repository = parseRepositoryReference(fallbackLink.repositoryUrl);
2055
+ if (!repository) {
2056
+ return null;
2057
+ }
2058
+ let octokit;
2059
+ try {
2060
+ octokit = await createGitHubToolOctokit(ctx);
2061
+ } catch {
2062
+ return null;
2063
+ }
2064
+ try {
2065
+ const response = await octokit.rest.issues.get({
2066
+ owner: repository.owner,
2067
+ repo: repository.repo,
2068
+ issue_number: fallbackLink.githubIssueNumber,
2069
+ headers: {
2070
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
2071
+ }
2072
+ });
2073
+ const githubIssue = normalizeGitHubIssueRecord(response.data);
2074
+ const linkedPullRequests = await listLinkedPullRequestsForIssue(octokit, repository, githubIssue.number);
2075
+ const linkedPullRequestNumbers = linkedPullRequests.map((pullRequest) => pullRequest.number);
2076
+ const entityRecord = buildGitHubIssueLinkRecord(
2077
+ {
2078
+ companyId: fallbackLink.companyId,
2079
+ paperclipProjectId: fallbackLink.paperclipProjectId,
2080
+ repositoryUrl: fallbackLink.repositoryUrl
2081
+ },
2082
+ issueId,
2083
+ githubIssue,
2084
+ linkedPullRequestNumbers
2085
+ );
2086
+ await upsertGitHubIssueLinkRecord(
2087
+ ctx,
2088
+ {
2089
+ companyId: fallbackLink.companyId,
2090
+ paperclipProjectId: fallbackLink.paperclipProjectId,
2091
+ repositoryUrl: fallbackLink.repositoryUrl
2092
+ },
2093
+ issueId,
2094
+ githubIssue,
2095
+ linkedPullRequestNumbers
2096
+ );
2097
+ return {
2098
+ source: "entity",
2099
+ companyId: fallbackLink.companyId,
2100
+ paperclipProjectId: fallbackLink.paperclipProjectId,
2101
+ repositoryUrl: fallbackLink.repositoryUrl,
2102
+ githubIssueId: githubIssue.id,
2103
+ githubIssueNumber: githubIssue.number,
2104
+ githubIssueUrl: normalizeGitHubIssueHtmlUrl(githubIssue.htmlUrl) ?? githubIssue.htmlUrl,
2105
+ linkedPullRequestNumbers,
2106
+ entityRecord
2107
+ };
2108
+ } catch (error) {
2109
+ ctx.logger.warn("Unable to hydrate recovered GitHub issue metadata for a Paperclip issue fallback link.", {
2110
+ issueId,
2111
+ companyId: fallbackLink.companyId,
2112
+ repositoryUrl: fallbackLink.repositoryUrl,
2113
+ githubIssueNumber: fallbackLink.githubIssueNumber,
2114
+ error: getErrorMessage(error)
2115
+ });
2116
+ return null;
2117
+ }
2040
2118
  }
2041
2119
  async function resolveManualSyncTarget(ctx, settings, input) {
2042
2120
  if (input.issueId) {
@@ -2225,7 +2303,13 @@ async function buildIssueGitHubDetails(ctx, input) {
2225
2303
  const linkRecords = await listGitHubIssueLinkRecords(ctx, {
2226
2304
  paperclipIssueId: issueId
2227
2305
  });
2228
- const entityMatch = linkRecords.find((record) => !record.data.companyId || record.data.companyId === companyId);
2306
+ const link = await resolvePaperclipIssueGitHubLink(ctx, issueId, companyId, {
2307
+ linkRecords
2308
+ });
2309
+ if (!link) {
2310
+ return null;
2311
+ }
2312
+ const entityMatch = link.entityRecord;
2229
2313
  if (entityMatch) {
2230
2314
  return {
2231
2315
  paperclipIssueId: issueId,
@@ -2241,17 +2325,13 @@ async function buildIssueGitHubDetails(ctx, input) {
2241
2325
  syncedAt: entityMatch.data.syncedAt
2242
2326
  };
2243
2327
  }
2244
- const fallbackLink = await resolvePaperclipIssueGitHubLink(ctx, issueId, companyId);
2245
- if (!fallbackLink) {
2246
- return null;
2247
- }
2248
2328
  return {
2249
2329
  paperclipIssueId: issueId,
2250
- source: fallbackLink.source,
2251
- githubIssueNumber: fallbackLink.githubIssueNumber,
2252
- githubIssueUrl: fallbackLink.githubIssueUrl,
2253
- repositoryUrl: fallbackLink.repositoryUrl,
2254
- linkedPullRequestNumbers: fallbackLink.linkedPullRequestNumbers
2330
+ source: link.source,
2331
+ githubIssueNumber: link.githubIssueNumber,
2332
+ githubIssueUrl: link.githubIssueUrl,
2333
+ repositoryUrl: link.repositoryUrl,
2334
+ linkedPullRequestNumbers: link.linkedPullRequestNumbers
2255
2335
  };
2256
2336
  }
2257
2337
  async function resolveIssueByIdentifier(ctx, input) {
@@ -2839,7 +2919,7 @@ function normalizeGitHubUsername(value) {
2839
2919
  if (typeof value !== "string") {
2840
2920
  return void 0;
2841
2921
  }
2842
- const trimmed = value.trim().replace(/^@+/, "");
2922
+ const trimmed = stripNullBytes(value).trim().replace(/^@+/, "");
2843
2923
  return trimmed ? trimmed.toLowerCase() : void 0;
2844
2924
  }
2845
2925
  function buildGitHubUsernameAliases(value) {
@@ -3126,7 +3206,7 @@ function normalizeGitHubIssueLabels(value) {
3126
3206
  const seen = /* @__PURE__ */ new Set();
3127
3207
  const labels = [];
3128
3208
  for (const entry of value) {
3129
- const name = typeof entry === "string" ? entry.trim() : entry && typeof entry === "object" && typeof entry.name === "string" ? entry.name.trim() : "";
3209
+ const name = typeof entry === "string" ? stripNullBytes(entry).trim() : entry && typeof entry === "object" && typeof entry.name === "string" ? stripNullBytes(entry.name).trim() : "";
3130
3210
  if (!name) {
3131
3211
  continue;
3132
3212
  }
@@ -3146,8 +3226,8 @@ function normalizeGitHubIssueRecord(issue) {
3146
3226
  return {
3147
3227
  id: issue.id,
3148
3228
  number: issue.number,
3149
- title: issue.title,
3150
- body: issue.body ?? null,
3229
+ title: stripNullBytes(issue.title),
3230
+ body: typeof issue.body === "string" ? stripNullBytes(issue.body) : null,
3151
3231
  htmlUrl: issue.html_url,
3152
3232
  ...normalizeGitHubUsername(issue.user?.login) ? { authorLogin: normalizeGitHubUsername(issue.user?.login) } : {},
3153
3233
  labels: normalizeGitHubIssueLabels(issue.labels),
@@ -3632,7 +3712,7 @@ function resolvePaperclipIssueStatus(params) {
3632
3712
  return defaultImportedStatus;
3633
3713
  }
3634
3714
  if (currentStatus === "done" || currentStatus === "cancelled") {
3635
- return "backlog";
3715
+ return "todo";
3636
3716
  }
3637
3717
  return currentStatus;
3638
3718
  }
@@ -3978,7 +4058,7 @@ async function listNewGitHubIssueCommentsSinceCount(octokit, repository, issueNu
3978
4058
  for (const comment of response.data.slice(remainingOffset)) {
3979
4059
  comments.push({
3980
4060
  id: comment.id,
3981
- body: comment.body ?? "",
4061
+ body: typeof comment.body === "string" ? stripNullBytes(comment.body) : "",
3982
4062
  url: comment.html_url ?? void 0,
3983
4063
  authorLogin: normalizeGitHubUserLogin(comment.user?.login),
3984
4064
  createdAt: comment.created_at ?? void 0,
@@ -4061,6 +4141,15 @@ function parseGitHubIssueHtmlUrl(value) {
4061
4141
  function normalizeGitHubIssueHtmlUrl(value) {
4062
4142
  return parseGitHubIssueHtmlUrl(value)?.issueUrl;
4063
4143
  }
4144
+ function escapeRegExp(value) {
4145
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4146
+ }
4147
+ function getHiddenGitHubImportMarkerPattern() {
4148
+ return new RegExp(
4149
+ `${escapeRegExp(HIDDEN_GITHUB_IMPORT_MARKER_PREFIX)}(\\S+?)${escapeRegExp(HIDDEN_GITHUB_IMPORT_MARKER_SUFFIX)}`,
4150
+ "i"
4151
+ );
4152
+ }
4064
4153
  function normalizeLinkedPullRequestNumbers(values) {
4065
4154
  return [...new Set(
4066
4155
  values.filter((pullRequestNumber) => Number.isInteger(pullRequestNumber) && pullRequestNumber > 0)
@@ -4070,6 +4159,10 @@ function extractImportedGitHubIssueUrlFromDescription(description) {
4070
4159
  if (typeof description !== "string") {
4071
4160
  return void 0;
4072
4161
  }
4162
+ const hiddenMarkerMatch = description.match(getHiddenGitHubImportMarkerPattern());
4163
+ if (hiddenMarkerMatch) {
4164
+ return normalizeGitHubIssueHtmlUrl(hiddenMarkerMatch[1]);
4165
+ }
4073
4166
  const markdownMetadataMatch = description.match(/^\*\s+GitHub issue:\s+\[[^\]]+\]\(([^)]+)\)/m);
4074
4167
  if (markdownMetadataMatch) {
4075
4168
  return normalizeGitHubIssueHtmlUrl(markdownMetadataMatch[1]);
@@ -4179,7 +4272,7 @@ function normalizeGitHubIssueBodyForPaperclip(body) {
4179
4272
  if (typeof body !== "string") {
4180
4273
  return void 0;
4181
4274
  }
4182
- const trimmed = body.trim();
4275
+ const trimmed = stripNullBytes(body).trim();
4183
4276
  if (!trimmed) {
4184
4277
  return void 0;
4185
4278
  }
@@ -4212,8 +4305,27 @@ ${markdownImage}
4212
4305
  }
4213
4306
  function buildPaperclipIssueDescription(issue, linkedPullRequestNumbers = []) {
4214
4307
  const normalizedBody = normalizeGitHubIssueBodyForPaperclip(issue.body);
4308
+ const hiddenImportMarker = buildHiddenGitHubImportMarker(issue.htmlUrl);
4215
4309
  void linkedPullRequestNumbers;
4216
- return normalizedBody ?? "";
4310
+ if (!hiddenImportMarker) {
4311
+ return normalizedBody ?? "";
4312
+ }
4313
+ if (!normalizedBody) {
4314
+ return hiddenImportMarker;
4315
+ }
4316
+ return `${normalizedBody}
4317
+
4318
+ ${hiddenImportMarker}`;
4319
+ }
4320
+ function buildHiddenGitHubImportMarker(githubIssueUrl) {
4321
+ if (typeof githubIssueUrl !== "string") {
4322
+ return void 0;
4323
+ }
4324
+ const normalizedIssueUrl = normalizeGitHubIssueHtmlUrl(githubIssueUrl);
4325
+ if (!normalizedIssueUrl) {
4326
+ return void 0;
4327
+ }
4328
+ return `${HIDDEN_GITHUB_IMPORT_MARKER_PREFIX}${normalizedIssueUrl}${HIDDEN_GITHUB_IMPORT_MARKER_SUFFIX}`;
4217
4329
  }
4218
4330
  function normalizeIssueDescriptionValue(value) {
4219
4331
  return typeof value === "string" ? value : "";
@@ -4460,19 +4572,17 @@ async function findStoredStatusTransitionCommentAnnotation(ctx, params) {
4460
4572
  }
4461
4573
  return null;
4462
4574
  }
4463
- async function upsertGitHubIssueLinkRecord(ctx, mapping, issueId, githubIssue, linkedPullRequestNumbers) {
4575
+ function buildGitHubIssueLinkRecord(target, issueId, githubIssue, linkedPullRequestNumbers) {
4464
4576
  const githubIssueUrl = normalizeGitHubIssueHtmlUrl(githubIssue.htmlUrl) ?? githubIssue.htmlUrl;
4465
- await ctx.entities.upsert({
4466
- entityType: ISSUE_LINK_ENTITY_TYPE,
4467
- scopeKind: "issue",
4468
- scopeId: issueId,
4469
- externalId: githubIssueUrl,
4577
+ const repositoryUrl = parseRepositoryReference(target.repositoryUrl)?.url ?? target.repositoryUrl.trim();
4578
+ return {
4579
+ paperclipIssueId: issueId,
4470
4580
  title: `GitHub issue #${githubIssue.number}`,
4471
4581
  status: githubIssue.state,
4472
4582
  data: {
4473
- ...mapping.companyId ? { companyId: mapping.companyId } : {},
4474
- ...mapping.paperclipProjectId ? { paperclipProjectId: mapping.paperclipProjectId } : {},
4475
- repositoryUrl: getNormalizedMappingRepositoryUrl(mapping),
4583
+ ...target.companyId ? { companyId: target.companyId } : {},
4584
+ ...target.paperclipProjectId ? { paperclipProjectId: target.paperclipProjectId } : {},
4585
+ repositoryUrl,
4476
4586
  githubIssueId: githubIssue.id,
4477
4587
  githubIssueNumber: githubIssue.number,
4478
4588
  githubIssueUrl,
@@ -4483,6 +4593,18 @@ async function upsertGitHubIssueLinkRecord(ctx, mapping, issueId, githubIssue, l
4483
4593
  labels: githubIssue.labels,
4484
4594
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
4485
4595
  }
4596
+ };
4597
+ }
4598
+ async function upsertGitHubIssueLinkRecord(ctx, target, issueId, githubIssue, linkedPullRequestNumbers) {
4599
+ const record = buildGitHubIssueLinkRecord(target, issueId, githubIssue, linkedPullRequestNumbers);
4600
+ await ctx.entities.upsert({
4601
+ entityType: ISSUE_LINK_ENTITY_TYPE,
4602
+ scopeKind: "issue",
4603
+ scopeId: issueId,
4604
+ externalId: record.data.githubIssueUrl,
4605
+ ...record.title ? { title: record.title } : {},
4606
+ ...record.status ? { status: record.status } : {},
4607
+ data: record.data
4486
4608
  });
4487
4609
  }
4488
4610
  async function upsertGitHubPullRequestLinkRecord(ctx, params) {
@@ -6405,7 +6527,7 @@ async function listAllGitHubIssueComments(octokit, repository, issueNumber) {
6405
6527
  for (const comment of response.data) {
6406
6528
  comments.push({
6407
6529
  id: comment.id,
6408
- body: comment.body ?? "",
6530
+ body: typeof comment.body === "string" ? stripNullBytes(comment.body) : "",
6409
6531
  url: comment.html_url ?? void 0,
6410
6532
  authorLogin: normalizeGitHubUserLogin(comment.user?.login),
6411
6533
  authorUrl: comment.user?.html_url ?? void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-github-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Paperclip plugin for synchronizing GitHub issues into Paperclip projects.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@octokit/rest": "^22.0.1",
44
- "@paperclipai/plugin-sdk": "^2026.403.0",
44
+ "@paperclipai/plugin-sdk": "^2026.416.0",
45
45
  "react": "^19.2.5",
46
46
  "react-markdown": "^10.1.0",
47
47
  "rehype-raw": "^7.0.0",