paperclip-github-plugin 0.8.7 → 0.8.8

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
@@ -72,6 +72,12 @@ Because GitHub alone cannot tell which pull requests came from a Paperclip compa
72
72
 
73
73
  That API route path matters on authenticated Paperclip deployments today because a current host bug blocks agents from calling plugin tools unless the instance runs in `local_trusted` mode. Those agents can still use `gh` with the propagated `GITHUB_TOKEN`, then call the agent-authenticated plugin API route from the shell after they create a PR. The Paperclip host authenticates `Authorization: Bearer <PAPERCLIP_API_KEY>`, scopes the request to the calling agent's company, and rejects anonymous or non-agent calls before dispatching to the worker.
74
74
 
75
+ ### Third-party issue links
76
+
77
+ Sometimes a Paperclip issue is implemented through a GitHub issue or pull request in a repository that is not mapped to any Paperclip project. GitHub Sync can now track those targeted links without enrolling the whole repository in sync. The `link_github_item` agent tool records the durable link on local-trusted Paperclip instances, and authenticated agent runs can post the same link to `/api/plugins/paperclip-github-plugin/api/issue-link` with `PAPERCLIP_API_KEY` when plugin tools are blocked by host board-auth gates.
78
+
79
+ Third-party links are company-scoped and sync only the linked GitHub issue or pull request. Future manual or scheduled company sync runs refresh those external records and update the Paperclip issue status from the same CI, mergeability, review, thread, and issue-state rules used for mapped repositories.
80
+
75
81
  ### Project pull request command center
76
82
 
77
83
  Each mapped project can expose a **Pull Requests** entry in the sidebar that opens a live GitHub queue page for that repository. The sidebar badge uses a lightweight total-count read, while the queue keeps the default view fast by loading only the current 10-row page, uses a repo-wide metrics read for the summary cards, reuses that cached metrics scan to keep filtered views fast by fetching only the visible filtered rows, keeps repo-scoped count, metrics, and per-PR review/check insight caches warm for repeat visits, lets operators explicitly bust those caches with Refresh when they want a live reread, shows total, mergeable, reviewable, and failing cards that filter the table, only treats a pull request as mergeable when it targets the current default branch with green checks, at least one approval, no outstanding change requests, and no unresolved review threads, includes an **Up to date** column that distinguishes current branches, clean update candidates, conflict cases, and unknown freshness when GitHub cannot confirm the comparison, shows the PR target branch with a highlighted default-branch badge, keeps the list sorted by most recently updated first, paginates larger repositories, keeps a compact bottom detail pane with markdown-and-HTML-rendered conversation plus an inline comment composer, supports deterministic **Update branch** actions for clean behind-base pull requests, adds Copilot quick actions that post `@copilot` requests for **Fix CI**, **Rebase**, and **Address review feedback**, requests Copilot through GitHub’s native reviewer flow for **Review**, keeps comment, review, quick approve/request-changes, re-run CI, merge, and close actions available, lets the review modal submit comment-only, approve, or request-changes reviews, hides any pull request action whose required GitHub permission is not verified for the saved token, and opens linked Paperclip issues in a plugin-provided right drawer so operators can stay on the queue page.
@@ -93,6 +99,7 @@ Linked Paperclip issues can also be unlinked from the GitHub detail surface. Unl
93
99
  ### Agent workflows built in
94
100
 
95
101
  Paperclip agents can search GitHub for duplicates, read and update issues, assign issues to the saved token owner, post comments, create pull requests, inspect changed files and CI, reply to review threads, resolve or unresolve threads, request reviewers, search org-level GitHub Projects, and associate pull requests with those projects without leaving the Paperclip plugin surface.
102
+ They can also link a Paperclip issue to a GitHub issue or pull request in any accessible repository with `link_github_item`, including third-party repositories that are not mapped to a Paperclip project.
96
103
 
97
104
  ## Requirements
98
105
 
@@ -259,10 +266,33 @@ Current host caveat: on authenticated Paperclip deployments, the Paperclip host
259
266
 
260
267
  Because the KPI attribution endpoint is a native plugin JSON route rather than a plugin tool, authenticated agent runs can still call it directly with `PAPERCLIP_API_KEY` even while that host bug blocks the GitHub Sync tool surface.
261
268
 
269
+ ### Issue link API route
270
+
271
+ Authenticated agent runs can link the current Paperclip issue to a GitHub issue or pull request by posting to `/api/plugins/paperclip-github-plugin/api/issue-link`. This is useful after creating a PR with `gh` in a repository that is not mapped to a Paperclip project.
272
+
273
+ Supported payload fields:
274
+
275
+ - `paperclipIssueId` required: the Paperclip issue id to link
276
+ - `kind` optional: `issue` or `pull_request`; omitted values are inferred from full GitHub URLs when possible
277
+ - `reference` optional: a GitHub issue or pull request number, or a full GitHub URL
278
+ - `repository` optional: `owner/repo` or `https://github.com/owner/repo`, required for number-only references when the issue project is not mapped to that repository
279
+ - `issueNumber`, `pullRequestNumber`, or `pullRequestUrl` optional alternatives to `reference`
280
+
281
+ Example:
282
+
283
+ ```bash
284
+ payload='{"paperclipIssueId":"iss_123","pullRequestUrl":"https://github.com/third-party/external/pull/77"}'
285
+
286
+ curl -X POST "${PAPERCLIP_API_URL%/}/api/plugins/paperclip-github-plugin/api/issue-link" \
287
+ -H "content-type: application/json" \
288
+ -H "authorization: Bearer ${PAPERCLIP_API_KEY}" \
289
+ -d "${payload}"
290
+ ```
291
+
262
292
  ## Troubleshooting
263
293
 
264
294
  - If an older GitHub Sync build fails upgrade with `requires host version 2026.427.0 or newer, but this server is running 0.0.0`, upgrade to a build that removes the strict manifest host-version gate. The host is reporting a development-version sentinel, so the plugin now relies on declared capabilities and runtime fallbacks instead.
265
- - If setup is reported as incomplete, confirm that a GitHub token has been saved or that `${PAPERCLIP_HOME:-~/.paperclip}/plugins/github-sync/config.json` contains `githubToken`, and make sure at least one mapping has a created Paperclip project.
295
+ - If setup is reported as incomplete, confirm that a GitHub token has been saved or that `${PAPERCLIP_HOME:-~/.paperclip}/plugins/github-sync/config.json` contains `githubToken`, and make sure at least one mapping has a created Paperclip project or at least one Paperclip issue has been linked to GitHub.
266
296
  - If Paperclip says board access is required, open plugin settings inside the affected company and complete the Paperclip board access flow before retrying sync.
267
297
  - If GitHub Sync agent tools fail with `403 {"error":"Board access required"}` on `/api/plugins/tools` or `/api/plugins/tools/execute`, the current Paperclip host rejected the request before the plugin worker ran. Re-run from a board-authenticated session or agent run that has board access to the target company.
268
298
  - If a KPI API route call is rejected, make sure the request includes `Authorization: Bearer ${PAPERCLIP_API_KEY}`, that the token is still valid for the current run, and that any `companyId` in the payload matches the calling agent's company.
package/dist/manifest.js CHANGED
@@ -522,6 +522,55 @@ var GITHUB_AGENT_TOOLS = [
522
522
  projectNumber: projectNumberProperty
523
523
  }
524
524
  }
