paperclip-github-plugin 0.8.6 → 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
@@ -62,7 +62,7 @@ If a company already has a Paperclip project bound to a GitHub repository worksp
62
62
 
63
63
  ### Status sync with delivery context
64
64
 
65
- The plugin does more than mirror issue text. It looks at linked pull requests, mergeability, CI, review decisions, review threads, and trusted new GitHub comments so imported Paperclip issues can reflect where the work actually is. When GitHub links an issue to a pull request in another repository, GitHub Sync now follows that pull request's actual repository for status checks, review state, and deep links instead of assuming the issue repository. When sync closes an imported issue as `done` or `cancelled`, it also clears any pending Paperclip review or approval execution state so the host accepts the terminal transition cleanly.
65
+ The plugin does more than mirror issue text. It looks at linked pull requests, mergeability, CI, review decisions, review threads, and trusted new GitHub comments so imported Paperclip issues can reflect where the work actually is. When GitHub links an issue to a pull request in another repository, GitHub Sync now follows that pull request's actual repository for status checks, review state, and deep links instead of assuming the issue repository. When sync closes an imported issue as `done` or `cancelled`, it also clears any pending Paperclip review or approval execution policy/state so the host accepts the terminal transition cleanly and does not keep waking stale review participants.
66
66
 
67
67
  ### Company KPI dashboard
68
68
 
@@ -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
 
@@ -158,7 +165,7 @@ When the local Paperclip API is available, the plugin also syncs labels by name,
158
165
  | --- | --- |
159
166
  | Open issue with no linked pull request, created by a repository maintainer | `todo` on first import |
160
167
  | Open issue with no linked pull request | Configured default status, which defaults to `backlog` |
161
- | Open issue with a linked pull request and unfinished CI | `in_progress` |
168
+ | Open issue with a linked pull request and unfinished CI | `in_progress`; already-blocked pending-only PR waits remain `blocked` |
162
169
  | Open issue with failing CI, a non-mergeable linked pull request, or unresolved review threads | `todo`, or `in_progress` when GitHub Sync can hand the work back to an executor |
163
170
  | Open issue with green CI, a merge-ready linked pull request, and all review threads resolved | `in_review` |
164
171
  | Closed issue completed as finished work | `done` |
@@ -170,7 +177,7 @@ Additional behavior:
170
177
  - If the Paperclip host initially creates that imported maintainer issue in `backlog`, GitHub Sync promotes it to `todo` without replacing the configured default assignee with the executor handoff assignee, so triage ownership stays intact.
171
178
  - When Paperclip board access is connected for a company, the advanced assignee dropdowns list both company agents and `Me` for the connected board user.
172
179
  - Newly imported issues that finish sync in `todo` and are assigned to an agent enqueue an assignee wakeup so the agent can pick them up promptly.
173
- - For linked pull requests, GitHub Sync treats merge-conflict, behind-branch, blocked, draft, unstable merge states, and unresolved review threads as executor work, while merge-ready states such as `CLEAN` and `HAS_HOOKS` can move work into `in_review` when CI is green and review threads are resolved. A stale aggregate `CHANGES_REQUESTED` review decision alone does not move that maintainer wait back to active execution. Transient `UNKNOWN` mergeability also does not move an already `in_review` maintainer wait back to active execution when CI is green and review threads are resolved.
180
+ - For linked pull requests, GitHub Sync treats merge-conflict, behind-branch, blocked, draft, unstable merge states, and unresolved review threads as executor work, while merge-ready states such as `CLEAN` and `HAS_HOOKS` can move work into `in_review` when CI is green and review threads are resolved. If an issue is already `blocked` and GitHub reports only pending external merge requirements while CI is unfinished, sync preserves the external wait instead of waking an executor. A stale aggregate `CHANGES_REQUESTED` review decision alone does not move that maintainer wait back to active execution. Transient `UNKNOWN` mergeability also does not move an already `in_review` maintainer wait back to active execution when CI is green and review threads are resolved.
174
181
  - Imported issues that are already `blocked` stay `blocked` while any first-class `blockedBy` issue is still non-terminal, even if the linked GitHub pull request is otherwise green and review-ready.
