patchrelay 0.57.0 → 0.59.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/dist/agent-session-plan.js +45 -9
- package/dist/agent-session-presentation.js +98 -11
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/linear.js +5 -1
- package/dist/db/linear-installation-store.js +25 -4
- package/dist/db/migrations.js +6 -0
- package/dist/github-linear-session-sync.js +7 -0
- package/dist/http.js +2 -2
- package/dist/linear-agent-session-client.js +7 -0
- package/dist/linear-client.js +28 -1
- package/dist/linear-oauth-service.js +15 -1
- package/dist/service.js +12 -1
- package/dist/webhook-handler.js +1 -1
- package/dist/webhook-installation-handler.js +65 -6
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@ function implementationPlan() {
|
|
|
15
15
|
return [
|
|
16
16
|
{ content: "Prepare workspace", status: "pending" },
|
|
17
17
|
{ content: "Implementing", status: "pending" },
|
|
18
|
-
{ content: "
|
|
18
|
+
{ content: "Fresh head pushed", status: "pending" },
|
|
19
19
|
{ content: "Merge", status: "pending" },
|
|
20
20
|
];
|
|
21
21
|
}
|
|
@@ -30,8 +30,8 @@ function orchestrationPlan() {
|
|
|
30
30
|
function reviewFixPlan() {
|
|
31
31
|
return [
|
|
32
32
|
{ content: "Prepare workspace", status: "completed" },
|
|
33
|
-
{ content: "Addressing
|
|
34
|
-
{ content: "
|
|
33
|
+
{ content: "Addressing requested changes", status: "pending" },
|
|
34
|
+
{ content: "Fresh head pushed", status: "pending" },
|
|
35
35
|
{ content: "Merge", status: "pending" },
|
|
36
36
|
];
|
|
37
37
|
}
|
|
@@ -39,7 +39,7 @@ function branchUpkeepPlan() {
|
|
|
39
39
|
return [
|
|
40
40
|
{ content: "Prepare workspace", status: "completed" },
|
|
41
41
|
{ content: "Repairing branch upkeep", status: "pending" },
|
|
42
|
-
{ content: "
|
|
42
|
+
{ content: "Fresh head pushed", status: "pending" },
|
|
43
43
|
{ content: "Merge", status: "pending" },
|
|
44
44
|
];
|
|
45
45
|
}
|
|
@@ -55,7 +55,7 @@ function mainRepairPlan(attempt) {
|
|
|
55
55
|
return [
|
|
56
56
|
{ content: "Inspect main failure", status: "pending" },
|
|
57
57
|
{ content: `Repairing main (${attemptLabel(attempt)})`, status: "pending" },
|
|
58
|
-
{ content: "
|
|
58
|
+
{ content: "Fresh head pushed", status: "pending" },
|
|
59
59
|
{ content: "Priority merge", status: "pending" },
|
|
60
60
|
];
|
|
61
61
|
}
|
|
@@ -152,7 +152,12 @@ export function buildAgentSessionPlan(params) {
|
|
|
152
152
|
? mainRepairPlan(params.ciRepairAttempts ?? 1)
|
|
153
153
|
: planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
|
|
154
154
|
case "pr_open":
|
|
155
|
-
return setStatuses(
|
|
155
|
+
return setStatuses([
|
|
156
|
+
{ content: "Prepare workspace", status: "completed" },
|
|
157
|
+
{ content: "Implementing", status: "completed" },
|
|
158
|
+
{ content: prOpenGateLabel(params), status: "inProgress" },
|
|
159
|
+
{ content: "Merge", status: "pending" },
|
|
160
|
+
], ["completed", "completed", "inProgress", "pending"]);
|
|
156
161
|
case "changes_requested":
|
|
157
162
|
return setStatuses(reviewFixPlan(), ["completed", "inProgress", "pending", "pending"]);
|
|
158
163
|
case "repairing_ci":
|
|
@@ -160,9 +165,9 @@ export function buildAgentSessionPlan(params) {
|
|
|
160
165
|
case "awaiting_queue":
|
|
161
166
|
return setStatuses([
|
|
162
167
|
{ content: "Prepare workspace", status: "completed" },
|
|
163
|
-
{ content: "
|
|
168
|
+
{ content: "Fresh head pushed", status: "completed" },
|
|
164
169
|
{ content: "Verification passed", status: "completed" },
|
|
165
|
-
{ content: "Awaiting
|
|
170
|
+
{ content: "Awaiting queue", status: "inProgress" },
|
|
166
171
|
], ["completed", "completed", "completed", "inProgress"]);
|
|
167
172
|
case "repairing_queue":
|
|
168
173
|
return setStatuses(queueRepairPlan(params.queueRepairAttempts ?? 1), ["completed", "completed", "completed", "inProgress"]);
|
|
@@ -175,12 +180,41 @@ export function buildAgentSessionPlan(params) {
|
|
|
175
180
|
case "done":
|
|
176
181
|
return setStatuses([
|
|
177
182
|
{ content: "Prepare workspace", status: "completed" },
|
|
178
|
-
{ content: "
|
|
183
|
+
{ content: "Fresh head pushed", status: "completed" },
|
|
179
184
|
{ content: "Verification passed", status: "completed" },
|
|
180
185
|
{ content: "Merged", status: "completed" },
|
|
181
186
|
], ["completed", "completed", "completed", "completed"]);
|
|
182
187
|
}
|
|
183
188
|
}
|
|
189
|
+
function prOpenGateLabel(params) {
|
|
190
|
+
const reviewState = normalizeState(params.prReviewState);
|
|
191
|
+
const checkStatus = normalizeState(params.prCheckStatus);
|
|
192
|
+
if (isPendingCheckStatus(checkStatus)) {
|
|
193
|
+
return "Awaiting checks";
|
|
194
|
+
}
|
|
195
|
+
if (isAwaitingReviewState(reviewState)) {
|
|
196
|
+
return "Awaiting review";
|
|
197
|
+
}
|
|
198
|
+
if (isApprovedReviewState(reviewState) && isPassedCheckStatus(checkStatus)) {
|
|
199
|
+
return "Awaiting queue";
|
|
200
|
+
}
|
|
201
|
+
return "Fresh head pushed";
|
|
202
|
+
}
|
|
203
|
+
function isAwaitingReviewState(value) {
|
|
204
|
+
return value === "review_required" || value === "commented" || value === "changes_requested";
|
|
205
|
+
}
|
|
206
|
+
function isApprovedReviewState(value) {
|
|
207
|
+
return value === "approved";
|
|
208
|
+
}
|
|
209
|
+
function isPendingCheckStatus(value) {
|
|
210
|
+
return value === "pending" || value === "queued" || value === "in_progress" || value === "waiting";
|
|
211
|
+
}
|
|
212
|
+
function isPassedCheckStatus(value) {
|
|
213
|
+
return value === "success" || value === "passed";
|
|
214
|
+
}
|
|
215
|
+
function normalizeState(value) {
|
|
216
|
+
return (value ?? "").trim().toLowerCase().replace(/[-/\s]+/g, "_");
|
|
217
|
+
}
|
|
184
218
|
function planForRunType(runType, params) {
|
|
185
219
|
switch (runType) {
|
|
186
220
|
case "main_repair":
|
|
@@ -206,6 +240,8 @@ export function buildAgentSessionPlanForIssue(issue, options) {
|
|
|
206
240
|
...(issue.issueClass ? { issueClass: issue.issueClass } : {}),
|
|
207
241
|
...(issue.orchestrationSettleUntil ? { orchestrationSettleUntil: issue.orchestrationSettleUntil } : {}),
|
|
208
242
|
...(issue.pendingRunType ? { pendingRunType: issue.pendingRunType } : {}),
|
|
243
|
+
...(issue.prReviewState ? { prReviewState: issue.prReviewState } : {}),
|
|
244
|
+
...(issue.prCheckStatus ? { prCheckStatus: issue.prCheckStatus } : {}),
|
|
209
245
|
...(options?.activeRunType ? { activeRunType: options.activeRunType } : {}),
|
|
210
246
|
});
|
|
211
247
|
}
|
|
@@ -2,19 +2,13 @@ import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSig
|
|
|
2
2
|
const SESSION_STATUS_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
3
3
|
export function buildAgentSessionExternalUrls(config, params) {
|
|
4
4
|
const urls = [];
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
ttlSeconds: SESSION_STATUS_TTL_SECONDS,
|
|
10
|
-
});
|
|
5
|
+
const statusUrl = params.issueKey && config.server.publicBaseUrl
|
|
6
|
+
? buildPublicStatusUrl(config, params.issueKey)
|
|
7
|
+
: undefined;
|
|
8
|
+
if (statusUrl) {
|
|
11
9
|
urls.push({
|
|
12
10
|
label: "PatchRelay status",
|
|
13
|
-
url:
|
|
14
|
-
publicBaseUrl: config.server.publicBaseUrl,
|
|
15
|
-
issueKey: params.issueKey,
|
|
16
|
-
token: token.token,
|
|
17
|
-
}),
|
|
11
|
+
url: statusUrl,
|
|
18
12
|
});
|
|
19
13
|
}
|
|
20
14
|
if (params.prUrl) {
|
|
@@ -23,5 +17,98 @@ export function buildAgentSessionExternalUrls(config, params) {
|
|
|
23
17
|
url: params.prUrl,
|
|
24
18
|
});
|
|
25
19
|
}
|
|
20
|
+
const reviewQuillUrl = buildReviewQuillUrl(params);
|
|
21
|
+
if (reviewQuillUrl) {
|
|
22
|
+
urls.push({
|
|
23
|
+
label: "Review-quill status",
|
|
24
|
+
url: reviewQuillUrl,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const mergeStewardUrl = buildMergeStewardUrl(params);
|
|
28
|
+
if (mergeStewardUrl) {
|
|
29
|
+
urls.push({
|
|
30
|
+
label: "Merge-steward queue",
|
|
31
|
+
url: mergeStewardUrl,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (statusUrl && params.activeRunId !== undefined) {
|
|
35
|
+
urls.push({
|
|
36
|
+
label: "Active run",
|
|
37
|
+
url: withFragment(statusUrl, "current-view"),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
26
40
|
return urls.length > 0 ? urls : undefined;
|
|
27
41
|
}
|
|
42
|
+
function buildPublicStatusUrl(config, issueKey) {
|
|
43
|
+
if (!config.server.publicBaseUrl)
|
|
44
|
+
return undefined;
|
|
45
|
+
const token = createSessionStatusToken({
|
|
46
|
+
issueKey,
|
|
47
|
+
secret: deriveSessionStatusSigningSecret(config.linear.tokenEncryptionKey),
|
|
48
|
+
ttlSeconds: SESSION_STATUS_TTL_SECONDS,
|
|
49
|
+
});
|
|
50
|
+
return buildSessionStatusUrl({
|
|
51
|
+
publicBaseUrl: config.server.publicBaseUrl,
|
|
52
|
+
issueKey,
|
|
53
|
+
token: token.token,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function buildReviewQuillUrl(params) {
|
|
57
|
+
if (isReviewQuillCheck(params.lastGitHubFailureCheckName) && params.lastGitHubFailureCheckUrl) {
|
|
58
|
+
return params.lastGitHubFailureCheckUrl;
|
|
59
|
+
}
|
|
60
|
+
if (!params.prUrl || !hasReviewQuillContext(params)) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return `${trimTrailingSlash(params.prUrl)}/checks`;
|
|
64
|
+
}
|
|
65
|
+
function buildMergeStewardUrl(params) {
|
|
66
|
+
const incidentUrl = parseQueueIncidentUrl(params.lastQueueIncidentJson);
|
|
67
|
+
if (incidentUrl)
|
|
68
|
+
return incidentUrl;
|
|
69
|
+
if (params.lastGitHubFailureSource === "queue_eviction"
|
|
70
|
+
|| isMergeStewardCheck(params.lastGitHubFailureCheckName)) {
|
|
71
|
+
return params.lastGitHubFailureCheckUrl;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function hasReviewQuillContext(params) {
|
|
76
|
+
if (isReviewQuillCheck(params.lastGitHubFailureCheckName))
|
|
77
|
+
return true;
|
|
78
|
+
const reviewState = normalizeState(params.prReviewState);
|
|
79
|
+
if (reviewState === "review_required" || reviewState === "changes_requested" || reviewState === "commented") {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return normalizeState(params.prCheckStatus).includes("review");
|
|
83
|
+
}
|
|
84
|
+
function isReviewQuillCheck(checkName) {
|
|
85
|
+
return normalizeState(checkName).includes("review_quill");
|
|
86
|
+
}
|
|
87
|
+
function isMergeStewardCheck(checkName) {
|
|
88
|
+
return normalizeState(checkName).includes("merge_steward");
|
|
89
|
+
}
|
|
90
|
+
function parseQueueIncidentUrl(value) {
|
|
91
|
+
if (!value)
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(value);
|
|
95
|
+
if (!parsed || typeof parsed !== "object")
|
|
96
|
+
return undefined;
|
|
97
|
+
const incidentUrl = parsed.incidentUrl;
|
|
98
|
+
return typeof incidentUrl === "string" && incidentUrl.trim() ? incidentUrl.trim() : undefined;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function withFragment(url, fragment) {
|
|
105
|
+
const parsed = new URL(url);
|
|
106
|
+
parsed.hash = fragment;
|
|
107
|
+
return parsed.toString();
|
|
108
|
+
}
|
|
109
|
+
function trimTrailingSlash(value) {
|
|
110
|
+
return value.replace(/\/+$/, "");
|
|
111
|
+
}
|
|
112
|
+
function normalizeState(value) {
|
|
113
|
+
return (value ?? "").trim().toLowerCase().replace(/[-/\s]+/g, "_");
|
|
114
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -28,7 +28,11 @@ export async function handleLinearCommand(params) {
|
|
|
28
28
|
? "No Linear workspaces connected.\n"
|
|
29
29
|
: `${result.workspaces.map((workspace) => {
|
|
30
30
|
const name = workspace.installation.workspaceKey ?? workspace.installation.workspaceName ?? `installation-${workspace.installation.id}`;
|
|
31
|
-
|
|
31
|
+
const health = workspace.installation.healthStatus && workspace.installation.healthStatus !== "ok"
|
|
32
|
+
? ` health=${workspace.installation.healthStatus}`
|
|
33
|
+
: "";
|
|
34
|
+
const reason = workspace.installation.healthReason ? ` ${workspace.installation.healthReason}` : "";
|
|
35
|
+
return `${name} repos=${workspace.linkedRepos.length} teams=${workspace.teams.length} projects=${workspace.projects.length}${health}${reason}`;
|
|
32
36
|
}).join("\n")}\n`);
|
|
33
37
|
return 0;
|
|
34
38
|
}
|
|
@@ -24,20 +24,24 @@ export class LinearInstallationStore {
|
|
|
24
24
|
scopes_json = ?,
|
|
25
25
|
token_type = COALESCE(?, token_type),
|
|
26
26
|
expires_at = COALESCE(?, expires_at),
|
|
27
|
+
health_status = 'ok',
|
|
28
|
+
health_reason = NULL,
|
|
29
|
+
health_updated_at = ?,
|
|
27
30
|
updated_at = ?
|
|
28
31
|
WHERE id = ?
|
|
29
32
|
`)
|
|
30
|
-
.run(params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, existing.id);
|
|
33
|
+
.run(params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now, existing.id);
|
|
31
34
|
return this.getLinearInstallation(existing.id);
|
|
32
35
|
}
|
|
33
36
|
const result = this.connection
|
|
34
37
|
.prepare(`
|
|
35
38
|
INSERT INTO linear_installations (
|
|
36
39
|
workspace_id, workspace_name, workspace_key, actor_id, actor_name,
|
|
37
|
-
access_token_ciphertext, refresh_token_ciphertext, scopes_json, token_type, expires_at,
|
|
38
|
-
|
|
40
|
+
access_token_ciphertext, refresh_token_ciphertext, scopes_json, token_type, expires_at,
|
|
41
|
+
health_status, health_reason, health_updated_at, created_at, updated_at
|
|
42
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ok', NULL, ?, ?, ?)
|
|
39
43
|
`)
|
|
40
|
-
.run(params.workspaceId ?? null, params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now);
|
|
44
|
+
.run(params.workspaceId ?? null, params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now, now);
|
|
41
45
|
return this.getLinearInstallation(Number(result.lastInsertRowid));
|
|
42
46
|
}
|
|
43
47
|
saveLinearInstallation(params) {
|
|
@@ -67,6 +71,20 @@ export class LinearInstallationStore {
|
|
|
67
71
|
WHERE id = ?`)
|
|
68
72
|
.run(params.workspaceName ?? null, params.workspaceKey ?? null, isoNow(), id);
|
|
69
73
|
}
|
|
74
|
+
updateLinearInstallationHealth(id, params) {
|
|
75
|
+
const healthUpdatedAt = params.healthUpdatedAt ?? isoNow();
|
|
76
|
+
this.connection
|
|
77
|
+
.prepare(`
|
|
78
|
+
UPDATE linear_installations
|
|
79
|
+
SET health_status = ?,
|
|
80
|
+
health_reason = ?,
|
|
81
|
+
health_updated_at = ?,
|
|
82
|
+
updated_at = ?
|
|
83
|
+
WHERE id = ?
|
|
84
|
+
`)
|
|
85
|
+
.run(params.healthStatus, params.healthReason ?? null, healthUpdatedAt, healthUpdatedAt, id);
|
|
86
|
+
return this.getLinearInstallation(id);
|
|
87
|
+
}
|
|
70
88
|
getLinearInstallation(id) {
|
|
71
89
|
const row = this.connection
|
|
72
90
|
.prepare("SELECT * FROM linear_installations WHERE id = ?")
|
|
@@ -252,6 +270,9 @@ function mapLinearInstallation(row) {
|
|
|
252
270
|
scopesJson: String(row.scopes_json),
|
|
253
271
|
...(row.token_type === null ? {} : { tokenType: String(row.token_type) }),
|
|
254
272
|
...(row.expires_at === null ? {} : { expiresAt: String(row.expires_at) }),
|
|
273
|
+
...(row.health_status === null || row.health_status === undefined ? {} : { healthStatus: row.health_status }),
|
|
274
|
+
...(row.health_reason === null || row.health_reason === undefined ? {} : { healthReason: String(row.health_reason) }),
|
|
275
|
+
...(row.health_updated_at === null || row.health_updated_at === undefined ? {} : { healthUpdatedAt: String(row.health_updated_at) }),
|
|
255
276
|
createdAt: String(row.created_at),
|
|
256
277
|
updatedAt: String(row.updated_at),
|
|
257
278
|
};
|
package/dist/db/migrations.js
CHANGED
|
@@ -141,6 +141,9 @@ CREATE TABLE IF NOT EXISTS linear_installations (
|
|
|
141
141
|
scopes_json TEXT NOT NULL,
|
|
142
142
|
token_type TEXT,
|
|
143
143
|
expires_at TEXT,
|
|
144
|
+
health_status TEXT NOT NULL DEFAULT 'ok',
|
|
145
|
+
health_reason TEXT,
|
|
146
|
+
health_updated_at TEXT,
|
|
144
147
|
created_at TEXT NOT NULL,
|
|
145
148
|
updated_at TEXT NOT NULL
|
|
146
149
|
);
|
|
@@ -330,6 +333,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
330
333
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
|
|
331
334
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
|
|
332
335
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
|
|
336
|
+
addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
|
|
337
|
+
addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
|
|
338
|
+
addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
|
|
333
339
|
removeRetiredIssueColumnsIfPresent(connection);
|
|
334
340
|
}
|
|
335
341
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
@@ -43,6 +43,13 @@ export async function syncGitHubLinearSession(params) {
|
|
|
43
43
|
const externalUrls = buildAgentSessionExternalUrls(config, {
|
|
44
44
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
45
45
|
...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
|
|
46
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
47
|
+
...(issue.prReviewState ? { prReviewState: issue.prReviewState } : {}),
|
|
48
|
+
...(issue.prCheckStatus ? { prCheckStatus: issue.prCheckStatus } : {}),
|
|
49
|
+
...(issue.lastGitHubFailureSource ? { lastGitHubFailureSource: issue.lastGitHubFailureSource } : {}),
|
|
50
|
+
...(issue.lastGitHubFailureCheckName ? { lastGitHubFailureCheckName: issue.lastGitHubFailureCheckName } : {}),
|
|
51
|
+
...(issue.lastGitHubFailureCheckUrl ? { lastGitHubFailureCheckUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
52
|
+
...(issue.lastQueueIncidentJson ? { lastQueueIncidentJson: issue.lastQueueIncidentJson } : {}),
|
|
46
53
|
});
|
|
47
54
|
await linear.updateAgentSession({
|
|
48
55
|
agentSessionId: issue.agentSessionId,
|
package/dist/http.js
CHANGED
|
@@ -578,7 +578,7 @@ function renderAgentSessionStatusPage(params) {
|
|
|
578
578
|
<span class="chip"><strong>Latest:</strong> ${latestStage}</span>
|
|
579
579
|
<span class="chip"><strong>Thread:</strong> ${threadInfo}</span>
|
|
580
580
|
</div>
|
|
581
|
-
<div class="section">
|
|
581
|
+
<div id="current-view" class="section">
|
|
582
582
|
<h2>Current View</h2>
|
|
583
583
|
<table>
|
|
584
584
|
<tbody>
|
|
@@ -600,7 +600,7 @@ function renderAgentSessionStatusPage(params) {
|
|
|
600
600
|
<span class="chip"><strong>CI repairs:</strong> ${escapeHtml(String(ciAttempts))}</span>
|
|
601
601
|
<span class="chip"><strong>Steward repairs:</strong> ${escapeHtml(String(queueAttempts))}</span>
|
|
602
602
|
</div>
|
|
603
|
-
<div class="section">
|
|
603
|
+
<div id="recent-stages" class="section">
|
|
604
604
|
<h2>Recent Stages</h2>
|
|
605
605
|
<table>
|
|
606
606
|
<thead>
|
|
@@ -75,6 +75,13 @@ export class LinearAgentSessionClient {
|
|
|
75
75
|
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
76
76
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
77
77
|
...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
|
|
78
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
79
|
+
...(issue.prReviewState ? { prReviewState: issue.prReviewState } : {}),
|
|
80
|
+
...(issue.prCheckStatus ? { prCheckStatus: issue.prCheckStatus } : {}),
|
|
81
|
+
...(issue.lastGitHubFailureSource ? { lastGitHubFailureSource: issue.lastGitHubFailureSource } : {}),
|
|
82
|
+
...(issue.lastGitHubFailureCheckName ? { lastGitHubFailureCheckName: issue.lastGitHubFailureCheckName } : {}),
|
|
83
|
+
...(issue.lastGitHubFailureCheckUrl ? { lastGitHubFailureCheckUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
84
|
+
...(issue.lastQueueIncidentJson ? { lastQueueIncidentJson: issue.lastQueueIncidentJson } : {}),
|
|
78
85
|
});
|
|
79
86
|
await linear.updateAgentSession({
|
|
80
87
|
agentSessionId: issue.agentSessionId,
|
package/dist/linear-client.js
CHANGED
|
@@ -528,13 +528,34 @@ export class DatabaseBackedLinearClientProvider {
|
|
|
528
528
|
if (!installation) {
|
|
529
529
|
return undefined;
|
|
530
530
|
}
|
|
531
|
+
if (installation.healthStatus === "revoked") {
|
|
532
|
+
this.logger.warn({
|
|
533
|
+
installationId: installation.id,
|
|
534
|
+
workspaceName: installation.workspaceName,
|
|
535
|
+
workspaceKey: installation.workspaceKey,
|
|
536
|
+
healthReason: installation.healthReason,
|
|
537
|
+
}, "Linear installation is revoked; reconnect before using it");
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
531
540
|
const encryptionKey = this.config.linear.tokenEncryptionKey;
|
|
532
541
|
let accessToken = decryptSecret(installation.accessTokenCiphertext, encryptionKey);
|
|
533
542
|
const refreshToken = installation.refreshTokenCiphertext
|
|
534
543
|
? decryptSecret(installation.refreshTokenCiphertext, encryptionKey)
|
|
535
544
|
: undefined;
|
|
536
545
|
if (shouldRefreshToken(installation.expiresAt) && refreshToken) {
|
|
537
|
-
|
|
546
|
+
let refreshed;
|
|
547
|
+
try {
|
|
548
|
+
refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
const healthReason = `Linear OAuth token refresh failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
552
|
+
this.db.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
553
|
+
healthStatus: "auth_error",
|
|
554
|
+
healthReason,
|
|
555
|
+
});
|
|
556
|
+
this.logger.error({ installationId: installation.id, workspaceName: installation.workspaceName, error: healthReason }, "Linear OAuth token refresh failed; reconnect this installation");
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
538
559
|
accessToken = refreshed.accessToken;
|
|
539
560
|
this.db.linearInstallations.updateLinearInstallationTokens(installation.id, {
|
|
540
561
|
accessTokenCiphertext: encryptSecret(refreshed.accessToken, encryptionKey),
|
|
@@ -544,6 +565,12 @@ export class DatabaseBackedLinearClientProvider {
|
|
|
544
565
|
scopesJson: JSON.stringify(refreshed.scopes),
|
|
545
566
|
...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
|
|
546
567
|
});
|
|
568
|
+
if (installation.healthStatus === "auth_error") {
|
|
569
|
+
this.db.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
570
|
+
healthStatus: "ok",
|
|
571
|
+
healthReason: null,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
547
574
|
}
|
|
548
575
|
return new LinearGraphqlClient({
|
|
549
576
|
accessToken,
|
|
@@ -133,9 +133,20 @@ export class LinearOAuthService {
|
|
|
133
133
|
...(identity.workspaceKey ? { workspaceKey: identity.workspaceKey } : {}),
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
|
+
if (installation.healthStatus === "auth_error") {
|
|
137
|
+
this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
138
|
+
healthStatus: "ok",
|
|
139
|
+
healthReason: null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
136
142
|
}
|
|
137
143
|
catch (error) {
|
|
138
|
-
|
|
144
|
+
const healthReason = `Linear viewer lookup failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
145
|
+
this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
146
|
+
healthStatus: "auth_error",
|
|
147
|
+
healthReason,
|
|
148
|
+
});
|
|
149
|
+
this.logger.debug({ installationId: installation.id, error: healthReason }, "Failed to refresh installation identity (non-blocking)");
|
|
139
150
|
}
|
|
140
151
|
}
|
|
141
152
|
getInstallationSummary(installation) {
|
|
@@ -146,6 +157,9 @@ export class LinearOAuthService {
|
|
|
146
157
|
...(installation.actorName ? { actorName: installation.actorName } : {}),
|
|
147
158
|
...(installation.actorId ? { actorId: installation.actorId } : {}),
|
|
148
159
|
...(installation.expiresAt ? { expiresAt: installation.expiresAt } : {}),
|
|
160
|
+
healthStatus: installation.healthStatus ?? "ok",
|
|
161
|
+
...(installation.healthReason ? { healthReason: installation.healthReason } : {}),
|
|
162
|
+
...(installation.healthUpdatedAt ? { healthUpdatedAt: installation.healthUpdatedAt } : {}),
|
|
149
163
|
};
|
|
150
164
|
}
|
|
151
165
|
}
|
package/dist/service.js
CHANGED
|
@@ -87,7 +87,18 @@ export class PatchRelayService {
|
|
|
87
87
|
anyLinearConnected = true;
|
|
88
88
|
}
|
|
89
89
|
else {
|
|
90
|
-
this.
|
|
90
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
91
|
+
if (installation?.healthStatus && installation.healthStatus !== "ok") {
|
|
92
|
+
this.logger.warn({
|
|
93
|
+
projectId: project.id,
|
|
94
|
+
installationId: installation.id,
|
|
95
|
+
healthStatus: installation.healthStatus,
|
|
96
|
+
healthReason: installation.healthReason,
|
|
97
|
+
}, "Linear installation is unhealthy — run 'patchrelay linear connect' to re-authorize before processing this project");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay linear connect' and then 'patchrelay repo link' to authorize");
|
|
101
|
+
}
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
104
|
catch (error) {
|
package/dist/webhook-handler.js
CHANGED
|
@@ -35,7 +35,7 @@ export class WebhookHandler {
|
|
|
35
35
|
this.enqueueIssue = enqueueIssue;
|
|
36
36
|
this.logger = logger;
|
|
37
37
|
this.feed = feed;
|
|
38
|
-
this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger);
|
|
38
|
+
this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
|
|
39
39
|
this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
|
|
40
40
|
this.commentWakeHandler = new CommentWakeHandler(db, codex, logger, feed);
|
|
41
41
|
this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
|
|
@@ -2,20 +2,18 @@ export class InstallationWebhookHandler {
|
|
|
2
2
|
config;
|
|
3
3
|
stores;
|
|
4
4
|
logger;
|
|
5
|
-
|
|
5
|
+
feed;
|
|
6
|
+
constructor(config, stores, logger, feed) {
|
|
6
7
|
this.config = config;
|
|
7
8
|
this.stores = stores;
|
|
8
9
|
this.logger = logger;
|
|
10
|
+
this.feed = feed;
|
|
9
11
|
}
|
|
10
12
|
handle(normalized) {
|
|
11
13
|
if (!normalized.installation)
|
|
12
14
|
return;
|
|
13
15
|
if (normalized.triggerEvent === "installationPermissionsChanged") {
|
|
14
|
-
const matchingInstallations = normalized.installation
|
|
15
|
-
? this.stores.linearInstallations
|
|
16
|
-
.listLinearInstallations()
|
|
17
|
-
.filter((installation) => installation.actorId === normalized.installation?.appUserId)
|
|
18
|
-
: [];
|
|
16
|
+
const matchingInstallations = this.findMatchingInstallations(normalized.installation);
|
|
19
17
|
const links = this.stores.linearInstallations.listProjectInstallations();
|
|
20
18
|
const impactedProjects = matchingInstallations.flatMap((installation) => links
|
|
21
19
|
.filter((link) => link.installationId === installation.id)
|
|
@@ -25,6 +23,23 @@ export class InstallationWebhookHandler {
|
|
|
25
23
|
const addedMatches = normalized.installation?.addedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
|
|
26
24
|
return { projectId: link.projectId, removedMatches, addedMatches };
|
|
27
25
|
}));
|
|
26
|
+
const impactedProjectIds = impactedProjects
|
|
27
|
+
.filter((project) => project.removedMatches)
|
|
28
|
+
.map((project) => project.projectId);
|
|
29
|
+
const healthReason = buildPermissionHealthReason(normalized.installation, impactedProjectIds);
|
|
30
|
+
for (const installation of matchingInstallations) {
|
|
31
|
+
this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
32
|
+
healthStatus: "permissions_changed",
|
|
33
|
+
healthReason,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
this.feed?.publish({
|
|
37
|
+
level: "warn",
|
|
38
|
+
kind: "linear",
|
|
39
|
+
status: "permissions_changed",
|
|
40
|
+
summary: "Linear app permissions changed",
|
|
41
|
+
detail: healthReason,
|
|
42
|
+
});
|
|
28
43
|
this.logger.warn({
|
|
29
44
|
appUserId: normalized.installation.appUserId,
|
|
30
45
|
addedTeamIds: normalized.installation.addedTeamIds,
|
|
@@ -35,9 +50,25 @@ export class InstallationWebhookHandler {
|
|
|
35
50
|
return;
|
|
36
51
|
}
|
|
37
52
|
if (normalized.triggerEvent === "installationRevoked") {
|
|
53
|
+
const matchingInstallations = this.findMatchingInstallations(normalized.installation, { allowOauthClientFallback: true });
|
|
54
|
+
const healthReason = "Linear OAuth app installation was revoked. Reconnect the affected workspace before PatchRelay can update Linear.";
|
|
55
|
+
for (const installation of matchingInstallations) {
|
|
56
|
+
this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
|
|
57
|
+
healthStatus: "revoked",
|
|
58
|
+
healthReason,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
this.feed?.publish({
|
|
62
|
+
level: "error",
|
|
63
|
+
kind: "linear",
|
|
64
|
+
status: "revoked",
|
|
65
|
+
summary: "Linear OAuth app installation was revoked",
|
|
66
|
+
detail: healthReason,
|
|
67
|
+
});
|
|
38
68
|
this.logger.warn({
|
|
39
69
|
organizationId: normalized.installation.organizationId,
|
|
40
70
|
oauthClientId: normalized.installation.oauthClientId,
|
|
71
|
+
installationIds: matchingInstallations.map((installation) => installation.id),
|
|
41
72
|
}, "Linear OAuth app installation was revoked; reconnect affected projects");
|
|
42
73
|
return;
|
|
43
74
|
}
|
|
@@ -49,4 +80,32 @@ export class InstallationWebhookHandler {
|
|
|
49
80
|
}, "Received Linear app-user notification webhook");
|
|
50
81
|
}
|
|
51
82
|
}
|
|
83
|
+
findMatchingInstallations(metadata, options) {
|
|
84
|
+
const installations = this.stores.linearInstallations.listLinearInstallations();
|
|
85
|
+
const specificMatches = installations.filter((installation) => Boolean((metadata.appUserId && installation.actorId === metadata.appUserId) ||
|
|
86
|
+
(metadata.organizationId && installation.workspaceId === metadata.organizationId)));
|
|
87
|
+
if (specificMatches.length > 0) {
|
|
88
|
+
return specificMatches;
|
|
89
|
+
}
|
|
90
|
+
if (options?.allowOauthClientFallback &&
|
|
91
|
+
metadata.oauthClientId &&
|
|
92
|
+
metadata.oauthClientId === this.config.linear.oauth?.clientId) {
|
|
93
|
+
return installations;
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function buildPermissionHealthReason(metadata, impactedProjectIds) {
|
|
99
|
+
const accessChange = metadata.removedTeamIds.length > 0
|
|
100
|
+
? `removed team access: ${metadata.removedTeamIds.join(", ")}`
|
|
101
|
+
: metadata.addedTeamIds.length > 0
|
|
102
|
+
? `added team access: ${metadata.addedTeamIds.join(", ")}`
|
|
103
|
+
: "team access changed";
|
|
104
|
+
const allPublicTeams = metadata.canAccessAllPublicTeams === false
|
|
105
|
+
? " App no longer has access to all public teams."
|
|
106
|
+
: "";
|
|
107
|
+
const impactedProjects = impactedProjectIds.length > 0
|
|
108
|
+
? ` Impacted PatchRelay projects: ${impactedProjectIds.join(", ")}.`
|
|
109
|
+
: " Verify routed teams and reconnect if updates start failing.";
|
|
110
|
+
return `Linear app permissions changed (${accessChange}).${allPublicTeams}${impactedProjects}`;
|
|
52
111
|
}
|