525
+ },
526
+ {
527
+ name: "link_github_item",
528
+ displayName: "Link GitHub Item",
529
+ description: "Link a Paperclip issue to a GitHub issue or pull request so GitHub Sync can monitor status even when the repository is not mapped to a Paperclip project.",
530
+ parametersSchema: {
531
+ type: "object",
532
+ additionalProperties: false,
533
+ required: ["kind", "paperclipIssueId"],
534
+ anyOf: [
535
+ {
536
+ required: ["reference"]
537
+ },
538
+ {
539
+ required: ["issueNumber"]
540
+ },
541
+ {
542
+ required: ["pullRequestNumber"]
543
+ },
544
+ {
545
+ required: ["pullRequestUrl"]
546
+ }
547
+ ],
548
+ properties: {
549
+ kind: {
550
+ type: "string",
551
+ enum: ["issue", "pull_request"],
552
+ description: "Whether to link a GitHub issue or pull request."
553
+ },
554
+ paperclipIssueId: {
555
+ type: "string",
556
+ description: "Paperclip issue id that should receive the GitHub link."
557
+ },
558
+ repository: {
559
+ type: "string",
560
+ description: "GitHub repository as owner/repo or https://github.com/owner/repo. Required when using a number instead of a full GitHub URL and the Paperclip issue project is not mapped to that repository."
561
+ },
562
+ reference: {
563
+ type: "string",
564
+ description: "GitHub issue or pull request number, or a full GitHub issue or pull request URL."
565
+ },
566
+ issueNumber: issueNumberProperty,
567
+ pullRequestNumber: pullRequestNumberProperty,
568
+ pullRequestUrl: {
569
+ type: "string",
570
+ description: "Full GitHub pull request URL."
571
+ }
572
+ }
573
+ }
525
574
  }
526
575
  ];
527
576
 
@@ -530,12 +579,15 @@ var GITHUB_SYNC_PLUGIN_ID = "paperclip-github-plugin";
530
579
  var COMPANY_METRIC_API_ROUTE_KEY = "record-company-metric-event";
531
580
  var COMPANY_METRIC_API_ROUTE_PATH = "/company-metrics/events";
532
581
  var COMPANY_METRIC_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${COMPANY_METRIC_API_ROUTE_PATH}`;
582
+ var ISSUE_LINK_API_ROUTE_KEY = "link-github-item";
583
+ var ISSUE_LINK_API_ROUTE_PATH = "/issue-link";
584
+ var ISSUE_LINK_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${ISSUE_LINK_API_ROUTE_PATH}`;
533
585
 
534
586
  // src/manifest.ts
535
587
  var require2 = createRequire(import.meta.url);
536
588
  var packageJson = require2("../package.json");
537
589
  var SCHEDULE_TICK_CRON = "* * * * *";
538
- var MANIFEST_VERSION = "0.8.7"?.trim() || typeof packageJson.version === "string" && packageJson.version.trim() || process.env.npm_package_version?.trim() || "0.0.0-dev";
590
+ var MANIFEST_VERSION = "0.8.8"?.trim() || typeof packageJson.version === "string" && packageJson.version.trim() || process.env.npm_package_version?.trim() || "0.0.0-dev";
539
591
  var manifest = {
540
592
  id: GITHUB_SYNC_PLUGIN_ID,
541
593
  apiVersion: 1,
@@ -607,6 +659,13 @@ var manifest = {
607
659
  path: COMPANY_METRIC_API_ROUTE_PATH,
608
660
  auth: "agent",
609
661
  capability: "api.routes.register"
662
+ },
663
+ {
664
+ routeKey: ISSUE_LINK_API_ROUTE_KEY,
665
+ method: "POST",
666
+ path: ISSUE_LINK_API_ROUTE_PATH,
667
+ auth: "agent",
668
+ capability: "api.routes.register"
610
669
  }
611
670
  ],
612
671
  tools: GITHUB_AGENT_TOOLS,
package/dist/worker.js CHANGED
@@ -531,6 +531,55 @@ var GITHUB_AGENT_TOOLS = [
531
531
  projectNumber: projectNumberProperty
532
532
  }
533
533
  }
534
+ },
535
+ {
536
+ name: "link_github_item",
537
+ displayName: "Link GitHub Item",
538
+ description: "Link a Paperclip issue to a GitHub issue or pull request so GitHub Sync can monitor status even when the repository is not mapped to a Paperclip project.",
539
+ parametersSchema: {
540
+ type: "object",
541
+ additionalProperties: false,
542
+ required: ["kind", "paperclipIssueId"],
543
+ anyOf: [
544
+ {
545
+ required: ["reference"]
546
+ },
547
+ {
548
+ required: ["issueNumber"]
549
+ },
550
+ {
551
+ required: ["pullRequestNumber"]
552
+ },
553
+ {
554
+ required: ["pullRequestUrl"]
555
+ }
556
+ ],
557
+ properties: {
558
+ kind: {
559
+ type: "string",
560
+ enum: ["issue", "pull_request"],
561
+ description: "Whether to link a GitHub issue or pull request."
562
+ },
563
+ paperclipIssueId: {
564
+ type: "string",
565
+ description: "Paperclip issue id that should receive the GitHub link."
566
+ },
567
+ repository: {
568
+ type: "string",
569
+ description: "GitHub repository as owner/repo or https://github.com/owner/repo. Required when using a number instead of a full GitHub URL and the Paperclip issue project is not mapped to that repository."
570
+ },
571
+ reference: {
572
+ type: "string",
573
+ description: "GitHub issue or pull request number, or a full GitHub issue or pull request URL."
574
+ },
575
+ issueNumber: issueNumberProperty,
576
+ pullRequestNumber: pullRequestNumberProperty,
577
+ pullRequestUrl: {
578
+ type: "string",
579
+ description: "Full GitHub pull request URL."
580
+ }
581
+ }
582
+ }
534
583
  }
535
584
  ];