175
182
  - When sync moves work into `in_review`, GitHub Sync first follows the Paperclip issue execution policy's current reviewer or approver when that stage is visible on the issue. If Paperclip exposes an internal review or approval stage but not yet the participant, the plugin falls back to the configured reviewer or approver handoff assignee. If the transition is only a healthy linked-PR wait with no visible internal review or approval stage, GitHub Sync leaves the issue unassigned so it can wait on normal maintainer review without waking an internal owner.
176
183
  - When sync moves work back into active execution, GitHub Sync first follows the Paperclip issue execution policy `returnAssignee` when it is available. Otherwise it falls back to the configured executor handoff assignee and then to the default imported assignee.
@@ -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.6"?.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) {
@@ -5329,6 +5424,9 @@ function isHealthyMaintainerWaitTransition(params) {
5329
5424
  const { currentStatus, nextStatus, syncContext } = params;
5330
5425
  return nextStatus === "in_review" && (currentStatus === "done" || currentStatus === "in_review") && syncContext.executionState === null && syncContext.executionPolicy !== null;
5331
5426
  }
5427
+ function shouldClearCompletedSyncExecutionPolicy(params) {
5428
+ return (params.nextStatus === "done" || params.nextStatus === "cancelled") && (params.syncContext.executionPolicy !== null || params.syncContext.executionState !== null);
5429
+ }
5332
5430
  function shouldPreserveImportedTriageAssignee(params) {
5333
5431
  return params.wasImportedThisRun && params.maintainerAuthoredImportedIssue === true && params.currentStatus === "backlog" && params.nextStatus === "todo";
5334
5432
  }
@@ -5518,6 +5616,12 @@ function resolvePaperclipIssueStatus(params) {
5518
5616
  return hasExecutorHandoffTarget ? "in_progress" : "todo";
5519
5617
  }
