paperclip-github-plugin 0.7.3 → 0.7.5

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
@@ -255,6 +255,10 @@ var GITHUB_AGENT_TOOLS = [
255
255
  required: ["head", "base", "title"],
256
256
  properties: {
257
257
  repository: repositoryProperty,
258
+ paperclipIssueId: {
259
+ type: "string",
260
+ description: "Optional Paperclip issue id to link with the created pull request so GitHub Sync can monitor PR status for that issue."
261
+ },
258
262
  head: {
259
263
  type: "string",
260
264
  description: "Head branch name or owner:branch."
@@ -2300,6 +2304,18 @@ async function resolvePaperclipIssueGitHubLink(ctx, issueId, companyId, options
2300
2304
  };
2301
2305
  return await hydrateRecoveredPaperclipIssueGitHubLink(ctx, issueId, fallbackLink) ?? fallbackLink;
2302
2306
  }
2307
+ async function resolvePaperclipIssueGitHubPullRequestLink(ctx, issueId, companyId) {
2308
+ const links = await listGitHubPullRequestLinkRecords(ctx, {
2309
+ paperclipIssueId: issueId
2310
+ });
2311
+ return links.filter((record) => !record.data.companyId || record.data.companyId === companyId).sort((left, right) => {
2312
+ const rightTimestamp = Date.parse(right.updatedAt ?? right.createdAt ?? "");
2313
+ const leftTimestamp = Date.parse(left.updatedAt ?? left.createdAt ?? "");
2314
+ const safeRightTimestamp = Number.isFinite(rightTimestamp) ? rightTimestamp : 0;
2315
+ const safeLeftTimestamp = Number.isFinite(leftTimestamp) ? leftTimestamp : 0;
2316
+ return safeRightTimestamp - safeLeftTimestamp;
2317
+ })[0] ?? null;
2318
+ }
2303
2319
  async function hydrateRecoveredPaperclipIssueGitHubLink(ctx, issueId, fallbackLink) {
2304
2320
  const repository = parseRepositoryReference(fallbackLink.repositoryUrl);
2305
2321
  if (!repository) {
@@ -2375,21 +2391,33 @@ async function resolveManualSyncTarget(ctx, settings, input) {
2375
2391
  }
2376
2392
  const link = await resolvePaperclipIssueGitHubLink(ctx, input.issueId, companyId2);
2377
2393
  if (!link) {
2378
- throw new Error("This Paperclip issue is not linked to a GitHub issue yet. Run a broader sync first.");
2379
- }
2380
- const candidateMappings = getSyncableMappingsForTarget(settings.mappings, {
2381
- kind: "issue",
2382
- companyId: companyId2,
2383
- projectId: link.paperclipProjectId,
2384
- repositoryUrl: link.repositoryUrl,
2385
- issueId: input.issueId,
2386
- githubIssueId: link.githubIssueId,
2387
- githubIssueNumber: link.githubIssueNumber,
2388
- githubIssueUrl: link.githubIssueUrl,
2389
- displayLabel: `issue #${link.githubIssueNumber}`
2390
- });
2391
- if (candidateMappings.length === 0) {
2392
- throw new Error("No saved GitHub repository mapping matches this Paperclip issue.");
2394
+ const pullRequestLink = await resolvePaperclipIssueGitHubPullRequestLink(ctx, input.issueId, companyId2);
2395
+ if (!pullRequestLink) {
2396
+ throw new Error("This Paperclip issue is not linked to a GitHub issue or pull request yet. Run a broader sync first.");
2397
+ }
2398
+ const candidateMappings = getSyncableMappingsForTarget(settings.mappings, {
2399
+ kind: "issue",
2400
+ companyId: companyId2,
2401
+ projectId: pullRequestLink.data.paperclipProjectId,
2402
+ repositoryUrl: pullRequestLink.data.repositoryUrl,
2403
+ issueId: input.issueId,
2404
+ githubPullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
2405
+ githubPullRequestUrl: pullRequestLink.data.githubPullRequestUrl,
2406
+ displayLabel: `pull request #${pullRequestLink.data.githubPullRequestNumber}`
2407
+ });
2408
+ if (candidateMappings.length === 0) {
2409
+ throw new Error("No saved GitHub repository mapping matches this Paperclip issue.");
2410
+ }
2411
+ return {
2412
+ kind: "issue",
2413
+ companyId: companyId2,
2414
+ projectId: pullRequestLink.data.paperclipProjectId,
2415
+ issueId: input.issueId,
2416
+ repositoryUrl: pullRequestLink.data.repositoryUrl,
2417
+ githubPullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
2418
+ githubPullRequestUrl: pullRequestLink.data.githubPullRequestUrl,
2419
+ displayLabel: `pull request #${pullRequestLink.data.githubPullRequestNumber}`
2420
+ };
2393
2421
  }
2394
2422
  return {
2395
2423
  kind: "issue",
@@ -2513,23 +2541,46 @@ async function buildToolbarSyncState(ctx, input) {
2513
2541
  }
2514
2542
  if (entityType === "issue" && entityId && companyId) {
2515
2543
  const link = await resolvePaperclipIssueGitHubLink(ctx, entityId, companyId);
2516
- const mappings = link ? getSyncableMappingsForTarget(settings.mappings, {
2544
+ if (link) {
2545
+ const mappings2 = getSyncableMappingsForTarget(settings.mappings, {
2546
+ kind: "issue",
2547
+ companyId,
2548
+ projectId: link.paperclipProjectId,
2549
+ issueId: entityId,
2550
+ repositoryUrl: link.repositoryUrl,
2551
+ githubIssueId: link.githubIssueId,
2552
+ githubIssueNumber: link.githubIssueNumber,
2553
+ githubIssueUrl: link.githubIssueUrl,
2554
+ displayLabel: `issue #${link.githubIssueNumber}`
2555
+ });
2556
+ return {
2557
+ kind: "issue",
2558
+ visible: false,
2559
+ canRun: githubTokenConfigured && mappings2.length > 0,
2560
+ label: `Sync #${link.githubIssueNumber}`,
2561
+ message: `Sync ${link.repositoryUrl.replace(/^https:\/\/github\.com\//, "")} issue #${link.githubIssueNumber}.`,
2562
+ syncState: settings.syncState,
2563
+ githubTokenConfigured,
2564
+ savedMappingCount
2565
+ };
2566
+ }
2567
+ const pullRequestLink = await resolvePaperclipIssueGitHubPullRequestLink(ctx, entityId, companyId);
2568
+ const mappings = pullRequestLink ? getSyncableMappingsForTarget(settings.mappings, {
2517
2569
  kind: "issue",
2518
2570
  companyId,
2519
- projectId: link.paperclipProjectId,
2571
+ projectId: pullRequestLink.data.paperclipProjectId,
2520
2572
  issueId: entityId,
2521
- repositoryUrl: link.repositoryUrl,
2522
- githubIssueId: link.githubIssueId,
2523
- githubIssueNumber: link.githubIssueNumber,
2524
- githubIssueUrl: link.githubIssueUrl,
2525
- displayLabel: `issue #${link.githubIssueNumber}`
2573
+ repositoryUrl: pullRequestLink.data.repositoryUrl,
2574
+ githubPullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
2575
+ githubPullRequestUrl: pullRequestLink.data.githubPullRequestUrl,
2576
+ displayLabel: `pull request #${pullRequestLink.data.githubPullRequestNumber}`
2526
2577
  }) : [];
2527
2578
  return {
2528
2579
  kind: "issue",
2529
2580
  visible: false,
2530
2581
  canRun: githubTokenConfigured && mappings.length > 0,
2531
- label: link?.githubIssueNumber ? `Sync #${link.githubIssueNumber}` : "Sync issue",
2532
- message: link ? `Sync ${link.repositoryUrl.replace(/^https:\/\/github\.com\//, "")} issue #${link.githubIssueNumber}.` : "This Paperclip issue is not linked to GitHub yet.",
2582
+ label: pullRequestLink?.data.githubPullRequestNumber ? `Sync PR #${pullRequestLink.data.githubPullRequestNumber}` : "Sync issue",
2583
+ message: pullRequestLink ? `Sync ${pullRequestLink.data.repositoryUrl.replace(/^https:\/\/github\.com\//, "")} pull request #${pullRequestLink.data.githubPullRequestNumber}.` : "This Paperclip issue is not linked to GitHub yet.",
2533
2584
  syncState: settings.syncState,
2534
2585
  githubTokenConfigured,
2535
2586
  savedMappingCount
@@ -2696,13 +2747,11 @@ async function buildIssueGitHubDetails(ctx, input) {
2696
2747
  const link = await resolvePaperclipIssueGitHubLink(ctx, issueId, companyId, {
2697
2748
  linkRecords
2698
2749
  });
2699
- if (!link) {
2700
- return null;
2701
- }
2702
- const entityMatch = link.entityRecord;
2703
- if (entityMatch) {
2750
+ if (link?.entityRecord) {
2751
+ const entityMatch = link.entityRecord;
2704
2752
  return {
2705
2753
  paperclipIssueId: issueId,
2754
+ kind: "issue",
2706
2755
  source: "entity",
2707
2756
  githubIssueNumber: entityMatch.data.githubIssueNumber,
2708
2757
  githubIssueUrl: entityMatch.data.githubIssueUrl,
@@ -2723,14 +2772,48 @@ async function buildIssueGitHubDetails(ctx, input) {
2723
2772
  syncedAt: entityMatch.data.syncedAt
2724
2773
  };
2725
2774
  }
2775
+ if (link) {
2776
+ return {
2777
+ paperclipIssueId: issueId,
2778
+ kind: "issue",
2779
+ source: link.source,
2780
+ githubIssueNumber: link.githubIssueNumber,
2781
+ githubIssueUrl: link.githubIssueUrl,
2782
+ repositoryUrl: link.repositoryUrl,
2783
+ linkedPullRequestNumbers: link.linkedPullRequestNumbers,
2784
+ linkedPullRequests: link.linkedPullRequests
2785
+ };
2786
+ }
2787
+ const pullRequestLinks = await listGitHubPullRequestLinkRecords(ctx, {
2788
+ paperclipIssueId: issueId
2789
+ });
2790
+ const pullRequestLink = pullRequestLinks.filter((record) => !record.data.companyId || record.data.companyId === companyId).sort((left, right) => {
2791
+ const rightTimestamp = Date.parse(right.updatedAt ?? right.createdAt ?? "");
2792
+ const leftTimestamp = Date.parse(left.updatedAt ?? left.createdAt ?? "");
2793
+ const safeRightTimestamp = Number.isFinite(rightTimestamp) ? rightTimestamp : 0;
2794
+ const safeLeftTimestamp = Number.isFinite(leftTimestamp) ? leftTimestamp : 0;
2795
+ return safeRightTimestamp - safeLeftTimestamp;
2796
+ })[0];
2797
+ if (!pullRequestLink) {
2798
+ return null;
2799
+ }
2726
2800
  return {
2727
2801
  paperclipIssueId: issueId,
2728
- source: link.source,
2729
- githubIssueNumber: link.githubIssueNumber,
2730
- githubIssueUrl: link.githubIssueUrl,
2731
- repositoryUrl: link.repositoryUrl,
2732
- linkedPullRequestNumbers: link.linkedPullRequestNumbers,
2733
- linkedPullRequests: link.linkedPullRequests
2802
+ kind: "pull_request",
2803
+ source: "pull_request_entity",
2804
+ repositoryUrl: pullRequestLink.data.repositoryUrl,
2805
+ githubPullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
2806
+ githubPullRequestUrl: pullRequestLink.data.githubPullRequestUrl,
2807
+ githubPullRequestState: pullRequestLink.data.githubPullRequestState,
2808
+ title: pullRequestLink.data.title,
2809
+ linkedPullRequestNumbers: [pullRequestLink.data.githubPullRequestNumber],
2810
+ linkedPullRequests: [
2811
+ {
2812
+ number: pullRequestLink.data.githubPullRequestNumber,
2813
+ repositoryUrl: pullRequestLink.data.repositoryUrl
2814
+ }
2815
+ ],
2816
+ syncedAt: pullRequestLink.data.syncedAt
2734
2817
  };
2735
2818
  }
2736
2819
  async function resolveIssueByIdentifier(ctx, input) {
@@ -6014,24 +6097,34 @@ function parseGitHubIssueHtmlUrl(value) {
6014
6097
  function normalizeGitHubIssueHtmlUrl(value) {
6015
6098
  return parseGitHubIssueHtmlUrl(value)?.issueUrl;
6016
6099
  }
6017
- function normalizeGitHubPullRequestHtmlUrl(value) {
6018
- if (typeof value !== "string" || !value.trim()) {
6019
- return void 0;
6020
- }
6100
+ function parseGitHubPullRequestHtmlUrl(value) {
6021
6101
  try {
6022
- const parsed = new URL(value);
6023
- if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") {
6102
+ const url = new URL(value.trim());
6103
+ const hostname = url.hostname.trim().toLowerCase();
6104
+ if (hostname !== "github.com" && hostname !== "www.github.com") {
6024
6105
  return void 0;
6025
6106
  }
6026
- const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i);
6107
+ const match = url.pathname.match(/^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)\/?$/);
6027
6108
  if (!match) {
6028
6109
  return void 0;
6029
6110
  }
6030
- return `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`;
6111
+ return {
6112
+ owner: match[1],
6113
+ repo: match[2],
6114
+ repositoryUrl: `https://github.com/${match[1]}/${match[2]}`,
6115
+ pullRequestNumber: Number(match[3]),
6116
+ pullRequestUrl: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`
6117
+ };
6031
6118
  } catch {
6032
6119
  return void 0;
6033
6120
  }
6034
6121
  }
6122
+ function normalizeGitHubPullRequestHtmlUrl(value) {
6123
+ if (typeof value !== "string" || !value.trim()) {
6124
+ return void 0;
6125
+ }
6126
+ return parseGitHubPullRequestHtmlUrl(value)?.pullRequestUrl;
6127
+ }
6035
6128
  function escapeRegExp(value) {
6036
6129
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6037
6130
  }
@@ -6705,6 +6798,257 @@ async function upsertGitHubPullRequestLinkRecord(ctx, params) {
6705
6798
  }
6706
6799
  });
6707
6800
  }
6801
+ function normalizeIssueGitHubLinkKind(value) {
6802
+ if (value === "issue" || value === "github_issue") {
6803
+ return "issue";
6804
+ }
6805
+ if (value === "pull_request" || value === "pr" || value === "github_pull_request") {
6806
+ return "pull_request";
6807
+ }
6808
+ return null;
6809
+ }
6810
+ function getGitHubPullRequestStateForLink(value) {
6811
+ return getPullRequestApiState(value) === "open" ? "open" : "closed";
6812
+ }
6813
+ async function assertPaperclipIssueHasNoManualGitHubLink(ctx, params) {
6814
+ const existingIssueLink = await resolvePaperclipIssueGitHubLink(ctx, params.issueId, params.companyId);
6815
+ if (existingIssueLink) {
6816
+ throw new Error("This Paperclip issue is already linked to a GitHub issue.");
6817
+ }
6818
+ const existingPullRequestLinks = await listGitHubPullRequestLinkRecords(ctx, {
6819
+ paperclipIssueId: params.issueId
6820
+ });
6821
+ const matchingPullRequestLink = existingPullRequestLinks.find(
6822
+ (record) => !record.data.companyId || record.data.companyId === params.companyId
6823
+ );
6824
+ if (matchingPullRequestLink) {
6825
+ throw new Error("This Paperclip issue is already linked to a GitHub pull request.");
6826
+ }
6827
+ }
6828
+ async function resolveIssueGitHubLinkMapping(ctx, params) {
6829
+ const issue = await ctx.issues.get(params.issueId, params.companyId);
6830
+ if (!issue) {
6831
+ throw new Error("Paperclip issue was not found.");
6832
+ }
6833
+ const projectId = typeof issue.projectId === "string" && issue.projectId.trim() ? issue.projectId.trim() : void 0;
6834
+ if (!projectId) {
6835
+ throw new Error("This Paperclip issue is not in a project that can be mapped to a GitHub repository.");
6836
+ }
6837
+ const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
6838
+ const mappings = await resolveProjectScopedMappings(ctx, settings.mappings, {
6839
+ companyId: params.companyId,
6840
+ projectId
6841
+ });
6842
+ if (mappings.length === 0) {
6843
+ throw new Error("This Paperclip issue project is not mapped to a GitHub repository.");
6844
+ }
6845
+ const requestedRepository = params.repositoryUrl ? parseRepositoryReference(params.repositoryUrl) : null;
6846
+ if (params.repositoryUrl && !requestedRepository) {
6847
+ throw new Error(`Invalid GitHub repository: ${params.repositoryUrl}. Use owner/repo or https://github.com/owner/repo.`);
6848
+ }
6849
+ const matchingMappings = requestedRepository ? mappings.filter(
6850
+ (mapping2) => areRepositoriesEqual(requireRepositoryReference(mapping2.repositoryUrl), requestedRepository)
6851
+ ) : mappings;
6852
+ if (matchingMappings.length === 0 && requestedRepository) {
6853
+ throw new Error("The current Paperclip issue project is not mapped to the selected GitHub repository.");
6854
+ }
6855
+ if (matchingMappings.length > 1 && !requestedRepository) {
6856
+ throw new Error("This Paperclip issue project has multiple GitHub repositories. Enter the full GitHub URL.");
6857
+ }
6858
+ const mapping = matchingMappings[0];
6859
+ if (!mapping) {
6860
+ throw new Error("This Paperclip issue project is not mapped to a GitHub repository.");
6861
+ }
6862
+ return {
6863
+ issue,
6864
+ projectId,
6865
+ mapping,
6866
+ repository: requestedRepository ?? requireRepositoryReference(mapping.repositoryUrl)
6867
+ };
6868
+ }
6869
+ function resolveGitHubIssueLinkReference(input) {
6870
+ const reference = normalizeOptionalString2(input.reference);
6871
+ if (reference) {
6872
+ const parsedIssueUrl = parseGitHubIssueHtmlUrl(reference);
6873
+ if (parsedIssueUrl) {
6874
+ return {
6875
+ repositoryUrl: parsedIssueUrl.repositoryUrl,
6876
+ issueNumber: parsedIssueUrl.issueNumber,
6877
+ issueUrl: parsedIssueUrl.issueUrl
6878
+ };
6879
+ }
6880
+ if (parseGitHubPullRequestHtmlUrl(reference)) {
6881
+ throw new Error("That reference is a GitHub pull request. Choose pull request instead.");
6882
+ }
6883
+ const referenceNumber = normalizePositiveIntegerReference(reference);
6884
+ if (referenceNumber) {
6885
+ return {
6886
+ repositoryUrl: input.repositoryUrl,
6887
+ issueNumber: referenceNumber
6888
+ };
6889
+ }
6890
+ }
6891
+ const explicitIssueNumber = normalizePositiveIntegerReference(input.issueNumber);
6892
+ if (explicitIssueNumber) {
6893
+ return {
6894
+ repositoryUrl: input.repositoryUrl,
6895
+ issueNumber: explicitIssueNumber
6896
+ };
6897
+ }
6898
+ throw new Error("Enter a GitHub issue number or full GitHub issue URL.");
6899
+ }
6900
+ function resolveGitHubPullRequestLinkReference(input) {
6901
+ const explicitPullRequestUrl = normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(input.pullRequestUrl));
6902
+ const parsedExplicitPullRequestUrl = explicitPullRequestUrl ? parseGitHubPullRequestHtmlUrl(explicitPullRequestUrl) : void 0;
6903
+ const reference = normalizeOptionalString2(input.reference);
6904
+ const parsedReferenceUrl = reference ? parseGitHubPullRequestHtmlUrl(reference) : void 0;
6905
+ if (reference && parseGitHubIssueHtmlUrl(reference)) {
6906
+ throw new Error("That reference is a GitHub issue. Choose issue instead.");
6907
+ }
6908
+ const parsedUrl = parsedReferenceUrl ?? parsedExplicitPullRequestUrl;
6909
+ const explicitPullRequestNumber = normalizePositiveIntegerReference(input.pullRequestNumber);
6910
+ const referenceNumber = reference && !parsedReferenceUrl ? normalizePositiveIntegerReference(reference) : void 0;
6911
+ const pullRequestNumber = parsedUrl?.pullRequestNumber ?? explicitPullRequestNumber ?? referenceNumber;
6912
+ const repositoryUrl = parsedUrl?.repositoryUrl ?? input.repositoryUrl;
6913
+ if (!pullRequestNumber) {
6914
+ throw new Error("Enter a GitHub pull request number or full GitHub pull request URL.");
6915
+ }
6916
+ if (parsedUrl && explicitPullRequestNumber && explicitPullRequestNumber !== parsedUrl.pullRequestNumber) {
6917
+ throw new Error("pullRequestNumber must match the supplied GitHub pull request URL.");
6918
+ }
6919
+ if (parsedUrl && input.repositoryUrl) {
6920
+ const requestedRepository = parseRepositoryReference(input.repositoryUrl);
6921
+ const urlRepository = parseRepositoryReference(parsedUrl.repositoryUrl);
6922
+ if (requestedRepository && urlRepository && !areRepositoriesEqual(requestedRepository, urlRepository)) {
6923
+ throw new Error("repository must match the supplied GitHub pull request URL.");
6924
+ }
6925
+ }
6926
+ return {
6927
+ repositoryUrl,
6928
+ pullRequestNumber,
6929
+ ...parsedUrl?.pullRequestUrl ? { pullRequestUrl: parsedUrl.pullRequestUrl } : {}
6930
+ };
6931
+ }
6932
+ async function linkPaperclipIssueToGitHubIssue(ctx, params) {
6933
+ const companyId = normalizeCompanyId(params.companyId);
6934
+ const issueId = normalizeOptionalString2(params.issueId);
6935
+ if (!companyId || !issueId) {
6936
+ throw new Error("companyId and issueId are required.");
6937
+ }
6938
+ if (params.requireUnlinked) {
6939
+ await assertPaperclipIssueHasNoManualGitHubLink(ctx, {
6940
+ companyId,
6941
+ issueId
6942
+ });
6943
+ }
6944
+ const reference = resolveGitHubIssueLinkReference({
6945
+ kind: "issue",
6946
+ reference: params.reference,
6947
+ repositoryUrl: params.repositoryUrl,
6948
+ issueNumber: params.issueNumber
6949
+ });
6950
+ const scope = await resolveIssueGitHubLinkMapping(ctx, {
6951
+ companyId,
6952
+ issueId,
6953
+ repositoryUrl: reference.repositoryUrl
6954
+ });
6955
+ const octokit = await createGitHubToolOctokit(ctx, companyId);
6956
+ const response = await octokit.rest.issues.get({
6957
+ owner: scope.repository.owner,
6958
+ repo: scope.repository.repo,
6959
+ issue_number: reference.issueNumber,
6960
+ headers: {
6961
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
6962
+ }
6963
+ });
6964
+ const rawIssue = response.data;
6965
+ if (rawIssue.pull_request) {
6966
+ throw new Error("That GitHub number is a pull request. Choose pull request instead.");
6967
+ }
6968
+ const githubIssue = normalizeGitHubIssueRecord(rawIssue);
6969
+ const linkedPullRequests = await listLinkedPullRequestsForIssue(octokit, scope.repository, githubIssue.number);
6970
+ await upsertGitHubIssueLinkRecord(ctx, scope.mapping, issueId, githubIssue, linkedPullRequests);
6971
+ const importRegistry = normalizeImportRegistry(await ctx.state.get(IMPORT_REGISTRY_SCOPE));
6972
+ upsertImportedIssueRecord(
6973
+ importRegistry,
6974
+ buildImportedIssueRecord(scope.mapping, githubIssue, issueId, (/* @__PURE__ */ new Date()).toISOString())
6975
+ );
6976
+ await ctx.state.set(IMPORT_REGISTRY_SCOPE, importRegistry);
6977
+ invalidateProjectPullRequestCaches({
6978
+ companyId,
6979
+ projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
6980
+ repository: scope.repository
6981
+ });
6982
+ return {
6983
+ kind: "issue",
6984
+ paperclipIssueId: issueId,
6985
+ repositoryUrl: scope.repository.url,
6986
+ githubIssueNumber: githubIssue.number,
6987
+ githubIssueUrl: normalizeGitHubIssueHtmlUrl(githubIssue.htmlUrl) ?? githubIssue.htmlUrl,
6988
+ linkedPullRequestNumbers: linkedPullRequests.map((pullRequest) => pullRequest.number)
6989
+ };
6990
+ }
6991
+ async function linkPaperclipIssueToGitHubPullRequest(ctx, params) {
6992
+ const companyId = normalizeCompanyId(params.companyId);
6993
+ const issueId = normalizeOptionalString2(params.issueId);
6994
+ if (!companyId || !issueId) {
6995
+ throw new Error("companyId and issueId are required.");
6996
+ }
6997
+ if (params.requireUnlinked) {
6998
+ await assertPaperclipIssueHasNoManualGitHubLink(ctx, {
6999
+ companyId,
7000
+ issueId
7001
+ });
7002
+ }
7003
+ const reference = resolveGitHubPullRequestLinkReference({
7004
+ reference: params.reference,
7005
+ repositoryUrl: params.repositoryUrl,
7006
+ pullRequestNumber: params.pullRequestNumber,
7007
+ pullRequestUrl: params.pullRequestUrl
7008
+ });
7009
+ const scope = await resolveIssueGitHubLinkMapping(ctx, {
7010
+ companyId,
7011
+ issueId,
7012
+ repositoryUrl: reference.repositoryUrl
7013
+ });
7014
+ const octokit = await createGitHubToolOctokit(ctx, companyId);
7015
+ const response = await octokit.rest.pulls.get({
7016
+ owner: scope.repository.owner,
7017
+ repo: scope.repository.repo,
7018
+ pull_number: reference.pullRequestNumber,
7019
+ headers: {
7020
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
7021
+ }
7022
+ });
7023
+ const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(response.data.html_url ?? reference.pullRequestUrl) ?? reference.pullRequestUrl ?? `${scope.repository.url}/pull/${reference.pullRequestNumber}`;
7024
+ const pullRequestState = getGitHubPullRequestStateForLink({
7025
+ state: response.data.state,
7026
+ merged: response.data.merged
7027
+ });
7028
+ await upsertGitHubPullRequestLinkRecord(ctx, {
7029
+ companyId,
7030
+ projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7031
+ issueId,
7032
+ repositoryUrl: scope.repository.url,
7033
+ pullRequestNumber: reference.pullRequestNumber,
7034
+ pullRequestUrl,
7035
+ pullRequestTitle: response.data.title || `Pull request #${reference.pullRequestNumber}`,
7036
+ pullRequestState
7037
+ });
7038
+ invalidateProjectPullRequestCaches({
7039
+ companyId,
7040
+ projectId: scope.mapping.paperclipProjectId ?? scope.projectId,
7041
+ repository: scope.repository
7042
+ });
7043
+ return {
7044
+ kind: "pull_request",
7045
+ paperclipIssueId: issueId,
7046
+ repositoryUrl: scope.repository.url,
7047
+ githubPullRequestNumber: reference.pullRequestNumber,
7048
+ githubPullRequestUrl: pullRequestUrl,
7049
+ githubPullRequestState: pullRequestState
7050
+ };
7051
+ }
6708
7052
  async function upsertStatusTransitionCommentAnnotation(ctx, params) {
6709
7053
  const { issueId, commentId, annotation } = params;
6710
7054
  await ctx.entities.upsert({
@@ -7743,6 +8087,40 @@ async function listRepositoryIssues(octokit, repository, state = "open", options
7743
8087
  }
7744
8088
  return normalizedIssues;
7745
8089
  }
8090
+ async function listRepositoryIssuesForSyncTarget(octokit, repository, state, target, options = {}) {
8091
+ if (target?.kind !== "issue") {
8092
+ return listRepositoryIssues(octokit, repository, state, options);
8093
+ }
8094
+ if (target.githubIssueNumber === void 0) {
8095
+ if (options.onProgress) {
8096
+ await options.onProgress({
8097
+ loadedIssueCount: 0
8098
+ });
8099
+ }
8100
+ return [];
8101
+ }
8102
+ const response = await octokit.rest.issues.get({
8103
+ owner: repository.owner,
8104
+ repo: repository.repo,
8105
+ issue_number: target.githubIssueNumber,
8106
+ headers: {
8107
+ accept: "application/vnd.github+json",
8108
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
8109
+ }
8110
+ });
8111
+ const issue = response.data;
8112
+ if ("pull_request" in issue) {
8113
+ return [];
8114
+ }
8115
+ const normalizedIssue = normalizeGitHubIssueRecord(issue);
8116
+ const issues = state === "open" && normalizedIssue.state !== "open" ? [] : [normalizedIssue];
8117
+ if (options.onProgress) {
8118
+ await options.onProgress({
8119
+ loadedIssueCount: issues.length
8120
+ });
8121
+ }
8122
+ return issues;
8123
+ }
7746
8124
  async function listRepositoryIssuesForImport(allIssues) {
7747
8125
  return sortIssuesForImport(allIssues.filter((issue) => issue.state === "open"));
7748
8126
  }
@@ -8565,6 +8943,20 @@ function normalizeOptionalToolString(value) {
8565
8943
  function normalizeToolPositiveInteger(value) {
8566
8944
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
8567
8945
  }
8946
+ function normalizePositiveIntegerReference(value) {
8947
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
8948
+ return value;
8949
+ }
8950
+ if (typeof value !== "string") {
8951
+ return void 0;
8952
+ }
8953
+ const match = value.trim().match(/^#?(\d+)$/);
8954
+ if (!match) {
8955
+ return void 0;
8956
+ }
8957
+ const parsed = Number(match[1]);
8958
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
8959
+ }
8568
8960
  function normalizeToolStringArray(value) {
8569
8961
  if (!Array.isArray(value)) {
8570
8962
  return [];
@@ -8722,12 +9114,34 @@ async function handleCompanyMetricApiRoute(ctx, input) {
8722
9114
  const pullRequestNumber = normalizeToolPositiveInteger(payload.pullRequestNumber);
8723
9115
  const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(payload.pullRequestUrl));
8724
9116
  const eventKey = normalizeOptionalString2(payload.eventKey);
9117
+ const paperclipIssueId = normalizeOptionalString2(payload.paperclipIssueId);
9118
+ let linkedPaperclipIssueId;
9119
+ let linkedRepository = null;
9120
+ let linkedPullRequestNumber;
9121
+ let linkedPullRequestUrl;
9122
+ if (paperclipIssueId) {
9123
+ const linkResult = await linkPaperclipIssueToGitHubPullRequest(ctx, {
9124
+ companyId,
9125
+ issueId: paperclipIssueId,
9126
+ repositoryUrl: repository?.url,
9127
+ pullRequestNumber,
9128
+ pullRequestUrl
9129
+ });
9130
+ linkedPaperclipIssueId = typeof linkResult.paperclipIssueId === "string" ? linkResult.paperclipIssueId : paperclipIssueId;
9131
+ const resultRepositoryUrl = normalizeOptionalString2(linkResult.repositoryUrl);
9132
+ linkedRepository = resultRepositoryUrl ? parseRepositoryReference(resultRepositoryUrl) : null;
9133
+ linkedPullRequestNumber = normalizeToolPositiveInteger(linkResult.githubPullRequestNumber);
9134
+ linkedPullRequestUrl = normalizeGitHubPullRequestHtmlUrl(normalizeOptionalString2(linkResult.githubPullRequestUrl));
9135
+ }
9136
+ const metricRepositoryUrl = repository?.url ?? linkedRepository?.url;
9137
+ const metricPullRequestNumber = pullRequestNumber ?? linkedPullRequestNumber;
9138
+ const metricPullRequestUrl = pullRequestUrl ?? linkedPullRequestUrl;
8725
9139
  const dedupeKey = buildCompanyMetricEventKey({
8726
9140
  metric,
8727
9141
  eventKey,
8728
- repositoryUrl: repository?.url,
8729
- pullRequestNumber,
8730
- pullRequestUrl
9142
+ repositoryUrl: metricRepositoryUrl,
9143
+ pullRequestNumber: metricPullRequestNumber,
9144
+ pullRequestUrl: metricPullRequestUrl
8731
9145
  });
8732
9146
  if (!dedupeKey) {
8733
9147
  throw new Error(
@@ -8742,9 +9156,9 @@ async function handleCompanyMetricApiRoute(ctx, input) {
8742
9156
  count: normalizeToolPositiveInteger(payload.count),
8743
9157
  occurredAt: normalizeOptionalString2(payload.occurredAt),
8744
9158
  eventKey,
8745
- repositoryUrl: repository?.url,
8746
- pullRequestNumber,
8747
- pullRequestUrl
9159
+ repositoryUrl: metricRepositoryUrl,
9160
+ pullRequestNumber: metricPullRequestNumber,
9161
+ pullRequestUrl: metricPullRequestUrl
8748
9162
  },
8749
9163
  {
8750
9164
  throwOnPersistFailure: true
@@ -8756,9 +9170,10 @@ async function handleCompanyMetricApiRoute(ctx, input) {
8756
9170
  routeKey: input.routeKey,
8757
9171
  companyId,
8758
9172
  metric,
8759
- repositoryUrl: repository?.url,
8760
- pullRequestNumber,
8761
- pullRequestUrl,
9173
+ repositoryUrl: metricRepositoryUrl,
9174
+ pullRequestNumber: metricPullRequestNumber,
9175
+ pullRequestUrl: metricPullRequestUrl,
9176
+ linkedPaperclipIssueId: linkedPaperclipIssueId ?? null,
8762
9177
  agentId: input.actor.agentId ?? null,
8763
9178
  runId: input.actor.runId ?? null
8764
9179
  }
@@ -8769,7 +9184,8 @@ async function handleCompanyMetricApiRoute(ctx, input) {
8769
9184
  status: recordedMetric.recorded ? "recorded" : "duplicate",
8770
9185
  recorded: recordedMetric.recorded,
8771
9186
  companyId,
8772
- metric: "pull_request_created"
9187
+ metric: "pull_request_created",
9188
+ ...linkedPaperclipIssueId ? { paperclipIssueId: linkedPaperclipIssueId } : {}
8773
9189
  }
8774
9190
  };
8775
9191
  }
@@ -11966,10 +12382,11 @@ async function performSync(ctx, trigger, options = {}) {
11966
12382
  updateSyncFailureContext(failureContext, {
11967
12383
  phase: "listing_github_issues"
11968
12384
  });
11969
- const allIssues = await listRepositoryIssues(
12385
+ const allIssues = await listRepositoryIssuesForSyncTarget(
11970
12386
  octokit,
11971
12387
  repository,
11972
12388
  shouldLoadClosedIssues ? "all" : "open",
12389
+ options.target,
11973
12390
  {
11974
12391
  onProgress: async (progress) => {
11975
12392
  currentProgress = {
@@ -12092,7 +12509,7 @@ async function performSync(ctx, trigger, options = {}) {
12092
12509
  };
12093
12510
  await persistRunningProgress(true);
12094
12511
  try {
12095
- const warmedLinkedPullRequests = await loadLinkedPullRequestsForOpenIssues(octokit, repository);
12512
+ const warmedLinkedPullRequests = options.target?.kind === "issue" ? /* @__PURE__ */ new Map() : await loadLinkedPullRequestsForOpenIssues(octokit, repository);
12096
12513
  for (const [issueNumber, linkedPullRequests] of warmedLinkedPullRequests.entries()) {
12097
12514
  linkedPullRequestsByIssueNumber.set(issueNumber, linkedPullRequests);
12098
12515
  }
@@ -12727,7 +13144,13 @@ function registerGitHubAgentTools(ctx) {
12727
13144
  getGitHubAgentToolDeclaration("create_pull_request"),
12728
13145
  async (params, runCtx) => executeGitHubTool(async () => {
12729
13146
  const input = getToolInputRecord(params);
12730
- const repository = await resolveGitHubToolRepository(ctx, runCtx, input);
13147
+ const paperclipIssueId = normalizeOptionalToolString(input.paperclipIssueId);
13148
+ const explicitRepository = normalizeOptionalToolString(input.repository);
13149
+ const issueLinkScope = paperclipIssueId && !explicitRepository ? await resolveIssueGitHubLinkMapping(ctx, {
13150
+ companyId: runCtx.companyId,
13151
+ issueId: paperclipIssueId
13152
+ }) : null;
13153
+ const repository = issueLinkScope?.repository ?? await resolveGitHubToolRepository(ctx, runCtx, input);
12731
13154
  const head = normalizeOptionalToolString(input.head);
12732
13155
  const base = normalizeOptionalToolString(input.base);
12733
13156
  const title = normalizeOptionalToolString(input.title);
@@ -12752,6 +13175,32 @@ function registerGitHubAgentTools(ctx) {
12752
13175
  "X-GitHub-Api-Version": GITHUB_API_VERSION
12753
13176
  }
12754
13177
  });
13178
+ if (paperclipIssueId) {
13179
+ const linkScope = issueLinkScope ?? await resolveIssueGitHubLinkMapping(ctx, {
13180
+ companyId: runCtx.companyId,
13181
+ issueId: paperclipIssueId,
13182
+ repositoryUrl: repository.url
13183
+ });
13184
+ const pullRequestUrl = normalizeGitHubPullRequestHtmlUrl(response.data.html_url) ?? `${repository.url}/pull/${response.data.number}`;
13185
+ await upsertGitHubPullRequestLinkRecord(ctx, {
13186
+ companyId: runCtx.companyId,
13187
+ projectId: linkScope.mapping.paperclipProjectId ?? linkScope.projectId,
13188
+ issueId: paperclipIssueId,
13189
+ repositoryUrl: repository.url,
13190
+ pullRequestNumber: response.data.number,
13191
+ pullRequestUrl,
13192
+ pullRequestTitle: response.data.title || title,
13193
+ pullRequestState: getGitHubPullRequestStateForLink({
13194
+ state: response.data.state,
13195
+ merged: false
13196
+ })
13197
+ });
13198
+ invalidateProjectPullRequestCaches({
13199
+ companyId: runCtx.companyId,
13200
+ projectId: linkScope.mapping.paperclipProjectId ?? linkScope.projectId,
13201
+ repository
13202
+ });
13203
+ }
12755
13204
  await persistCompanyActivityMetricEvent(
12756
13205
  ctx,
12757
13206
  {
@@ -13346,6 +13795,34 @@ var plugin = definePlugin({
13346
13795
  const record = input && typeof input === "object" ? input : {};
13347
13796
  return buildCommentAnnotationData(ctx, record);
13348
13797
  });
13798
+ ctx.actions.register("issue.linkGitHubItem", async (input) => {
13799
+ const record = input && typeof input === "object" ? input : {};
13800
+ const kind = normalizeIssueGitHubLinkKind(record.kind);
13801
+ if (!kind) {
13802
+ throw new Error('kind must be "issue" or "pull_request".');
13803
+ }
13804
+ const companyId = normalizeCompanyId(record.companyId);
13805
+ const issueId = normalizeOptionalString2(record.issueId);
13806
+ if (!companyId || !issueId) {
13807
+ throw new Error("companyId and issueId are required.");
13808
+ }
13809
+ return kind === "issue" ? linkPaperclipIssueToGitHubIssue(ctx, {
13810
+ companyId,
13811
+ issueId,
13812
+ reference: record.reference,
13813
+ repositoryUrl: normalizeOptionalString2(record.repository),
13814
+ issueNumber: record.issueNumber,
13815
+ requireUnlinked: true
13816
+ }) : linkPaperclipIssueToGitHubPullRequest(ctx, {
13817
+ companyId,
13818
+ issueId,
13819
+ reference: record.reference,
13820
+ repositoryUrl: normalizeOptionalString2(record.repository),
13821
+ pullRequestNumber: record.pullRequestNumber,
13822
+ pullRequestUrl: record.pullRequestUrl,
13823
+ requireUnlinked: true
13824
+ });
13825
+ });
13349
13826
  ctx.actions.register("settings.saveRegistration", async (input) => {
13350
13827
  const previous = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
13351
13828
  const config = await getResolvedConfig(ctx);