536
585
  function getGitHubAgentToolDeclaration(name) {
@@ -585,6 +634,9 @@ var GITHUB_SYNC_PLUGIN_ID = "paperclip-github-plugin";
585
634
  var COMPANY_METRIC_API_ROUTE_KEY = "record-company-metric-event";
586
635
  var COMPANY_METRIC_API_ROUTE_PATH = "/company-metrics/events";
587
636
  var COMPANY_METRIC_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${COMPANY_METRIC_API_ROUTE_PATH}`;
637
+ var ISSUE_LINK_API_ROUTE_KEY = "link-github-item";
638
+ var ISSUE_LINK_API_ROUTE_PATH = "/issue-link";
639
+ var ISSUE_LINK_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${ISSUE_LINK_API_ROUTE_PATH}`;
588
640
 
589
641
  // src/paperclip-health.ts
590
642
  function normalizeOptionalString(value) {
@@ -636,6 +688,10 @@ var COMPANY_KPI_SCOPE = {
636
688
  scopeKind: "instance",
637
689
  stateKey: "paperclip-github-plugin-company-kpis"
638
690
  };
691
+ var EXTERNAL_LINK_COMPANY_INDEX_SCOPE = {
692
+ scopeKind: "instance",
693
+ stateKey: "paperclip-github-plugin-external-link-companies"
694
+ };
639
695
  var DEFAULT_SCHEDULE_FREQUENCY_MINUTES = 15;
640
696
  var DEFAULT_IMPORTED_ISSUE_STATUS = "backlog";
641
697
  var DEFAULT_IGNORED_GITHUB_ISSUE_USERNAMES = ["renovate"];
@@ -2544,19 +2600,6 @@ async function resolveManualSyncTarget(ctx, settings, input) {
2544
2600
  if (!pullRequestLink) {
2545
2601
  throw new Error("This Paperclip issue is not linked to a GitHub issue or pull request yet. Run a broader sync first.");
2546
2602
  }
2547
- const candidateMappings = getSyncableMappingsForTarget(settings.mappings, {
2548
- kind: "issue",
2549
- companyId: companyId2,
2550
- projectId: pullRequestLink.data.paperclipProjectId,
2551
- repositoryUrl: pullRequestLink.data.repositoryUrl,
2552
- issueId: input.issueId,
2553
- githubPullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
2554
- githubPullRequestUrl: pullRequestLink.data.githubPullRequestUrl,
2555
- displayLabel: `pull request #${pullRequestLink.data.githubPullRequestNumber}`
2556
- });
2557
- if (candidateMappings.length === 0) {
2558
- throw new Error("No saved GitHub repository mapping matches this Paperclip issue.");
2559
- }
2560
2603
  return {
2561
2604
  kind: "issue",
2562
2605
  companyId: companyId2,
@@ -4481,6 +4524,58 @@ function normalizeImportRegistry(value) {
4481
4524
  };
4482
4525
  }).filter((entry) => entry !== null);
4483
4526
  }
4527
+ function normalizeExternalGitHubLinkCompanyIndex(value) {
4528
+ const rawCompanyIds = Array.isArray(value) ? value : value && typeof value === "object" && Array.isArray(value.companyIds) ? value.companyIds : [];
4529
+ const companyIds = [
4530
+ ...new Set(
4531
+ rawCompanyIds.map((entry) => normalizeCompanyId(entry)).filter((companyId) => Boolean(companyId))
4532
+ )
4533
+ ].sort();
4534
+ const updatedAt = value && typeof value === "object" && typeof value.updatedAt === "string" ? value.updatedAt.trim() : void 0;
4535
+ return {
4536
+ companyIds,
4537
+ ...updatedAt ? { updatedAt } : {}
4538
+ };
4539
+ }
4540
+ async function getExternalGitHubLinkCompanyIds(ctx) {
4541
+ return normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE)).companyIds;
4542
+ }
4543
+ async function rememberExternalGitHubLinkCompany(ctx, companyId) {
4544
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4545
+ if (!normalizedCompanyId) {
4546
+ return;
4547
+ }
4548
+ const index = normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE));
4549
+ if (index.companyIds.includes(normalizedCompanyId)) {
4550
+ return;
4551
+ }
4552
+ await ctx.state.set(EXTERNAL_LINK_COMPANY_INDEX_SCOPE, {
4553
+ companyIds: [...index.companyIds, normalizedCompanyId].sort(),
4554
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4555
+ });
4556
+ }
4557
+ async function forgetExternalGitHubLinkCompanyIfEmpty(ctx, companyId) {
4558
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4559
+ if (!normalizedCompanyId) {
4560
+ return;
4561
+ }
4562
+ const [issueLinks, pullRequestLinks] = await Promise.all([
4563
+ listGitHubIssueLinkRecords(ctx),
4564
+ listGitHubPullRequestLinkRecords(ctx)
4565
+ ]);
4566
+ const hasRemainingLinks = issueLinks.some((record) => record.data.companyId === normalizedCompanyId) || pullRequestLinks.some((record) => record.data.companyId === normalizedCompanyId);
4567
+ if (hasRemainingLinks) {
4568
+ return;
4569
+ }
4570
+ const index = normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE));
4571
+ if (!index.companyIds.includes(normalizedCompanyId)) {
4572
+ return;
4573
+ }
4574
+ await ctx.state.set(EXTERNAL_LINK_COMPANY_INDEX_SCOPE, {
4575
+ companyIds: index.companyIds.filter((entry) => entry !== normalizedCompanyId),
4576
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4577
+ });
4578
+ }
4484
4579
  function requireRepositoryReference(repositoryInput) {
4485
4580
  const parsed = parseRepositoryReference(repositoryInput);
4486
4581
  if (!parsed) {
@@ -6967,6 +7062,126 @@ async function listGitHubPullRequestIssueLinksForMapping(ctx, mapping, target) {
6967
7062
  }
6968
7063
  return [...recordsByKey.values()];
6969
7064
  }
7065
+ function doesGitHubIssueLinkRecordMatchMapping(record, mapping) {
7066
+ if (record.data.repositoryUrl !== getNormalizedMappingRepositoryUrl(mapping)) {
7067
+ return false;
7068
+ }
7069
+ if (record.data.companyId && record.data.companyId !== mapping.companyId) {
7070
+ return false;
7071
+ }
7072
+ if (record.data.paperclipProjectId && record.data.paperclipProjectId !== mapping.paperclipProjectId) {
7073
+ return false;
7074
+ }
7075
+ return Boolean(mapping.companyId && mapping.paperclipProjectId);
7076
+ }
7077
+ function doesGitHubIssueLinkRecordMatchTarget(record, target) {
7078
+ if (!target) {
7079
+ return true;
7080
+ }
7081
+ switch (target.kind) {
7082
+ case "company":
7083
+ return !record.data.companyId || record.data.companyId === target.companyId;
7084
+ case "project":
7085
+ return (!record.data.companyId || record.data.companyId === target.companyId) && (!record.data.paperclipProjectId || record.data.paperclipProjectId === target.projectId);
7086
+ case "issue":
7087
+ return Boolean(target.issueId && record.paperclipIssueId === target.issueId) && (!record.data.companyId || record.data.companyId === target.companyId);
7088
+ default:
7089
+ return true;
7090
+ }
7091
+ }
7092
+ function isGitHubIssueLinkCoveredByMappings(record, mappings) {
7093
+ return mappings.some((mapping) => doesGitHubIssueLinkRecordMatchMapping(record, mapping));
7094
+ }
7095
+ function isGitHubPullRequestLinkCoveredByMappings(record, mappings) {
7096
+ return mappings.some((mapping) => doesGitHubPullRequestLinkRecordMatchMapping(record, mapping));
7097
+ }
7098
+ async function listExternalGitHubLinkSyncWork(ctx, mappings, target) {
7099
+ const syncableMappings = getSyncableMappingsForTarget(mappings, target);
7100
+ const issueLinksByKey = /* @__PURE__ */ new Map();
7101
+ const pullRequestLinksByKey = /* @__PURE__ */ new Map();
7102
+ const [issueLinks, pullRequestLinks] = await Promise.all([
7103
+ listGitHubIssueLinkRecords(ctx, {
7104
+ ...target?.kind === "issue" && target.issueId ? { paperclipIssueId: target.issueId } : {}
7105
+ }),
7106
+ listGitHubPullRequestLinkRecords(ctx, {
7107
+ ...target?.kind === "issue" && target.issueId ? { paperclipIssueId: target.issueId } : {}
7108
+ })
7109
+ ]);
7110
+ for (const record of issueLinks) {
7111
+ if (!doesGitHubIssueLinkRecordMatchTarget(record, target) || isGitHubIssueLinkCoveredByMappings(record, syncableMappings)) {
7112
+ continue;
7113
+ }
7114
+ issueLinksByKey.set(
7115
+ `${record.paperclipIssueId}:${record.data.githubIssueUrl}`,
7116
+ record
7117
+ );
7118
+ }
7119
+ for (const record of pullRequestLinks) {
7120
+ if (!doesGitHubPullRequestLinkRecordMatchTarget(record, target) || isGitHubPullRequestLinkCoveredByMappings(record, syncableMappings)) {
7121
+ continue;
7122
+ }
7123
+ pullRequestLinksByKey.set(
7124
+ `${record.paperclipIssueId}:${buildGitHubPullRequestReferenceKey({
7125
+ number: record.data.githubPullRequestNumber,
7126
+ repositoryUrl: record.data.repositoryUrl
7127
+ })}`,
7128
+ record
7129
+ );
7130
+ }
7131
+ return {
7132
+ issueLinks: [...issueLinksByKey.values()],
7133
+ pullRequestLinks: [...pullRequestLinksByKey.values()]
7134
+ };
7135
+ }
7136
+ function buildExternalLinkAuthMappings(work) {
7137
+ const mappingsByKey = /* @__PURE__ */ new Map();
7138
+ const addMapping = (params) => {
7139
+ const companyId = normalizeCompanyId(params.companyId);
7140
+ if (!companyId) {
7141
+ return;
7142
+ }
7143
+ const repositoryUrl = getNormalizedMappingRepositoryUrl({
7144
+ repositoryUrl: params.repositoryUrl
7145
+ });
7146
+ const key = `${companyId}:${params.projectId ?? ""}:${repositoryUrl}`;
7147
+ mappingsByKey.set(key, {
7148
+ id: `external-link:${key}`,
7149
+ repositoryUrl,
7150
+ paperclipProjectName: "",
7151
+ ...params.projectId ? { paperclipProjectId: params.projectId } : {},
7152
+ companyId
7153
+ });
7154
+ };
7155
+ for (const link of work.issueLinks) {
7156
+ addMapping({
7157
+ companyId: link.data.companyId,
7158
+ projectId: link.data.paperclipProjectId,
7159
+ repositoryUrl: link.data.repositoryUrl
7160
+ });
7161
+ }
7162
+ for (const link of work.pullRequestLinks) {
7163
+ addMapping({
7164
+ companyId: link.data.companyId,
7165
+ projectId: link.data.paperclipProjectId,
7166
+ repositoryUrl: link.data.repositoryUrl
7167
+ });
7168
+ }
7169
+ return [...mappingsByKey.values()];
7170
+ }
7171
+ function countExternalLinkRepositories(work) {
7172
+ const repositories = /* @__PURE__ */ new Set();
7173
+ for (const link of work.issueLinks) {
7174
+ if (link.data.companyId) {
7175
+ repositories.add(`${link.data.companyId}:${link.data.repositoryUrl}`);
7176
+ }
7177
+ }
7178
+ for (const link of work.pullRequestLinks) {
7179
+ if (link.data.companyId) {
7180
+ repositories.add(`${link.data.companyId}:${link.data.repositoryUrl}`);
7181
+ }
7182
+ }
7183
+ return repositories.size;
7184
+ }
6970
7185
  async function findStoredStatusTransitionCommentAnnotation(ctx, params) {
6971
7186
  const issueId = params.issueId.trim();
6972
7187
  const commentId = params.commentId.trim();
@@ -7052,7 +7267,7 @@ async function upsertGitHubPullRequestLinkRecord(ctx, params) {
7052
7267
  status: params.pullRequestState,
7053
7268
  data: {
7054
7269
  companyId: params.companyId,
7055
- paperclipProjectId: params.projectId,
7270
+ ...params.projectId ? { paperclipProjectId: params.projectId } : {},
7056
7271
  repositoryUrl: getNormalizedMappingRepositoryUrl({
7057
7272
  repositoryUrl: params.repositoryUrl
7058
7273
  }),
@@ -7091,6 +7306,56 @@ async function assertPaperclipIssueHasNoManualGitHubLink(ctx, params) {
7091
7306
  throw new Error("This Paperclip issue is already linked to a GitHub pull request.");
7092
7307
  }
7093
7308
  }
7309
+ async function resolveIssueGitHubLinkScope(ctx, params) {
7310
+ if (!params.allowUnmapped) {
7311
+ const mappedScope = await resolveIssueGitHubLinkMapping(ctx, params);
7312
+ return {
7313
+ issue: mappedScope.issue,
7314
+ projectId: mappedScope.projectId,
7315
+ mapping: mappedScope.mapping,
7316
+ repository: mappedScope.repository
7317
+ };
7318
+ }
7319
+ const issue = await ctx.issues.get(params.issueId, params.companyId);
7320
+ if (!issue) {
7321
+ throw new Error("Paperclip issue was not found.");
7322
+ }
7323
+ const projectId = typeof issue.projectId === "string" && issue.projectId.trim() ? issue.projectId.trim() : void 0;
7324
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
7325
+ const requestedRepository = params.repositoryUrl ? parseRepositoryReference(params.repositoryUrl) : null;
7326
+ if (params.repositoryUrl && !requestedRepository) {
7327
+ throw new Error(`Invalid GitHub repository: ${params.repositoryUrl}. Use owner/repo or https://github.com/owner/repo.`);
7328
+ }
7329
+ const candidateMappings = projectId ? await resolveProjectScopedMappings(ctx, settings.mappings, {
7330
+ companyId: params.companyId,
7331
+ projectId
7332
+ }) : [];
7333
+ const matchingMappings = requestedRepository ? candidateMappings.filter(
7334
+ (mapping) => areRepositoriesEqual(requireRepositoryReference(mapping.repositoryUrl), requestedRepository)
7335
+ ) : candidateMappings;
7336
+ if (matchingMappings.length === 1) {
7337
+ const mapping = matchingMappings[0];
7338
+ return {
7339
+ issue,
7340
+ projectId,
7341
+ mapping,
7342
+ repository: requestedRepository ?? requireRepositoryReference(mapping.repositoryUrl)
7343
+ };
7344
+ }
7345
+ if (matchingMappings.length > 1 && !requestedRepository) {
7346
+ throw new Error("This Paperclip issue project has multiple GitHub repositories. Enter the full GitHub URL.");
7347
+ }
7348
+ if (!requestedRepository) {
7349
+ throw new Error(
7350
+ "Enter a full GitHub URL or repository because this Paperclip issue project is not mapped to that repository."
7351
+ );
7352
+ }
7353
+ return {
7354
+ issue,
7355
+ ...projectId ? { projectId } : {},
7356
+ repository: requestedRepository
7357
+ };
7358
+ }
7094
7359
  async function resolveIssueGitHubLinkMapping(ctx, params) {
7095
7360
  const issue = await ctx.issues.get(params.issueId, params.companyId);
7096
7361
  if (!issue) {
@@ -7213,10 +7478,11 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7213
7478
  repositoryUrl: params.repositoryUrl,
7214
7479
  issueNumber: params.issueNumber
7215
7480
  });
7216
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7481
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7217
7482
  companyId,
7218
7483
  issueId,
7219
- repositoryUrl: reference.repositoryUrl
7484
+ repositoryUrl: reference.repositoryUrl,
7485
+ allowUnmapped: params.allowUnmapped
7220
7486
  });
7221
7487
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7222
7488
  const response = await octokit.rest.issues.get({
@@ -7233,18 +7499,29 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7233
7499
  }
7234
7500
  const githubIssue = normalizeGitHubIssueRecord(rawIssue);
7235
7501
  const linkedPullRequests = await listLinkedPullRequestsForIssue(octokit, scope.repository, githubIssue.number);
7236
- await upsertGitHubIssueLinkRecord(ctx, scope.mapping, issueId, githubIssue, linkedPullRequests);
7237
- const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
7238
- upsertImportedIssueRecord(
7239
- importRegistry,
7240
- buildImportedIssueRecord(scope.mapping, githubIssue, issueId, (/* @__PURE__ */ new Date()).toISOString())
7241
- );
7242
- await ctx.state.set(IMPORT_REGISTRY_SCOPE, importRegistry);
7243
- invalidateProjectPullRequestCaches({
7502
+ const linkTarget = {
7244
7503
  companyId,
7245
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7246
- repository: scope.repository
7247
- });
7504
+ ...scope.mapping?.paperclipProjectId ?? scope.projectId ? { paperclipProjectId: scope.mapping?.paperclipProjectId ?? scope.projectId } : {},
7505
+ repositoryUrl: scope.repository.url
7506
+ };
7507
+ await upsertGitHubIssueLinkRecord(ctx, linkTarget, issueId, githubIssue, linkedPullRequests);
7508
+ await rememberExternalGitHubLinkCompany(ctx, companyId);
7509
+ if (scope.mapping) {
7510
+ const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
7511
+ upsertImportedIssueRecord(
7512
+ importRegistry,
7513
+ buildImportedIssueRecord(scope.mapping, githubIssue, issueId, (/* @__PURE__ */ new Date()).toISOString())
7514
+ );
7515
+ await ctx.state.set(IMPORT_REGISTRY_SCOPE, importRegistry);
7516
+ }
7517
+ const projectIdForCacheInvalidation = scope.mapping?.paperclipProjectId ?? scope.projectId;
7518
+ if (projectIdForCacheInvalidation) {
7519
+ invalidateProjectPullRequestCaches({
7520
+ companyId,
7521
+ projectId: projectIdForCacheInvalidation,
7522
+ repository: scope.repository
7523
+ });
7524
+ }
7248
7525
  return {
7249
7526
  kind: "issue",
7250
7527
  paperclipIssueId: issueId,
@@ -7272,10 +7549,11 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7272
7549
  pullRequestNumber: params.pullRequestNumber,
7273
7550
  pullRequestUrl: params.pullRequestUrl
7274
7551
  });
7275
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7552
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7276
7553
  companyId,
7277
7554
  issueId,
7278
- repositoryUrl: reference.repositoryUrl
7555
+ repositoryUrl: reference.repositoryUrl,
7556
+ allowUnmapped: params.allowUnmapped
7279
7557
  });