5520
5618
  if (snapshot.linkedPullRequests.length > 0) {
5619
+ if (shouldPreserveBlockedExternalPullRequestWait({
5620
+ currentStatus,
5621
+ linkedPullRequests: snapshot.linkedPullRequests
5622
+ })) {
5623
+ return "blocked";
5624
+ }
5521
5625
  return resolvePaperclipStatusFromLinkedPullRequests(snapshot.linkedPullRequests, {
5522
5626
  preferInProgress: hasExecutorHandoffTarget,
5523
5627
  preserveTransientUnknownMergeabilityWait: currentStatus === "done" || currentStatus === "in_review"
@@ -5536,6 +5640,12 @@ function resolvePaperclipPullRequestIssueStatus(params) {
5536
5640
  if (currentStatus === "done" || currentStatus === "cancelled") {
5537
5641
  return currentStatus;
5538
5642
  }
5643
+ if (shouldPreserveBlockedExternalPullRequestWait({
5644
+ currentStatus,
5645
+ linkedPullRequests: [pullRequest]
5646
+ })) {
5647
+ return "blocked";
5648
+ }
5539
5649
  return resolvePaperclipStatusFromLinkedPullRequests([pullRequest], {
5540
5650
  preferInProgress: hasExecutorHandoffTarget,
5541
5651
  preserveTransientUnknownMergeabilityWait: currentStatus === "in_review"
@@ -5687,6 +5797,12 @@ function normalizeGitHubPullRequestReviewDecision(value) {
5687
5797
  function isGitHubPullRequestActionRequiredForSync(pullRequest) {
5688
5798
  return pullRequest.mergeability === "conflicting" || ACTION_REQUIRED_GITHUB_PULL_REQUEST_MERGE_STATE_STATUSES.has(pullRequest.mergeStateStatus);
5689
5799
  }
5800
+ function isGitHubPullRequestPendingExternalWaitForSync(pullRequest) {
5801
+ return pullRequest.ciState === "unfinished" && !pullRequest.hasUnresolvedReviewThreads && pullRequest.mergeability !== "conflicting" && (pullRequest.mergeStateStatus === "blocked" || pullRequest.mergeStateStatus === "unstable");
5802
+ }
5803
+ function shouldPreserveBlockedExternalPullRequestWait(params) {
5804
+ return params.currentStatus === "blocked" && params.linkedPullRequests.length > 0 && params.linkedPullRequests.every((pullRequest) => isGitHubPullRequestPendingExternalWaitForSync(pullRequest));
5805
+ }
5690
5806
  function isGitHubPullRequestTransientUnknownMergeabilityWait(pullRequest) {
5691
5807
  return pullRequest.ciState === "green" && !pullRequest.hasUnresolvedReviewThreads && pullRequest.mergeability !== "conflicting" && pullRequest.mergeStateStatus === "unknown";
5692
5808
  }
@@ -6946,6 +7062,126 @@ async function listGitHubPullRequestIssueLinksForMapping(ctx, mapping, target) {
6946
7062
  }
6947
7063
  return [...recordsByKey.values()];
6948
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
+ }
6949
7185
  async function findStoredStatusTransitionCommentAnnotation(ctx, params) {
6950
7186
  const issueId = params.issueId.trim();
6951
7187
  const commentId = params.commentId.trim();
@@ -7031,7 +7267,7 @@ async function upsertGitHubPullRequestLinkRecord(ctx, params) {
7031
7267
  status: params.pullRequestState,
7032
7268
  data: {
7033
7269
  companyId: params.companyId,
7034
- paperclipProjectId: params.projectId,
7270
+ ...params.projectId ? { paperclipProjectId: params.projectId } : {},
7035
7271
  repositoryUrl: getNormalizedMappingRepositoryUrl({
7036
7272
  repositoryUrl: params.repositoryUrl
7037
7273
  }),
@@ -7070,6 +7306,56 @@ async function assertPaperclipIssueHasNoManualGitHubLink(ctx, params) {
7070
7306
  throw new Error("This Paperclip issue is already linked to a GitHub pull request.");
7071
7307
  }
7072
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
+ }
7073
7359
  async function resolveIssueGitHubLinkMapping(ctx, params) {
7074
7360
  const issue = await ctx.issues.get(params.issueId, params.companyId);
7075
7361
  if (!issue) {
@@ -7192,10 +7478,11 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7192
7478
  repositoryUrl: params.repositoryUrl,
7193
7479
  issueNumber: params.issueNumber
7194
7480
  });
7195
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7481
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7196
7482
  companyId,
7197
7483
  issueId,
7198
- repositoryUrl: reference.repositoryUrl
7484
+ repositoryUrl: reference.repositoryUrl,
7485
+ allowUnmapped: params.allowUnmapped
7199
7486
  });
7200
7487
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7201
7488
  const response = await octokit.rest.issues.get({
@@ -7212,18 +7499,29 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7212
7499
  }
7213
7500
  const githubIssue = normalizeGitHubIssueRecord(rawIssue);
7214
7501
  const linkedPullRequests = await listLinkedPullRequestsForIssue(octokit, scope.repository, githubIssue.number);
7215
- await upsertGitHubIssueLinkRecord(ctx, scope.mapping, issueId, githubIssue, linkedPullRequests);
7216
- const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
7217
- upsertImportedIssueRecord(
7218
- importRegistry,
7219
- buildImportedIssueRecord(scope.mapping, githubIssue, issueId, (/* @__PURE__ */ new Date()).toISOString())
7220
- );
7221
- await ctx.state.set(IMPORT_REGISTRY_SCOPE, importRegistry);
7222
- invalidateProjectPullRequestCaches({
7502
+ const linkTarget = {
7223
7503
  companyId,
7224
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7225
- repository: scope.repository
7226
- });
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
+ }
7227
7525
  return {
7228
7526
  kind: "issue",
7229
7527
  paperclipIssueId: issueId,
@@ -7251,10 +7549,11 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7251
7549
  pullRequestNumber: params.pullRequestNumber,
7252
7550
  pullRequestUrl: params.pullRequestUrl
7253
7551
  });
7254
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7552
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7255
7553
  companyId,
7256
7554
  issueId,
7257
- repositoryUrl: reference.repositoryUrl
7555
+ repositoryUrl: reference.repositoryUrl,
7556
+ allowUnmapped: params.allowUnmapped
7258
7557
  });
7259
7558
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7260
7559
  const response = await octokit.rest.pulls.get({
@@ -7272,7 +7571,7 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7272
7571
  });
7273
7572
  await upsertGitHubPullRequestLinkRecord(ctx, {
7274
7573
  companyId,
7275
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7574
+ projectId: scope.mapping?.paperclipProjectId ?? scope.projectId,
7276
7575
  issueId,
7277
7576
  repositoryUrl: scope.repository.url,
7278
7577
  pullRequestNumber: reference.pullRequestNumber,
@@ -7280,11 +7579,15 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7280
7579
  pullRequestTitle: response.data.title || `Pull request #${reference.pullRequestNumber}`,
7281
7580
  pullRequestState
7282
7581
  });
7283
- invalidateProjectPullRequestCaches({
7284
- companyId,
7285
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7286
- repository: scope.repository
7287
- });
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
+ }
7288
7591
  return {
7289
7592
  kind: "pull_request",
7290
7593
  paperclipIssueId: issueId,
@@ -7412,6 +7715,7 @@ async function unlinkPaperclipIssueFromGitHub(ctx, params) {
7412
7715
  if (Object.keys(issuePatch).length > 0) {
7413
7716
  await ctx.issues.update(issueId, issuePatch, companyId);
7414
7717
  }
7718
+ await forgetExternalGitHubLinkCompanyIfEmpty(ctx, companyId);
7415
7719
  return {
7416
7720
  paperclipIssueId: issueId,
7417
7721
  unlinked: issueLinkRecords.length > 0 || pullRequestLinkRecords.length > 0 || removedImportRegistryEntries.length > 0 || shouldClearGitHubOrigin || nextDescription !== void 0,
@@ -9171,6 +9475,10 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9171
9475
  nextStatus,
9172
9476
  syncContext: paperclipIssueSyncContext
9173
9477
  });
9478
+ const shouldClearCompletedExecutionPolicy = shouldClearCompletedSyncExecutionPolicy({
9479
+ nextStatus,
9480
+ syncContext: paperclipIssueSyncContext
9481
+ });
9174
9482
  const shouldPreserveImportedTriageRouting = shouldPreserveImportedTriageAssignee({
9175
9483
  currentStatus: paperclipIssue.status,
9176
9484
  nextStatus,
@@ -9192,7 +9500,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9192
9500
  importedIssue.lastSeenGitHubState = snapshot.state;
9193
9501
  importedIssue.linkedPullRequestCommentCounts = currentLinkedPullRequestCommentCounts;
9194
9502
  if (paperclipIssue.status === nextStatus) {
9195
- if (shouldClearTransitionAssignee) {
9503
+ if (shouldClearTransitionAssignee || shouldClearCompletedExecutionPolicy) {
9196
9504
  updateSyncFailureContext(syncFailureContext, {
9197
9505
  phase: "updating_paperclip_status",
9198
9506
  repositoryUrl: repository.url,
@@ -9204,8 +9512,8 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9204
9512
  currentStatus: paperclipIssue.status,
9205
9513
  syncContext: paperclipIssueSyncContext,
9206
9514
  nextStatus,
9207
- clearAssignee: true,
9208
- ...shouldPreserveMaintainerWaitRouting ? { clearExecutionPolicy: true } : {},
9515
+ ...shouldClearTransitionAssignee ? { clearAssignee: true } : {},
9516
+ ...shouldPreserveMaintainerWaitRouting || shouldClearCompletedExecutionPolicy ? { clearExecutionPolicy: true } : {},
9209
9517
  transitionComment: "",
9210
9518
  paperclipApiBaseUrl
9211
9519
  });
@@ -9241,7 +9549,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9241
9549
  nextStatus,
9242
9550
  ...nextTransitionAssignee ? { nextAssignee: nextTransitionAssignee.principal } : {},
9243
9551
  ...shouldClearTransitionAssignee ? { clearAssignee: true } : {},
9244
- ...shouldPreserveMaintainerWaitRouting ? { clearExecutionPolicy: true } : {},
9552
+ ...shouldPreserveMaintainerWaitRouting || shouldClearCompletedExecutionPolicy ? { clearExecutionPolicy: true } : {},
9245
9553
  transitionComment: transitionComment.body,
9246
9554
  transitionCommentAnnotation: transitionComment.annotation,
9247
9555
  paperclipApiBaseUrl
@@ -9304,7 +9612,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9304
9612
  };
9305
9613
  }
9306
9614
  async function synchronizePaperclipPullRequestIssueStatuses(ctx, octokit, mapping, advancedSettings, pullRequestLinks, paperclipApiBaseUrl, pullRequestStatusCache, syncFailureContext, failures, assertNotCancelled, onProgress) {
9307
- 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") {
9308
9616
  return {
9309
9617
  updatedStatusesCount: 0
9310
9618
  };
@@ -9753,14 +10061,6 @@ function parseCompanyMetricApiRouteBody(input) {
9753
10061
  return input.body;
9754
10062
  }
9755
10063
  async function handleCompanyMetricApiRoute(ctx, input) {
9756
- if (input.routeKey !== COMPANY_METRIC_API_ROUTE_KEY) {
9757
- return {
9758
- status: 404,
9759
- body: {
9760
- error: `Unsupported plugin API route: ${input.routeKey}.`
9761
- }
9762
- };
9763
- }
9764
10064
  if (input.actor.actorType !== "agent") {
9765
10065
  throw new Error("Company KPI metric events must be recorded by an authenticated Paperclip agent.");
9766
10066
  }
@@ -9860,6 +10160,83 @@ async function handleCompanyMetricApiRoute(ctx, input) {
9860
10160
  }
9861
10161
  };
9862
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
+ }
9863
10240
  async function createGitHubToolOctokit(ctx, companyId, context = {}) {
9864
10241
  const token = (await resolveGithubToken(ctx, { companyId })).trim();
9865
10242
  if (!token) {
@@ -12811,10 +13188,14 @@ function shouldRunScheduledSync(settings, scheduledAt) {
12811
13188
  }
12812
13189
  return now - lastCheckedAt >= settings.scheduleFrequencyMinutes * 6e4;
12813
13190
  }
12814
- function listScheduledSyncTargets(settings) {
13191
+ async function listScheduledSyncTargets(ctx, settings) {
13192
+ const externalLinkCompanyIds = await getExternalGitHubLinkCompanyIds(ctx);
12815
13193
  const companyIds = [
12816
- ...new Set(
12817
- 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
+ ]
12818
13199
  )
12819
13200
  ];
12820
13201
  if (companyIds.length === 0) {
@@ -12833,6 +13214,9 @@ async function performSync(ctx, trigger, options = {}) {
12833
13214
  const token = typeof options.resolvedToken === "string" ? options.resolvedToken : await resolveGithubToken(ctx, { companyId: targetCompanyId });
12834
13215
  const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(baseSettings, config, targetCompanyId);
12835
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;
12836
13220
  activePaperclipApiAuthTokensByCompanyId = null;
12837
13221
  const failureContext = {
12838
13222
  phase: "configuration"
@@ -12844,14 +13228,18 @@ async function performSync(ctx, trigger, options = {}) {
12844
13228
  };
12845
13229
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12846
13230
  }
12847
- if (mappings.length === 0) {
13231
+ if (mappings.length === 0 && externalLinkCount === 0) {
12848
13232
  const next = {
12849
13233
  ...settings,
12850
13234
  syncState: createSetupConfigurationErrorSyncState("missing_mapping", trigger)
12851
13235
  };
12852
13236
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12853
13237
  }
12854
- const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(settings, config, mappings);
13238
+ const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(
13239
+ settings,
13240
+ config,
13241
+ [...mappings, ...externalLinkAuthMappings]
13242
+ );
12855
13243
  if (mappingsMissingBoardAccess.length > 0 && await detectPaperclipBoardAccessRequirement(paperclipApiBaseUrl)) {
12856
13244
  const next = {
12857
13245
  ...settings,
@@ -12859,7 +13247,7 @@ async function performSync(ctx, trigger, options = {}) {
12859
13247
  };
12860
13248
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12861
13249
  }
12862
- if (!ctx.issues || typeof ctx.issues.create !== "function") {
13250
+ if (mappings.length > 0 && (!ctx.issues || typeof ctx.issues.create !== "function")) {
12863
13251
  const errorDetails = {
12864
13252
  phase: "configuration",
12865
13253
  suggestedAction: "Update Paperclip to a runtime that supports plugin issue creation, then retry sync."
@@ -12885,7 +13273,12 @@ async function performSync(ctx, trigger, options = {}) {
12885
13273
  };
12886
13274
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12887
13275
  }
12888
- activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(ctx, settings, config, mappings);
13276
+ activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(
13277
+ ctx,
13278
+ settings,
13279
+ config,
13280
+ [...mappings, ...externalLinkAuthMappings]
13281
+ );
12889
13282
  const octokitLogContext = {
12890
13283
  companyId: targetCompanyId,
12891
13284
  operation: "sync.github-issues",
@@ -13137,6 +13530,8 @@ async function performSync(ctx, trigger, options = {}) {
13137
13530
  continue;
13138
13531
  }
13139
13532
  }
13533
+ totalTrackedIssueCount += externalLinkCount;
13534
+ syncedIssuesCount = totalTrackedIssueCount;
13140
13535
  recordCompanyBacklogSnapshotsFromPlans(repositoryPlans);
13141
13536
  if (repositoryPlans.length > 0) {
13142
13537
  const firstPlan = repositoryPlans[0];
@@ -13149,6 +13544,14 @@ async function performSync(ctx, trigger, options = {}) {
13149
13544
  totalIssueCount: totalTrackedIssueCount,
13150
13545
  detailLabel: "Loading linked pull requests, review threads, and CI status before syncing."
13151
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
+ };
13152
13555
  } else {
13153
13556
  currentProgress = {
13154
13557
  phase: "preparing",
@@ -13413,6 +13816,194 @@ async function performSync(ctx, trigger, options = {}) {
13413
13816
  continue;
13414
13817
  }
13415
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
+ }
13416
14007
  if (recoverableFailures.length > 0) {
13417
14008
  const primaryFailure = recoverableFailures[0];
13418
14009
  const errorDetails = buildSyncErrorDetails(primaryFailure.error, primaryFailure.context);
@@ -13541,10 +14132,12 @@ async function startSync(ctx, trigger, options = {}) {
13541
14132
  if (getActiveGitHubRateLimitPause(currentSettings.syncState)) {
13542
14133
  return currentSettings;
13543
14134
  }
13544
- if (trigger !== "manual" && getSyncableMappingsForTarget(currentSettings.mappings, options.target).length === 0) {
14135
+ if (trigger !== "manual" && !token.trim()) {
13545
14136
  return currentSettings;
13546
14137
  }
13547
- 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) {
13548
14141
  return currentSettings;
13549
14142
  }
13550
14143
  await setSyncCancellationRequest(ctx, null);
@@ -13558,7 +14151,7 @@ async function startSync(ctx, trigger, options = {}) {
13558
14151
  message: getSyncTargetRunningMessage(options.target),
13559
14152
  progress: {
13560
14153
  phase: "preparing",
13561
- totalRepositoryCount: syncableMappings.length
14154
+ totalRepositoryCount: syncableMappings.length + countExternalLinkRepositories(externalSyncWork)
13562
14155
  }
13563
14156
  });
13564
14157
  activeRunningSyncState = {
@@ -13573,7 +14166,8 @@ async function startSync(ctx, trigger, options = {}) {
13573
14166
  await runningStatePromise;
13574
14167
  return await performSync(ctx, trigger, {
13575
14168
  resolvedToken: token,
13576
- target: options.target
14169
+ target: options.target,
14170
+ externalSyncWork
13577
14171
  });
13578
14172
  } catch (error) {
13579
14173
  return await createUnexpectedSyncErrorResult(ctx, trigger, error, targetCompanyId);
@@ -14468,6 +15062,42 @@ function registerGitHubAgentTools(ctx) {
14468
15062
  );
14469
15063
  })
14470
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
+ );
14471
15101
  }
14472
15102
  function shouldStartWorkerHost(moduleUrl, entry = process.argv[1]) {
14473
15103
  if (typeof entry !== "string" || !entry.trim()) {
@@ -14876,7 +15506,7 @@ var plugin = definePlugin({
14876
15506
  ctx.jobs.register("sync.github-issues", async (job) => {
14877
15507
  const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
14878
15508
  const trigger = job.trigger === "retry" ? "retry" : "schedule";
14879
- const scheduledTargets = listScheduledSyncTargets(settings);
15509
+ const scheduledTargets = await listScheduledSyncTargets(ctx, settings);
14880
15510
  if (scheduledTargets.length === 0) {
14881
15511
  const reconciledSettings = await reconcileOrphanedRunningSyncState(ctx);
14882
15512
  if (job.trigger === "schedule" && !shouldRunScheduledSync(reconciledSettings, job.scheduledAt)) {
@@ -14898,7 +15528,18 @@ var plugin = definePlugin({
14898
15528
  if (!pluginRuntimeContext) {
14899
15529
  throw new Error("GitHub Sync worker is not ready to handle API routes yet.");
14900
15530
  }
14901
- 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
+ };
14902
15543
  },
14903
15544
  async onShutdown() {
14904
15545
  pluginRuntimeContext = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-github-plugin",
3
- "version": "0.8.6",
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",