paperclip-github-plugin 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worker.js CHANGED
@@ -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"];
@@ -726,15 +782,15 @@ var PaperclipLabelSyncError = class extends Error {
726
782
  const location = params.paperclipApiBaseUrl ? ` at ${params.paperclipApiBaseUrl}` : "";
727
783
  let message;
728
784
  if (failure?.requiresAuthentication) {
729
- message = `Could not map ${labelSubject} because the worker reached an authenticated Paperclip API response${location} instead of JSON. Connect Paperclip board access in plugin settings, set \`PAPERCLIP_API_URL\` to a worker-accessible Paperclip API origin, or expose the local Paperclip API to the worker without browser-session auth.`;
785
+ message = `Could not map ${labelSubject} because the worker reached an authenticated Paperclip API response${location} instead of JSON. Connect Paperclip board access in plugin settings, set Worker Paperclip API URL to a worker-accessible Paperclip API origin, or expose the local Paperclip API to the worker without browser-session auth.`;
730
786
  } else if (failure?.status === 404 || failure?.status === 405) {
731
- message = `Could not map ${labelSubject} because the Paperclip label API${location} is not available to the worker. Set \`PAPERCLIP_API_URL\` to a worker-accessible Paperclip API origin, then retry sync.`;
787
+ message = `Could not map ${labelSubject} because the Paperclip label API${location} is not available to the worker. Set Worker Paperclip API URL to a worker-accessible Paperclip API origin, then retry sync.`;
732
788
  } else if (failure?.errorMessage) {
733
789
  message = `Could not map ${labelSubject} because the Paperclip label API${location} failed: ${failure.errorMessage}`;
734
790
  } else if (params.paperclipApiBaseUrl) {
735
791
  message = `Could not map ${labelSubject} because the Paperclip label API at ${params.paperclipApiBaseUrl} is unavailable to the worker.`;
736
792
  } else {
737
- message = `Could not map ${labelSubject} because no worker-accessible Paperclip label API is configured. Set \`PAPERCLIP_API_URL\` to a worker-accessible Paperclip API origin, then retry sync.`;
793
+ message = `Could not map ${labelSubject} because no worker-accessible Paperclip label API is configured. Set Worker Paperclip API URL to a worker-accessible Paperclip API origin, then retry sync.`;
738
794
  }
739
795
  super(message);
740
796
  this.status = failure?.status;
@@ -1458,6 +1514,39 @@ function getErrorMessage(error) {
1458
1514
  }
1459
1515
  return String(error);
1460
1516
  }
1517
+ function getErrorCause(error) {
1518
+ if (!error || typeof error !== "object" || !("cause" in error)) {
1519
+ return void 0;
1520
+ }
1521
+ return error.cause;
1522
+ }
1523
+ function getErrorCode(error) {
1524
+ if (!error || typeof error !== "object" || !("code" in error)) {
1525
+ return void 0;
1526
+ }
1527
+ const code = error.code;
1528
+ return typeof code === "string" && code.trim() ? code.trim() : void 0;
1529
+ }
1530
+ function getErrorDiagnosticMessage(error) {
1531
+ const primaryMessage = getErrorMessage(error).trim();
1532
+ const cause = getErrorCause(error);
1533
+ const causeMessage = cause ? getErrorMessage(cause).trim() : "";
1534
+ const errorCode = getErrorCode(error);
1535
+ const causeCode = cause ? getErrorCode(cause) : void 0;
1536
+ const code = errorCode ?? causeCode;
1537
+ const parts = [primaryMessage || String(error)];
1538
+ if (causeMessage && causeMessage !== primaryMessage) {
1539
+ parts.push(`cause: ${causeMessage}`);
1540
+ }
1541
+ if (code) {
1542
+ parts.push(`code: ${code}`);
1543
+ }
1544
+ return parts.join(" | ");
1545
+ }
1546
+ function formatPaperclipApiFetchErrorMessage(error, url, init) {
1547
+ const method = typeof init?.method === "string" && init.method.trim() ? init.method.trim().toUpperCase() : "GET";
1548
+ return `Paperclip API fetch failed (${method} ${url}): ${getErrorDiagnosticMessage(error)}`;
1549
+ }
1461
1550
  function isPaperclipLabelSyncError(error) {
1462
1551
  return error instanceof PaperclipLabelSyncError;
1463
1552
  }
@@ -2079,12 +2168,12 @@ function getSyncFailureSuggestedAction(error, context) {
2079
2168
  }
2080
2169
  if (isPaperclipLabelSyncError(error)) {
2081
2170
  if (error.requiresAuthentication || error.status === 401 || error.status === 403) {
2082
- return "The worker could not reuse the board login session for the Paperclip label API. Connect Paperclip board access in settings, or set `PAPERCLIP_API_URL` to a worker-accessible Paperclip API origin, then retry sync.";
2171
+ return "The worker could not reuse the board login session for the Paperclip label API. Connect Paperclip board access in settings, or set Worker Paperclip API URL to a worker-accessible Paperclip API origin, then retry sync.";
2083
2172
  }
2084
2173
  if (error.paperclipApiBaseUrl) {
2085
2174
  return `Confirm that the Paperclip label API at ${error.paperclipApiBaseUrl} is reachable from the plugin worker and returns JSON, then retry sync.`;
2086
2175
  }
2087
- return "Set `PAPERCLIP_API_URL` to a worker-accessible Paperclip API origin, then retry sync.";
2176
+ return "Set Worker Paperclip API URL to a worker-accessible Paperclip API origin, then retry sync.";
2088
2177
  }
2089
2178
  const rawMessage = getErrorMessage(error).trim().toLowerCase();
