patchrelay 0.10.2 → 0.10.4
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/build-info.json +3 -3
- package/dist/cli/index.js +11 -1
- package/dist/cli/output.js +8 -1
- package/dist/db/linear-installation-store.js +9 -0
- package/dist/db/migrations.js +2 -0
- package/dist/db.js +1 -1
- package/dist/http.js +1 -1
- package/dist/linear-oauth-service.js +24 -4
- package/dist/preflight.js +15 -6
- package/dist/service.js +2 -2
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -212,7 +212,17 @@ export async function runCli(argv, options) {
|
|
|
212
212
|
if (command === "doctor") {
|
|
213
213
|
const { runPreflight } = await import("../preflight.js");
|
|
214
214
|
const report = await runPreflight(config);
|
|
215
|
-
|
|
215
|
+
const cliVersion = getBuildInfo().version;
|
|
216
|
+
let serviceVersion;
|
|
217
|
+
try {
|
|
218
|
+
const healthUrl = `http://${config.server.bind}:${config.server.port}${config.server.healthPath}`;
|
|
219
|
+
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) });
|
|
220
|
+
const body = await res.json();
|
|
221
|
+
serviceVersion = body.version ?? undefined;
|
|
222
|
+
}
|
|
223
|
+
catch { /* service not reachable */ }
|
|
224
|
+
const doctorReport = { ...report, cliVersion, serviceVersion };
|
|
225
|
+
writeOutput(stdout, json ? formatJson(doctorReport) : formatDoctor(doctorReport, cliVersion, serviceVersion));
|
|
216
226
|
return report.ok ? 0 : 1;
|
|
217
227
|
}
|
|
218
228
|
if (command === "inspect") {
|
package/dist/cli/output.js
CHANGED
|
@@ -5,8 +5,15 @@ export function writeOutput(stream, text) {
|
|
|
5
5
|
export function writeUsageError(stream, error) {
|
|
6
6
|
writeOutput(stream, `${helpTextFor(error.helpTopic)}\n\nError: ${error.message}\n`);
|
|
7
7
|
}
|
|
8
|
-
export function formatDoctor(report) {
|
|
8
|
+
export function formatDoctor(report, cliVersion, serviceVersion) {
|
|
9
9
|
const lines = ["PatchRelay doctor", ""];
|
|
10
|
+
if (cliVersion) {
|
|
11
|
+
const versionLine = serviceVersion
|
|
12
|
+
? (cliVersion === serviceVersion ? `cli=${cliVersion} service=${serviceVersion}` : `cli=${cliVersion} service=${serviceVersion} (mismatch!)`)
|
|
13
|
+
: `cli=${cliVersion} service=not reachable`;
|
|
14
|
+
lines.push(versionLine);
|
|
15
|
+
lines.push("");
|
|
16
|
+
}
|
|
10
17
|
for (const check of report.checks) {
|
|
11
18
|
const marker = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
12
19
|
lines.push(`${marker} [${check.scope}] ${check.message}`);
|
|
@@ -58,6 +58,15 @@ export class LinearInstallationStore {
|
|
|
58
58
|
.run(params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson ?? null, params.tokenType ?? null, params.expiresAt ?? null, isoNow(), id);
|
|
59
59
|
return this.getLinearInstallation(id);
|
|
60
60
|
}
|
|
61
|
+
updateLinearInstallationIdentity(id, params) {
|
|
62
|
+
this.connection
|
|
63
|
+
.prepare(`UPDATE linear_installations
|
|
64
|
+
SET workspace_name = COALESCE(?, workspace_name),
|
|
65
|
+
workspace_key = COALESCE(?, workspace_key),
|
|
66
|
+
updated_at = ?
|
|
67
|
+
WHERE id = ?`)
|
|
68
|
+
.run(params.workspaceName ?? null, params.workspaceKey ?? null, isoNow(), id);
|
|
69
|
+
}
|
|
61
70
|
getLinearInstallation(id) {
|
|
62
71
|
const row = this.connection
|
|
63
72
|
.prepare("SELECT * FROM linear_installations WHERE id = ?")
|
package/dist/db/migrations.js
CHANGED
|
@@ -127,4 +127,6 @@ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_eve
|
|
|
127
127
|
`;
|
|
128
128
|
export function runPatchRelayMigrations(connection) {
|
|
129
129
|
connection.exec(schema);
|
|
130
|
+
// Clean up stale dedupe-only webhook records (no payload, never processable)
|
|
131
|
+
connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
|
|
130
132
|
}
|
package/dist/db.js
CHANGED
|
@@ -30,7 +30,7 @@ export class PatchRelayDatabase {
|
|
|
30
30
|
return { id: existing.id, duplicate: true };
|
|
31
31
|
}
|
|
32
32
|
const result = this.connection
|
|
33
|
-
.prepare("INSERT INTO webhook_events (webhook_id, received_at) VALUES (?,
|
|
33
|
+
.prepare("INSERT INTO webhook_events (webhook_id, received_at, processing_status) VALUES (?, ?, 'processed')")
|
|
34
34
|
.run(webhookId, receivedAt);
|
|
35
35
|
return { id: Number(result.lastInsertRowid), duplicate: false };
|
|
36
36
|
}
|
package/dist/http.js
CHANGED
|
@@ -332,7 +332,7 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
332
332
|
});
|
|
333
333
|
app.get("/api/oauth/linear/start", async (request, reply) => {
|
|
334
334
|
const projectId = getQueryParam(request, "projectId");
|
|
335
|
-
const result = service.createLinearOAuthStart(projectId ? { projectId } : undefined);
|
|
335
|
+
const result = await service.createLinearOAuthStart(projectId ? { projectId } : undefined);
|
|
336
336
|
return reply.send({ ok: true, ...result });
|
|
337
337
|
});
|
|
338
338
|
app.get("/api/oauth/linear/state/:state", async (request, reply) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createLinearOAuthUrl, createOAuthStateToken, installLinearOAuthCode } from "./linear-oauth.js";
|
|
1
|
+
import { createLinearOAuthUrl, createOAuthStateToken, fetchLinearViewerIdentity, installLinearOAuthCode } from "./linear-oauth.js";
|
|
2
|
+
import { decryptSecret } from "./token-crypto.js";
|
|
2
3
|
const LINEAR_OAUTH_STATE_TTL_MS = 15 * 60 * 1000;
|
|
3
4
|
function oauthStateExpired(createdAt) {
|
|
4
5
|
const createdAtMs = Date.parse(createdAt);
|
|
@@ -13,7 +14,7 @@ export class LinearOAuthService {
|
|
|
13
14
|
this.stores = stores;
|
|
14
15
|
this.logger = logger;
|
|
15
16
|
}
|
|
16
|
-
createStart(params) {
|
|
17
|
+
async createStart(params) {
|
|
17
18
|
if (params?.projectId && !this.config.projects.some((project) => project.id === params.projectId)) {
|
|
18
19
|
throw new Error(`Unknown project: ${params.projectId}`);
|
|
19
20
|
}
|
|
@@ -22,11 +23,13 @@ export class LinearOAuthService {
|
|
|
22
23
|
if (existingLink) {
|
|
23
24
|
const installation = this.stores.linearInstallations.getLinearInstallation(existingLink.installationId);
|
|
24
25
|
if (installation) {
|
|
26
|
+
await this.refreshInstallationIdentity(installation);
|
|
27
|
+
const updated = this.stores.linearInstallations.getLinearInstallation(installation.id) ?? installation;
|
|
25
28
|
return {
|
|
26
29
|
completed: true,
|
|
27
30
|
reusedExisting: true,
|
|
28
31
|
projectId: params.projectId,
|
|
29
|
-
installation: this.getInstallationSummary(
|
|
32
|
+
installation: this.getInstallationSummary(updated),
|
|
30
33
|
};
|
|
31
34
|
}
|
|
32
35
|
}
|
|
@@ -35,11 +38,13 @@ export class LinearOAuthService {
|
|
|
35
38
|
const installation = installations[0];
|
|
36
39
|
if (installation) {
|
|
37
40
|
this.stores.linearInstallations.linkProjectInstallation(params.projectId, installation.id);
|
|
41
|
+
await this.refreshInstallationIdentity(installation);
|
|
42
|
+
const updated = this.stores.linearInstallations.getLinearInstallation(installation.id) ?? installation;
|
|
38
43
|
return {
|
|
39
44
|
completed: true,
|
|
40
45
|
reusedExisting: true,
|
|
41
46
|
projectId: params.projectId,
|
|
42
|
-
installation: this.getInstallationSummary(
|
|
47
|
+
installation: this.getInstallationSummary(updated),
|
|
43
48
|
};
|
|
44
49
|
}
|
|
45
50
|
}
|
|
@@ -118,6 +123,21 @@ export class LinearOAuthService {
|
|
|
118
123
|
linkedProjects: links.filter((link) => link.installationId === installation.id).map((link) => link.projectId),
|
|
119
124
|
}));
|
|
120
125
|
}
|
|
126
|
+
async refreshInstallationIdentity(installation) {
|
|
127
|
+
try {
|
|
128
|
+
const accessToken = decryptSecret(installation.accessTokenCiphertext, this.config.linear.tokenEncryptionKey);
|
|
129
|
+
const identity = await fetchLinearViewerIdentity(this.config.linear.graphqlUrl, accessToken, this.logger);
|
|
130
|
+
if (identity.workspaceName || identity.workspaceKey) {
|
|
131
|
+
this.stores.linearInstallations.updateLinearInstallationIdentity(installation.id, {
|
|
132
|
+
...(identity.workspaceName ? { workspaceName: identity.workspaceName } : {}),
|
|
133
|
+
...(identity.workspaceKey ? { workspaceKey: identity.workspaceKey } : {}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
this.logger.debug({ installationId: installation.id, error: error instanceof Error ? error.message : String(error) }, "Failed to refresh installation identity (non-blocking)");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
121
141
|
getInstallationSummary(installation) {
|
|
122
142
|
return {
|
|
123
143
|
id: installation.id,
|
package/dist/preflight.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function runPreflight(config) {
|
|
|
70
70
|
checks.push(...checkPublicBaseUrl(config));
|
|
71
71
|
checks.push(...checkOAuthRedirectUri(config));
|
|
72
72
|
checks.push(...checkPath("database", path.dirname(config.database.path), "directory", { createIfMissing: true, writable: true }));
|
|
73
|
-
checks.push(
|
|
73
|
+
checks.push(...checkDatabaseHealth(config));
|
|
74
74
|
checks.push(...checkPath("logging", path.dirname(config.logging.filePath), "directory", { createIfMissing: true, writable: true }));
|
|
75
75
|
if (config.projects.length === 0) {
|
|
76
76
|
checks.push(warn("projects", "No projects are configured yet; add one with `patchrelay project apply <id> <repo-path>` before connecting Linear"));
|
|
@@ -87,7 +87,8 @@ export async function runPreflight(config) {
|
|
|
87
87
|
ok: checks.every((check) => check.status !== "fail"),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
-
function
|
|
90
|
+
function checkDatabaseHealth(config) {
|
|
91
|
+
const checks = [];
|
|
91
92
|
let connection;
|
|
92
93
|
try {
|
|
93
94
|
connection = new SqliteConnection(config.database.path);
|
|
@@ -99,7 +100,8 @@ function checkDatabaseSchema(config) {
|
|
|
99
100
|
const quickCheck = connection.prepare("PRAGMA quick_check").get();
|
|
100
101
|
const quickCheckResult = quickCheck ? Object.values(quickCheck)[0] : undefined;
|
|
101
102
|
if (quickCheckResult !== "ok") {
|
|
102
|
-
|
|
103
|
+
checks.push(fail("database_schema", `SQLite quick_check failed: ${String(quickCheckResult ?? "unknown result")}`));
|
|
104
|
+
return checks;
|
|
103
105
|
}
|
|
104
106
|
const schemaStats = connection
|
|
105
107
|
.prepare(`
|
|
@@ -112,16 +114,23 @@ function checkDatabaseSchema(config) {
|
|
|
112
114
|
.get();
|
|
113
115
|
const objectCount = Number(schemaStats?.object_count ?? 0);
|
|
114
116
|
if (objectCount < 1) {
|
|
115
|
-
|
|
117
|
+
checks.push(fail("database_schema", "Database schema is empty after migrations"));
|
|
118
|
+
return checks;
|
|
119
|
+
}
|
|
120
|
+
checks.push(pass("database_schema", `Database opened, migrations applied, and schema is readable (${objectCount} objects)`));
|
|
121
|
+
// Check for stale pending webhooks
|
|
122
|
+
const pendingCount = Number(connection.prepare("SELECT COUNT(*) AS n FROM webhook_events WHERE processing_status = 'pending'").get()?.n ?? 0);
|
|
123
|
+
if (pendingCount > 50) {
|
|
124
|
+
checks.push(warn("webhook_queue", `${pendingCount} pending webhook events in queue`));
|
|
116
125
|
}
|
|
117
|
-
return pass("database_schema", `Database opened, migrations applied, and schema is readable (${objectCount} objects)`);
|
|
118
126
|
}
|
|
119
127
|
catch (error) {
|
|
120
|
-
|
|
128
|
+
checks.push(fail("database_schema", `Unable to open or validate database schema at ${config.database.path}: ${formatError(error)}`));
|
|
121
129
|
}
|
|
122
130
|
finally {
|
|
123
131
|
connection?.close();
|
|
124
132
|
}
|
|
133
|
+
return checks;
|
|
125
134
|
}
|
|
126
135
|
function checkPath(scope, targetPath, expectedType, options) {
|
|
127
136
|
const checks = [];
|
package/dist/service.js
CHANGED
|
@@ -48,8 +48,8 @@ export class PatchRelayService {
|
|
|
48
48
|
stop() {
|
|
49
49
|
this.runtime.stop();
|
|
50
50
|
}
|
|
51
|
-
createLinearOAuthStart(params) {
|
|
52
|
-
return this.oauthService.createStart(params);
|
|
51
|
+
async createLinearOAuthStart(params) {
|
|
52
|
+
return await this.oauthService.createStart(params);
|
|
53
53
|
}
|
|
54
54
|
async completeLinearOAuth(params) {
|
|
55
55
|
return await this.oauthService.complete(params);
|