patchrelay 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent-session-plan.js +7 -7
- package/dist/build-info.json +3 -3
- package/dist/config.js +1 -4
- package/dist/index.js +0 -3
- package/dist/preflight.js +0 -6
- package/dist/project-resolution.js +0 -6
- package/dist/service-webhooks.js +0 -29
- package/dist/stage-agent-activity-publisher.js +4 -4
- package/dist/stage-lifecycle-publisher.js +2 -5
- package/dist/webhook-agent-session-handler.js +6 -6
- package/dist/webhook-desired-stage-recorder.js +6 -19
- package/dist/webhooks.js +132 -47
- package/package.json +1 -1
- package/dist/webhook-archive.js +0 -24
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ PatchRelay is the system around the model:
|
|
|
11
11
|
- issue-to-repo routing
|
|
12
12
|
- issue worktree and branch lifecycle
|
|
13
13
|
- stage orchestration and thread continuity
|
|
14
|
-
-
|
|
14
|
+
- native Linear agent input forwarding into active runs
|
|
15
15
|
- read-only inspection and stage reporting
|
|
16
16
|
|
|
17
17
|
If you want Codex to work inside your real repos with your real tools, secrets, SSH access, and deployment surface, PatchRelay is the harness that makes that loop reliable.
|
|
@@ -22,7 +22,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
|
|
|
22
22
|
- Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
|
|
23
23
|
- Keep deterministic workflow logic outside the model: routing, staging, worktree ownership, and reporting.
|
|
24
24
|
- Choose the Codex approval and sandbox settings that match your risk tolerance.
|
|
25
|
-
- Let Linear drive the loop through delegation,
|
|
25
|
+
- Let Linear drive the loop through delegation, mentions, and workflow stages.
|
|
26
26
|
- Drop into the exact issue worktree and resume control manually when needed.
|
|
27
27
|
|
|
28
28
|
## What PatchRelay Owns
|
|
@@ -34,7 +34,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
|
|
|
34
34
|
- creates and reuses one durable worktree and branch per issue lifecycle
|
|
35
35
|
- starts or forks Codex threads for the workflows you bind to Linear states
|
|
36
36
|
- persists enough state to correlate the Linear issue, local workspace, stage run, and Codex thread
|
|
37
|
-
- reports progress back to Linear and forwards follow-up
|
|
37
|
+
- reports progress back to Linear and forwards follow-up agent input into active runs
|
|
38
38
|
- exposes CLI and optional read-only inspection surfaces so operators can understand what happened
|
|
39
39
|
|
|
40
40
|
## System Layers
|
|
@@ -77,7 +77,7 @@ For the exact OAuth app settings and webhook categories, use the Linear onboardi
|
|
|
77
77
|
2. PatchRelay verifies the webhook and routes the issue to the right local project.
|
|
78
78
|
3. Delegated issues create or reuse the issue worktree and launch the matching workflow through `codex app-server`.
|
|
79
79
|
4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
|
|
80
|
-
5. Mentions stay conversational, while delegated sessions and
|
|
80
|
+
5. Mentions stay conversational, while delegated sessions and native agent prompts can steer the active run. An operator can take over from the exact same worktree when needed.
|
|
81
81
|
|
|
82
82
|
## Restart And Reconciliation
|
|
83
83
|
|
|
@@ -221,7 +221,7 @@ Important:
|
|
|
221
221
|
1. Delegate a Linear issue to the PatchRelay app.
|
|
222
222
|
2. PatchRelay reads the current Linear state like `Start`, `Ready for QA`, or `Deploy` to choose the matching workflow.
|
|
223
223
|
3. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches the matching workflow.
|
|
224
|
-
4. Follow up in the
|
|
224
|
+
4. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
|
|
225
225
|
5. Watch progress from the terminal or open the same worktree and take over manually.
|
|
226
226
|
|
|
227
227
|
Useful commands:
|
|
@@ -11,24 +11,24 @@ function titleCase(value) {
|
|
|
11
11
|
function buildPlan(stage, statuses) {
|
|
12
12
|
const stageLabel = titleCase(formatStageLabel(stage));
|
|
13
13
|
return [
|
|
14
|
-
{
|
|
15
|
-
{
|
|
16
|
-
{
|
|
14
|
+
{ content: "Prepare workspace", status: statuses[0] },
|
|
15
|
+
{ content: `Run ${stageLabel} workflow`, status: statuses[1] },
|
|
16
|
+
{ content: "Review next Linear step", status: statuses[2] },
|
|
17
17
|
];
|
|
18
18
|
}
|
|
19
19
|
export function buildPreparingSessionPlan(stage) {
|
|
20
|
-
return buildPlan(stage, ["
|
|
20
|
+
return buildPlan(stage, ["inProgress", "pending", "pending"]);
|
|
21
21
|
}
|
|
22
22
|
export function buildRunningSessionPlan(stage) {
|
|
23
|
-
return buildPlan(stage, ["completed", "
|
|
23
|
+
return buildPlan(stage, ["completed", "inProgress", "pending"]);
|
|
24
24
|
}
|
|
25
25
|
export function buildCompletedSessionPlan(stage) {
|
|
26
26
|
return buildPlan(stage, ["completed", "completed", "completed"]);
|
|
27
27
|
}
|
|
28
28
|
export function buildAwaitingHandoffSessionPlan(stage) {
|
|
29
|
-
return buildPlan(stage, ["completed", "completed", "
|
|
29
|
+
return buildPlan(stage, ["completed", "completed", "inProgress"]);
|
|
30
30
|
}
|
|
31
31
|
export function buildFailedSessionPlan(stage, stageRun) {
|
|
32
|
-
const workflowStepStatus = stageRun?.threadId || stageRun?.turnId ? "completed" : "
|
|
32
|
+
const workflowStepStatus = stageRun?.threadId || stageRun?.turnId ? "completed" : "inProgress";
|
|
33
33
|
return buildPlan(stage, ["completed", workflowStepStatus, "pending"]);
|
|
34
34
|
}
|
package/dist/build-info.json
CHANGED
package/dist/config.js
CHANGED
|
@@ -54,7 +54,6 @@ const configSchema = z.object({
|
|
|
54
54
|
level: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
55
55
|
format: z.literal("logfmt").default("logfmt"),
|
|
56
56
|
file_path: z.string().min(1).default(getDefaultLogPath()),
|
|
57
|
-
webhook_archive_dir: z.string().optional(),
|
|
58
57
|
}),
|
|
59
58
|
database: z.object({
|
|
60
59
|
path: z.string().min(1).default(getDefaultDatabasePath()),
|
|
@@ -101,7 +100,7 @@ const configSchema = z.object({
|
|
|
101
100
|
});
|
|
102
101
|
function defaultTriggerEvents(actor) {
|
|
103
102
|
if (actor === "app") {
|
|
104
|
-
return ["
|
|
103
|
+
return ["agentSessionCreated", "agentPrompted"];
|
|
105
104
|
}
|
|
106
105
|
return ["statusChanged"];
|
|
107
106
|
}
|
|
@@ -314,7 +313,6 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
314
313
|
throw new Error(`Missing env var ${parsed.linear.token_encryption_key_env}`);
|
|
315
314
|
}
|
|
316
315
|
const logFilePath = env.PATCHRELAY_LOG_FILE ?? parsed.logging.file_path;
|
|
317
|
-
const webhookArchiveDir = env.PATCHRELAY_WEBHOOK_ARCHIVE_DIR ?? parsed.logging.webhook_archive_dir;
|
|
318
316
|
const oauthRedirectUri = parsed.linear.oauth.redirect_uri ?? deriveLinearOAuthRedirectUri(parsed.server);
|
|
319
317
|
const config = {
|
|
320
318
|
server: {
|
|
@@ -333,7 +331,6 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
333
331
|
level: env.PATCHRELAY_LOG_LEVEL ?? parsed.logging.level,
|
|
334
332
|
format: parsed.logging.format,
|
|
335
333
|
filePath: ensureAbsolutePath(logFilePath),
|
|
336
|
-
...(webhookArchiveDir ? { webhookArchiveDir: ensureAbsolutePath(webhookArchiveDir) } : {}),
|
|
337
334
|
},
|
|
338
335
|
database: {
|
|
339
336
|
path: ensureAbsolutePath(env.PATCHRELAY_DB_PATH ?? parsed.database.path),
|
package/dist/index.js
CHANGED
|
@@ -24,9 +24,6 @@ async function main() {
|
|
|
24
24
|
await enforceServiceEnvPermissions(getAdjacentEnvFilePaths(configPath).serviceEnvPath);
|
|
25
25
|
await ensureDir(dirname(config.database.path));
|
|
26
26
|
await ensureDir(dirname(config.logging.filePath));
|
|
27
|
-
if (config.logging.webhookArchiveDir) {
|
|
28
|
-
await ensureDir(config.logging.webhookArchiveDir);
|
|
29
|
-
}
|
|
30
27
|
for (const project of config.projects) {
|
|
31
28
|
await ensureDir(project.worktreeRoot);
|
|
32
29
|
}
|
package/dist/preflight.js
CHANGED
|
@@ -66,12 +66,6 @@ export async function runPreflight(config) {
|
|
|
66
66
|
checks.push(...checkPath("database", path.dirname(config.database.path), "directory", { createIfMissing: true, writable: true }));
|
|
67
67
|
checks.push(checkDatabaseSchema(config));
|
|
68
68
|
checks.push(...checkPath("logging", path.dirname(config.logging.filePath), "directory", { createIfMissing: true, writable: true }));
|
|
69
|
-
if (config.logging.webhookArchiveDir) {
|
|
70
|
-
checks.push(...checkPath("archive", config.logging.webhookArchiveDir, "directory", { createIfMissing: true, writable: true }));
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
checks.push(warn("archive", "Raw webhook archival is disabled"));
|
|
74
|
-
}
|
|
75
69
|
if (config.projects.length === 0) {
|
|
76
70
|
checks.push(warn("projects", "No projects are configured yet; add one with `patchrelay project apply <id> <repo-path>` before connecting Linear"));
|
|
77
71
|
}
|
|
@@ -7,12 +7,6 @@ export function resolveProject(config, issue) {
|
|
|
7
7
|
return undefined;
|
|
8
8
|
}
|
|
9
9
|
export function triggerEventAllowed(project, triggerEvent) {
|
|
10
|
-
if (project.triggerEvents.includes(triggerEvent)) {
|
|
11
|
-
return true;
|
|
12
|
-
}
|
|
13
|
-
if (triggerEvent === "agentSessionCreated") {
|
|
14
|
-
return project.triggerEvents.includes("delegateChanged") || project.triggerEvents.includes("statusChanged");
|
|
15
|
-
}
|
|
16
10
|
return project.triggerEvents.includes(triggerEvent);
|
|
17
11
|
}
|
|
18
12
|
function normalizeTrustValue(value) {
|
package/dist/service-webhooks.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { archiveWebhook } from "./webhook-archive.js";
|
|
2
1
|
import { normalizeWebhook } from "./webhooks.js";
|
|
3
2
|
import { redactSensitiveHeaders, timestampMsWithinSkew, verifyHmacSha256Hex } from "./utils.js";
|
|
4
3
|
export async function acceptIncomingWebhook(params) {
|
|
@@ -35,15 +34,6 @@ export async function acceptIncomingWebhook(params) {
|
|
|
35
34
|
const headersJson = JSON.stringify(sanitizedHeaders);
|
|
36
35
|
const payloadJson = JSON.stringify(payload);
|
|
37
36
|
logWebhookSummary(params.logger, normalized);
|
|
38
|
-
await archiveAcceptedPayload({
|
|
39
|
-
config: params.config,
|
|
40
|
-
logger: params.logger,
|
|
41
|
-
webhookId: params.webhookId,
|
|
42
|
-
receivedAt,
|
|
43
|
-
headers: params.headers,
|
|
44
|
-
rawBody: params.rawBody,
|
|
45
|
-
payload,
|
|
46
|
-
});
|
|
47
37
|
const stored = params.stores.webhookEvents.insertWebhookEvent({
|
|
48
38
|
webhookId: params.webhookId,
|
|
49
39
|
receivedAt,
|
|
@@ -127,22 +117,3 @@ function logWebhookSummary(logger, normalized) {
|
|
|
127
117
|
issueId: normalized.issue?.id,
|
|
128
118
|
}, "Webhook metadata");
|
|
129
119
|
}
|
|
130
|
-
async function archiveAcceptedPayload(params) {
|
|
131
|
-
if (!params.config.logging.webhookArchiveDir) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
const archivePath = await archiveWebhook({
|
|
136
|
-
archiveDir: params.config.logging.webhookArchiveDir,
|
|
137
|
-
webhookId: params.webhookId,
|
|
138
|
-
receivedAt: params.receivedAt,
|
|
139
|
-
headers: redactSensitiveHeaders(params.headers),
|
|
140
|
-
rawBody: params.rawBody,
|
|
141
|
-
payload: params.payload,
|
|
142
|
-
});
|
|
143
|
-
params.logger.debug({ webhookId: params.webhookId, archivePath }, "Archived webhook to local file");
|
|
144
|
-
}
|
|
145
|
-
catch (error) {
|
|
146
|
-
params.logger.error({ webhookId: params.webhookId, error }, "Failed to archive webhook to local file");
|
|
147
|
-
}
|
|
148
|
-
}
|
|
@@ -8,7 +8,7 @@ export class StageAgentActivityPublisher {
|
|
|
8
8
|
this.linearProvider = linearProvider;
|
|
9
9
|
this.logger = logger;
|
|
10
10
|
}
|
|
11
|
-
async publishForSession(projectId, agentSessionId, content) {
|
|
11
|
+
async publishForSession(projectId, agentSessionId, content, options) {
|
|
12
12
|
const linear = await this.linearProvider.forProject(projectId);
|
|
13
13
|
if (!linear) {
|
|
14
14
|
return;
|
|
@@ -17,7 +17,7 @@ export class StageAgentActivityPublisher {
|
|
|
17
17
|
await linear.createAgentActivity({
|
|
18
18
|
agentSessionId,
|
|
19
19
|
content,
|
|
20
|
-
ephemeral: content.type === "thought" || content.type === "action",
|
|
20
|
+
ephemeral: options?.ephemeral ?? (content.type === "thought" || content.type === "action"),
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
catch (error) {
|
|
@@ -27,11 +27,11 @@ export class StageAgentActivityPublisher {
|
|
|
27
27
|
}, "Failed to publish Linear agent activity");
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
async publishForIssue(issue, content) {
|
|
30
|
+
async publishForIssue(issue, content, options) {
|
|
31
31
|
if (!issue.activeAgentSessionId) {
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
await this.publishForSession(issue.projectId, issue.activeAgentSessionId, content);
|
|
34
|
+
await this.publishForSession(issue.projectId, issue.activeAgentSessionId, content, options);
|
|
35
35
|
}
|
|
36
36
|
async updateSession(params) {
|
|
37
37
|
const linear = await this.linearProvider.forProject(params.projectId);
|
|
@@ -84,12 +84,9 @@ export class StageLifecyclePublisher {
|
|
|
84
84
|
await linear.createAgentActivity({
|
|
85
85
|
agentSessionId: issue.activeAgentSessionId,
|
|
86
86
|
content: {
|
|
87
|
-
type: "
|
|
88
|
-
|
|
89
|
-
parameter: stage,
|
|
90
|
-
result: `PatchRelay started the ${stage} workflow.`,
|
|
87
|
+
type: "thought",
|
|
88
|
+
body: `PatchRelay started the ${stage} workflow and is working in the background.`,
|
|
91
89
|
},
|
|
92
|
-
ephemeral: true,
|
|
93
90
|
});
|
|
94
91
|
return true;
|
|
95
92
|
}
|
|
@@ -66,9 +66,9 @@ export class AgentSessionWebhookHandler {
|
|
|
66
66
|
if (desiredStage) {
|
|
67
67
|
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
|
|
68
68
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
69
|
-
type: "
|
|
70
|
-
body: `PatchRelay
|
|
71
|
-
});
|
|
69
|
+
type: "thought",
|
|
70
|
+
body: `PatchRelay started working on the ${desiredStage} workflow and is preparing the workspace.`,
|
|
71
|
+
}, { ephemeral: false });
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
if (activeStage) {
|
|
@@ -137,9 +137,9 @@ export class AgentSessionWebhookHandler {
|
|
|
137
137
|
if (!activeRunLease && desiredStage) {
|
|
138
138
|
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
|
|
139
139
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
140
|
-
type: "
|
|
141
|
-
body: `PatchRelay
|
|
142
|
-
});
|
|
140
|
+
type: "thought",
|
|
141
|
+
body: `PatchRelay is preparing the ${desiredStage} workflow from your latest prompt.`,
|
|
142
|
+
}, { ephemeral: false });
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
if (!activeRunLease && !desiredStage && (promptBody || promptContext)) {
|
|
@@ -76,27 +76,14 @@ export class WebhookDesiredStageRecorder {
|
|
|
76
76
|
if (!normalizedIssue) {
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (normalized.triggerEvent === "delegateChanged") {
|
|
82
|
-
desiredStage = delegatedToPatchRelay ? resolveWorkflowStage(project, normalizedIssue.stateName) : undefined;
|
|
83
|
-
if (!desiredStage) {
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
86
|
-
if (!stageAllowed && !project.triggerEvents.includes("statusChanged")) {
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
else if (normalized.triggerEvent === "agentSessionCreated" || normalized.triggerEvent === "agentPrompted") {
|
|
91
|
-
if (!delegatedToPatchRelay || !stageAllowed) {
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
|
|
79
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
80
|
+
return undefined;
|
|
95
81
|
}
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
|
|
83
|
+
return undefined;
|
|
98
84
|
}
|
|
99
|
-
|
|
85
|
+
const desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
|
|
86
|
+
if (!desiredStage) {
|
|
100
87
|
return undefined;
|
|
101
88
|
}
|
|
102
89
|
if (activeStageRun && desiredStage === activeStageRun.stage) {
|
package/dist/webhooks.js
CHANGED
|
@@ -1,9 +1,55 @@
|
|
|
1
|
+
function getPayloadRecord(payload) {
|
|
2
|
+
return payload;
|
|
3
|
+
}
|
|
4
|
+
function getPayloadData(payload) {
|
|
5
|
+
return asRecord(getPayloadRecord(payload).data) ?? getPayloadRecord(payload);
|
|
6
|
+
}
|
|
7
|
+
function getNestedRecord(record, path) {
|
|
8
|
+
let current = record;
|
|
9
|
+
for (const segment of path) {
|
|
10
|
+
const currentRecord = asRecord(current);
|
|
11
|
+
if (!currentRecord) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
current = currentRecord[segment];
|
|
15
|
+
}
|
|
16
|
+
return asRecord(current);
|
|
17
|
+
}
|
|
18
|
+
function getFirstNestedRecord(record, paths) {
|
|
19
|
+
for (const path of paths) {
|
|
20
|
+
const candidate = getNestedRecord(record, path);
|
|
21
|
+
if (candidate) {
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function looksLikeIssueRecord(record) {
|
|
28
|
+
if (!record) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return Boolean(getString(record, "identifier") ||
|
|
32
|
+
getString(record, "title") ||
|
|
33
|
+
getString(record, "delegateId") ||
|
|
34
|
+
asRecord(record.delegate) ||
|
|
35
|
+
asRecord(record.team) ||
|
|
36
|
+
asRecord(record.state) ||
|
|
37
|
+
Array.isArray(record.labels));
|
|
38
|
+
}
|
|
1
39
|
function deriveTriggerEvent(payload) {
|
|
2
|
-
|
|
3
|
-
|
|
40
|
+
const data = getPayloadData(payload);
|
|
41
|
+
const hasAgentSession = Boolean(getFirstNestedRecord(data, [
|
|
42
|
+
["agentSession"],
|
|
43
|
+
["session"],
|
|
44
|
+
["agentSessionEvent", "agentSession"],
|
|
45
|
+
["payload", "agentSession"],
|
|
46
|
+
["resource", "agentSession"],
|
|
47
|
+
])) || Boolean(getString(data, "agentSessionId"));
|
|
48
|
+
if (payload.type === "AgentSessionEvent" || payload.type === "AgentSession" || hasAgentSession) {
|
|
49
|
+
if (payload.action === "created" || payload.action === "create") {
|
|
4
50
|
return "agentSessionCreated";
|
|
5
51
|
}
|
|
6
|
-
if (payload.action === "prompted") {
|
|
52
|
+
if (payload.action === "prompted" || payload.action === "prompt") {
|
|
7
53
|
return "agentPrompted";
|
|
8
54
|
}
|
|
9
55
|
return "issueUpdated";
|
|
@@ -93,28 +139,42 @@ function extractLabelNames(record) {
|
|
|
93
139
|
return [];
|
|
94
140
|
}
|
|
95
141
|
function extractIssueMetadata(payload) {
|
|
96
|
-
const data =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
142
|
+
const data = getPayloadData(payload);
|
|
143
|
+
const sessionRecord = getFirstNestedRecord(data, [
|
|
144
|
+
["agentSession"],
|
|
145
|
+
["session"],
|
|
146
|
+
["agentSessionEvent", "agentSession"],
|
|
147
|
+
["payload", "agentSession"],
|
|
148
|
+
["resource", "agentSession"],
|
|
149
|
+
]) ?? data;
|
|
150
|
+
const commentRecord = getFirstNestedRecord(data, [["comment"]]);
|
|
151
|
+
const notificationRecord = getFirstNestedRecord(data, [["notification"]]) ?? data;
|
|
100
152
|
const issueRecord = payload.type === "Issue"
|
|
101
153
|
? data
|
|
102
|
-
: payload.type === "
|
|
103
|
-
? (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
: payload.type === "AppUserNotification"
|
|
155
|
+
? getFirstNestedRecord(notificationRecord, [["issue"], ["comment", "issue"]]) ?? getFirstNestedRecord(data, [["issue"]])
|
|
156
|
+
: getFirstNestedRecord(data, [
|
|
157
|
+
["issue"],
|
|
158
|
+
["agentSession", "issue"],
|
|
159
|
+
["session", "issue"],
|
|
160
|
+
["agentSessionEvent", "issue"],
|
|
161
|
+
["agentSessionEvent", "agentSession", "issue"],
|
|
162
|
+
["payload", "issue"],
|
|
163
|
+
["payload", "agentSession", "issue"],
|
|
164
|
+
["resource", "issue"],
|
|
165
|
+
["resource", "agentSession", "issue"],
|
|
166
|
+
["comment", "issue"],
|
|
167
|
+
["comment", "parent", "issue"],
|
|
168
|
+
["comment", "commentThread", "issue"],
|
|
169
|
+
["comment", "parentEntity", "issue"],
|
|
170
|
+
["parent", "issue"],
|
|
171
|
+
["commentThread", "issue"],
|
|
172
|
+
["parentEntity", "issue"],
|
|
173
|
+
["notification", "issue"],
|
|
174
|
+
["notification", "comment", "issue"],
|
|
175
|
+
]) ??
|
|
176
|
+
(looksLikeIssueRecord(sessionRecord) ? sessionRecord : undefined) ??
|
|
177
|
+
(looksLikeIssueRecord(commentRecord) ? commentRecord : undefined);
|
|
118
178
|
if (!issueRecord) {
|
|
119
179
|
return undefined;
|
|
120
180
|
}
|
|
@@ -184,16 +244,24 @@ function extractActorMetadata(payload) {
|
|
|
184
244
|
return fallbacks.find(Boolean);
|
|
185
245
|
}
|
|
186
246
|
function extractCommentMetadata(payload) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
247
|
+
const data = getPayloadData(payload);
|
|
248
|
+
const commentRecord = payload.type === "Comment"
|
|
249
|
+
? data
|
|
250
|
+
: getFirstNestedRecord(data, [
|
|
251
|
+
["comment"],
|
|
252
|
+
["agentSession", "comment"],
|
|
253
|
+
["session", "comment"],
|
|
254
|
+
["agentSessionEvent", "comment"],
|
|
255
|
+
["payload", "comment"],
|
|
256
|
+
["resource", "comment"],
|
|
257
|
+
["notification", "comment"],
|
|
258
|
+
]);
|
|
259
|
+
if (!commentRecord) {
|
|
192
260
|
return undefined;
|
|
193
261
|
}
|
|
194
|
-
const id = getString(
|
|
195
|
-
const body = getString(
|
|
196
|
-
const userRecord = asRecord(
|
|
262
|
+
const id = getString(commentRecord, "id");
|
|
263
|
+
const body = getString(commentRecord, "body");
|
|
264
|
+
const userRecord = asRecord(commentRecord.user);
|
|
197
265
|
const userName = getString(userRecord ?? {}, "name");
|
|
198
266
|
if (!id) {
|
|
199
267
|
return undefined;
|
|
@@ -205,23 +273,43 @@ function extractCommentMetadata(payload) {
|
|
|
205
273
|
};
|
|
206
274
|
}
|
|
207
275
|
function extractAgentSessionMetadata(payload) {
|
|
208
|
-
|
|
276
|
+
const data = getPayloadData(payload);
|
|
277
|
+
const sessionRecord = getFirstNestedRecord(data, [
|
|
278
|
+
["agentSession"],
|
|
279
|
+
["session"],
|
|
280
|
+
["agentSessionEvent", "agentSession"],
|
|
281
|
+
["payload", "agentSession"],
|
|
282
|
+
["resource", "agentSession"],
|
|
283
|
+
]) ?? (payload.type === "AgentSession" ? data : undefined);
|
|
284
|
+
if (payload.type !== "AgentSessionEvent" && payload.type !== "AgentSession" && !sessionRecord && !getString(data, "agentSessionId")) {
|
|
209
285
|
return undefined;
|
|
210
286
|
}
|
|
211
|
-
const
|
|
212
|
-
if (!data) {
|
|
213
|
-
return undefined;
|
|
214
|
-
}
|
|
215
|
-
const sessionRecord = asRecord(data.agentSession) ?? data;
|
|
216
|
-
const id = getString(sessionRecord, "id") ?? getString(data, "agentSessionId");
|
|
287
|
+
const id = getString(sessionRecord ?? {}, "id") ?? getString(data, "agentSessionId");
|
|
217
288
|
if (!id) {
|
|
218
289
|
return undefined;
|
|
219
290
|
}
|
|
220
|
-
const agentActivity =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
291
|
+
const agentActivity = getFirstNestedRecord(data, [
|
|
292
|
+
["agentActivity"],
|
|
293
|
+
["agentSession", "agentActivity"],
|
|
294
|
+
["session", "agentActivity"],
|
|
295
|
+
["agentSessionEvent", "agentActivity"],
|
|
296
|
+
["payload", "agentActivity"],
|
|
297
|
+
["resource", "agentActivity"],
|
|
298
|
+
]);
|
|
299
|
+
const commentRecord = getFirstNestedRecord(data, [
|
|
300
|
+
["comment"],
|
|
301
|
+
["agentSession", "comment"],
|
|
302
|
+
["session", "comment"],
|
|
303
|
+
["agentSessionEvent", "comment"],
|
|
304
|
+
["payload", "comment"],
|
|
305
|
+
["resource", "comment"],
|
|
306
|
+
]) ??
|
|
307
|
+
getFirstNestedRecord(sessionRecord, [["comment"]]);
|
|
308
|
+
const promptContext = getString(data, "promptContext") ?? getString(sessionRecord ?? {}, "promptContext");
|
|
309
|
+
const promptBody = getString(agentActivity ?? {}, "body") ??
|
|
310
|
+
getString(commentRecord ?? {}, "body") ??
|
|
311
|
+
getString(data, "body");
|
|
312
|
+
const issueCommentId = getString(commentRecord ?? {}, "id") ?? getString(data, "issueCommentId");
|
|
225
313
|
return {
|
|
226
314
|
id,
|
|
227
315
|
...(promptContext ? { promptContext } : {}),
|
|
@@ -230,10 +318,7 @@ function extractAgentSessionMetadata(payload) {
|
|
|
230
318
|
};
|
|
231
319
|
}
|
|
232
320
|
function extractInstallationMetadata(payload) {
|
|
233
|
-
const data =
|
|
234
|
-
if (!data) {
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
321
|
+
const data = getPayloadData(payload);
|
|
237
322
|
if (payload.type === "PermissionChange") {
|
|
238
323
|
const organizationId = getString(data, "organizationId");
|
|
239
324
|
const oauthClientId = getString(data, "oauthClientId");
|
package/package.json
CHANGED
package/dist/webhook-archive.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
const REDACTED_HEADERS = new Set(["authorization", "cookie", "set-cookie", "linear-signature"]);
|
|
4
|
-
function sanitizePathSegment(value) {
|
|
5
|
-
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
6
|
-
}
|
|
7
|
-
export async function archiveWebhook(params) {
|
|
8
|
-
const datePrefix = params.receivedAt.slice(0, 10);
|
|
9
|
-
const directory = path.join(params.archiveDir, datePrefix);
|
|
10
|
-
const fileName = `${params.receivedAt.replace(/[:.]/g, "-")}-${sanitizePathSegment(params.webhookId)}.json`;
|
|
11
|
-
const filePath = path.join(directory, fileName);
|
|
12
|
-
await mkdir(directory, { recursive: true });
|
|
13
|
-
await writeFile(filePath, JSON.stringify({
|
|
14
|
-
webhookId: params.webhookId,
|
|
15
|
-
receivedAt: params.receivedAt,
|
|
16
|
-
headers: redactHeaders(params.headers),
|
|
17
|
-
rawBodyUtf8: params.rawBody.toString("utf8"),
|
|
18
|
-
payload: params.payload,
|
|
19
|
-
}, null, 2), "utf8");
|
|
20
|
-
return filePath;
|
|
21
|
-
}
|
|
22
|
-
function redactHeaders(headers) {
|
|
23
|
-
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, REDACTED_HEADERS.has(key.toLowerCase()) ? "[redacted]" : value]));
|
|
24
|
-
}
|