2090
2179
  if (rawMessage.includes("could not resolve to a pullrequest")) {
@@ -2544,19 +2633,6 @@ async function resolveManualSyncTarget(ctx, settings, input) {
2544
2633
  if (!pullRequestLink) {
2545
2634
  throw new Error("This Paperclip issue is not linked to a GitHub issue or pull request yet. Run a broader sync first.");
2546
2635
  }
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
2636
  return {
2561
2637
  kind: "issue",
2562
2638
  companyId: companyId2,
@@ -3912,17 +3988,7 @@ function normalizePaperclipApiBaseUrlByCompanyId(value) {
3912
3988
  }).filter((entry) => entry !== null);
3913
3989
  return entries.length > 0 ? Object.fromEntries(entries) : void 0;
3914
3990
  }
3915
- function getRuntimePaperclipApiBaseUrl() {
3916
- if (typeof process === "undefined" || !process?.env) {
3917
- return void 0;
3918
- }
3919
- return normalizePaperclipApiBaseUrl(process.env.PAPERCLIP_API_URL);
3920
- }
3921
3991
  function resolvePaperclipApiBaseUrl(...values) {
3922
- const runtimePaperclipApiBaseUrl = getRuntimePaperclipApiBaseUrl();
3923
- if (runtimePaperclipApiBaseUrl) {
3924
- return runtimePaperclipApiBaseUrl;
3925
- }
3926
3992
  for (const value of values) {
3927
3993
  const normalizedValue = normalizePaperclipApiBaseUrl(value);
3928
3994
  if (normalizedValue) {
@@ -3940,10 +4006,6 @@ function getConfiguredPaperclipApiBaseUrl(settings, config, companyId) {
3940
4006
  ) : resolvePaperclipApiBaseUrl(config?.paperclipApiBaseUrl, settings?.paperclipApiBaseUrl);
3941
4007
  }
3942
4008
  function resolveTrustedPaperclipApiBaseUrlInput(value, settings, config, companyId) {
3943
- const runtimePaperclipApiBaseUrl = getRuntimePaperclipApiBaseUrl();
3944
- if (runtimePaperclipApiBaseUrl) {
3945
- return runtimePaperclipApiBaseUrl;
3946
- }
3947
4009
  const requestedPaperclipApiBaseUrl = normalizePaperclipApiBaseUrl(value);
3948
4010
  const configuredPaperclipApiBaseUrl = normalizePaperclipApiBaseUrl(config?.paperclipApiBaseUrl);
3949
4011
  const normalizedCompanyId = normalizeCompanyId(companyId);
@@ -4481,6 +4543,58 @@ function normalizeImportRegistry(value) {
4481
4543
  };
4482
4544
  }).filter((entry) => entry !== null);
4483
4545
  }
4546
+ function normalizeExternalGitHubLinkCompanyIndex(value) {
4547
+ const rawCompanyIds = Array.isArray(value) ? value : value && typeof value === "object" && Array.isArray(value.companyIds) ? value.companyIds : [];
4548
+ const companyIds = [
4549
+ ...new Set(
4550
+ rawCompanyIds.map((entry) => normalizeCompanyId(entry)).filter((companyId) => Boolean(companyId))
4551
+ )
4552
+ ].sort();
4553
+ const updatedAt = value && typeof value === "object" && typeof value.updatedAt === "string" ? value.updatedAt.trim() : void 0;
4554
+ return {
4555
+ companyIds,
4556
+ ...updatedAt ? { updatedAt } : {}
4557
+ };
4558
+ }
4559
+ async function getExternalGitHubLinkCompanyIds(ctx) {
4560
+ return normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE)).companyIds;
4561
+ }
4562
+ async function rememberExternalGitHubLinkCompany(ctx, companyId) {
4563
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4564
+ if (!normalizedCompanyId) {
4565
+ return;
4566
+ }
4567
+ const index = normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE));
4568
+ if (index.companyIds.includes(normalizedCompanyId)) {
4569
+ return;
4570
+ }
4571
+ await ctx.state.set(EXTERNAL_LINK_COMPANY_INDEX_SCOPE, {
4572
+ companyIds: [...index.companyIds, normalizedCompanyId].sort(),
4573
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4574
+ });
4575
+ }
4576
+ async function forgetExternalGitHubLinkCompanyIfEmpty(ctx, companyId) {
4577
+ const normalizedCompanyId = normalizeCompanyId(companyId);
4578
+ if (!normalizedCompanyId) {
4579
+ return;
4580
+ }
4581
+ const [issueLinks, pullRequestLinks] = await Promise.all([
4582
+ listGitHubIssueLinkRecords(ctx),
4583
+ listGitHubPullRequestLinkRecords(ctx)
4584
+ ]);
4585
+ const hasRemainingLinks = issueLinks.some((record) => record.data.companyId === normalizedCompanyId) || pullRequestLinks.some((record) => record.data.companyId === normalizedCompanyId);
4586
+ if (hasRemainingLinks) {
4587
+ return;
4588
+ }
4589
+ const index = normalizeExternalGitHubLinkCompanyIndex(await ctx.state.get(EXTERNAL_LINK_COMPANY_INDEX_SCOPE));
4590
+ if (!index.companyIds.includes(normalizedCompanyId)) {
4591
+ return;
4592
+ }
4593
+ await ctx.state.set(EXTERNAL_LINK_COMPANY_INDEX_SCOPE, {
4594
+ companyIds: index.companyIds.filter((entry) => entry !== normalizedCompanyId),
4595
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4596
+ });
4597
+ }
4484
4598
  function requireRepositoryReference(repositoryInput) {
4485
4599
  const parsed = parseRepositoryReference(repositoryInput);
4486
4600
  if (!parsed) {
@@ -5542,9 +5656,6 @@ function resolvePaperclipIssueStatus(params) {
5542
5656
  }
5543
5657
  function resolvePaperclipPullRequestIssueStatus(params) {
5544
5658
  const { currentStatus, pullRequest, hasExecutorHandoffTarget } = params;
5545
- if (currentStatus === "done" || currentStatus === "cancelled") {
5546
- return currentStatus;
5547
- }
5548
5659
  if (shouldPreserveBlockedExternalPullRequestWait({
5549
5660
  currentStatus,
5550
5661
  linkedPullRequests: [pullRequest]
@@ -5553,7 +5664,7 @@ function resolvePaperclipPullRequestIssueStatus(params) {
5553
5664
  }
5554
5665
  return resolvePaperclipStatusFromLinkedPullRequests([pullRequest], {
5555
5666
  preferInProgress: hasExecutorHandoffTarget,
5556
- preserveTransientUnknownMergeabilityWait: currentStatus === "in_review"
5667
+ preserveTransientUnknownMergeabilityWait: currentStatus === "done" || currentStatus === "in_review"
5557
5668
  });
5558
5669
  }
5559
5670
  async function listLinkedPullRequestsForIssue(octokit, repository, issueNumber) {
@@ -6967,6 +7078,126 @@ async function listGitHubPullRequestIssueLinksForMapping(ctx, mapping, target) {
6967
7078
  }
6968
7079
  return [...recordsByKey.values()];
6969
7080
  }
7081
+ function doesGitHubIssueLinkRecordMatchMapping(record, mapping) {
7082
+ if (record.data.repositoryUrl !== getNormalizedMappingRepositoryUrl(mapping)) {
7083
+ return false;
7084
+ }
7085
+ if (record.data.companyId && record.data.companyId !== mapping.companyId) {
7086
+ return false;
7087
+ }
7088
+ if (record.data.paperclipProjectId && record.data.paperclipProjectId !== mapping.paperclipProjectId) {
7089
+ return false;
7090
+ }
7091
+ return Boolean(mapping.companyId && mapping.paperclipProjectId);
7092
+ }
7093
+ function doesGitHubIssueLinkRecordMatchTarget(record, target) {
7094
+ if (!target) {
7095
+ return true;
7096
+ }
7097
+ switch (target.kind) {
7098
+ case "company":
7099
+ return !record.data.companyId || record.data.companyId === target.companyId;
7100
+ case "project":
7101
+ return (!record.data.companyId || record.data.companyId === target.companyId) && (!record.data.paperclipProjectId || record.data.paperclipProjectId === target.projectId);
7102
+ case "issue":
7103
+ return Boolean(target.issueId && record.paperclipIssueId === target.issueId) && (!record.data.companyId || record.data.companyId === target.companyId);
7104
+ default:
7105
+ return true;
7106
+ }
7107
+ }
7108
+ function isGitHubIssueLinkCoveredByMappings(record, mappings) {
7109
+ return mappings.some((mapping) => doesGitHubIssueLinkRecordMatchMapping(record, mapping));
7110
+ }
7111
+ function isGitHubPullRequestLinkCoveredByMappings(record, mappings) {
7112
+ return mappings.some((mapping) => doesGitHubPullRequestLinkRecordMatchMapping(record, mapping));
7113
+ }
7114
+ async function listExternalGitHubLinkSyncWork(ctx, mappings, target) {
7115
+ const syncableMappings = getSyncableMappingsForTarget(mappings, target);
7116
+ const issueLinksByKey = /* @__PURE__ */ new Map();
7117
+ const pullRequestLinksByKey = /* @__PURE__ */ new Map();
7118
+ const [issueLinks, pullRequestLinks] = await Promise.all([
7119
+ listGitHubIssueLinkRecords(ctx, {
7120
+ ...target?.kind === "issue" && target.issueId ? { paperclipIssueId: target.issueId } : {}
7121
+ }),
7122
+ listGitHubPullRequestLinkRecords(ctx, {
7123
+ ...target?.kind === "issue" && target.issueId ? { paperclipIssueId: target.issueId } : {}
7124
+ })
7125
+ ]);
7126
+ for (const record of issueLinks) {
7127
+ if (!doesGitHubIssueLinkRecordMatchTarget(record, target) || isGitHubIssueLinkCoveredByMappings(record, syncableMappings)) {
7128
+ continue;
7129
+ }
7130
+ issueLinksByKey.set(
7131
+ `${record.paperclipIssueId}:${record.data.githubIssueUrl}`,
7132
+ record
7133
+ );
7134
+ }
7135
+ for (const record of pullRequestLinks) {
7136
+ if (!doesGitHubPullRequestLinkRecordMatchTarget(record, target) || isGitHubPullRequestLinkCoveredByMappings(record, syncableMappings)) {
7137
+ continue;
7138
+ }
7139
+ pullRequestLinksByKey.set(
7140
+ `${record.paperclipIssueId}:${buildGitHubPullRequestReferenceKey({
7141
+ number: record.data.githubPullRequestNumber,
7142
+ repositoryUrl: record.data.repositoryUrl
7143
+ })}`,
7144
+ record
7145
+ );
7146
+ }
7147
+ return {
7148
+ issueLinks: [...issueLinksByKey.values()],
7149
+ pullRequestLinks: [...pullRequestLinksByKey.values()]
7150
+ };
7151
+ }
7152
+ function buildExternalLinkAuthMappings(work) {
7153
+ const mappingsByKey = /* @__PURE__ */ new Map();
7154
+ const addMapping = (params) => {
7155
+ const companyId = normalizeCompanyId(params.companyId);
7156
+ if (!companyId) {
7157
+ return;
7158
+ }
7159
+ const repositoryUrl = getNormalizedMappingRepositoryUrl({
7160
+ repositoryUrl: params.repositoryUrl
7161
+ });
7162
+ const key = `${companyId}:${params.projectId ?? ""}:${repositoryUrl}`;
7163
+ mappingsByKey.set(key, {
7164
+ id: `external-link:${key}`,
7165
+ repositoryUrl,
7166
+ paperclipProjectName: "",
7167
+ ...params.projectId ? { paperclipProjectId: params.projectId } : {},
7168
+ companyId
7169
+ });
7170
+ };
7171
+ for (const link of work.issueLinks) {
7172
+ addMapping({
7173
+ companyId: link.data.companyId,
7174
+ projectId: link.data.paperclipProjectId,
7175
+ repositoryUrl: link.data.repositoryUrl
7176
+ });
7177
+ }
7178
+ for (const link of work.pullRequestLinks) {
7179
+ addMapping({
7180
+ companyId: link.data.companyId,
7181
+ projectId: link.data.paperclipProjectId,
7182
+ repositoryUrl: link.data.repositoryUrl
7183
+ });
7184
+ }
7185
+ return [...mappingsByKey.values()];
7186
+ }
7187
+ function countExternalLinkRepositories(work) {
7188
+ const repositories = /* @__PURE__ */ new Set();
7189
+ for (const link of work.issueLinks) {
7190
+ if (link.data.companyId) {
7191
+ repositories.add(`${link.data.companyId}:${link.data.repositoryUrl}`);
7192
+ }
7193
+ }
7194
+ for (const link of work.pullRequestLinks) {
7195
+ if (link.data.companyId) {
7196
+ repositories.add(`${link.data.companyId}:${link.data.repositoryUrl}`);
7197
+ }
7198
+ }
7199
+ return repositories.size;
7200
+ }
6970
7201
  async function findStoredStatusTransitionCommentAnnotation(ctx, params) {
6971
7202
  const issueId = params.issueId.trim();
6972
7203
  const commentId = params.commentId.trim();
@@ -7052,7 +7283,7 @@ async function upsertGitHubPullRequestLinkRecord(ctx, params) {
7052
7283
  status: params.pullRequestState,
7053
7284
  data: {
7054
7285
  companyId: params.companyId,
7055
- paperclipProjectId: params.projectId,
7286
+ ...params.projectId ? { paperclipProjectId: params.projectId } : {},
7056
7287
  repositoryUrl: getNormalizedMappingRepositoryUrl({
7057
7288
  repositoryUrl: params.repositoryUrl
7058
7289
  }),
@@ -7091,6 +7322,56 @@ async function assertPaperclipIssueHasNoManualGitHubLink(ctx, params) {
7091
7322
  throw new Error("This Paperclip issue is already linked to a GitHub pull request.");
7092
7323
  }
7093
7324
  }
7325
+ async function resolveIssueGitHubLinkScope(ctx, params) {
7326
+ if (!params.allowUnmapped) {
7327
+ const mappedScope = await resolveIssueGitHubLinkMapping(ctx, params);
7328
+ return {
7329
+ issue: mappedScope.issue,
7330
+ projectId: mappedScope.projectId,
7331
+ mapping: mappedScope.mapping,
7332
+ repository: mappedScope.repository
7333
+ };
7334
+ }
7335
+ const issue = await ctx.issues.get(params.issueId, params.companyId);
7336
+ if (!issue) {
7337
+ throw new Error("Paperclip issue was not found.");
7338
+ }
7339
+ const projectId = typeof issue.projectId === "string" && issue.projectId.trim() ? issue.projectId.trim() : void 0;
7340
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
7341
+ const requestedRepository = params.repositoryUrl ? parseRepositoryReference(params.repositoryUrl) : null;
7342
+ if (params.repositoryUrl && !requestedRepository) {
7343
+ throw new Error(`Invalid GitHub repository: ${params.repositoryUrl}. Use owner/repo or https://github.com/owner/repo.`);
7344
+ }
7345
+ const candidateMappings = projectId ? await resolveProjectScopedMappings(ctx, settings.mappings, {
7346
+ companyId: params.companyId,
7347
+ projectId
7348
+ }) : [];
7349
+ const matchingMappings = requestedRepository ? candidateMappings.filter(
7350
+ (mapping) => areRepositoriesEqual(requireRepositoryReference(mapping.repositoryUrl), requestedRepository)
7351
+ ) : candidateMappings;
7352
+ if (matchingMappings.length === 1) {
7353
+ const mapping = matchingMappings[0];
7354
+ return {
7355
+ issue,
7356
+ projectId,
7357
+ mapping,
7358
+ repository: requestedRepository ?? requireRepositoryReference(mapping.repositoryUrl)
7359
+ };
7360
+ }
7361
+ if (matchingMappings.length > 1 && !requestedRepository) {
7362
+ throw new Error("This Paperclip issue project has multiple GitHub repositories. Enter the full GitHub URL.");
7363
+ }
7364
+ if (!requestedRepository) {
7365
+ throw new Error(
7366
+ "Enter a full GitHub URL or repository because this Paperclip issue project is not mapped to that repository."
7367
+ );
7368
+ }
7369
+ return {
7370
+ issue,
7371
+ ...projectId ? { projectId } : {},
7372
+ repository: requestedRepository
7373
+ };
7374
+ }
7094
7375
  async function resolveIssueGitHubLinkMapping(ctx, params) {
7095
7376
  const issue = await ctx.issues.get(params.issueId, params.companyId);
7096
7377
  if (!issue) {
@@ -7213,10 +7494,11 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7213
7494
  repositoryUrl: params.repositoryUrl,
7214
7495
  issueNumber: params.issueNumber
7215
7496
  });
7216
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7497
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7217
7498
  companyId,
7218
7499
  issueId,
7219
- repositoryUrl: reference.repositoryUrl
7500
+ repositoryUrl: reference.repositoryUrl,
7501
+ allowUnmapped: params.allowUnmapped
7220
7502
  });
7221
7503
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7222
7504
  const response = await octokit.rest.issues.get({
@@ -7233,18 +7515,29 @@ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
7233
7515
  }
7234
7516
  const githubIssue = normalizeGitHubIssueRecord(rawIssue);
7235
7517
  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({
7518
+ const linkTarget = {
7244
7519
  companyId,
7245
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7246
- repository: scope.repository
7247
- });
7520
+ ...scope.mapping?.paperclipProjectId ?? scope.projectId ? { paperclipProjectId: scope.mapping?.paperclipProjectId ?? scope.projectId } : {},
7521
+ repositoryUrl: scope.repository.url
7522
+ };
7523
+ await upsertGitHubIssueLinkRecord(ctx, linkTarget, issueId, githubIssue, linkedPullRequests);
7524
+ await rememberExternalGitHubLinkCompany(ctx, companyId);
7525
+ if (scope.mapping) {
7526
+ const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
7527
+ upsertImportedIssueRecord(
7528
+ importRegistry,
7529
+ buildImportedIssueRecord(scope.mapping, githubIssue, issueId, (/* @__PURE__ */ new Date()).toISOString())
7530
+ );
7531
+ await ctx.state.set(IMPORT_REGISTRY_SCOPE, importRegistry);
7532
+ }
7533
+ const projectIdForCacheInvalidation = scope.mapping?.paperclipProjectId ?? scope.projectId;
7534
+ if (projectIdForCacheInvalidation) {
7535
+ invalidateProjectPullRequestCaches({
7536
+ companyId,
7537
+ projectId: projectIdForCacheInvalidation,
7538
+ repository: scope.repository
7539
+ });
7540
+ }
7248
7541
  return {
7249
7542
  kind: "issue",
7250
7543
  paperclipIssueId: issueId,
@@ -7272,10 +7565,11 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7272
7565
  pullRequestNumber: params.pullRequestNumber,
7273
7566
  pullRequestUrl: params.pullRequestUrl
7274
7567
  });
7275
- const scope = await resolveIssueGitHubLinkMapping(ctx, {
7568
+ const scope = await resolveIssueGitHubLinkScope(ctx, {
7276
7569
  companyId,
7277
7570
  issueId,
7278
- repositoryUrl: reference.repositoryUrl
7571
+ repositoryUrl: reference.repositoryUrl,
7572
+ allowUnmapped: params.allowUnmapped
7279
7573
  });
7280
7574
  const octokit = await createGitHubToolOctokit(ctx, companyId);
7281
7575
  const response = await octokit.rest.pulls.get({
@@ -7293,7 +7587,7 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7293
7587
  });
7294
7588
  await upsertGitHubPullRequestLinkRecord(ctx, {
7295
7589
  companyId,
7296
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7590
+ projectId: scope.mapping?.paperclipProjectId ?? scope.projectId,
7297
7591
  issueId,
7298
7592
  repositoryUrl: scope.repository.url,
7299
7593
  pullRequestNumber: reference.pullRequestNumber,
@@ -7301,11 +7595,15 @@ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
7301
7595
  pullRequestTitle: response.data.title || `Pull request #${reference.pullRequestNumber}`,
7302
7596
  pullRequestState
7303
7597
  });
7304
- invalidateProjectPullRequestCaches({
7305
- companyId,
7306
- projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7307
- repository: scope.repository
7308
- });
7598
+ await rememberExternalGitHubLinkCompany(ctx, companyId);
7599
+ const projectIdForCacheInvalidation = scope.mapping?.paperclipProjectId ?? scope.projectId;
7600
+ if (projectIdForCacheInvalidation) {
7601
+ invalidateProjectPullRequestCaches({
7602
+ companyId,
7603
+ projectId: projectIdForCacheInvalidation,
7604
+ repository: scope.repository
7605
+ });
7606
+ }
7309
7607
  return {
7310
7608
  kind: "pull_request",
7311
7609
  paperclipIssueId: issueId,
@@ -7433,6 +7731,7 @@ async function unlinkPaperclipIssueFromGitHub(ctx, params) {
7433
7731
  if (Object.keys(issuePatch).length > 0) {
7434
7732
  await ctx.issues.update(issueId, issuePatch, companyId);
7435
7733
  }
7734
+ await forgetExternalGitHubLinkCompanyIfEmpty(ctx, companyId);
7436
7735
  return {
7437
7736
  paperclipIssueId: issueId,
7438
7737
  unlinked: issueLinkRecords.length > 0 || pullRequestLinkRecords.length > 0 || removedImportRegistryEntries.length > 0 || shouldClearGitHubOrigin || nextDescription !== void 0,
@@ -7545,7 +7844,14 @@ function applyPaperclipApiAuthentication(init, companyId) {
7545
7844
  };
7546
7845
  }
7547
7846
  async function fetchPaperclipApi(url, init, options) {
7548
- return fetch(url, applyPaperclipApiAuthentication(init, options?.companyId));
7847
+ const authenticatedInit = applyPaperclipApiAuthentication(init, options?.companyId);
7848
+ try {
7849
+ return await fetch(url, authenticatedInit);
7850
+ } catch (error) {
7851
+ throw new Error(formatPaperclipApiFetchErrorMessage(error, url, authenticatedInit), {
7852
+ cause: error
7853
+ });
7854
+ }
7549
7855
  }
7550
7856
  async function detectPaperclipBoardAccessRequirement(paperclipApiBaseUrl) {
7551
7857
  if (!paperclipApiBaseUrl) {
@@ -9329,7 +9635,7 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
9329
9635
  };
9330
9636
  }
9331
9637
  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") {
9638
+ if (!mapping.companyId || !ctx.issues || typeof ctx.issues.get !== "function" || typeof ctx.issues.update !== "function") {
9333
9639
  return {
9334
9640
  updatedStatusesCount: 0
9335
9641
  };
@@ -9778,14 +10084,6 @@ function parseCompanyMetricApiRouteBody(input) {
9778
10084
  return input.body;
9779
10085
  }
9780
10086
  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
10087
  if (input.actor.actorType !== "agent") {
9790
10088
  throw new Error("Company KPI metric events must be recorded by an authenticated Paperclip agent.");
9791
10089
  }
@@ -9885,6 +10183,83 @@ async function handleCompanyMetricApiRoute(ctx, input) {
9885
10183
  }
9886
10184
  };
9887
10185
  }
10186
+ function parseIssueLinkApiRouteBody(input) {
10187
+ if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) {
10188
+ throw new Error("Issue link route body must be a JSON object.");
10189
+ }
10190
+ return input.body;
10191
+ }
10192
+ function normalizeIssueLinkApiRouteKind(payload) {
10193
+ const explicitKind = normalizeIssueGitHubLinkKind(payload.kind);
10194
+ if (explicitKind) {
10195
+ return explicitKind;
10196
+ }
10197
+ const reference = normalizeOptionalString2(payload.reference);
10198
+ if (normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(payload.pullRequestUrl)) || normalizeToolPositiveInteger(payload.pullRequestNumber) || reference && parseGitHubPullRequestHtmlUrl(reference)) {
10199
+ return "pull_request";
10200
+ }
10201
+ if (normalizeToolPositiveInteger(payload.issueNumber) || reference && parseGitHubIssueHtmlUrl(reference)) {
10202
+ return "issue";
10203
+ }
10204
+ return null;
10205
+ }
10206
+ async function handleIssueLinkApiRoute(ctx, input) {
10207
+ if (input.actor.actorType !== "agent") {
10208
+ throw new Error("GitHub issue links must be recorded by an authenticated Paperclip agent.");
10209
+ }
10210
+ const companyId = normalizeCompanyId(input.companyId);
10211
+ if (!companyId) {
10212
+ throw new Error("GitHub issue links require the host to provide the authenticated agent company.");
10213
+ }
10214
+ const payload = parseIssueLinkApiRouteBody(input);
10215
+ const requestedCompanyId = normalizeCompanyId(payload.companyId);
10216
+ if (requestedCompanyId && requestedCompanyId !== companyId) {
10217
+ throw new Error("companyId must match the authenticated Paperclip agent company.");
10218
+ }
10219
+ const kind = normalizeIssueLinkApiRouteKind(payload);
10220
+ if (!kind) {
10221
+ throw new Error('kind must be "issue" or "pull_request", or the payload must include a full GitHub URL.');
10222
+ }
10223
+ const paperclipIssueId = normalizeOptionalString2(payload.paperclipIssueId) ?? normalizeOptionalString2(payload.issueId);
10224
+ if (!paperclipIssueId) {
10225
+ throw new Error("paperclipIssueId is required.");
10226
+ }
10227
+ const linkResult = kind === "issue" ? await linkPaperclipIssueToGitHubIssue(ctx, {
10228
+ companyId,
10229
+ issueId: paperclipIssueId,
10230
+ reference: payload.reference,
10231
+ repositoryUrl: normalizeOptionalString2(payload.repository),
10232
+ issueNumber: payload.issueNumber,
10233
+ allowUnmapped: true
10234
+ }) : await linkPaperclipIssueToGitHubPullRequest(ctx, {
10235
+ companyId,
10236
+ issueId: paperclipIssueId,
10237
+ reference: payload.reference,
10238
+ repositoryUrl: normalizeOptionalString2(payload.repository),
10239
+ pullRequestNumber: payload.pullRequestNumber,
10240
+ pullRequestUrl: payload.pullRequestUrl,
10241
+ allowUnmapped: true
10242
+ });
10243
+ ctx.logger.info("GitHub Sync recorded a GitHub issue link API route event.", {
10244
+ routeKey: input.routeKey,
10245
+ companyId,
10246
+ kind,
10247
+ paperclipIssueId,
10248
+ repositoryUrl: normalizeOptionalString2(linkResult.repositoryUrl),
10249
+ githubIssueNumber: normalizeToolPositiveInteger(linkResult.githubIssueNumber),
10250
+ githubPullRequestNumber: normalizeToolPositiveInteger(linkResult.githubPullRequestNumber),
10251
+ agentId: input.actor.agentId ?? null,
10252
+ runId: input.actor.runId ?? null
10253
+ });
10254
+ return {
10255
+ status: 201,
10256
+ body: {
10257
+ status: "linked",
10258
+ companyId,
10259
+ ...linkResult
10260
+ }
10261
+ };
10262
+ }
9888
10263
  async function createGitHubToolOctokit(ctx, companyId, context = {}) {
9889
10264
  const token = (await resolveGithubToken(ctx, { companyId })).trim();
9890
10265
  if (!token) {
@@ -12836,10 +13211,14 @@ function shouldRunScheduledSync(settings, scheduledAt) {
12836
13211
  }
12837
13212
  return now - lastCheckedAt >= settings.scheduleFrequencyMinutes * 6e4;
12838
13213
  }
12839
- function listScheduledSyncTargets(settings) {
13214
+ async function listScheduledSyncTargets(ctx, settings) {
13215
+ const externalLinkCompanyIds = await getExternalGitHubLinkCompanyIds(ctx);
12840
13216
  const companyIds = [
12841
- ...new Set(
12842
- settings.mappings.map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId))
13217
+ .../* @__PURE__ */ new Set(
13218
+ [
13219
+ ...settings.mappings.map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId)),
13220
+ ...externalLinkCompanyIds
13221
+ ]
12843
13222
  )
12844
13223
  ];
12845
13224
  if (companyIds.length === 0) {
@@ -12858,6 +13237,9 @@ async function performSync(ctx, trigger, options = {}) {
12858
13237
  const token = typeof options.resolvedToken === "string" ? options.resolvedToken : await resolveGithubToken(ctx, { companyId: targetCompanyId });
12859
13238
  const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(baseSettings, config, targetCompanyId);
12860
13239
  const mappings = getSyncableMappingsForTarget(settings.mappings, options.target);
13240
+ const externalSyncWork = options.externalSyncWork ?? await listExternalGitHubLinkSyncWork(ctx, settings.mappings, options.target);
13241
+ const externalLinkAuthMappings = buildExternalLinkAuthMappings(externalSyncWork);
13242
+ const externalLinkCount = externalSyncWork.issueLinks.length + externalSyncWork.pullRequestLinks.length;
12861
13243
  activePaperclipApiAuthTokensByCompanyId = null;
12862
13244
  const failureContext = {
12863
13245
  phase: "configuration"
@@ -12869,14 +13251,18 @@ async function performSync(ctx, trigger, options = {}) {
12869
13251
  };
12870
13252
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12871
13253
  }
12872
- if (mappings.length === 0) {
13254
+ if (mappings.length === 0 && externalLinkCount === 0) {
12873
13255
  const next = {
12874
13256
  ...settings,
12875
13257
  syncState: createSetupConfigurationErrorSyncState("missing_mapping", trigger)
12876
13258
  };
12877
13259
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12878
13260
  }
12879
- const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(settings, config, mappings);
13261
+ const mappingsMissingBoardAccess = getMappingsMissingPaperclipBoardAccess(
13262
+ settings,
13263
+ config,
13264
+ [...mappings, ...externalLinkAuthMappings]
13265
+ );
12880
13266
  if (mappingsMissingBoardAccess.length > 0 && await detectPaperclipBoardAccessRequirement(paperclipApiBaseUrl)) {
12881
13267
  const next = {
12882
13268
  ...settings,
@@ -12884,7 +13270,7 @@ async function performSync(ctx, trigger, options = {}) {
12884
13270
  };
12885
13271
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12886
13272
  }
12887
- if (!ctx.issues || typeof ctx.issues.create !== "function") {
13273
+ if (mappings.length > 0 && (!ctx.issues || typeof ctx.issues.create !== "function")) {
12888
13274
  const errorDetails = {
12889
13275
  phase: "configuration",
12890
13276
  suggestedAction: "Update Paperclip to a runtime that supports plugin issue creation, then retry sync."
@@ -12910,7 +13296,12 @@ async function performSync(ctx, trigger, options = {}) {
12910
13296
  };
12911
13297
  return saveSettingsSyncState(ctx, settings, next.syncState, targetCompanyId);
12912
13298
  }
12913
- activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(ctx, settings, config, mappings);
13299
+ activePaperclipApiAuthTokensByCompanyId = await resolvePaperclipApiAuthTokens(
13300
+ ctx,
13301
+ settings,
13302
+ config,
13303
+ [...mappings, ...externalLinkAuthMappings]
13304
+ );
12914
13305
  const octokitLogContext = {
12915
13306
  companyId: targetCompanyId,
12916
13307
  operation: "sync.github-issues",
@@ -13162,6 +13553,8 @@ async function performSync(ctx, trigger, options = {}) {
13162
13553
  continue;
13163
13554
  }
13164
13555
  }
13556
+ totalTrackedIssueCount += externalLinkCount;
13557
+ syncedIssuesCount = totalTrackedIssueCount;
13165
13558
  recordCompanyBacklogSnapshotsFromPlans(repositoryPlans);
13166
13559
  if (repositoryPlans.length > 0) {
13167
13560
  const firstPlan = repositoryPlans[0];
@@ -13174,6 +13567,14 @@ async function performSync(ctx, trigger, options = {}) {
13174
13567
  totalIssueCount: totalTrackedIssueCount,
13175
13568
  detailLabel: "Loading linked pull requests, review threads, and CI status before syncing."
13176
13569
  };
13570
+ } else if (externalLinkCount > 0) {
13571
+ currentProgress = {
13572
+ phase: "preparing",
13573
+ totalRepositoryCount: countExternalLinkRepositories(externalSyncWork),
13574
+ completedIssueCount: completedTrackedIssueCount,
13575
+ totalIssueCount: totalTrackedIssueCount,
13576
+ detailLabel: "Loading linked GitHub issues and pull requests before syncing."
13577
+ };
13177
13578
  } else {
13178
13579
  currentProgress = {
13179
13580
  phase: "preparing",
@@ -13438,6 +13839,194 @@ async function performSync(ctx, trigger, options = {}) {
13438
13839
  continue;
13439
13840
  }
13440
13841
  }
13842
+ for (const [externalIssueIndex, issueLink] of externalSyncWork.issueLinks.entries()) {
13843
+ await throwIfSyncCancelled();
13844
+ try {
13845
+ const companyId = normalizeCompanyId(issueLink.data.companyId);
13846
+ if (!companyId) {
13847
+ continue;
13848
+ }
13849
+ const repository = requireRepositoryReference(issueLink.data.repositoryUrl);
13850
+ octokitLogContext.repositoryUrl = repository.url;
13851
+ const advancedSettings = getCompanyAdvancedSettings(settings, companyId);
13852
+ let availableLabels = companyLabelDirectoryCache.get(companyId);
13853
+ if (!availableLabels) {
13854
+ updateSyncFailureContext(failureContext, {
13855
+ phase: "loading_paperclip_labels",
13856
+ repositoryUrl: repository.url,
13857
+ githubIssueNumber: issueLink.data.githubIssueNumber
13858
+ });
13859
+ availableLabels = supportsPaperclipLabelMapping ? await buildPaperclipLabelDirectory(ctx, companyId, paperclipApiBaseUrl) : /* @__PURE__ */ new Map();
13860
+ companyLabelDirectoryCache.set(companyId, availableLabels);
13861
+ }
13862
+ currentProgress = {
13863
+ phase: "syncing",
13864
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13865
+ currentRepositoryUrl: repository.url,
13866
+ completedIssueCount: completedTrackedIssueCount,
13867
+ totalIssueCount: totalTrackedIssueCount,
13868
+ currentIssueNumber: issueLink.data.githubIssueNumber,
13869
+ detailLabel: `Syncing linked GitHub issue #${issueLink.data.githubIssueNumber} in ${repository.owner}/${repository.repo}.`
13870
+ };
13871
+ await persistRunningProgress(true);
13872
+ updateSyncFailureContext(failureContext, {
13873
+ phase: "evaluating_github_status",
13874
+ repositoryUrl: repository.url,
13875
+ githubIssueNumber: issueLink.data.githubIssueNumber
13876
+ });
13877
+ const response = await octokit.rest.issues.get({
13878
+ owner: repository.owner,
13879
+ repo: repository.repo,
13880
+ issue_number: issueLink.data.githubIssueNumber,
13881
+ headers: {
13882
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
13883
+ }
13884
+ });
13885
+ const githubIssue = normalizeGitHubIssueRecord(response.data);
13886
+ if (response.data.pull_request) {
13887
+ continue;
13888
+ }
13889
+ const virtualMapping = {
13890
+ id: `external-link:${companyId}:${repository.url}`,
13891
+ repositoryUrl: repository.url,
13892
+ paperclipProjectName: "",
13893
+ ...issueLink.data.paperclipProjectId ? { paperclipProjectId: issueLink.data.paperclipProjectId } : {},
13894
+ companyId
13895
+ };
13896
+ const importedIssue = {
13897
+ mappingId: virtualMapping.id,
13898
+ githubIssueId: githubIssue.id,
13899
+ githubIssueNumber: githubIssue.number,
13900
+ paperclipIssueId: issueLink.paperclipIssueId,
13901
+ importedAt: issueLink.createdAt ?? issueLink.data.syncedAt,
13902
+ lastSeenCommentCount: issueLink.data.commentsCount,
13903
+ lastSeenGitHubState: issueLink.data.githubIssueState,
13904
+ linkedPullRequestCommentCounts: [],
13905
+ repositoryUrl: repository.url,
13906
+ ...issueLink.data.paperclipProjectId ? { paperclipProjectId: issueLink.data.paperclipProjectId } : {},
13907
+ companyId
13908
+ };
13909
+ const synchronizationResult = await synchronizePaperclipIssueStatuses(
13910
+ ctx,
13911
+ octokit,
13912
+ repository,
13913
+ virtualMapping,
13914
+ advancedSettings,
13915
+ /* @__PURE__ */ new Map([[githubIssue.id, githubIssue]]),
13916
+ [importedIssue],
13917
+ /* @__PURE__ */ new Set(),
13918
+ availableLabels,
13919
+ paperclipApiBaseUrl,
13920
+ /* @__PURE__ */ new Map(),
13921
+ /* @__PURE__ */ new Map(),
13922
+ /* @__PURE__ */ new Map(),
13923
+ repositoryMaintainerCache,
13924
+ failureContext,
13925
+ recoverableFailures,
13926
+ throwIfSyncCancelled,
13927
+ async (params) => {
13928
+ const recorded = recordCompanyActivityMetricEvent(companyKpiState, {
13929
+ companyId: params.companyId,
13930
+ metric: "githubIssuesClosedCount",
13931
+ eventKey: `${params.repositoryUrl}/issues/${params.githubIssueNumber}:${coerceDate(params.occurredAt).toISOString()}`,
13932
+ repositoryUrl: params.repositoryUrl,
13933
+ occurredAt: params.occurredAt
13934
+ });
13935
+ companyKpiState = recorded.state;
13936
+ companyKpiStateDirty = companyKpiStateDirty || recorded.recorded;
13937
+ },
13938
+ async (progress) => {
13939
+ completedTrackedIssueCount += 1;
13940
+ currentProgress = {
13941
+ phase: "syncing",
13942
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13943
+ currentRepositoryUrl: repository.url,
13944
+ completedIssueCount: completedTrackedIssueCount,
13945
+ totalIssueCount: totalTrackedIssueCount,
13946
+ ...progress.currentIssueNumber !== void 0 ? { currentIssueNumber: progress.currentIssueNumber } : {}
13947
+ };
13948
+ await persistRunningProgress(externalIssueIndex === externalSyncWork.issueLinks.length - 1);
13949
+ }
13950
+ );
13951
+ updatedStatusesCount += synchronizationResult.updatedStatusesCount;
13952
+ updatedLabelsCount += synchronizationResult.updatedLabelsCount;
13953
+ updatedDescriptionsCount += synchronizationResult.updatedDescriptionsCount;
13954
+ } catch (error) {
13955
+ if (error instanceof SyncCancellationError || isGitHubRateLimitError(error)) {
13956
+ throw error;
13957
+ }
13958
+ recordRecoverableSyncFailure(ctx, recoverableFailures, error, failureContext);
13959
+ continue;
13960
+ }
13961
+ }
13962
+ const externalPullRequestLinksByCompanyId = /* @__PURE__ */ new Map();
13963
+ for (const pullRequestLink of externalSyncWork.pullRequestLinks) {
13964
+ const companyId = normalizeCompanyId(pullRequestLink.data.companyId);
13965
+ if (!companyId) {
13966
+ continue;
13967
+ }
13968
+ const companyLinks = externalPullRequestLinksByCompanyId.get(companyId) ?? [];
13969
+ companyLinks.push(pullRequestLink);
13970
+ externalPullRequestLinksByCompanyId.set(companyId, companyLinks);
13971
+ }
13972
+ for (const [companyId, pullRequestLinks] of externalPullRequestLinksByCompanyId.entries()) {
13973
+ await throwIfSyncCancelled();
13974
+ try {
13975
+ const firstRepositoryUrl = pullRequestLinks[0]?.data.repositoryUrl;
13976
+ if (!firstRepositoryUrl) {
13977
+ continue;
13978
+ }
13979
+ const repository = requireRepositoryReference(firstRepositoryUrl);
13980
+ octokitLogContext.repositoryUrl = repository.url;
13981
+ currentProgress = {
13982
+ phase: "syncing",
13983
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
13984
+ currentRepositoryUrl: repository.url,
13985
+ completedIssueCount: completedTrackedIssueCount,
13986
+ totalIssueCount: totalTrackedIssueCount,
13987
+ detailLabel: `Syncing ${pullRequestLinks.length} linked GitHub pull request${pullRequestLinks.length === 1 ? "" : "s"}.`
13988
+ };
13989
+ await persistRunningProgress(true);
13990
+ const pullRequestStatusCache = /* @__PURE__ */ new Map();
13991
+ const synchronizationResult = await synchronizePaperclipPullRequestIssueStatuses(
13992
+ ctx,
13993
+ octokit,
13994
+ {
13995
+ id: `external-link:${companyId}:${repository.url}`,
13996
+ repositoryUrl: repository.url,
13997
+ paperclipProjectName: "",
13998
+ companyId
13999
+ },
14000
+ getCompanyAdvancedSettings(settings, companyId),
14001
+ pullRequestLinks,
14002
+ paperclipApiBaseUrl,
14003
+ pullRequestStatusCache,
14004
+ failureContext,
14005
+ recoverableFailures,
14006
+ throwIfSyncCancelled,
14007
+ async (progress) => {
14008
+ completedTrackedIssueCount += 1;
14009
+ const pullRequestRepository = requireRepositoryReference(progress.pullRequestLink.data.repositoryUrl);
14010
+ currentProgress = {
14011
+ phase: "syncing",
14012
+ totalRepositoryCount: Math.max(mappings.length, countExternalLinkRepositories(externalSyncWork)),
14013
+ currentRepositoryUrl: pullRequestRepository.url,
14014
+ completedIssueCount: completedTrackedIssueCount,
14015
+ totalIssueCount: totalTrackedIssueCount,
14016
+ detailLabel: `Synced pull request #${progress.pullRequestLink.data.githubPullRequestNumber} in ${pullRequestRepository.owner}/${pullRequestRepository.repo}.`
14017
+ };
14018
+ await persistRunningProgress(progress.completedIssueCount === progress.totalIssueCount);
14019
+ }
14020
+ );
14021
+ updatedStatusesCount += synchronizationResult.updatedStatusesCount;
14022
+ } catch (error) {
14023
+ if (error instanceof SyncCancellationError || isGitHubRateLimitError(error)) {
14024
+ throw error;
14025
+ }
14026
+ recordRecoverableSyncFailure(ctx, recoverableFailures, error, failureContext);
14027
+ continue;
14028
+ }
14029
+ }
13441
14030
  if (recoverableFailures.length > 0) {
13442
14031
  const primaryFailure = recoverableFailures[0];
13443
14032
  const errorDetails = buildSyncErrorDetails(primaryFailure.error, primaryFailure.context);
@@ -13566,10 +14155,12 @@ async function startSync(ctx, trigger, options = {}) {
13566
14155
  if (getActiveGitHubRateLimitPause(currentSettings.syncState)) {
13567
14156
  return currentSettings;
13568
14157
  }
13569
- if (trigger !== "manual" && getSyncableMappingsForTarget(currentSettings.mappings, options.target).length === 0) {
14158
+ if (trigger !== "manual" && !token.trim()) {
13570
14159
  return currentSettings;
13571
14160
  }
13572
- if (trigger !== "manual" && !token.trim()) {
14161
+ const externalSyncWork = await listExternalGitHubLinkSyncWork(ctx, currentSettings.mappings, options.target);
14162
+ const externalLinkCount = externalSyncWork.issueLinks.length + externalSyncWork.pullRequestLinks.length;
14163
+ if (trigger !== "manual" && getSyncableMappingsForTarget(currentSettings.mappings, options.target).length === 0 && externalLinkCount === 0) {
13573
14164
  return currentSettings;
13574
14165
  }
13575
14166
  await setSyncCancellationRequest(ctx, null);
@@ -13583,7 +14174,7 @@ async function startSync(ctx, trigger, options = {}) {
13583
14174
  message: getSyncTargetRunningMessage(options.target),
13584
14175
  progress: {
13585
14176
  phase: "preparing",
13586
- totalRepositoryCount: syncableMappings.length
14177
+ totalRepositoryCount: syncableMappings.length + countExternalLinkRepositories(externalSyncWork)
13587
14178
  }
13588
14179
  });
13589
14180
  activeRunningSyncState = {
@@ -13598,7 +14189,8 @@ async function startSync(ctx, trigger, options = {}) {
13598
14189
  await runningStatePromise;
13599
14190
  return await performSync(ctx, trigger, {
13600
14191
  resolvedToken: token,
13601
- target: options.target
14192
+ target: options.target,
14193
+ externalSyncWork
13602
14194
  });
13603
14195
  } catch (error) {
13604
14196
  return await createUnexpectedSyncErrorResult(ctx, trigger, error, targetCompanyId);
@@ -14493,6 +15085,42 @@ function registerGitHubAgentTools(ctx) {
14493
15085
  );
14494
15086
  })
14495
15087
  );
15088
+ ctx.tools.register(
15089
+ "link_github_item",
15090
+ getGitHubAgentToolDeclaration("link_github_item"),
15091
+ async (params, runCtx) => executeGitHubTool(async () => {
15092
+ const input = getToolInputRecord(params);
15093
+ const kind = normalizeIssueGitHubLinkKind(input.kind);
15094
+ if (!kind) {
15095
+ throw new Error('kind must be "issue" or "pull_request".');
15096
+ }
15097
+ const paperclipIssueId = normalizeOptionalToolString(input.paperclipIssueId);
15098
+ if (!paperclipIssueId) {
15099
+ throw new Error("paperclipIssueId is required.");
15100
+ }
15101
+ const linkResult = kind === "issue" ? await linkPaperclipIssueToGitHubIssue(ctx, {
15102
+ companyId: runCtx.companyId,
15103
+ issueId: paperclipIssueId,
15104
+ reference: input.reference,
15105
+ repositoryUrl: normalizeOptionalToolString(input.repository),
15106
+ issueNumber: input.issueNumber,
15107
+ allowUnmapped: true
15108
+ }) : await linkPaperclipIssueToGitHubPullRequest(ctx, {
15109
+ companyId: runCtx.companyId,
15110
+ issueId: paperclipIssueId,
15111
+ reference: input.reference,
15112
+ repositoryUrl: normalizeOptionalToolString(input.repository),
15113
+ pullRequestNumber: input.pullRequestNumber,
15114
+ pullRequestUrl: input.pullRequestUrl,
15115
+ allowUnmapped: true
15116
+ });
15117
+ const itemLabel = kind === "issue" ? `issue #${normalizeToolPositiveInteger(linkResult.githubIssueNumber) ?? ""}`.trim() : `pull request #${normalizeToolPositiveInteger(linkResult.githubPullRequestNumber) ?? ""}`.trim();
15118
+ return buildToolSuccessResult(
15119
+ `Linked Paperclip issue ${paperclipIssueId} to GitHub ${itemLabel}.`,
15120
+ linkResult
15121
+ );
15122
+ })
15123
+ );
14496
15124
  }
14497
15125
  function shouldStartWorkerHost(moduleUrl, entry = process.argv[1]) {
14498
15126
  if (typeof entry !== "string" || !entry.trim()) {
@@ -14508,6 +15136,7 @@ function shouldStartWorkerHost(moduleUrl, entry = process.argv[1]) {
14508
15136
  var __testing = {
14509
15137
  buildSyncFallbackExecutionStatePatch,
14510
15138
  createGitHubToolOctokit,
15139
+ formatPaperclipApiFetchErrorMessage,
14511
15140
  hasUnresolvedPaperclipIssueBlocker,
14512
15141
  isHealthyMaintainerWaitTransition,
14513
15142
  resolveSyncTransitionAssignee
@@ -14543,6 +15172,7 @@ var plugin = definePlugin({
14543
15172
  ...getPublicSettingsForScope(settingsForResponse, requestedCompanyId),
14544
15173
  ...includeAssignees ? { availableAssignees } : {},
14545
15174
  totalSyncedIssuesCount: countImportedIssuesForMappings(importRegistry, scopedMappings),
15175
+ paperclipApiBaseUrlConfigured: Boolean(normalizePaperclipApiBaseUrl(config.paperclipApiBaseUrl)),
14546
15176
  githubTokenConfigured,
14547
15177
  paperclipBoardAccessConfigured: requestedCompanyId ? hasConfiguredPaperclipBoardAccess(settingsForResponse, config, requestedCompanyId) : hasConfiguredPaperclipBoardAccessForMappings(settingsForResponse, config, scopedMappings),
14548
15178
  ...savedBoardTokenRef ? { paperclipBoardAccessConfigSyncRef: savedBoardTokenRef } : {},
@@ -14901,7 +15531,7 @@ var plugin = definePlugin({
14901
15531
  ctx.jobs.register("sync.github-issues", async (job) => {
14902
15532
  const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
14903
15533
  const trigger = job.trigger === "retry" ? "retry" : "schedule";
14904
- const scheduledTargets = listScheduledSyncTargets(settings);
15534
+ const scheduledTargets = await listScheduledSyncTargets(ctx, settings);
14905
15535
  if (scheduledTargets.length === 0) {
14906
15536
  const reconciledSettings = await reconcileOrphanedRunningSyncState(ctx);
14907
15537
  if (job.trigger === "schedule" && !shouldRunScheduledSync(reconciledSettings, job.scheduledAt)) {
@@ -14923,7 +15553,18 @@ var plugin = definePlugin({
14923
15553
  if (!pluginRuntimeContext) {
14924
15554
  throw new Error("GitHub Sync worker is not ready to handle API routes yet.");
14925
15555
  }
14926
- return handleCompanyMetricApiRoute(pluginRuntimeContext, input);
15556
+ if (input.routeKey === COMPANY_METRIC_API_ROUTE_KEY) {
15557
+ return handleCompanyMetricApiRoute(pluginRuntimeContext, input);
15558
+ }
15559
+ if (input.routeKey === ISSUE_LINK_API_ROUTE_KEY) {
15560
+ return handleIssueLinkApiRoute(pluginRuntimeContext, input);
15561
+ }
15562
+ return {
15563
+ status: 404,
15564
+ body: {
15565
+ error: `Unsupported plugin API route: ${input.routeKey}.`
15566
+ }
15567
+ };
14927
15568
  },
14928
15569
  async onShutdown() {
14929
15570
  pluginRuntimeContext = null;