paperclip-github-plugin 0.6.0 → 0.7.0
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 +16 -14
- package/dist/manifest.js +14 -10
- package/dist/worker.js +378 -173
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -44,11 +44,11 @@ The plugin adds a full in-host workflow instead of a one-off import script:
|
|
|
44
44
|
2. Connect one or more GitHub repositories to Paperclip projects.
|
|
45
45
|
3. Run a sync manually or let the scheduled job keep things up to date.
|
|
46
46
|
|
|
47
|
-
During sync, the plugin imports one top-level Paperclip issue per GitHub issue, updates already imported issues instead of recreating them, maps GitHub labels into Paperclip labels, and keeps GitHub-specific metadata in dedicated Paperclip surfaces rather than stuffing everything into the issue description.
|
|
47
|
+
During sync, the plugin imports one top-level Paperclip issue per GitHub issue, stamps it with a namespaced GitHub Sync plugin origin, updates already imported issues instead of recreating them, maps GitHub labels into Paperclip labels, and keeps GitHub-specific metadata in dedicated Paperclip surfaces rather than stuffing everything into the issue description.
|
|
48
48
|
|
|
49
49
|
When the host exposes plugin issue creation, imported GitHub issues are created through the Paperclip plugin SDK path so they are not attributed to the connected board user. The worker still uses direct local Paperclip REST calls for label sync and for description, assignee, or status repair paths when those routes are available.
|
|
50
50
|
|
|
51
|
-
Long-running syncs continue in the background, so quick actions do not have to wait for the whole import to finish. Once a sync has started, the settings page, dashboard widget, and toolbar actions can request cancellation; the worker stops cooperatively after the current repository or issue step finishes. If the worker restarts mid-run, GitHub Sync now recovers that orphaned `running` state on the next read or control action instead of leaving the UI stuck in `running` or silently restarting the old run.
|
|
51
|
+
Long-running syncs continue in the background, so quick actions do not have to wait for the whole import to finish. Once a sync has started, the settings page, dashboard widget, and toolbar actions can request cancellation; the worker stops cooperatively after the current repository or issue step finishes. If the worker restarts mid-run, GitHub Sync now recovers that orphaned `running` state on the next read or control action instead of leaving the UI stuck in `running` or silently restarting the old run. When sync needs to wake an assigned agent, it uses Paperclip's host-owned issue wakeup API first so blocker, liveness, budget, and auth checks stay centralized, then falls back to the local wakeup route only when an older or partial host bridge cannot service the SDK call.
|
|
52
52
|
|
|
53
53
|
## Highlights
|
|
54
54
|
|
|
@@ -68,9 +68,9 @@ The plugin does more than mirror issue text. It looks at linked pull requests, m
|
|
|
68
68
|
|
|
69
69
|
GitHub Sync exposes a dedicated KPI dashboard widget alongside the operational sync widget. During full company syncs, the worker snapshots the current open GitHub backlog and records when already-imported GitHub issues move from open to closed. The KPI widget turns that worker-owned state into backlog, issue-closure, and Paperclip PR-creation cards with recent history and comparisons against older periods.
|
|
70
70
|
|
|
71
|
-
Because GitHub alone cannot tell which pull requests came from a Paperclip company, the plugin uses explicit Paperclip attribution for delivery activity. `create_pull_request` automatically records a Paperclip-created PR event, and agents that use `gh` or another non-plugin GitHub client can post pull-request-created events to the plugin
|
|
71
|
+
Because GitHub alone cannot tell which pull requests came from a Paperclip company, the plugin uses explicit Paperclip attribution for delivery activity. `create_pull_request` automatically records a Paperclip-created PR event, and agents that use `gh` or another non-plugin GitHub client can post pull-request-created events to the plugin API route so the KPI history stays specific to Paperclip work.
|
|
72
72
|
|
|
73
|
-
That
|
|
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
75
|
### Project pull request command center
|
|
76
76
|
|
|
@@ -78,6 +78,8 @@ Each mapped project can expose a **Pull Requests** entry in the sidebar that ope
|
|
|
78
78
|
|
|
79
79
|
Paperclip issue linkage on the queue prefers the GitHub issue that the pull request closes, so imported GitHub issues and delivery work stay connected in the same project view. If a pull request has no closing-issue-backed link yet, the queue falls back to the Paperclip issue created directly from that pull request and updates the table immediately when that create action returns.
|
|
80
80
|
|
|
81
|
+
Those pull-request-created Paperclip issues also stay in the scheduled/manual sync loop even when the pull request does not close a GitHub issue. GitHub Sync checks their CI, merge state, and review threads so new failures or unresolved feedback move the Paperclip issue back into active work.
|
|
82
|
+
|
|
81
83
|
The issue detail panel and sync-created comment annotations also preserve cross-repository linked pull requests, showing those PRs with their real repository path so operators land in the right place on GitHub.
|
|
82
84
|
|
|
83
85
|
### Agent workflows built in
|
|
@@ -87,7 +89,7 @@ Paperclip agents can search GitHub for duplicates, read and update issues, post
|
|
|
87
89
|
## Requirements
|
|
88
90
|
|
|
89
91
|
- Node.js 20+
|
|
90
|
-
- a Paperclip host
|
|
92
|
+
- a Paperclip host on `2026.427.0` or newer with plugin installation enabled
|
|
91
93
|
- a GitHub token with API access to the repositories you want to sync
|
|
92
94
|
|
|
93
95
|
## Install from npm
|
|
@@ -176,7 +178,7 @@ The plugin is designed to avoid persisting raw credentials in plugin state.
|
|
|
176
178
|
- On authenticated deployments, any selected propagation agents receive `GITHUB_TOKEN` as an agent env secret-ref binding that points at the same saved GitHub token secret instead of a copied raw token.
|
|
177
179
|
- The worker resolves those secret references at runtime instead of storing raw tokens in plugin state.
|
|
178
180
|
- On authenticated Paperclip deployments, sync is blocked until the relevant company has connected Paperclip board access.
|
|
179
|
-
- KPI
|
|
181
|
+
- KPI API route requests must include `Authorization: Bearer <PAPERCLIP_API_KEY>` from an agent run; the Paperclip host authenticates the token and supplies the agent company before the worker records any metric event.
|
|
180
182
|
|
|
181
183
|
### Optional worker-local token file
|
|
182
184
|
|
|
@@ -206,14 +208,14 @@ The plugin exposes GitHub workflow tools to Paperclip agents, including:
|
|
|
206
208
|
|
|
207
209
|
When an agent sends GitHub body content through the plugin, including issue bodies, pull request descriptions, comments, and review-thread replies, the plugin adds a GitHub-flavored Markdown footer with a horizontal rule and compact heading that discloses AI authorship. If the tool caller supplies `llmModel`, the footer also includes the model name, for example `###### ✨ This comment was AI-generated using gpt-5.4`.
|
|
208
210
|
|
|
209
|
-
### KPI attribution
|
|
211
|
+
### KPI attribution API route
|
|
210
212
|
|
|
211
|
-
The `create_pull_request` tool automatically records a company-level Paperclip PR creation metric. For delivery flows that use `gh` or another non-plugin GitHub client, post a JSON payload to `/api/plugins/paperclip-github-plugin/
|
|
213
|
+
The `create_pull_request` tool automatically records a company-level Paperclip PR creation metric. For delivery flows that use `gh` or another non-plugin GitHub client, post a JSON payload to `/api/plugins/paperclip-github-plugin/api/company-metrics/events` after the PR is created.
|
|
212
214
|
|
|
213
215
|
Supported payload fields:
|
|
214
216
|
|
|
215
217
|
- `metric` required: `pull_request_created`
|
|
216
|
-
- `companyId` optional when
|
|
218
|
+
- `companyId` optional; when present it must match the authenticated agent's company
|
|
217
219
|
- `repository` optional: `owner/repo` or `https://github.com/owner/repo`
|
|
218
220
|
- `pullRequestNumber` optional
|
|
219
221
|
- `pullRequestUrl` optional
|
|
@@ -221,18 +223,18 @@ Supported payload fields:
|
|
|
221
223
|
- `eventKey` optional custom dedupe key
|
|
222
224
|
- `count` optional positive integer
|
|
223
225
|
|
|
224
|
-
Each request must include:
|
|
226
|
+
Each request must be made by a Paperclip agent run and include:
|
|
225
227
|
|
|
226
228
|
- `Authorization: Bearer <PAPERCLIP_API_KEY>`
|
|
227
229
|
|
|
228
|
-
The
|
|
230
|
+
The Paperclip host validates that bearer token and passes the authenticated agent company to the plugin worker. Requests are rejected before worker dispatch when the token is missing, invalid, expired, or not an agent token.
|
|
229
231
|
|
|
230
232
|
Example:
|
|
231
233
|
|
|
232
234
|
```bash
|
|
233
235
|
payload='{"metric":"pull_request_created","repository":"paperclipai/example-repo","pullRequestNumber":21}'
|
|
234
236
|
|
|
235
|
-
curl -X POST "${PAPERCLIP_API_URL%/}/api/plugins/paperclip-github-plugin/
|
|
237
|
+
curl -X POST "${PAPERCLIP_API_URL%/}/api/plugins/paperclip-github-plugin/api/company-metrics/events" \
|
|
236
238
|
-H "content-type: application/json" \
|
|
237
239
|
-H "authorization: Bearer ${PAPERCLIP_API_KEY}" \
|
|
238
240
|
-d "${payload}"
|
|
@@ -242,14 +244,14 @@ The worker deduplicates repeated PR events by preferring the pull request URL, t
|
|
|
242
244
|
|
|
243
245
|
Current host caveat: on authenticated Paperclip deployments, the Paperclip host currently guards `GET /api/plugins/tools` and `POST /api/plugins/tools/execute` with board authentication before dispatching to any plugin worker. If an agent run does not have board access for the target company, GitHub Sync tool discovery and execution fail with `403 {"error":"Board access required"}` before this plugin's worker code runs.
|
|
244
246
|
|
|
245
|
-
Because the KPI attribution endpoint is a
|
|
247
|
+
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.
|
|
246
248
|
|
|
247
249
|
## Troubleshooting
|
|
248
250
|
|
|
249
251
|
- 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.
|
|
250
252
|
- If Paperclip says board access is required, open plugin settings inside the affected company and complete the Paperclip board access flow before retrying sync.
|
|
251
253
|
- 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.
|
|
252
|
-
- If a KPI
|
|
254
|
+
- 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.
|
|
253
255
|
- If the worker reaches an authenticated HTML page instead of the Paperclip API JSON responses it expects, connect Paperclip board access for that company or set `PAPERCLIP_API_URL` to a worker-accessible Paperclip API origin.
|
|
254
256
|
- If a sync run finishes with partial failures, open the saved troubleshooting panel in GitHub Sync to inspect the repository, issue number, raw error, and suggested fix for each recorded failure.
|
|
255
257
|
- If sync says the Paperclip API URL is not trusted, reopen the plugin from the current Paperclip host so the settings UI can refresh the saved origin, or set `PAPERCLIP_API_URL` for the worker.
|
package/dist/manifest.js
CHANGED
|
@@ -508,19 +508,20 @@ var GITHUB_AGENT_TOOLS = [
|
|
|
508
508
|
|
|
509
509
|
// src/kpi-contract.ts
|
|
510
510
|
var GITHUB_SYNC_PLUGIN_ID = "paperclip-github-plugin";
|
|
511
|
-
var
|
|
512
|
-
var
|
|
511
|
+
var COMPANY_METRIC_API_ROUTE_KEY = "record-company-metric-event";
|
|
512
|
+
var COMPANY_METRIC_API_ROUTE_PATH = "/company-metrics/events";
|
|
513
|
+
var COMPANY_METRIC_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${COMPANY_METRIC_API_ROUTE_PATH}`;
|
|
513
514
|
|
|
514
515
|
// src/manifest.ts
|
|
515
516
|
var require2 = createRequire(import.meta.url);
|
|
516
517
|
var packageJson = require2("../package.json");
|
|
517
|
-
var DASHBOARD_WIDGET_CAPABILITY = "ui.dashboardWidget.register";
|
|
518
518
|
var SCHEDULE_TICK_CRON = "* * * * *";
|
|
519
|
-
var MANIFEST_VERSION = "0.
|
|
519
|
+
var MANIFEST_VERSION = "0.7.0"?.trim() || typeof packageJson.version === "string" && packageJson.version.trim() || process.env.npm_package_version?.trim() || "0.0.0-dev";
|
|
520
520
|
var manifest = {
|
|
521
521
|
id: GITHUB_SYNC_PLUGIN_ID,
|
|
522
522
|
apiVersion: 1,
|
|
523
523
|
version: MANIFEST_VERSION,
|
|
524
|
+
minimumHostVersion: "2026.427.0",
|
|
524
525
|
displayName: "GitHub Sync",
|
|
525
526
|
description: "Synchronize GitHub issues into Paperclip projects.",
|
|
526
527
|
author: "\xC1lvaro S\xE1nchez-Mariscal",
|
|
@@ -528,7 +529,7 @@ var manifest = {
|
|
|
528
529
|
capabilities: [
|
|
529
530
|
"ui.sidebar.register",
|
|
530
531
|
"ui.page.register",
|
|
531
|
-
|
|
532
|
+
"ui.dashboardWidget.register",
|
|
532
533
|
"ui.detailTab.register",
|
|
533
534
|
"ui.commentAnnotation.register",
|
|
534
535
|
"ui.action.register",
|
|
@@ -539,11 +540,12 @@ var manifest = {
|
|
|
539
540
|
"issues.read",
|
|
540
541
|
"issues.create",
|
|
541
542
|
"issues.update",
|
|
543
|
+
"issues.wakeup",
|
|
542
544
|
"issue.comments.read",
|
|
543
545
|
"issue.comments.create",
|
|
544
546
|
"agents.read",
|
|
545
547
|
"jobs.schedule",
|
|
546
|
-
"
|
|
548
|
+
"api.routes.register",
|
|
547
549
|
"http.outbound",
|
|
548
550
|
"secrets.read-ref",
|
|
549
551
|
"agent.tools.register"
|
|
@@ -579,11 +581,13 @@ var manifest = {
|
|
|
579
581
|
schedule: SCHEDULE_TICK_CRON
|
|
580
582
|
}
|
|
581
583
|
],
|
|
582
|
-
|
|
584
|
+
apiRoutes: [
|
|
583
585
|
{
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
586
|
+
routeKey: COMPANY_METRIC_API_ROUTE_KEY,
|
|
587
|
+
method: "POST",
|
|
588
|
+
path: COMPANY_METRIC_API_ROUTE_PATH,
|
|
589
|
+
auth: "agent",
|
|
590
|
+
capability: "api.routes.register"
|
|
587
591
|
}
|
|
588
592
|
],
|
|
589
593
|
tools: GITHUB_AGENT_TOOLS,
|
package/dist/worker.js
CHANGED
|
@@ -563,9 +563,9 @@ function parseRepositoryReference(repositoryInput) {
|
|
|
563
563
|
|
|
564
564
|
// src/kpi-contract.ts
|
|
565
565
|
var GITHUB_SYNC_PLUGIN_ID = "paperclip-github-plugin";
|
|
566
|
-
var
|
|
567
|
-
var
|
|
568
|
-
var
|
|
566
|
+
var COMPANY_METRIC_API_ROUTE_KEY = "record-company-metric-event";
|
|
567
|
+
var COMPANY_METRIC_API_ROUTE_PATH = "/company-metrics/events";
|
|
568
|
+
var COMPANY_METRIC_API_ROUTE_URL_PATH = `/api/plugins/${GITHUB_SYNC_PLUGIN_ID}/api${COMPANY_METRIC_API_ROUTE_PATH}`;
|
|
569
569
|
|
|
570
570
|
// src/paperclip-health.ts
|
|
571
571
|
function normalizeOptionalString(value) {
|
|
@@ -594,6 +594,8 @@ function requiresPaperclipBoardAccess(value) {
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
// src/worker.ts
|
|
597
|
+
var GITHUB_ISSUE_ORIGIN_KIND = `plugin:${GITHUB_SYNC_PLUGIN_ID}:github-issue`;
|
|
598
|
+
var GITHUB_PULL_REQUEST_ORIGIN_KIND = `plugin:${GITHUB_SYNC_PLUGIN_ID}:github-pull-request`;
|
|
597
599
|
var SETTINGS_SCOPE = {
|
|
598
600
|
scopeKind: "instance",
|
|
599
601
|
stateKey: "paperclip-github-plugin-settings"
|
|
@@ -4262,6 +4264,12 @@ function countImportedIssuesForMappings(importRegistry, mappings) {
|
|
|
4262
4264
|
function buildTrackedIssueProgressKey(mapping, githubIssueId) {
|
|
4263
4265
|
return `${mapping.id}:${githubIssueId}`;
|
|
4264
4266
|
}
|
|
4267
|
+
function buildTrackedPullRequestIssueProgressKey(mapping, record) {
|
|
4268
|
+
return `${mapping.id}:pull-request:${record.paperclipIssueId}:${buildGitHubPullRequestReferenceKey({
|
|
4269
|
+
number: record.data.githubPullRequestNumber,
|
|
4270
|
+
repositoryUrl: record.data.repositoryUrl
|
|
4271
|
+
})}`;
|
|
4272
|
+
}
|
|
4265
4273
|
function buildImportedIssueRecord(mapping, issue, paperclipIssueId, importedAt) {
|
|
4266
4274
|
return {
|
|
4267
4275
|
mappingId: mapping.id,
|
|
@@ -5104,6 +5112,27 @@ function buildSyncFallbackExecutionStatePatch(params) {
|
|
|
5104
5112
|
}
|
|
5105
5113
|
return void 0;
|
|
5106
5114
|
}
|
|
5115
|
+
function describeGitHubLinkedPullRequestsStatusReason(linkedPullRequests) {
|
|
5116
|
+
const linkedPullRequestSubject = linkedPullRequests.length === 1 ? "the linked pull request" : "linked pull requests";
|
|
5117
|
+
const linkedPullRequestVerb = linkedPullRequests.length === 1 ? "has" : "have";
|
|
5118
|
+
const blockingConditions = [...new Set(
|
|
5119
|
+
linkedPullRequests.flatMap((pullRequest) => listGitHubPullRequestSyncBlockingConditions(pullRequest))
|
|
5120
|
+
)];
|
|
5121
|
+
const hasUnfinishedCi = linkedPullRequests.some((pullRequest) => pullRequest.ciState === "unfinished");
|
|
5122
|
+
const hasUnknownMergeability = linkedPullRequests.some(
|
|
5123
|
+
(pullRequest) => pullRequest.mergeStateStatus === "unknown"
|
|
5124
|
+
);
|
|
5125
|
+
if (blockingConditions.length > 0) {
|
|
5126
|
+
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} ${formatPlainTextList(blockingConditions)}`;
|
|
5127
|
+
}
|
|
5128
|
+
if (hasUnfinishedCi) {
|
|
5129
|
+
return `${linkedPullRequestSubject} still ${linkedPullRequestVerb} unfinished CI jobs`;
|
|
5130
|
+
}
|
|
5131
|
+
if (hasUnknownMergeability) {
|
|
5132
|
+
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} unknown mergeability`;
|
|
5133
|
+
}
|
|
5134
|
+
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} green CI with all review threads resolved`;
|
|
5135
|
+
}
|
|
5107
5136
|
function describeGitHubStatusTransitionReason(params) {
|
|
5108
5137
|
const { snapshot, hasTrustedNewComment, maintainerAuthoredImportedIssue } = params;
|
|
5109
5138
|
if (snapshot.state === "closed") {
|
|
@@ -5125,25 +5154,7 @@ function describeGitHubStatusTransitionReason(params) {
|
|
|
5125
5154
|
}
|
|
5126
5155
|
return "the GitHub issue is open with no linked pull requests";
|
|
5127
5156
|
}
|
|
5128
|
-
|
|
5129
|
-
const linkedPullRequestVerb = snapshot.linkedPullRequests.length === 1 ? "has" : "have";
|
|
5130
|
-
const blockingConditions = [...new Set(
|
|
5131
|
-
snapshot.linkedPullRequests.flatMap((pullRequest) => listGitHubPullRequestSyncBlockingConditions(pullRequest))
|
|
5132
|
-
)];
|
|
5133
|
-
const hasUnfinishedCi = snapshot.linkedPullRequests.some((pullRequest) => pullRequest.ciState === "unfinished");
|
|
5134
|
-
const hasUnknownMergeability = snapshot.linkedPullRequests.some(
|
|
5135
|
-
(pullRequest) => pullRequest.mergeStateStatus === "unknown"
|
|
5136
|
-
);
|
|
5137
|
-
if (blockingConditions.length > 0) {
|
|
5138
|
-
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} ${formatPlainTextList(blockingConditions)}`;
|
|
5139
|
-
}
|
|
5140
|
-
if (hasUnfinishedCi) {
|
|
5141
|
-
return `${linkedPullRequestSubject} still ${linkedPullRequestVerb} unfinished CI jobs`;
|
|
5142
|
-
}
|
|
5143
|
-
if (hasUnknownMergeability) {
|
|
5144
|
-
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} unknown mergeability`;
|
|
5145
|
-
}
|
|
5146
|
-
return `${linkedPullRequestSubject} ${linkedPullRequestVerb} green CI with all review threads resolved`;
|
|
5157
|
+
return describeGitHubLinkedPullRequestsStatusReason(snapshot.linkedPullRequests);
|
|
5147
5158
|
}
|
|
5148
5159
|
function buildStatusTransitionCommentAnnotation(params) {
|
|
5149
5160
|
const { repository, snapshot, previousStatus, nextStatus, reason } = params;
|
|
@@ -5186,6 +5197,10 @@ function buildPaperclipIssueStatusTransitionComment(params) {
|
|
|
5186
5197
|
})
|
|
5187
5198
|
};
|
|
5188
5199
|
}
|
|
5200
|
+
function buildPaperclipPullRequestIssueStatusTransitionComment(params) {
|
|
5201
|
+
const reason = describeGitHubLinkedPullRequestsStatusReason([params.pullRequest]);
|
|
5202
|
+
return `GitHub Sync updated the status from \`${formatPaperclipIssueStatus(params.previousStatus)}\` to \`${formatPaperclipIssueStatus(params.nextStatus)}\` because ${reason}.`;
|
|
5203
|
+
}
|
|
5189
5204
|
function resolvePaperclipIssueStatus(params) {
|
|
5190
5205
|
const {
|
|
5191
5206
|
currentStatus,
|
|
@@ -5218,6 +5233,15 @@ function resolvePaperclipIssueStatus(params) {
|
|
|
5218
5233
|
}
|
|
5219
5234
|
return currentStatus;
|
|
5220
5235
|
}
|
|
5236
|
+
function resolvePaperclipPullRequestIssueStatus(params) {
|
|
5237
|
+
const { currentStatus, pullRequest, hasExecutorHandoffTarget } = params;
|
|
5238
|
+
if (currentStatus === "done" || currentStatus === "cancelled") {
|
|
5239
|
+
return currentStatus;
|
|
5240
|
+
}
|
|
5241
|
+
return resolvePaperclipStatusFromLinkedPullRequests([pullRequest], {
|
|
5242
|
+
preferInProgress: hasExecutorHandoffTarget
|
|
5243
|
+
});
|
|
5244
|
+
}
|
|
5221
5245
|
async function listLinkedPullRequestsForIssue(octokit, repository, issueNumber) {
|
|
5222
5246
|
const linkedPullRequests = [];
|
|
5223
5247
|
const seenPullRequestKeys = /* @__PURE__ */ new Set();
|
|
@@ -6531,6 +6555,52 @@ async function listGitHubPullRequestLinkRecords(ctx, query = {}) {
|
|
|
6531
6555
|
}
|
|
6532
6556
|
return records;
|
|
6533
6557
|
}
|
|
6558
|
+
function doesGitHubPullRequestLinkRecordMatchMapping(record, mapping) {
|
|
6559
|
+
if (record.data.repositoryUrl !== getNormalizedMappingRepositoryUrl(mapping)) {
|
|
6560
|
+
return false;
|
|
6561
|
+
}
|
|
6562
|
+
if (record.data.companyId && record.data.companyId !== mapping.companyId) {
|
|
6563
|
+
return false;
|
|
6564
|
+
}
|
|
6565
|
+
if (record.data.paperclipProjectId && record.data.paperclipProjectId !== mapping.paperclipProjectId) {
|
|
6566
|
+
return false;
|
|
6567
|
+
}
|
|
6568
|
+
return Boolean(mapping.companyId && mapping.paperclipProjectId);
|
|
6569
|
+
}
|
|
6570
|
+
function doesGitHubPullRequestLinkRecordMatchTarget(record, target) {
|
|
6571
|
+
if (!target) {
|
|
6572
|
+
return true;
|
|
6573
|
+
}
|
|
6574
|
+
switch (target.kind) {
|
|
6575
|
+
case "company":
|
|
6576
|
+
return !record.data.companyId || record.data.companyId === target.companyId;
|
|
6577
|
+
case "project":
|
|
6578
|
+
return (!record.data.companyId || record.data.companyId === target.companyId) && (!record.data.paperclipProjectId || record.data.paperclipProjectId === target.projectId);
|
|
6579
|
+
case "issue":
|
|
6580
|
+
return Boolean(target.issueId && record.paperclipIssueId === target.issueId);
|
|
6581
|
+
default:
|
|
6582
|
+
return true;
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
6585
|
+
async function listGitHubPullRequestIssueLinksForMapping(ctx, mapping, target) {
|
|
6586
|
+
const records = await listGitHubPullRequestLinkRecords(ctx, {
|
|
6587
|
+
...target?.kind === "issue" && target.issueId ? { paperclipIssueId: target.issueId } : {}
|
|
6588
|
+
});
|
|
6589
|
+
const recordsByKey = /* @__PURE__ */ new Map();
|
|
6590
|
+
for (const record of records) {
|
|
6591
|
+
if (!doesGitHubPullRequestLinkRecordMatchMapping(record, mapping) || !doesGitHubPullRequestLinkRecordMatchTarget(record, target)) {
|
|
6592
|
+
continue;
|
|
6593
|
+
}
|
|
6594
|
+
recordsByKey.set(
|
|
6595
|
+
`${record.paperclipIssueId}:${buildGitHubPullRequestReferenceKey({
|
|
6596
|
+
number: record.data.githubPullRequestNumber,
|
|
6597
|
+
repositoryUrl: record.data.repositoryUrl
|
|
6598
|
+
})}`,
|
|
6599
|
+
record
|
|
6600
|
+
);
|
|
6601
|
+
}
|
|
6602
|
+
return [...recordsByKey.values()];
|
|
6603
|
+
}
|
|
6534
6604
|
async function findStoredStatusTransitionCommentAnnotation(ctx, params) {
|
|
6535
6605
|
const issueId = params.issueId.trim();
|
|
6536
6606
|
const commentId = params.commentId.trim();
|
|
@@ -6705,9 +6775,6 @@ function getPaperclipIssueEndpoint(baseUrl, issueId) {
|
|
|
6705
6775
|
function getPaperclipHealthEndpoint(baseUrl) {
|
|
6706
6776
|
return new URL("/api/health", baseUrl).toString();
|
|
6707
6777
|
}
|
|
6708
|
-
function getPaperclipCurrentAgentEndpoint(baseUrl) {
|
|
6709
|
-
return new URL("/api/agents/me", baseUrl).toString();
|
|
6710
|
-
}
|
|
6711
6778
|
function getPaperclipAgentWakeupEndpoint(baseUrl, agentId) {
|
|
6712
6779
|
return new URL(`/api/agents/${agentId}/wakeup`, baseUrl).toString();
|
|
6713
6780
|
}
|
|
@@ -6756,7 +6823,38 @@ async function detectPaperclipBoardAccessRequirement(paperclipApiBaseUrl) {
|
|
|
6756
6823
|
}
|
|
6757
6824
|
}
|
|
6758
6825
|
async function wakePaperclipIssueAssignee(ctx, params) {
|
|
6759
|
-
if (!params.assigneeAgentId
|
|
6826
|
+
if (!params.assigneeAgentId) {
|
|
6827
|
+
return;
|
|
6828
|
+
}
|
|
6829
|
+
if (ctx.issues && typeof ctx.issues.requestWakeup === "function" && params.companyId) {
|
|
6830
|
+
try {
|
|
6831
|
+
await ctx.issues.requestWakeup(params.paperclipIssueId, params.companyId, {
|
|
6832
|
+
reason: params.reason,
|
|
6833
|
+
contextSource: `github-sync.${params.mutation}`,
|
|
6834
|
+
...params.mutation === "import" ? { idempotencyKey: ["github-sync", params.mutation, params.paperclipIssueId].join(":") } : {}
|
|
6835
|
+
});
|
|
6836
|
+
return;
|
|
6837
|
+
} catch (error) {
|
|
6838
|
+
if (!params.paperclipApiBaseUrl) {
|
|
6839
|
+
ctx.logger.warn("GitHub sync could not wake the assignee for a Paperclip issue through the SDK.", {
|
|
6840
|
+
issueId: params.paperclipIssueId,
|
|
6841
|
+
agentId: params.assigneeAgentId,
|
|
6842
|
+
companyId: params.companyId,
|
|
6843
|
+
mutation: params.mutation,
|
|
6844
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6845
|
+
});
|
|
6846
|
+
return;
|
|
6847
|
+
}
|
|
6848
|
+
ctx.logger.warn("GitHub sync could not wake the assignee through the SDK. Falling back to the local Paperclip API.", {
|
|
6849
|
+
issueId: params.paperclipIssueId,
|
|
6850
|
+
agentId: params.assigneeAgentId,
|
|
6851
|
+
companyId: params.companyId,
|
|
6852
|
+
mutation: params.mutation,
|
|
6853
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6854
|
+
});
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6857
|
+
if (!params.paperclipApiBaseUrl) {
|
|
6760
6858
|
return;
|
|
6761
6859
|
}
|
|
6762
6860
|
try {
|
|
@@ -7705,6 +7803,8 @@ async function createPaperclipIssue(ctx, mapping, advancedSettings, issue, avail
|
|
|
7705
7803
|
projectId: mapping.paperclipProjectId,
|
|
7706
7804
|
title,
|
|
7707
7805
|
...description ? { description } : {},
|
|
7806
|
+
originKind: GITHUB_ISSUE_ORIGIN_KIND,
|
|
7807
|
+
originId: normalizeGitHubIssueHtmlUrl(issue.htmlUrl) ?? issue.htmlUrl,
|
|
7708
7808
|
...defaultAssignee?.kind === "agent" ? { assigneeAgentId: defaultAssignee.id } : defaultAssignee?.kind === "user" ? { assigneeUserId: defaultAssignee.id } : {}
|
|
7709
7809
|
});
|
|
7710
7810
|
const ensuredCreatedIssueId = createdIssue.id;
|
|
@@ -8140,6 +8240,171 @@ async function synchronizePaperclipIssueStatuses(ctx, octokit, repository, mappi
|
|
|
8140
8240
|
updatedDescriptionsCount
|
|
8141
8241
|
};
|
|
8142
8242
|
}
|
|
8243
|
+
async function synchronizePaperclipPullRequestIssueStatuses(ctx, octokit, mapping, advancedSettings, pullRequestLinks, paperclipApiBaseUrl, pullRequestStatusCache, syncFailureContext, failures, assertNotCancelled, onProgress) {
|
|
8244
|
+
if (!mapping.companyId || !mapping.paperclipProjectId || !ctx.issues || typeof ctx.issues.get !== "function" || typeof ctx.issues.update !== "function") {
|
|
8245
|
+
return {
|
|
8246
|
+
updatedStatusesCount: 0
|
|
8247
|
+
};
|
|
8248
|
+
}
|
|
8249
|
+
let updatedStatusesCount = 0;
|
|
8250
|
+
let completedIssueCount = 0;
|
|
8251
|
+
const mappingCompanyId = mapping.companyId;
|
|
8252
|
+
const mappingProjectId = mapping.paperclipProjectId;
|
|
8253
|
+
const totalIssueCount = pullRequestLinks.length;
|
|
8254
|
+
const queuedIssueWakeups = [];
|
|
8255
|
+
for (const pullRequestLink of pullRequestLinks) {
|
|
8256
|
+
if (assertNotCancelled) {
|
|
8257
|
+
await assertNotCancelled();
|
|
8258
|
+
}
|
|
8259
|
+
try {
|
|
8260
|
+
const pullRequestRepository = requireRepositoryReference(pullRequestLink.data.repositoryUrl);
|
|
8261
|
+
updateSyncFailureContext(syncFailureContext, {
|
|
8262
|
+
phase: "evaluating_github_status",
|
|
8263
|
+
repositoryUrl: pullRequestRepository.url,
|
|
8264
|
+
githubIssueNumber: void 0
|
|
8265
|
+
});
|
|
8266
|
+
const pullRequestResponse = await octokit.rest.pulls.get({
|
|
8267
|
+
owner: pullRequestRepository.owner,
|
|
8268
|
+
repo: pullRequestRepository.repo,
|
|
8269
|
+
pull_number: pullRequestLink.data.githubPullRequestNumber,
|
|
8270
|
+
headers: {
|
|
8271
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION
|
|
8272
|
+
}
|
|
8273
|
+
});
|
|
8274
|
+
const livePullRequestState = getPullRequestApiState({
|
|
8275
|
+
state: pullRequestResponse.data.state,
|
|
8276
|
+
merged: pullRequestResponse.data.merged
|
|
8277
|
+
}) === "open" ? "open" : "closed";
|
|
8278
|
+
if (livePullRequestState !== pullRequestLink.data.githubPullRequestState || pullRequestResponse.data.html_url !== pullRequestLink.data.githubPullRequestUrl || pullRequestResponse.data.title !== pullRequestLink.data.title) {
|
|
8279
|
+
await upsertGitHubPullRequestLinkRecord(ctx, {
|
|
8280
|
+
companyId: pullRequestLink.data.companyId ?? mappingCompanyId,
|
|
8281
|
+
projectId: pullRequestLink.data.paperclipProjectId ?? mappingProjectId,
|
|
8282
|
+
issueId: pullRequestLink.paperclipIssueId,
|
|
8283
|
+
repositoryUrl: pullRequestRepository.url,
|
|
8284
|
+
pullRequestNumber: pullRequestLink.data.githubPullRequestNumber,
|
|
8285
|
+
pullRequestUrl: pullRequestResponse.data.html_url ?? pullRequestLink.data.githubPullRequestUrl,
|
|
8286
|
+
pullRequestTitle: pullRequestResponse.data.title || pullRequestLink.data.title || `Pull request #${pullRequestLink.data.githubPullRequestNumber}`,
|
|
8287
|
+
pullRequestState: livePullRequestState
|
|
8288
|
+
});
|
|
8289
|
+
}
|
|
8290
|
+
if (livePullRequestState !== "open") {
|
|
8291
|
+
continue;
|
|
8292
|
+
}
|
|
8293
|
+
const pullRequest = await getGitHubPullRequestStatusSnapshot(
|
|
8294
|
+
octokit,
|
|
8295
|
+
pullRequestRepository,
|
|
8296
|
+
pullRequestLink.data.githubPullRequestNumber,
|
|
8297
|
+
pullRequestStatusCache
|
|
8298
|
+
);
|
|
8299
|
+
const paperclipIssue = await ctx.issues.get(pullRequestLink.paperclipIssueId, mapping.companyId);
|
|
8300
|
+
if (!paperclipIssue) {
|
|
8301
|
+
continue;
|
|
8302
|
+
}
|
|
8303
|
+
const paperclipIssueSyncContext = getPaperclipIssueSyncContext(paperclipIssue);
|
|
8304
|
+
const executorTransitionAssignee = resolvePaperclipIssueExecutorAssignee(
|
|
8305
|
+
paperclipIssueSyncContext,
|
|
8306
|
+
advancedSettings
|
|
8307
|
+
);
|
|
8308
|
+
const nextStatus = resolvePaperclipPullRequestIssueStatus({
|
|
8309
|
+
currentStatus: paperclipIssue.status,
|
|
8310
|
+
pullRequest,
|
|
8311
|
+
hasExecutorHandoffTarget: Boolean(executorTransitionAssignee)
|
|
8312
|
+
});
|
|
8313
|
+
const nextTransitionAssignee = resolveSyncTransitionAssignee({
|
|
8314
|
+
nextStatus,
|
|
8315
|
+
syncContext: paperclipIssueSyncContext,
|
|
8316
|
+
advancedSettings
|
|
8317
|
+
});
|
|
8318
|
+
const shouldClearTransitionAssignee = nextStatus === "in_review" && nextTransitionAssignee === null && paperclipIssueSyncContext.assignee !== null;
|
|
8319
|
+
const nextAssigneeChanged = nextTransitionAssignee ? !doesPaperclipIssueAssigneeMatch(paperclipIssueSyncContext.assignee, nextTransitionAssignee.principal) : false;
|
|
8320
|
+
const shouldWakeTransitionAssignee = paperclipIssue.status !== nextStatus && nextTransitionAssignee?.principal.kind === "agent" && isActionablePaperclipIssueStatus(nextStatus) && (nextAssigneeChanged || paperclipIssue.status !== nextStatus);
|
|
8321
|
+
if (paperclipIssue.status === nextStatus) {
|
|
8322
|
+
if (shouldClearTransitionAssignee) {
|
|
8323
|
+
updateSyncFailureContext(syncFailureContext, {
|
|
8324
|
+
phase: "updating_paperclip_status",
|
|
8325
|
+
repositoryUrl: pullRequestRepository.url,
|
|
8326
|
+
githubIssueNumber: void 0
|
|
8327
|
+
});
|
|
8328
|
+
await updatePaperclipIssueState(ctx, {
|
|
8329
|
+
companyId: mapping.companyId,
|
|
8330
|
+
issueId: pullRequestLink.paperclipIssueId,
|
|
8331
|
+
currentStatus: paperclipIssue.status,
|
|
8332
|
+
syncContext: paperclipIssueSyncContext,
|
|
8333
|
+
nextStatus,
|
|
8334
|
+
clearAssignee: true,
|
|
8335
|
+
transitionComment: "",
|
|
8336
|
+
paperclipApiBaseUrl
|
|
8337
|
+
});
|
|
8338
|
+
}
|
|
8339
|
+
continue;
|
|
8340
|
+
}
|
|
8341
|
+
const transitionComment = buildPaperclipPullRequestIssueStatusTransitionComment({
|
|
8342
|
+
previousStatus: paperclipIssue.status,
|
|
8343
|
+
nextStatus,
|
|
8344
|
+
pullRequest
|
|
8345
|
+
});
|
|
8346
|
+
updateSyncFailureContext(syncFailureContext, {
|
|
8347
|
+
phase: "updating_paperclip_status",
|
|
8348
|
+
repositoryUrl: pullRequestRepository.url,
|
|
8349
|
+
githubIssueNumber: void 0
|
|
8350
|
+
});
|
|
8351
|
+
await updatePaperclipIssueState(ctx, {
|
|
8352
|
+
companyId: mapping.companyId,
|
|
8353
|
+
issueId: pullRequestLink.paperclipIssueId,
|
|
8354
|
+
currentStatus: paperclipIssue.status,
|
|
8355
|
+
syncContext: paperclipIssueSyncContext,
|
|
8356
|
+
nextStatus,
|
|
8357
|
+
...nextTransitionAssignee ? { nextAssignee: nextTransitionAssignee.principal } : {},
|
|
8358
|
+
...shouldClearTransitionAssignee ? { clearAssignee: true } : {},
|
|
8359
|
+
transitionComment,
|
|
8360
|
+
paperclipApiBaseUrl
|
|
8361
|
+
});
|
|
8362
|
+
updatedStatusesCount += 1;
|
|
8363
|
+
if (shouldWakeTransitionAssignee && nextTransitionAssignee?.principal.kind === "agent") {
|
|
8364
|
+
queuedIssueWakeups.push({
|
|
8365
|
+
assigneeAgentId: nextTransitionAssignee.principal.id,
|
|
8366
|
+
paperclipIssueId: pullRequestLink.paperclipIssueId,
|
|
8367
|
+
reason: STATUS_TRANSITION_WAKE_REASON,
|
|
8368
|
+
mutation: "status_transition",
|
|
8369
|
+
previousStatus: paperclipIssue.status,
|
|
8370
|
+
nextStatus
|
|
8371
|
+
});
|
|
8372
|
+
}
|
|
8373
|
+
} catch (error) {
|
|
8374
|
+
if (isGitHubRateLimitError(error)) {
|
|
8375
|
+
throw error;
|
|
8376
|
+
}
|
|
8377
|
+
recordRecoverableSyncFailure(ctx, failures, error, syncFailureContext);
|
|
8378
|
+
continue;
|
|
8379
|
+
} finally {
|
|
8380
|
+
completedIssueCount += 1;
|
|
8381
|
+
if (onProgress) {
|
|
8382
|
+
await onProgress({
|
|
8383
|
+
pullRequestLink,
|
|
8384
|
+
completedIssueCount,
|
|
8385
|
+
totalIssueCount
|
|
8386
|
+
});
|
|
8387
|
+
}
|
|
8388
|
+
}
|
|
8389
|
+
}
|
|
8390
|
+
await mapWithConcurrency(
|
|
8391
|
+
queuedIssueWakeups,
|
|
8392
|
+
IMPORTED_ISSUE_WAKEUP_CONCURRENCY,
|
|
8393
|
+
async (queuedWakeup) => wakePaperclipIssueAssignee(ctx, {
|
|
8394
|
+
assigneeAgentId: queuedWakeup.assigneeAgentId,
|
|
8395
|
+
paperclipIssueId: queuedWakeup.paperclipIssueId,
|
|
8396
|
+
companyId: mapping.companyId,
|
|
8397
|
+
paperclipApiBaseUrl,
|
|
8398
|
+
reason: queuedWakeup.reason,
|
|
8399
|
+
mutation: queuedWakeup.mutation,
|
|
8400
|
+
previousStatus: queuedWakeup.previousStatus,
|
|
8401
|
+
nextStatus: queuedWakeup.nextStatus
|
|
8402
|
+
})
|
|
8403
|
+
);
|
|
8404
|
+
return {
|
|
8405
|
+
updatedStatusesCount
|
|
8406
|
+
};
|
|
8407
|
+
}
|
|
8143
8408
|
async function getResolvedConfig(ctx) {
|
|
8144
8409
|
const [savedConfig, externalConfig] = await Promise.all([
|
|
8145
8410
|
ctx.config.get(),
|
|
@@ -8393,156 +8658,38 @@ async function persistCompanyActivityMetricEvent(ctx, params, options = {}) {
|
|
|
8393
8658
|
...result.eventKey ? { eventKey: result.eventKey } : {}
|
|
8394
8659
|
};
|
|
8395
8660
|
}
|
|
8396
|
-
function
|
|
8397
|
-
if (input.
|
|
8398
|
-
|
|
8399
|
-
}
|
|
8400
|
-
const rawBody = input.rawBody.trim();
|
|
8401
|
-
if (!rawBody) {
|
|
8402
|
-
throw new Error("Webhook body must be a JSON object.");
|
|
8403
|
-
}
|
|
8404
|
-
let parsed;
|
|
8405
|
-
try {
|
|
8406
|
-
parsed = JSON.parse(rawBody);
|
|
8407
|
-
} catch {
|
|
8408
|
-
throw new Error("Webhook body must be valid JSON.");
|
|
8409
|
-
}
|
|
8410
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8411
|
-
throw new Error("Webhook body must be a JSON object.");
|
|
8412
|
-
}
|
|
8413
|
-
return parsed;
|
|
8414
|
-
}
|
|
8415
|
-
async function resolveCompanyIdForCompanyMetricEvent(ctx, params) {
|
|
8416
|
-
const requestedCompanyId = normalizeCompanyId(params.companyId);
|
|
8417
|
-
if (requestedCompanyId) {
|
|
8418
|
-
return requestedCompanyId;
|
|
8661
|
+
function parseCompanyMetricApiRouteBody(input) {
|
|
8662
|
+
if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) {
|
|
8663
|
+
throw new Error("Company KPI route body must be a JSON object.");
|
|
8419
8664
|
}
|
|
8420
|
-
|
|
8421
|
-
return void 0;
|
|
8422
|
-
}
|
|
8423
|
-
const settings = normalizeSettings(await ctx.state.get(SETTINGS_SCOPE));
|
|
8424
|
-
const matchingCompanyIds = [
|
|
8425
|
-
...new Set(
|
|
8426
|
-
settings.mappings.filter((mapping) => getNormalizedMappingRepositoryUrl(mapping) === params.repositoryUrl).map((mapping) => normalizeCompanyId(mapping.companyId)).filter((companyId) => Boolean(companyId))
|
|
8427
|
-
)
|
|
8428
|
-
];
|
|
8429
|
-
return matchingCompanyIds.length === 1 ? matchingCompanyIds[0] : void 0;
|
|
8665
|
+
return input.body;
|
|
8430
8666
|
}
|
|
8431
|
-
function
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
if (typeof headerValue === "string") {
|
|
8438
|
-
const trimmedValue = headerValue.trim();
|
|
8439
|
-
return trimmedValue || void 0;
|
|
8440
|
-
}
|
|
8441
|
-
if (!Array.isArray(headerValue)) {
|
|
8442
|
-
continue;
|
|
8443
|
-
}
|
|
8444
|
-
for (const entry of headerValue) {
|
|
8445
|
-
if (typeof entry !== "string") {
|
|
8446
|
-
continue;
|
|
8447
|
-
}
|
|
8448
|
-
const trimmedValue = entry.trim();
|
|
8449
|
-
if (trimmedValue) {
|
|
8450
|
-
return trimmedValue;
|
|
8667
|
+
async function handleCompanyMetricApiRoute(ctx, input) {
|
|
8668
|
+
if (input.routeKey !== COMPANY_METRIC_API_ROUTE_KEY) {
|
|
8669
|
+
return {
|
|
8670
|
+
status: 404,
|
|
8671
|
+
body: {
|
|
8672
|
+
error: `Unsupported plugin API route: ${input.routeKey}.`
|
|
8451
8673
|
}
|
|
8452
|
-
}
|
|
8453
|
-
}
|
|
8454
|
-
return void 0;
|
|
8455
|
-
}
|
|
8456
|
-
function normalizeCompanyMetricWebhookBearerToken(value) {
|
|
8457
|
-
if (!value) {
|
|
8458
|
-
return void 0;
|
|
8459
|
-
}
|
|
8460
|
-
const trimmedValue = value.trim();
|
|
8461
|
-
if (!trimmedValue) {
|
|
8462
|
-
return void 0;
|
|
8463
|
-
}
|
|
8464
|
-
const bearerMatch = trimmedValue.match(/^Bearer\s+(.+)$/i);
|
|
8465
|
-
if (!bearerMatch) {
|
|
8466
|
-
return void 0;
|
|
8467
|
-
}
|
|
8468
|
-
const token = bearerMatch[1]?.trim();
|
|
8469
|
-
return token || void 0;
|
|
8470
|
-
}
|
|
8471
|
-
function normalizePaperclipCurrentAgentRecord(value) {
|
|
8472
|
-
if (!value || typeof value !== "object") {
|
|
8473
|
-
return null;
|
|
8474
|
-
}
|
|
8475
|
-
const record = value;
|
|
8476
|
-
const id = normalizeOptionalString2(record.id);
|
|
8477
|
-
const companyId = normalizeCompanyId(record.companyId);
|
|
8478
|
-
return id && companyId ? {
|
|
8479
|
-
id,
|
|
8480
|
-
companyId
|
|
8481
|
-
} : null;
|
|
8482
|
-
}
|
|
8483
|
-
async function readCompanyMetricWebhookCurrentAgent(paperclipApiBaseUrl, bearerToken) {
|
|
8484
|
-
const response = await fetchPaperclipApi(getPaperclipCurrentAgentEndpoint(paperclipApiBaseUrl), {
|
|
8485
|
-
method: "GET",
|
|
8486
|
-
headers: {
|
|
8487
|
-
accept: "application/json",
|
|
8488
|
-
authorization: `Bearer ${bearerToken}`
|
|
8489
|
-
}
|
|
8490
|
-
});
|
|
8491
|
-
const payloadResult = await readPaperclipApiJsonResponse(response, {
|
|
8492
|
-
operationLabel: "current agent"
|
|
8493
|
-
});
|
|
8494
|
-
if (payloadResult.failure) {
|
|
8495
|
-
if (payloadResult.failure.requiresAuthentication) {
|
|
8496
|
-
throw new Error("Company KPI webhook Authorization must be a valid PAPERCLIP_API_KEY bearer token.");
|
|
8497
|
-
}
|
|
8498
|
-
const detail = payloadResult.failure.errorMessage ? ` ${payloadResult.failure.errorMessage}` : "";
|
|
8499
|
-
throw new Error(`Could not validate the KPI webhook Paperclip API key.${detail}`);
|
|
8500
|
-
}
|
|
8501
|
-
const agent = normalizePaperclipCurrentAgentRecord(payloadResult.data);
|
|
8502
|
-
if (!agent) {
|
|
8503
|
-
throw new Error("Paperclip did not return a usable current agent record while validating the KPI webhook caller.");
|
|
8504
|
-
}
|
|
8505
|
-
return agent;
|
|
8506
|
-
}
|
|
8507
|
-
async function assertCompanyMetricWebhookAuthenticated(ctx, input, companyId) {
|
|
8508
|
-
const rawAuthorization = getWebhookHeaderValue(input.headers, COMPANY_METRIC_WEBHOOK_AUTH_HEADER);
|
|
8509
|
-
const bearerToken = normalizeCompanyMetricWebhookBearerToken(rawAuthorization);
|
|
8510
|
-
if (!bearerToken) {
|
|
8511
|
-
throw new Error(
|
|
8512
|
-
`Missing or invalid ${COMPANY_METRIC_WEBHOOK_AUTH_HEADER} header. Use Bearer <PAPERCLIP_API_KEY>.`
|
|
8513
|
-
);
|
|
8674
|
+
};
|
|
8514
8675
|
}
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
const paperclipApiBaseUrl = getConfiguredPaperclipApiBaseUrl(settings, config, companyId);
|
|
8518
|
-
if (!paperclipApiBaseUrl) {
|
|
8519
|
-
throw new Error(
|
|
8520
|
-
"A trusted Paperclip API origin is required to validate PAPERCLIP_API_KEY. Set PAPERCLIP_API_URL or save the Paperclip host origin before sending KPI webhook events."
|
|
8521
|
-
);
|
|
8676
|
+
if (input.actor.actorType !== "agent") {
|
|
8677
|
+
throw new Error("Company KPI metric events must be recorded by an authenticated Paperclip agent.");
|
|
8522
8678
|
}
|
|
8523
|
-
const
|
|
8524
|
-
if (
|
|
8525
|
-
throw new Error("Company KPI
|
|
8679
|
+
const companyId = normalizeCompanyId(input.companyId);
|
|
8680
|
+
if (!companyId) {
|
|
8681
|
+
throw new Error("Company KPI metric events require the host to provide the authenticated agent company.");
|
|
8526
8682
|
}
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
if (
|
|
8530
|
-
throw new Error(
|
|
8683
|
+
const payload = parseCompanyMetricApiRouteBody(input);
|
|
8684
|
+
const requestedCompanyId = normalizeCompanyId(payload.companyId);
|
|
8685
|
+
if (requestedCompanyId && requestedCompanyId !== companyId) {
|
|
8686
|
+
throw new Error("companyId must match the authenticated Paperclip agent company.");
|
|
8531
8687
|
}
|
|
8532
|
-
const payload = parseWebhookPayloadRecord(input);
|
|
8533
8688
|
const repositoryInput = normalizeOptionalString2(payload.repository);
|
|
8534
8689
|
const repository = repositoryInput ? parseRepositoryReference(repositoryInput) : null;
|
|
8535
8690
|
if (repositoryInput && !repository) {
|
|
8536
8691
|
throw new Error("repository must be owner/repo or https://github.com/owner/repo.");
|
|
8537
8692
|
}
|
|
8538
|
-
const companyId = await resolveCompanyIdForCompanyMetricEvent(ctx, {
|
|
8539
|
-
companyId: payload.companyId,
|
|
8540
|
-
repositoryUrl: repository?.url
|
|
8541
|
-
});
|
|
8542
|
-
if (!companyId) {
|
|
8543
|
-
throw new Error("companyId is required unless repository maps to exactly one company.");
|
|
8544
|
-
}
|
|
8545
|
-
await assertCompanyMetricWebhookAuthenticated(ctx, input, companyId);
|
|
8546
8693
|
const metric = normalizeCompanyActivityMetricInputValue(payload.metric);
|
|
8547
8694
|
if (!metric) {
|
|
8548
8695
|
throw new Error('metric must be "pull_request_created".');
|
|
@@ -8559,7 +8706,7 @@ async function handleCompanyMetricWebhook(ctx, input) {
|
|
|
8559
8706
|
});
|
|
8560
8707
|
if (!dedupeKey) {
|
|
8561
8708
|
throw new Error(
|
|
8562
|
-
"Company KPI
|
|
8709
|
+
"Company KPI metric events require pullRequestUrl, repository plus pullRequestNumber, or eventKey so duplicate deliveries can be ignored."
|
|
8563
8710
|
);
|
|
8564
8711
|
}
|
|
8565
8712
|
const recordedMetric = await persistCompanyActivityMetricEvent(
|
|
@@ -8579,17 +8726,27 @@ async function handleCompanyMetricWebhook(ctx, input) {
|
|
|
8579
8726
|
}
|
|
8580
8727
|
);
|
|
8581
8728
|
ctx.logger.info(
|
|
8582
|
-
recordedMetric.recorded ? "GitHub Sync recorded a company KPI
|
|
8729
|
+
recordedMetric.recorded ? "GitHub Sync recorded a company KPI API route event." : "GitHub Sync ignored a duplicate company KPI API route event.",
|
|
8583
8730
|
{
|
|
8584
|
-
|
|
8731
|
+
routeKey: input.routeKey,
|
|
8585
8732
|
companyId,
|
|
8586
8733
|
metric,
|
|
8587
8734
|
repositoryUrl: repository?.url,
|
|
8588
8735
|
pullRequestNumber,
|
|
8589
8736
|
pullRequestUrl,
|
|
8590
|
-
|
|
8737
|
+
agentId: input.actor.agentId ?? null,
|
|
8738
|
+
runId: input.actor.runId ?? null
|
|
8591
8739
|
}
|
|
8592
8740
|
);
|
|
8741
|
+
return {
|
|
8742
|
+
status: recordedMetric.recorded ? 201 : 200,
|
|
8743
|
+
body: {
|
|
8744
|
+
status: recordedMetric.recorded ? "recorded" : "duplicate",
|
|
8745
|
+
recorded: recordedMetric.recorded,
|
|
8746
|
+
companyId,
|
|
8747
|
+
metric: "pull_request_created"
|
|
8748
|
+
}
|
|
8749
|
+
};
|
|
8593
8750
|
}
|
|
8594
8751
|
async function createGitHubToolOctokit(ctx, companyId) {
|
|
8595
8752
|
const token = (await resolveGithubToken(ctx, { companyId })).trim();
|
|
@@ -10916,6 +11073,8 @@ async function createProjectPullRequestPaperclipIssue(ctx, input) {
|
|
|
10916
11073
|
companyId: scope.companyId,
|
|
10917
11074
|
projectId: scope.projectId,
|
|
10918
11075
|
title: requestedTitle,
|
|
11076
|
+
originKind: GITHUB_PULL_REQUEST_ORIGIN_KIND,
|
|
11077
|
+
originId: pullRequestUrl,
|
|
10919
11078
|
description: buildPaperclipIssueDescriptionFromPullRequest({
|
|
10920
11079
|
repository: scope.repository,
|
|
10921
11080
|
pullRequestNumber,
|
|
@@ -11697,6 +11856,14 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
11697
11856
|
completedTrackedIssueKeys.add(key);
|
|
11698
11857
|
completedTrackedIssueCount += 1;
|
|
11699
11858
|
}
|
|
11859
|
+
function markTrackedPullRequestIssueProcessed(mapping, record) {
|
|
11860
|
+
const key = buildTrackedPullRequestIssueProgressKey(mapping, record);
|
|
11861
|
+
if (completedTrackedIssueKeys.has(key)) {
|
|
11862
|
+
return;
|
|
11863
|
+
}
|
|
11864
|
+
completedTrackedIssueKeys.add(key);
|
|
11865
|
+
completedTrackedIssueCount += 1;
|
|
11866
|
+
}
|
|
11700
11867
|
function recordCompanyBacklogSnapshotsFromPlans(repositoryPlans2) {
|
|
11701
11868
|
if (options.target?.kind === "project" || options.target?.kind === "issue") {
|
|
11702
11869
|
return;
|
|
@@ -11804,12 +11971,13 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
11804
11971
|
const importRegistryByIssueId = new Map(
|
|
11805
11972
|
importedIssueRecords.map((entry) => [entry.githubIssueId, entry])
|
|
11806
11973
|
);
|
|
11974
|
+
const pullRequestLinks = await listGitHubPullRequestIssueLinksForMapping(ctx, mapping, options.target);
|
|
11807
11975
|
const ensuredPaperclipIssueIds = /* @__PURE__ */ new Map();
|
|
11808
11976
|
const trackedIssueIds = /* @__PURE__ */ new Set([
|
|
11809
11977
|
...issues.map((issue) => issue.id),
|
|
11810
11978
|
...importRegistryByIssueId.keys()
|
|
11811
11979
|
]);
|
|
11812
|
-
const trackedIssueCount = [...trackedIssueIds].filter((issueId) => allIssuesById.has(issueId)).length;
|
|
11980
|
+
const trackedIssueCount = [...trackedIssueIds].filter((issueId) => allIssuesById.has(issueId)).length + pullRequestLinks.length;
|
|
11813
11981
|
totalTrackedIssueCount += trackedIssueCount;
|
|
11814
11982
|
syncedIssuesCount = totalTrackedIssueCount;
|
|
11815
11983
|
currentProgress = {
|
|
@@ -11828,6 +11996,7 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
11828
11996
|
allIssues: eligibleIssues,
|
|
11829
11997
|
issues,
|
|
11830
11998
|
allIssuesById,
|
|
11999
|
+
pullRequestLinks,
|
|
11831
12000
|
trackedIssueCount
|
|
11832
12001
|
});
|
|
11833
12002
|
} catch (error) {
|
|
@@ -11863,7 +12032,7 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
11863
12032
|
for (const plan of repositoryPlans) {
|
|
11864
12033
|
await throwIfSyncCancelled();
|
|
11865
12034
|
try {
|
|
11866
|
-
const { mapping, advancedSettings, repository, repositoryIndex, allIssuesById, issues } = plan;
|
|
12035
|
+
const { mapping, advancedSettings, repository, repositoryIndex, allIssuesById, issues, pullRequestLinks } = plan;
|
|
11867
12036
|
const companyId = mapping.companyId;
|
|
11868
12037
|
let availableLabels = companyId ? companyLabelDirectoryCache.get(companyId) : void 0;
|
|
11869
12038
|
if (!availableLabels) {
|
|
@@ -11916,6 +12085,15 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
11916
12085
|
}
|
|
11917
12086
|
}
|
|
11918
12087
|
}
|
|
12088
|
+
for (const pullRequestLink of pullRequestLinks) {
|
|
12089
|
+
const pullRequestRepository = requireRepositoryReference(pullRequestLink.data.repositoryUrl);
|
|
12090
|
+
const entry = openLinkedPullRequestNumbersByRepository.get(pullRequestRepository.url) ?? {
|
|
12091
|
+
repository: pullRequestRepository,
|
|
12092
|
+
numbers: /* @__PURE__ */ new Set()
|
|
12093
|
+
};
|
|
12094
|
+
entry.numbers.add(pullRequestLink.data.githubPullRequestNumber);
|
|
12095
|
+
openLinkedPullRequestNumbersByRepository.set(pullRequestRepository.url, entry);
|
|
12096
|
+
}
|
|
11919
12097
|
for (const entry of openLinkedPullRequestNumbersByRepository.values()) {
|
|
11920
12098
|
await warmGitHubPullRequestStatusCache(
|
|
11921
12099
|
octokit,
|
|
@@ -12053,6 +12231,33 @@ async function performSync(ctx, trigger, options = {}) {
|
|
|
12053
12231
|
updatedStatusesCount += synchronizationResult.updatedStatusesCount;
|
|
12054
12232
|
updatedLabelsCount += synchronizationResult.updatedLabelsCount;
|
|
12055
12233
|
updatedDescriptionsCount += synchronizationResult.updatedDescriptionsCount;
|
|
12234
|
+
const pullRequestSynchronizationResult = await synchronizePaperclipPullRequestIssueStatuses(
|
|
12235
|
+
ctx,
|
|
12236
|
+
octokit,
|
|
12237
|
+
mapping,
|
|
12238
|
+
advancedSettings,
|
|
12239
|
+
pullRequestLinks,
|
|
12240
|
+
paperclipApiBaseUrl,
|
|
12241
|
+
pullRequestStatusCache,
|
|
12242
|
+
failureContext,
|
|
12243
|
+
recoverableFailures,
|
|
12244
|
+
throwIfSyncCancelled,
|
|
12245
|
+
async (progress) => {
|
|
12246
|
+
markTrackedPullRequestIssueProcessed(mapping, progress.pullRequestLink);
|
|
12247
|
+
const pullRequestRepository = requireRepositoryReference(progress.pullRequestLink.data.repositoryUrl);
|
|
12248
|
+
currentProgress = {
|
|
12249
|
+
phase: "syncing",
|
|
12250
|
+
totalRepositoryCount: mappings.length,
|
|
12251
|
+
currentRepositoryIndex: repositoryIndex,
|
|
12252
|
+
currentRepositoryUrl: repository.url,
|
|
12253
|
+
completedIssueCount: completedTrackedIssueCount,
|
|
12254
|
+
totalIssueCount: totalTrackedIssueCount,
|
|
12255
|
+
detailLabel: `Synced pull request #${progress.pullRequestLink.data.githubPullRequestNumber} in ${pullRequestRepository.owner}/${pullRequestRepository.repo}.`
|
|
12256
|
+
};
|
|
12257
|
+
await persistRunningProgress(progress.completedIssueCount === progress.totalIssueCount);
|
|
12258
|
+
}
|
|
12259
|
+
);
|
|
12260
|
+
updatedStatusesCount += pullRequestSynchronizationResult.updatedStatusesCount;
|
|
12056
12261
|
} catch (error) {
|
|
12057
12262
|
if (error instanceof SyncCancellationError || isGitHubRateLimitError(error)) {
|
|
12058
12263
|
throw error;
|
|
@@ -13397,11 +13602,11 @@ var plugin = definePlugin({
|
|
|
13397
13602
|
}
|
|
13398
13603
|
});
|
|
13399
13604
|
},
|
|
13400
|
-
async
|
|
13605
|
+
async onApiRequest(input) {
|
|
13401
13606
|
if (!pluginRuntimeContext) {
|
|
13402
|
-
throw new Error("GitHub Sync worker is not ready to handle
|
|
13607
|
+
throw new Error("GitHub Sync worker is not ready to handle API routes yet.");
|
|
13403
13608
|
}
|
|
13404
|
-
|
|
13609
|
+
return handleCompanyMetricApiRoute(pluginRuntimeContext, input);
|
|
13405
13610
|
},
|
|
13406
13611
|
async onShutdown() {
|
|
13407
13612
|
pluginRuntimeContext = null;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-github-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Paperclip plugin for synchronizing GitHub issues into Paperclip projects.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"packageManager": "pnpm@10.33.
|
|
7
|
+
"packageManager": "pnpm@10.33.2",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20"
|
|
10
10
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@octokit/rest": "^22.0.1",
|
|
44
|
-
"@paperclipai/plugin-sdk": "^2026.
|
|
44
|
+
"@paperclipai/plugin-sdk": "^2026.427.0",
|
|
45
45
|
"react": "^19.2.5",
|
|
46
46
|
"react-markdown": "^10.1.0",
|
|
47
47
|
"rehype-raw": "^7.0.0",
|