7280
7558
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7281
7559
  const response = await octokit.rest.pulls.get({
@@ -7293,7 +7571,7 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7293
7571
  });
7294
7572
  await upsertGitHubPullRequestLinkRecord(ctx, {
7295
7573
  companyId,
7296
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7574
+ projectId: scope.mapping?.paperclipProjectId ?? scope.projectId,
7297
7575
  issueId,
7298
7576
  repositoryUrl: scope.repository.url,
7299
7577
  pullRequestNumber: reference.pullRequestNumber,
@@ -7301,11 +7579,15 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7301
7579
  pullRequestTitle: response.data.title || `Pull request #${reference.pullRequestNumber}`,
7302
7580
  pullRequestState
7303
7581
  });
7304
- invalidateProjectPullRequestCaches({
7305
- companyId,
7306
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7307
- repository: scope.repository
7308
- });
7582
+ await rememberExternalGitHubLinkCompany(ctx, companyId);
7583
+ const projectIdForCacheInvalidation = scope.mapping?.paperclipProjectId ?? scope.projectId;
7584
+ if (projectIdForCacheInvalidation) {
7585
+ invalidateProjectPullRequestCaches({
7586
+ companyId,
7587
+ projectId: projectIdForCacheInvalidation,
7588
+ repository: scope.repository
7589
+ });
7590
+ }
7309
7591
  return {
7310
7592
  kind: "pull_request",
7311
7593
  paperclipIssueId: issueId,
@@ -7433,6 +7715,7 @@ async function unlinkPaperclipIssueFromGitHub(ctx, params) {
7433
7715
  if (Object.keys(issuePatch).length > 0) {
7434
7716
  await ctx.issues.update(issueId, issuePatch, companyId);
7435
7717
  }
7718
+ await forgetExternalGitHubLinkCompanyIfEmpty(ctx, companyId);
7436
7719
  return {
7437
7720
  paperclipIssueId: issueId,
7438
7721
  unlinked: issueLinkRecords.length > 0 || pullRequestLinkRecords.length > 0 || removedImportRegistryEntries.length > 0 || shouldClearGitHubOrigin || nextDescription !== void 0,
@@ -9329,7 +9612,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9329
9612
  };
9330
9613
  }
9331
9614
  async function synchronizePaperclipPullRequestIssueStatuses(ctx, octokit, mapping, advancedSettings, pullRequestLinks, paperclipApiBaseUrl, pullRequestStatusCache, syncFailureContext, failures, assertNotCancelled, onProgress) {
9332
- if (!mapping.companyId || !mapping.paperclipProjectId || !ctx.issues || typeof ctx.issues.get !== "function" || typeof ctx.issues.update !== "function") {
9615
+ if (!mapping.companyId || !ctx.issues || typeof ctx.issues.get !== "function" || typeof ctx.issues.update !== "function") {
9333
9616
  return {
9334
9617
  updatedStatusesCount: 0
9335
9618
  };
@@ -9778,14 +10061,6 @@ function parseCompanyMetricApiRouteBody(input) {
9778
10061
  return input.body;
9779
10062
  }
9780
10063
  async function handleCompanyMetricApiRoute(ctx, input) {
9781
- if (input.routeKey !== COMPANY_METRIC_API_ROUTE_KEY) {
9782
- return {
9783
- status: 404,
9784
- body: {
9785
- error: `Unsupported plugin API route: ${input.routeKey}.`
9786
- }
9787
- };
9788
- }
9789
10064
  if (input.actor.actorType !== "agent") {
9790
10065
  throw new Error("Company KPI metric events must be recorded by an authenticated Paperclip agent.");
9791
10066
  }
@@ -9885,6 +10160,83 @@ async function handleCompanyMetricApiRoute(ctx, input) {
9885
10160
  }
9886
10161
  };
9887
10162
  }
10163
+ function parseIssueLinkApiRouteBody(input) {
10164
+ if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) {
10165
+ throw new Error("Issue link route body must be a JSON object.");
10166
+ }
10167
+ return input.body;
10168
+ }
10169
+ function normalizeIssueLinkApiRouteKind(payload) {
10170
+ const explicitKind = normalizeIssueGitHubLinkKind(payload.kind);
10171
+ if (explicitKind) {
10172
+ return explicitKind;
10173
+ }
10174
+ const reference = normalizeOptionalString2(payload.reference);
10175
+ if (normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(payload.pullRequestUrl)) || normalizeToolPositiveInteger(payload.pullRequestNumber) || reference && parseGitHubPullRequestHtmlUrl(reference)) {
10176
+ return "pull_request";
10177
+ }
10178
+ if (normalizeToolPositiveInteger(payload.issueNumber) || reference && parseGitHubIssueHtmlUrl(reference)) {
10179
+ return "issue";
10180
+ }
10181
+ return null;
10182
+ }
10183
+ async function handleIssueLinkApiRoute(ctx, input) {
10184
+ if (input.actor.actorType !== "agent") {
10185
+ throw new Error("GitHub issue links must be recorded by an authenticated Paperclip agent.");
10186
+ }
10187
+ const companyId = normalizeCompanyId(input.companyId);
10188
+ if (!companyId) {
10189
+ throw new Error("GitHub issue links require the host to provide the authenticated agent company.");
10190
+ }
10191
+ const payload = parseIssueLinkApiRouteBody(input);
10192
+ const requestedCompanyId = normalizeCompanyId(payload.companyId);
10193
+ if (requestedCompanyId && requestedCompanyId !== companyId) {
10194
+ throw new Error("companyId must match the authenticated Paperclip agent company.");
10195
+ }
10196
+ const kind = normalizeIssueLinkApiRouteKind(payload);
10197
+ if (!kind) {
10198
+ throw new Error('kind must be "issue" or "pull_request", or the payload must include a full GitHub URL.');
10199
+ }
10200
+ const paperclipIssueId = normalizeOptionalString2(payload.paperclipIssueId) ?? normalizeOptionalString2(payload.issueId);
10201
+ if (!paperclipIssueId) {
10202
+ throw new Error("paperclipIssueId is required.");
10203
+ }
10204
+ const linkResult = kind === "issue" ? await linkPaperclipIssueToGitHubIssue(ctx, {
10205
+ companyId,
10206
+ issueId: paperclipIssueId,
10207
+ reference: payload.reference,
10208
+ repositoryUrl: normalizeOptionalString2(payload.repository),
10209
+ issueNumber: payload.issueNumber,
10210
+ allowUnmapped: true
10211
+ }) : await linkPaperclipIssueToGitHubPullRequest(ctx, {
10212
+ companyId,
10213
+ issueId: paperclipIssueId,
10214
+ reference: payload.reference,
10215
+ repositoryUrl: normalizeOptionalString2(payload.repository),
10216
+ pullRequestNumber: payload.pullRequestNumber,
10217
+ pullRequestUrl: payload.pullRequestUrl,
10218
+ allowUnmapped: true
10219
+ });
10220
+ ctx.logger.info("GitHub Sync recorded a GitHub issue link API route event.", {
10221
+ routeKey: input.routeKey,
10222
+ companyId,
10223
+ kind,
10224
+ paperclipIssueId,
10225
+ repositoryUrl: normalizeOptionalString2(linkResult.repositoryUrl),
10226
+ githubIssueNumber: normalizeToolPositiveInteger(linkResult.githubIssueNumber),
10227
+ githubPullRequestNumber: normalizeToolPositiveInteger(linkResult.githubPullRequestNumber),
10228
+ agentId: input.actor.agentId ?? null,
10229
+ runId: input.actor.runId ?? null
10230
+ });
10231
+ return {
10232
+ status: 201,
10233
+ body: {
10234
+ status: "linked",
10235
+ companyId,
10236
+ ...linkResult
10237
+ }
10238
+ };
10239
+ }
9888
10240
  async function createGitHubToolOctokit(ctx, companyId, context = {}) {
9889
10241
  const token = (await resolveGithubToken(ctx, { companyId })).trim();
9890
10242
  if (!token) {
@@ -12836,10 +13188,14 @@ function shouldRunScheduledSync(settings, scheduledAt) {
12836
13188
  }
12837
13189
  return now - lastCheckedAt >= settings.scheduleFrequencyMinutes * 6e4;
12838
13190
  }
12839
- function listScheduledSyncTargets(settings) {
13191
+ async function listScheduledSyncTargets(ctx, settings) {
13192
+ const externalLinkCompanyIds = await getExternalGitHubLinkCompanyIds(ctx);
12840
13193
  const companyIds = [
12841
- ...new Set(
12842
- settings.mappings.map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId))
13194
+ .../* @__PURE__ */ new Set(
13195
+ [
13196
+ ...settings.mappings.map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId)),
13197
+ ...externalLinkCompanyIds
13198
+ ]
12843
13199
  )
12844
13200
  ];
12845
13201
  if (companyIds.length === 0) {
@@ -12858,6 +13214,9 @@ async function performSync(ctx, trigger, options = {}) {
12858
13214
  const token = typeof options.resolvedToken === "string" ? options.resolvedToken : await resolveGithubToken(ctx, { companyId: targetCompanyId });
12859
13215
  const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(baseSettings, config, targetCompanyId);
12860
13216
  const mappings = getSyncableMappingsForTarget(settings.mappings, options.target);
13217
+ const externalSyncWork = options.externalSyncWork ?? await listExternalGitHubLinkSyncWork(ctx, settings.mappings, options.target);
13218
+ const externalLinkAuthMappings = buildExternalLinkAuthMappings(externalSyncWork);
13219
+ const externalLinkCount = externalSyncWork.issueLinks.length + externalSyncWork.pullRequestLinks.length;
12861
13220
  activePaperclipApiAuthTokensByCompanyId = null;
12862
13221
  const failureContext = {
12863
13222
  phase: "configuration"
@@ -12869,14 +13228,18 @@ async function performSync(ctx, trigger, options = {}) {
12869
13228
  };
12870
13229
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12871
13230
  }
12872
- if (mappings.length === 0) {
13231
+ if (mappings.length === 0 && externalLinkCount === 0) {
12873
13232
  const next = {
12874
13233
  ...settings,
12875
13234
  syncState: createSetupConfigurationErrorSyncState("missing_mapping", trigger)
12876
13235
  };
12877
13236
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12878
13237
  }
12879
- const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(settings, config, mappings);
13238
+ const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(
13239
+ settings,
13240
+ config,
13241
+ [...mappings, ...externalLinkAuthMappings]
13242
+ );
12880
13243
  if (mappingsMissingBoardAccess.length > 0 && await detectPaperclipBoardAccessRequirement(paperclipApiBaseUrl)) {
12881
13244
  const next = {
12882
13245
  ...settings,
@@ -12884,7 +13247,7 @@ async function performSync(ctx, trigger, options = {}) {
12884
13247
  };
12885
13248
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12886
13249
  }
12887
- if (!ctx.issues || typeof ctx.issues.create !== "function") {
13250
+ if (mappings.length > 0 && (!ctx.issues || typeof ctx.issues.create !== "function")) {
12888
13251
  const errorDetails = {
12889
13252
  phase: "configuration",
12890
13253
  suggestedAction: "Update Paperclip to a runtime that supports plugin issue creation, then retry sync."
@@ -12910,7 +13273,12 @@ async function performSync(ctx, trigger, options = {}) {
12910
13273
  };
12911
13274
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12912
13275
  }
12913
- activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(ctx, settings, config, mappings);
13276
+ activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(
13277
+ ctx,
13278
+ settings,
13279
+ config,
13280
+ [...mappings, ...externalLinkAuthMappings]
13281
+ );
12914
13282
  const octokitLogContext = {
12915
13283
  companyId: targetCompanyId,
12916
13284
  operation: "sync.github-issues",
@@ -13162,6 +13530,8 @@ async function performSync(ctx, trigger, options = {}) {
13162
13530
  continue;
13163
13531
  }
13164
13532
  }
13533
+ totalTrackedIssueCount += externalLinkCount;
13534
+ syncedIssuesCount = totalTrackedIssueCount;
13165
13535
  recordCompanyBacklogSnapshotsFromPlans(repositoryPlans);
13166
13536
  if (repositoryPlans.length > 0) {
13167
13537
  const firstPlan = repositoryPlans[0];
@@ -13174,6 +13544,14 @@ async function performSync(ctx, trigger, options = {}) {
13174
13544
  totalIssueCount: totalTrackedIssueCount,
13175
13545
  detailLabel: "Loading linked pull requests, review threads, and CI status before syncing."
13176
13546
  };
13547
+ } else if (externalLinkCount > 0) {
13548
+ currentProgress = {
13549
+ phase: "preparing",
13550
+ totalRepositoryCount: countExternalLinkRepositories(externalSyncWork),
13551
+ completedIssueCount: completedTrackedIssueCount,
13552
+ totalIssueCount: totalTrackedIssueCount,
13553
+ detailLabel: "Loading linked GitHub issues and pull requests before syncing."
13554
+ };
13177
13555
  } else {
13178
13556
  currentProgress = {
13179
13557
  phase: "preparing",
@@ -13438,6 +13816,194 @@ async function performSync(ctx, trigger, options = {}) {
13438
13816
  continue;
13439
13817
  }
13440
13818
  }
13819
+ for (const [externalIssueIndex, issueLink] of externalSyncWork.issueLinks.entries()) {
13820
+ await throwIfSyncCancelled();
13821
+ try {
13822
+ const companyId = normalizeCompanyId(issueLink.data.companyId);
13823
+ if (!companyId) {
13824
+ continue;
13825
+ }
13826
+ const repository = requireRepositoryReference(issueLink.data.repositoryUrl);
13827
+ octokitLogContext.repositoryUrl = repository.url;
13828
+ const advancedSettings = getCompanyAdvancedSettings(settings, companyId);
13829
+ let availableLabels = companyLabelDirectoryCache.get(companyId);
13830
+ if (!availableLabels) {
13831
+ updateSyncFailureContext(failureContext, {
13832
+ phase: "loading_paperclip_labels",
13833
+ repositoryUrl: repository.url,
13834
+ githubIssueNumber: issueLink.data.githubIssueNumber
13835
+ });
13836
+ availableLabels = supportsPaperclipLabelMapping ? await buildPaperclipLabelDirectory(ctx, companyId, paperclipApiBaseUrl) : /* @__PURE__ */ new Map();
13837
+ companyLabelDirectoryCache.set(companyId, availableLabels);
13838
+ }
13839
+ currentProgress = {
13840
+ phase: "syncing",
13841
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13842
+ currentRepositoryUrl: repository.url,
13843
+ completedIssueCount: completedTrackedIssueCount,
13844
+ totalIssueCount: totalTrackedIssueCount,
13845
+ currentIssueNumber: issueLink.data.githubIssueNumber,
13846
+ detailLabel: `Syncing linked GitHub issue #${issueLink.data.githubIssueNumber} in ${repository.owner}/${repository.repo}.`
13847
+ };
13848
+ await persistRunningProgress(true);
13849
+ updateSyncFailureContext(failureContext, {
13850
+ phase: "evaluating_github_status",
13851
+ repositoryUrl: repository.url,
13852
+ githubIssueNumber: issueLink.data.githubIssueNumber
13853
+ });
13854
+ const response = await octokit.rest.issues.get({
13855
+ owner: repository.owner,
13856
+ repo: repository.repo,
13857
+ issue_number: issueLink.data.githubIssueNumber,
13858
+ headers: {
13859
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
13860
+ }
13861
+ });
13862
+ const githubIssue = normalizeGitHubIssueRecord(response.data);
13863
+ if (response.data.pull_request) {
13864
+ continue;
13865
+ }
13866
+ const virtualMapping = {
13867
+ id: `external-link:${companyId}:${repository.url}`,
13868
+ repositoryUrl: repository.url,
13869
+ paperclipProjectName: "",
13870
+ ...issueLink.data.paperclipProjectId ? { paperclipProjectId: issueLink.data.paperclipProjectId } : {},
13871
+ companyId
13872
+ };
13873
+ const importedIssue = {
13874
+ mappingId: virtualMapping.id,
13875
+ githubIssueId: githubIssue.id,
13876
+ githubIssueNumber: githubIssue.number,
13877
+ paperclipIssueId: issueLink.paperclipIssueId,
13878
+ importedAt: issueLink.createdAt ?? issueLink.data.syncedAt,
13879
+ lastSeenCommentCount: issueLink.data.commentsCount,
13880
+ lastSeenGitHubState: issueLink.data.githubIssueState,
13881
+ linkedPullRequestCommentCounts: [],
13882
+ repositoryUrl: repository.url,
13883
+ ...issueLink.data.paperclipProjectId ? { paperclipProjectId: issueLink.data.paperclipProjectId } : {},
13884
+ companyId
13885
+ };
13886
+ const synchronizationResult = await synchronizePaperclipIssueStatuses(
13887
+ ctx,
13888
+ octokit,
13889
+ repository,
13890
+ virtualMapping,
13891
+ advancedSettings,
13892
+ /* @__PURE__ */ new Map([[githubIssue.id, githubIssue]]),
13893
+ [importedIssue],
13894
+ /* @__PURE__ */ new Set(),
13895
+ availableLabels,
13896
+ paperclipApiBaseUrl,
13897
+ /* @__PURE__ */ new Map(),
13898
+ /* @__PURE__ */ new Map(),
13899
+ /* @__PURE__ */ new Map(),
13900
+ repositoryMaintainerCache,
13901
+ failureContext,
13902
+ recoverableFailures,
13903
+ throwIfSyncCancelled,
13904
+ async (params) => {
13905
+ const recorded = recordCompanyActivityMetricEvent(companyKpiState, {
13906
+ companyId: params.companyId,
13907
+ metric: "githubIssuesClosedCount",
13908
+ eventKey: `${params.repositoryUrl}/issues/${params.githubIssueNumber}:${coerceDate(params.occurredAt).toISOString()}`,
13909
+ repositoryUrl: params.repositoryUrl,
13910
+ occurredAt: params.occurredAt
13911
+ });
13912
+ companyKpiState = recorded.state;
13913
+ companyKpiStateDirty = companyKpiStateDirty || recorded.recorded;
13914
+ },
13915
+ async (progress) => {
13916
+ completedTrackedIssueCount += 1;
13917
+ currentProgress = {
13918
+ phase: "syncing",
13919
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13920
+ currentRepositoryUrl: repository.url,
13921
+ completedIssueCount: completedTrackedIssueCount,
13922
+ totalIssueCount: totalTrackedIssueCount,
13923
+ ...progress.currentIssueNumber !== void 0 ? { currentIssueNumber: progress.currentIssueNumber } : {}
13924
+ };
13925
+ await persistRunningProgress(externalIssueIndex === externalSyncWork.issueLinks.length - 1);
13926
+ }
13927
+ );
13928
+ updatedStatusesCount += synchronizationResult.updatedStatusesCount;
13929
+ updatedLabelsCount += synchronizationResult.updatedLabelsCount;
13930
+ updatedDescriptionsCount += synchronizationResult.updatedDescriptionsCount;
13931
+ } catch (error) {
13932
+ if (error instanceof SyncCancellationError || isGitHubRateLimitError(error)) {
13933
+ throw error;
13934
+ }
13935
+ recordRecoverableSyncFailure(ctx, recoverableFailures, error, failureContext);
13936
+ continue;
13937
+ }
13938
+ }
13939
+ const externalPullRequestLinksByCompanyId = /* @__PURE__ */ new Map();
13940
+ for (const pullRequestLink of externalSyncWork.pullRequestLinks) {
13941
+ const companyId = normalizeCompanyId(pullRequestLink.data.companyId);
13942
+ if (!companyId) {
13943
+ continue;
13944
+ }
13945
+ const companyLinks = externalPullRequestLinksByCompanyId.get(companyId) ?? [];
13946
+ companyLinks.push(pullRequestLink);
13947
+ externalPullRequestLinksByCompanyId.set(companyId, companyLinks);
13948
+ }
13949
+ for (const [companyId, pullRequestLinks] of externalPullRequestLinksByCompanyId.entries()) {
13950
+ await throwIfSyncCancelled();
13951
+ try {
13952
+ const firstRepositoryUrl = pullRequestLinks[0]?.data.repositoryUrl;
13953
+ if (!firstRepositoryUrl) {
13954
+ continue;
13955
+ }
13956
+ const repository = requireRepositoryReference(firstRepositoryUrl);
13957
+ octokitLogContext.repositoryUrl = repository.url;
13958
+ currentProgress = {
13959
+ phase: "syncing",
13960
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13961
+ currentRepositoryUrl: repository.url,
13962
+ completedIssueCount: completedTrackedIssueCount,
13963
+ totalIssueCount: totalTrackedIssueCount,
13964
+ detailLabel: `Syncing ${pullRequestLinks.length} linked GitHub pull request${pullRequestLinks.length === 1 ? "" : "s"}.`
13965
+ };
13966
+ await persistRunningProgress(true);
13967
+ const pullRequestStatusCache = /* @__PURE__ */ new Map();
13968
+ const synchronizationResult = await synchronizePaperclipPullRequestIssueStatuses(
13969
+ ctx,
13970
+ octokit,
13971
+ {
13972
+ id: `external-link:${companyId}:${repository.url}`,
13973
+ repositoryUrl: repository.url,
13974
+ paperclipProjectName: "",
13975
+ companyId
13976
+ },
13977
+ getCompanyAdvancedSettings(settings, companyId),
13978
+ pullRequestLinks,
13979
+ paperclipApiBaseUrl,
13980
+ pullRequestStatusCache,
13981
+ failureContext,
13982
+ recoverableFailures,
13983
+ throwIfSyncCancelled,
13984
+ async (progress) => {
13985
+ completedTrackedIssueCount += 1;
13986
+ const pullRequestRepository = requireRepositoryReference(progress.pullRequestLink.data.repositoryUrl);
13987
+ currentProgress = {
13988
+ phase: "syncing",
13989
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13990
+ currentRepositoryUrl: pullRequestRepository.url,
13991
+ completedIssueCount: completedTrackedIssueCount,
13992
+ totalIssueCount: totalTrackedIssueCount,
13993
+ detailLabel: `Synced pull request #${progress.pullRequestLink.data.githubPullRequestNumber} in ${pullRequestRepository.owner}/${pullRequestRepository.repo}.`
13994
+ };
13995
+ await persistRunningProgress(progress.completedIssueCount === progress.totalIssueCount);
13996
+ }
13997
+ );
13998
+ updatedStatusesCount += synchronizationResult.updatedStatusesCount;
13999
+ } catch (error) {
14000
+ if (error instanceof SyncCancellationError || isGitHubRateLimitError(error)) {
14001
+ throw error;
14002
+ }
14003
+ recordRecoverableSyncFailure(ctx, recoverableFailures, error, failureContext);
14004
+ continue;
14005
+ }
14006
+ }
13441
14007
  if (recoverableFailures.length > 0) {
13442
14008
  const primaryFailure = recoverableFailures[0];
13443
14009
  const errorDetails = buildSyncErrorDetails(primaryFailure.error, primaryFailure.context);
@@ -13566,10 +14132,12 @@ async function startSync(ctx, trigger, options = {}) {
13566
14132
  if (getActiveGitHubRateLimitPause(currentSettings.syncState)) {
13567
14133
  return currentSettings;
13568
14134
  }
13569
- if (trigger !== "manual" && getSyncableMappingsForTarget(currentSettings.mappings, options.target).length === 0) {
14135
+ if (trigger !== "manual" && !token.trim()) {
13570
14136
  return currentSettings;
13571
14137
  }
13572
- if (trigger !== "manual" && !token.trim()) {
14138
+ const externalSyncWork = await listExternalGitHubLinkSyncWork(ctx, currentSettings.mappings, options.target);
14139
+ const externalLinkCount = externalSyncWork.issueLinks.length + externalSyncWork.pullRequestLinks.length;
14140
+ if (trigger !== "manual" && getSyncableMappingsForTarget(currentSettings.mappings, options.target).length === 0 && externalLinkCount === 0) {
13573
14141
  return currentSettings;
13574
14142
  }
13575
14143
  await setSyncCancellationRequest(ctx, null);
@@ -13583,7 +14151,7 @@ async function startSync(ctx, trigger, options = {}) {
13583
14151
  message: getSyncTargetRunningMessage(options.target),
13584
14152
  progress: {
13585
14153
  phase: "preparing",
13586
- totalRepositoryCount: syncableMappings.length
14154
+ totalRepositoryCount: syncableMappings.length + countExternalLinkRepositories(externalSyncWork)
13587
14155
  }
13588
14156
  });
13589
14157
  activeRunningSyncState = {
@@ -13598,7 +14166,8 @@ async function startSync(ctx, trigger, options = {}) {
13598
14166
  await runningStatePromise;
13599
14167
  return await performSync(ctx, trigger, {
13600
14168
  resolvedToken: token,
13601
- target: options.target
14169
+ target: options.target,
14170
+ externalSyncWork
13602
14171
  });
13603
14172
  } catch (error) {
13604
14173
  return await createUnexpectedSyncErrorResult(ctx, trigger, error, targetCompanyId);
@@ -14493,6 +15062,42 @@ function registerGitHubAgentTools(ctx) {
14493
15062
  );
14494
15063
  })
14495
15064
  );
15065
+ ctx.tools.register(
15066
+ "link_github_item",
15067
+ getGitHubAgentToolDeclaration("link_github_item"),
15068
+ async (params, runCtx) => executeGitHubTool(async () => {
15069
+ const input = getToolInputRecord(params);
15070
+ const kind = normalizeIssueGitHubLinkKind(input.kind);
15071
+ if (!kind) {
15072
+ throw new Error('kind must be "issue" or "pull_request".');
15073
+ }
15074
+ const paperclipIssueId = normalizeOptionalToolString(input.paperclipIssueId);
15075
+ if (!paperclipIssueId) {
15076
+ throw new Error("paperclipIssueId is required.");
15077
+ }
15078
+ const linkResult = kind === "issue" ? await linkPaperclipIssueToGitHubIssue(ctx, {
15079
+ companyId: runCtx.companyId,
15080
+ issueId: paperclipIssueId,
15081
+ reference: input.reference,
15082
+ repositoryUrl: normalizeOptionalToolString(input.repository),
15083
+ issueNumber: input.issueNumber,
15084
+ allowUnmapped: true
15085
+ }) : await linkPaperclipIssueToGitHubPullRequest(ctx, {
15086
+ companyId: runCtx.companyId,
15087
+ issueId: paperclipIssueId,
15088
+ reference: input.reference,
15089
+ repositoryUrl: normalizeOptionalToolString(input.repository),
15090
+ pullRequestNumber: input.pullRequestNumber,
15091
+ pullRequestUrl: input.pullRequestUrl,
15092
+ allowUnmapped: true
15093
+ });
15094
+ const itemLabel = kind === "issue" ? `issue #${normalizeToolPositiveInteger(linkResult.githubIssueNumber) ?? ""}`.trim() : `pull request #${normalizeToolPositiveInteger(linkResult.githubPullRequestNumber) ?? ""}`.trim();
15095
+ return buildToolSuccessResult(
15096
+ `Linked Paperclip issue ${paperclipIssueId} to GitHub ${itemLabel}.`,
15097
+ linkResult
15098
+ );
15099
+ })
15100
+ );
14496
15101
  }
14497
15102
  function shouldStartWorkerHost(moduleUrl, entry = process.argv[1]) {
14498
15103
  if (typeof entry !== "string" || !entry.trim()) {
@@ -14901,7 +15506,7 @@ var plugin = definePlugin({
14901
15506
  ctx.jobs.register("sync.github-issues", async (job) => {
14902
15507
  const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
14903
15508
  const trigger = job.trigger === "retry" ? "retry" : "schedule";
14904
- const scheduledTargets = listScheduledSyncTargets(settings);
15509
+ const scheduledTargets = await listScheduledSyncTargets(ctx, settings);
14905
15510
  if (scheduledTargets.length === 0) {
14906
15511
  const reconciledSettings = await reconcileOrphanedRunningSyncState(ctx);
14907
15512
  if (job.trigger === "schedule" && !shouldRunScheduledSync(reconciledSettings, job.scheduledAt)) {
@@ -14923,7 +15528,18 @@ var plugin = definePlugin({
14923
15528
  if (!pluginRuntimeContext) {
14924
15529
  throw new Error("GitHub Sync worker is not ready to handle API routes yet.");
14925
15530
  }
14926
- return handleCompanyMetricApiRoute(pluginRuntimeContext, input);
15531
+ if (input.routeKey === COMPANY_METRIC_API_ROUTE_KEY) {
15532
+ return handleCompanyMetricApiRoute(pluginRuntimeContext, input);
15533
+ }
15534
+ if (input.routeKey === ISSUE_LINK_API_ROUTE_KEY) {
15535
+ return handleIssueLinkApiRoute(pluginRuntimeContext, input);
15536
+ }
15537
+ return {
15538
+ status: 404,
15539
+ body: {
15540
+ error: `Unsupported plugin API route: ${input.routeKey}.`
15541
+ }
15542
+ };
14927
15543
  },
14928
15544
  async onShutdown() {
14929
15545
  pluginRuntimeContext = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-github-plugin",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Paperclip plugin for synchronizing GitHub issues into Paperclip projects.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",