@agent-native/core 0.227.0 → 0.228.0-nightly-20261111004142
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/action.d.ts +6 -2
- package/dist/agent/engine/builder-engine.js +6 -1
- package/dist/agent/engine/tool-call-journal-seed.d.ts +2 -0
- package/dist/agent/engine/tool-call-journal-seed.js +14 -0
- package/dist/agent/harness/store.js +14 -0
- package/dist/agent/production-agent.js +257 -17
- package/dist/agent/reaped-run-thread.js +6 -0
- package/dist/agent/run-manager.js +6 -0
- package/dist/agent/tool-call-journal.d.ts +4 -0
- package/dist/agent/tool-call-journal.js +12 -0
- package/dist/agent/types.d.ts +7 -1
- package/dist/app-config/agent.d.ts +3 -0
- package/dist/app-config/agent.js +7 -0
- package/dist/app-config/schema.d.ts +3 -0
- package/dist/application-state/handlers.d.ts +3 -3
- package/dist/application-state/handlers.js +6 -1
- package/dist/application-state/index.d.ts +3 -2
- package/dist/application-state/index.js +3 -2
- package/dist/application-state/last-used-tab.d.ts +11 -0
- package/dist/application-state/last-used-tab.js +43 -0
- package/dist/application-state/script-helpers.d.ts +26 -0
- package/dist/application-state/script-helpers.js +83 -15
- package/dist/application-state/store.d.ts +8 -0
- package/dist/application-state/store.js +16 -0
- package/dist/cli/agent.js +7 -3
- package/dist/cli/code-agent-executor.d.ts +1 -0
- package/dist/cli/code-agent-executor.js +37 -2
- package/dist/cli/code.js +3 -1
- package/dist/client/agent-sidebar-state.d.ts +2 -0
- package/dist/client/browser-tab-use.d.ts +9 -0
- package/dist/client/browser-tab-use.js +112 -0
- package/dist/client/chat/runtime.js +3 -1
- package/dist/client/error-capture.js +33 -1
- package/dist/client/navigation/index.d.ts +1 -0
- package/dist/client/navigation/index.js +1 -0
- package/dist/client/resources/mcp-integration-logos.js +16 -0
- package/dist/client/review/use-review.d.ts +28 -0
- package/dist/client/review/use-review.js +41 -12
- package/dist/client/route-state.js +2 -0
- package/dist/client/sse-event-processor.d.ts +1 -0
- package/dist/client/sse-event-processor.js +7 -1
- package/dist/client/use-db-sync.d.ts +8 -1
- package/dist/client/use-db-sync.js +6 -5
- package/dist/coding-tools/run-code.js +12 -2
- package/dist/coding-tools/sandbox/executions-store.d.ts +1 -1
- package/dist/coding-tools/sandbox/executions-store.js +9 -3
- package/dist/collab/routes.d.ts +1 -1
- package/dist/data-programs/execute.js +1 -1
- package/dist/db/schema-snapshot.generated.js +17 -3
- package/dist/extensions/url-safety.d.ts +1 -3
- package/dist/extensions/url-safety.js +23 -23
- package/dist/file-upload/actions/upload-image.d.ts +5 -5
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.js +1 -1
- package/dist/file-upload/s3-client.d.ts +1 -0
- package/dist/file-upload/s3-client.js +70 -13
- package/dist/file-upload/s3.d.ts +9 -0
- package/dist/file-upload/s3.js +14 -1
- package/dist/framework-package-versions.json +8 -8
- package/dist/jobs/run-history.js +10 -3
- package/dist/jobs/scheduler.js +5 -36
- package/dist/localization/core-messages/supplemental/ar-SA.js +35 -0
- package/dist/localization/core-messages/supplemental/de-DE.js +35 -0
- package/dist/localization/core-messages/supplemental/en-US.js +35 -0
- package/dist/localization/core-messages/supplemental/es-ES.js +35 -0
- package/dist/localization/core-messages/supplemental/fr-FR.js +35 -0
- package/dist/localization/core-messages/supplemental/hi-IN.js +35 -0
- package/dist/localization/core-messages/supplemental/ja-JP.js +35 -0
- package/dist/localization/core-messages/supplemental/ko-KR.js +35 -0
- package/dist/localization/core-messages/supplemental/pt-BR.js +35 -0
- package/dist/localization/core-messages/supplemental/zh-CN.js +35 -0
- package/dist/localization/core-messages/supplemental/zh-TW.js +35 -0
- package/dist/localization/default-messages.d.ts +35 -0
- package/dist/localization/default-messages.js +35 -0
- package/dist/localization/mcp-settings-messages.d.ts +35 -0
- package/dist/mcp/actions/list-mcp-client-connections.d.ts +8 -0
- package/dist/mcp/actions/list-mcp-client-connections.js +39 -0
- package/dist/mcp/browser-tab-target.d.ts +16 -0
- package/dist/mcp/browser-tab-target.js +58 -0
- package/dist/mcp/build-server.d.ts +1 -0
- package/dist/mcp/build-server.js +38 -7
- package/dist/mcp/call-attribution.d.ts +4 -1
- package/dist/mcp/call-attribution.js +3 -1
- package/dist/mcp/client-connections.d.ts +6 -0
- package/dist/mcp/client-connections.js +91 -0
- package/dist/mcp/connect-route.js +12 -2
- package/dist/mcp/connect-store.d.ts +3 -0
- package/dist/mcp/connect-store.js +10 -1
- package/dist/mcp/direct-action-call.js +1 -0
- package/dist/mcp/oauth-store.d.ts +6 -0
- package/dist/mcp/oauth-store.js +23 -0
- package/dist/mcp/oauth-token.d.ts +1 -0
- package/dist/mcp/oauth-token.js +1 -0
- package/dist/mcp/server.js +9 -20
- package/dist/mcp-client/config.d.ts +2 -0
- package/dist/mcp-client/manager.js +23 -0
- package/dist/mcp-client/workspace-servers.js +42 -10
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/org/handlers.d.ts +1 -1
- package/dist/org/handlers.js +21 -17
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/provider-api/index.js +17 -36
- package/dist/review/actions/create-review-comment.d.ts +7 -0
- package/dist/review/actions/create-review-comment.js +29 -3
- package/dist/review/actions/delete-review-comment.js +2 -2
- package/dist/review/actions/list-review-comments.d.ts +3 -0
- package/dist/review/actions/list-review-comments.js +41 -17
- package/dist/review/actions/react-to-review-comment.js +2 -2
- package/dist/review/actions/reply-review-comment.d.ts +4 -0
- package/dist/review/actions/reply-review-comment.js +15 -6
- package/dist/review/actions/update-review-comment-anchor.d.ts +6 -0
- package/dist/review/actions/update-review-comment-anchor.js +39 -10
- package/dist/review/actions/update-review-comment.d.ts +3 -0
- package/dist/review/actions/update-review-comment.js +28 -3
- package/dist/review/errors.d.ts +33 -0
- package/dist/review/errors.js +51 -0
- package/dist/review/index.d.ts +5 -4
- package/dist/review/index.js +3 -2
- package/dist/review/registry.d.ts +51 -1
- package/dist/review/registry.js +117 -0
- package/dist/review/store.d.ts +59 -14
- package/dist/review/store.js +622 -101
- package/dist/review/suggestions/actions.js +65 -10
- package/dist/review/suggestions/actor-kind.d.ts +5 -0
- package/dist/review/suggestions/actor-kind.js +20 -0
- package/dist/review/suggestions/store.js +16 -2
- package/dist/review/suggestions/types.d.ts +13 -0
- package/dist/review/types.d.ts +89 -0
- package/dist/scripts/db/dev-query-proxy.d.ts +2 -0
- package/dist/scripts/db/dev-query-proxy.js +4 -1
- package/dist/scripts/db/query.js +3 -0
- package/dist/scripts/dev/shell.js +4 -0
- package/dist/scripts/dev-session.d.ts +31 -0
- package/dist/scripts/dev-session.js +58 -0
- package/dist/scripts/resources/list.js +53 -4
- package/dist/scripts/runner.js +22 -4
- package/dist/search/query.d.ts +11 -5
- package/dist/search/query.js +52 -15
- package/dist/search/tokenize.d.ts +1 -0
- package/dist/search/tokenize.js +3 -0
- package/dist/secrets/api-keys.d.ts +4 -0
- package/dist/secrets/api-keys.js +5 -0
- package/dist/secrets/register.d.ts +2 -0
- package/dist/secrets/routes.d.ts +2 -2
- package/dist/secrets/routes.js +85 -6
- package/dist/secrets/storage.d.ts +2 -0
- package/dist/secrets/storage.js +34 -9
- package/dist/secrets/substitution.d.ts +3 -2
- package/dist/secrets/substitution.js +56 -7
- package/dist/server/action-discovery.js +5 -0
- package/dist/server/action-routes.js +8 -6
- package/dist/server/agent-chat/prompt-resources.js +117 -79
- package/dist/server/agent-chat/script-entries.js +15 -8
- package/dist/server/agent-chat-plugin.js +9 -4
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/dist/server/agents-bundle.d.ts +3 -1
- package/dist/server/agents-bundle.js +4 -4
- package/dist/server/csrf.js +1 -0
- package/dist/server/dev-action-bridge.d.ts +2 -0
- package/dist/server/dev-action-bridge.js +34 -5
- package/dist/server/first-touch-route.d.ts +1 -1
- package/dist/server/http-response-telemetry.js +4 -0
- package/dist/server/loopback.js +3 -1
- package/dist/server/request-context.d.ts +26 -0
- package/dist/shared/app-state-routes.d.ts +7 -0
- package/dist/shared/app-state-routes.js +7 -0
- package/dist/shared/mcp-client-connections.d.ts +41 -0
- package/dist/shared/mcp-client-connections.js +18 -0
- package/dist/shared/mcp-connect-content.d.ts +18 -0
- package/dist/shared/mcp-connect-content.js +200 -172
- package/dist/shared/runtime-config.js +5 -1
- package/dist/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
- package/dist/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
- package/dist/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
- package/dist/tracking/providers.d.ts +7 -0
- package/dist/tracking/providers.js +39 -17
- package/dist/triggers/condition-evaluator.d.ts +2 -0
- package/dist/triggers/condition-evaluator.js +93 -3
- package/dist/triggers/dispatcher.js +24 -6
- package/docs/AGENTS.md +16 -16
- package/docs/content/create-with-an-agent.mdx +10 -4
- package/docs/content/durable-resume.mdx +11 -2
- package/docs/content/toolkit-comments-review.mdx +54 -1
- package/package.json +5 -4
- package/src/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
- package/src/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
- package/src/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
package/dist/review/store.js
CHANGED
|
@@ -2,6 +2,8 @@ import { getDbExec } from "../db/client.js";
|
|
|
2
2
|
import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db/ddl-guard.js";
|
|
3
3
|
import { sql } from "../db/sql.js";
|
|
4
4
|
import { defineStore } from "../db/store-registry.js";
|
|
5
|
+
import { ReviewAnchorError, ReviewCommentLinkConflictError, ReviewCommentRevisionConflictError, ReviewCursorError, } from "./errors.js";
|
|
6
|
+
import { hasReviewCommentWriteHook, normalizeReviewAnchor, runReviewCommentWriteHook, } from "./registry.js";
|
|
5
7
|
export const reviewStore = defineStore({
|
|
6
8
|
id: "review",
|
|
7
9
|
migrations: [
|
|
@@ -111,6 +113,32 @@ export const reviewStore = defineStore({
|
|
|
111
113
|
await ensureIndexExists("idx_agent_review_statuses_resource", indexes[5]);
|
|
112
114
|
},
|
|
113
115
|
},
|
|
116
|
+
{
|
|
117
|
+
// What every app's comments need from one table: the surface a thread
|
|
118
|
+
// sits on, a typed and versioned anchor, a revision that goes up on
|
|
119
|
+
// every change, and links to the same comments in other systems. Each
|
|
120
|
+
// column is nullable or has a default, so code deployed before this
|
|
121
|
+
// keeps inserting.
|
|
122
|
+
name: "storage-contract",
|
|
123
|
+
sql: [
|
|
124
|
+
"ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS surface_id TEXT",
|
|
125
|
+
"ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS anchor_kind TEXT",
|
|
126
|
+
"ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS anchor_version INTEGER",
|
|
127
|
+
"ALTER TABLE agent_review_comments ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 1",
|
|
128
|
+
`CREATE TABLE IF NOT EXISTS agent_review_comment_links (
|
|
129
|
+
provider TEXT NOT NULL,
|
|
130
|
+
external_kind TEXT NOT NULL,
|
|
131
|
+
external_id TEXT NOT NULL,
|
|
132
|
+
comment_id TEXT NOT NULL,
|
|
133
|
+
resource_type TEXT NOT NULL,
|
|
134
|
+
resource_id TEXT NOT NULL,
|
|
135
|
+
created_at TEXT NOT NULL,
|
|
136
|
+
PRIMARY KEY (provider, external_kind, external_id)
|
|
137
|
+
)`,
|
|
138
|
+
`CREATE INDEX IF NOT EXISTS idx_agent_review_comment_links_comment
|
|
139
|
+
ON agent_review_comment_links (comment_id)`,
|
|
140
|
+
],
|
|
141
|
+
},
|
|
114
142
|
],
|
|
115
143
|
});
|
|
116
144
|
export function ensureReviewTables() {
|
|
@@ -257,13 +285,36 @@ export async function filterUnmutedReviewThreadRecipients(threadId, recipients)
|
|
|
257
285
|
const muted = new Set(rows.map((row) => String(row.user_email)));
|
|
258
286
|
return recipients.filter((email) => !muted.has(email));
|
|
259
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Runs a comment write in one transaction, so the resource's write hook
|
|
290
|
+
* commits or rolls back with it. Pass the resource type when it's known.
|
|
291
|
+
*/
|
|
292
|
+
function inReviewWrite(resourceType, write) {
|
|
293
|
+
const client = getDbExec();
|
|
294
|
+
if (client.transaction)
|
|
295
|
+
return client.transaction(write);
|
|
296
|
+
assertReviewWriteCanRollBack(resourceType);
|
|
297
|
+
return write(client);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A write hook refuses a write by throwing, which only a transaction can
|
|
301
|
+
* undo. Every client core ships has transactions; one without them, such as a
|
|
302
|
+
* test double, is refused a write to a hooked resource before anything
|
|
303
|
+
* changes. A write that names no resource is checked against the resources
|
|
304
|
+
* it would touch, read first, so another resource's hook doesn't refuse it.
|
|
305
|
+
*/
|
|
306
|
+
function assertReviewWriteCanRollBack(...resourceTypes) {
|
|
307
|
+
if (resourceTypes.some((type) => hasReviewCommentWriteHook(type))) {
|
|
308
|
+
throw new Error("This database client has no transactions, so it can't run a comment write that has a write hook.");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
260
311
|
export async function insertReviewComment(input) {
|
|
261
312
|
await ensureReviewTables();
|
|
262
|
-
return
|
|
313
|
+
return inReviewWrite(input.resourceType, (tx) => insertReviewCommentWithClient(input, tx));
|
|
263
314
|
}
|
|
264
315
|
export async function insertReviewCommentIdempotently(input) {
|
|
265
316
|
await ensureReviewTables();
|
|
266
|
-
return
|
|
317
|
+
return inReviewWrite(input.resourceType, (tx) => insertReviewCommentIdempotentlyWithClient(input, tx));
|
|
267
318
|
}
|
|
268
319
|
export async function reviewCommentNotificationCompleted(id) {
|
|
269
320
|
await ensureReviewTables();
|
|
@@ -420,6 +471,7 @@ export async function insertReviewReply(input, routeTarget, resource) {
|
|
|
420
471
|
};
|
|
421
472
|
if (client.transaction)
|
|
422
473
|
return client.transaction(insertAndRoute);
|
|
474
|
+
assertReviewWriteCanRollBack(resource.resourceType);
|
|
423
475
|
// raw-sql-ok: the helper's statement is constant text; the ids are bound.
|
|
424
476
|
await assertLiveReviewRoot(client, input.threadId, resource);
|
|
425
477
|
const reply = await insertReviewCommentWithClient(input, client);
|
|
@@ -463,6 +515,7 @@ export async function insertReviewReplyIdempotently(input, routeTarget, resource
|
|
|
463
515
|
};
|
|
464
516
|
if (client.transaction)
|
|
465
517
|
return client.transaction(insertAndRoute);
|
|
518
|
+
assertReviewWriteCanRollBack(resource.resourceType);
|
|
466
519
|
// raw-sql-ok: the helper's statement is constant text; the ids are bound.
|
|
467
520
|
await assertLiveReviewRoot(client, input.threadId, resource, input.id);
|
|
468
521
|
const result = await insertReviewCommentIdempotentlyWithClient({ ...input, replyRouteTarget: routeTarget }, client);
|
|
@@ -496,11 +549,41 @@ async function assertMatchingReplyRouteTarget(client, commentId, routeTarget) {
|
|
|
496
549
|
}
|
|
497
550
|
}
|
|
498
551
|
export async function insertReviewCommentWithClient(input, client) {
|
|
499
|
-
return (await writeReviewCommentWithClient(input, client)).comment;
|
|
552
|
+
return (await writeReviewCommentWithClient(normalizeReviewCommentInput(input), client)).comment;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The input with its anchor checked against the resource's anchor kinds. A
|
|
556
|
+
* reply carries its thread's anchor as stored, so a thread begun before its
|
|
557
|
+
* resource declared kinds can still be answered.
|
|
558
|
+
*/
|
|
559
|
+
function normalizeReviewCommentInput(input) {
|
|
560
|
+
if (input.parentCommentId)
|
|
561
|
+
return input;
|
|
562
|
+
return {
|
|
563
|
+
...input,
|
|
564
|
+
...normalizeReviewAnchor(input.resourceType, input),
|
|
565
|
+
};
|
|
500
566
|
}
|
|
567
|
+
let lastReviewCommentWrittenAt = 0;
|
|
568
|
+
/**
|
|
569
|
+
* When a new comment is written. Comments written in the same millisecond
|
|
570
|
+
* list by their random ids, so on one server each new comment is stamped at
|
|
571
|
+
* least a millisecond after the last, keeping them in the order they were
|
|
572
|
+
* written. A clock that moved back a second or more, such as a test's fake
|
|
573
|
+
* clock, is taken as it is.
|
|
574
|
+
*/
|
|
575
|
+
function reviewCommentWriteTime() {
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
lastReviewCommentWrittenAt =
|
|
578
|
+
now > lastReviewCommentWrittenAt || lastReviewCommentWrittenAt - now >= 1000
|
|
579
|
+
? now
|
|
580
|
+
: lastReviewCommentWrittenAt + 1;
|
|
581
|
+
return new Date(lastReviewCommentWrittenAt).toISOString();
|
|
582
|
+
}
|
|
583
|
+
/** Writes an already normalized comment, then runs the resource's hook. */
|
|
501
584
|
async function writeReviewCommentWithClient(input, client, onConflictDoNothing = false) {
|
|
502
585
|
const id = input.id ?? createReviewId("comment");
|
|
503
|
-
const now =
|
|
586
|
+
const now = reviewCommentWriteTime();
|
|
504
587
|
const comment = {
|
|
505
588
|
id,
|
|
506
589
|
resourceType: input.resourceType,
|
|
@@ -508,9 +591,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
508
591
|
threadId: input.threadId ?? id,
|
|
509
592
|
parentCommentId: input.parentCommentId ?? null,
|
|
510
593
|
targetId: input.targetId ?? null,
|
|
594
|
+
surfaceId: input.surfaceId ?? null,
|
|
511
595
|
kind: input.kind ?? "comment",
|
|
512
596
|
status: "open",
|
|
513
597
|
anchor: input.anchor ?? null,
|
|
598
|
+
anchorKind: input.anchorKind ?? null,
|
|
599
|
+
anchorVersion: input.anchorVersion ?? null,
|
|
600
|
+
revision: 1,
|
|
514
601
|
body: input.body,
|
|
515
602
|
authorEmail: input.authorEmail ?? null,
|
|
516
603
|
authorName: input.authorName ?? null,
|
|
@@ -534,6 +621,7 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
534
621
|
? input.metadata.resolutionNote
|
|
535
622
|
: null,
|
|
536
623
|
};
|
|
624
|
+
// raw-sql-ok: the column list is constant text; every value is bound.
|
|
537
625
|
const result = await client.execute({
|
|
538
626
|
sql: `INSERT INTO agent_review_comments (
|
|
539
627
|
id,
|
|
@@ -542,9 +630,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
542
630
|
thread_id,
|
|
543
631
|
parent_comment_id,
|
|
544
632
|
target_id,
|
|
633
|
+
surface_id,
|
|
545
634
|
kind,
|
|
546
635
|
status,
|
|
547
636
|
anchor_json,
|
|
637
|
+
anchor_kind,
|
|
638
|
+
anchor_version,
|
|
639
|
+
revision,
|
|
548
640
|
body,
|
|
549
641
|
author_email,
|
|
550
642
|
author_name,
|
|
@@ -563,7 +655,7 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
563
655
|
created_at,
|
|
564
656
|
updated_at,
|
|
565
657
|
metadata_json
|
|
566
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${onConflictDoNothing ? " ON CONFLICT (id) DO NOTHING" : ""}`,
|
|
658
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${onConflictDoNothing ? " ON CONFLICT (id) DO NOTHING" : ""}`,
|
|
567
659
|
args: [
|
|
568
660
|
comment.id,
|
|
569
661
|
comment.resourceType,
|
|
@@ -571,9 +663,13 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
571
663
|
comment.threadId,
|
|
572
664
|
comment.parentCommentId,
|
|
573
665
|
comment.targetId,
|
|
666
|
+
comment.surfaceId,
|
|
574
667
|
comment.kind,
|
|
575
668
|
comment.status,
|
|
576
669
|
stringifyOptionalJson(comment.anchor),
|
|
670
|
+
comment.anchorKind,
|
|
671
|
+
comment.anchorVersion,
|
|
672
|
+
comment.revision,
|
|
577
673
|
comment.body,
|
|
578
674
|
comment.authorEmail,
|
|
579
675
|
comment.authorName,
|
|
@@ -594,15 +690,22 @@ async function writeReviewCommentWithClient(input, client, onConflictDoNothing =
|
|
|
594
690
|
stringifyOptionalJson(comment.metadata),
|
|
595
691
|
],
|
|
596
692
|
});
|
|
597
|
-
|
|
693
|
+
const replayed = result.rowsAffected < 1;
|
|
694
|
+
if (!replayed) {
|
|
695
|
+
await runReviewCommentWriteHook(comment.resourceType, { type: "created", comment }, client);
|
|
696
|
+
}
|
|
697
|
+
return { comment, replayed };
|
|
598
698
|
}
|
|
599
|
-
async function insertReviewCommentIdempotentlyWithClient(
|
|
600
|
-
|
|
601
|
-
|
|
699
|
+
async function insertReviewCommentIdempotentlyWithClient(rawInput, client) {
|
|
700
|
+
// A retry is read the way its first submission was stored, so a change to
|
|
701
|
+
// the resource's anchor kinds since then doesn't turn it into a conflict.
|
|
702
|
+
const existing = await getReviewCommentByIdWithClient(client, rawInput.id);
|
|
602
703
|
if (existing) {
|
|
603
|
-
assertMatchingReviewCommentReceipt(existing,
|
|
704
|
+
assertMatchingReviewCommentReceipt(existing, reviewCommentFromInput(replayedReviewCommentInput(rawInput, existing)));
|
|
604
705
|
return { comment: existing, replayed: true };
|
|
605
706
|
}
|
|
707
|
+
const input = { ...normalizeReviewCommentInput(rawInput), id: rawInput.id };
|
|
708
|
+
const submitted = reviewCommentFromInput(input);
|
|
606
709
|
const insertion = await writeReviewCommentWithClient(input, client, true);
|
|
607
710
|
if (!insertion.replayed)
|
|
608
711
|
return insertion;
|
|
@@ -613,6 +716,40 @@ async function insertReviewCommentIdempotentlyWithClient(input, client) {
|
|
|
613
716
|
assertMatchingReviewCommentReceipt(receipt, submitted);
|
|
614
717
|
return { comment: receipt, replayed: true };
|
|
615
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* A retried submission normalized as its stored comment was: untyped if it
|
|
721
|
+
* was stored untyped, and otherwise at its stored anchor version unless the
|
|
722
|
+
* retry names one. A stored kind the resource no longer reads leaves the
|
|
723
|
+
* retry as sent, so a retry of what was stored still finds it and any other
|
|
724
|
+
* still conflicts.
|
|
725
|
+
*/
|
|
726
|
+
function replayedReviewCommentInput(input, stored) {
|
|
727
|
+
if (input.parentCommentId)
|
|
728
|
+
return input;
|
|
729
|
+
if (stored.anchorKind == null &&
|
|
730
|
+
input.anchorKind == null &&
|
|
731
|
+
input.anchorVersion == null) {
|
|
732
|
+
return { ...input, anchorKind: null, anchorVersion: null };
|
|
733
|
+
}
|
|
734
|
+
const replayed = {
|
|
735
|
+
...input,
|
|
736
|
+
anchorVersion: input.anchorVersion ??
|
|
737
|
+
((input.anchorKind ?? null) === (stored.anchorKind ?? null)
|
|
738
|
+
? (stored.anchorVersion ?? null)
|
|
739
|
+
: null),
|
|
740
|
+
};
|
|
741
|
+
try {
|
|
742
|
+
return normalizeReviewCommentInput(replayed);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (!(error instanceof ReviewAnchorError) ||
|
|
746
|
+
stored.anchorKind == null ||
|
|
747
|
+
input.anchorKind !== stored.anchorKind) {
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
return replayed;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
616
753
|
function reviewCommentFromInput(input) {
|
|
617
754
|
const id = input.id ?? createReviewId("comment");
|
|
618
755
|
return {
|
|
@@ -622,9 +759,13 @@ function reviewCommentFromInput(input) {
|
|
|
622
759
|
threadId: input.threadId ?? id,
|
|
623
760
|
parentCommentId: input.parentCommentId ?? null,
|
|
624
761
|
targetId: input.targetId ?? null,
|
|
762
|
+
surfaceId: input.surfaceId ?? null,
|
|
625
763
|
kind: input.kind ?? "comment",
|
|
626
764
|
status: "open",
|
|
627
765
|
anchor: input.anchor ?? null,
|
|
766
|
+
anchorKind: input.anchorKind ?? null,
|
|
767
|
+
anchorVersion: input.anchorVersion ?? null,
|
|
768
|
+
revision: 1,
|
|
628
769
|
body: input.body,
|
|
629
770
|
authorEmail: input.authorEmail ?? null,
|
|
630
771
|
authorName: input.authorName ?? null,
|
|
@@ -671,8 +812,11 @@ function assertMatchingReviewCommentReceipt(existing, submitted) {
|
|
|
671
812
|
"threadId",
|
|
672
813
|
"parentCommentId",
|
|
673
814
|
"targetId",
|
|
815
|
+
"surfaceId",
|
|
674
816
|
"kind",
|
|
675
817
|
"anchor",
|
|
818
|
+
"anchorKind",
|
|
819
|
+
"anchorVersion",
|
|
676
820
|
"body",
|
|
677
821
|
"authorEmail",
|
|
678
822
|
"createdBy",
|
|
@@ -701,6 +845,13 @@ function stableReviewReceiptJson(value) {
|
|
|
701
845
|
/** Leave out deleted comments. Neither takes a parameter. */
|
|
702
846
|
const LIVE_COMMENT_FILTERS = ["deleted_at IS NULL", "status <> 'deleted'"];
|
|
703
847
|
export async function queryReviewComments(input) {
|
|
848
|
+
return selectReviewComments(input);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The comments a query asks for. `lookahead` reads that many past the limit,
|
|
852
|
+
* so a page can tell whether another follows even at the largest limit.
|
|
853
|
+
*/
|
|
854
|
+
async function selectReviewComments(input, lookahead = 0) {
|
|
704
855
|
await ensureReviewTables();
|
|
705
856
|
const client = getDbExec();
|
|
706
857
|
const { clause, params } = input.bypassScope
|
|
@@ -744,6 +895,15 @@ export async function queryReviewComments(input) {
|
|
|
744
895
|
filterParams.push(input.targetId, input.resourceType, input.resourceId, input.targetId, ...rootScopeParams);
|
|
745
896
|
}
|
|
746
897
|
}
|
|
898
|
+
if (input.surfaceId !== undefined) {
|
|
899
|
+
if (input.surfaceId === null) {
|
|
900
|
+
filters.push("surface_id IS NULL");
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
filters.push("surface_id = ?");
|
|
904
|
+
filterParams.push(input.surfaceId);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
747
907
|
if (input.rootOnly) {
|
|
748
908
|
filters.push("parent_comment_id IS NULL");
|
|
749
909
|
}
|
|
@@ -764,6 +924,9 @@ export async function queryReviewComments(input) {
|
|
|
764
924
|
? `(${resolutionFilters.join(" OR ")})`
|
|
765
925
|
: "1 = 0");
|
|
766
926
|
}
|
|
927
|
+
if (input.after && input.newestFirst) {
|
|
928
|
+
throw new ReviewCursorError("Comment lists page oldest first only.");
|
|
929
|
+
}
|
|
767
930
|
if (input.newestFirst && !input.rootOnly) {
|
|
768
931
|
// A root deleted before deletes took replies along still anchors its live
|
|
769
932
|
// replies, so they list here as they do oldest first.
|
|
@@ -808,6 +971,16 @@ export async function queryReviewComments(input) {
|
|
|
808
971
|
});
|
|
809
972
|
return (result.rows ?? []).map(mapCommentRow);
|
|
810
973
|
}
|
|
974
|
+
// A page starts after the last comment of the one before, in the same
|
|
975
|
+
// created-then-id order, so a run of comments written in the same
|
|
976
|
+
// millisecond is neither split nor repeated.
|
|
977
|
+
const afterSql = input.after
|
|
978
|
+
? "(created_at > ? OR (created_at = ? AND id > ?))"
|
|
979
|
+
: null;
|
|
980
|
+
const afterParams = input.after
|
|
981
|
+
? [input.after.createdAt, input.after.createdAt, input.after.id]
|
|
982
|
+
: [];
|
|
983
|
+
// raw-sql-ok: the columns and filters are constant text; the values are bound.
|
|
811
984
|
const selectSql = input.rootOnly
|
|
812
985
|
? `SELECT ${commentColumns()}
|
|
813
986
|
FROM (
|
|
@@ -819,20 +992,107 @@ export async function queryReviewComments(input) {
|
|
|
819
992
|
FROM agent_review_comments AS comment
|
|
820
993
|
WHERE ${filters.join(" AND ")}
|
|
821
994
|
) AS distinct_review_threads
|
|
822
|
-
WHERE review_thread_rank = 1`
|
|
995
|
+
WHERE review_thread_rank = 1${afterSql ? ` AND ${afterSql}` : ""}`
|
|
823
996
|
: `SELECT ${commentColumns()}
|
|
824
997
|
FROM agent_review_comments AS comment
|
|
825
|
-
WHERE ${filters.join(" AND ")}`;
|
|
998
|
+
WHERE ${[...filters, ...(afterSql ? [afterSql] : [])].join(" AND ")}`;
|
|
826
999
|
const order = input.newestFirst ? "DESC" : "ASC";
|
|
1000
|
+
// raw-sql-ok: the order is ASC or DESC and the rest constant; values are bound.
|
|
827
1001
|
const result = await client.execute({
|
|
828
1002
|
sql: `${selectSql}
|
|
829
|
-
ORDER BY created_at ${order}
|
|
1003
|
+
ORDER BY created_at ${order}, id ${order}
|
|
830
1004
|
LIMIT ?`,
|
|
831
|
-
args: [
|
|
1005
|
+
args: [
|
|
1006
|
+
...filterParams,
|
|
1007
|
+
...afterParams,
|
|
1008
|
+
clampLimit(input.limit) + lookahead,
|
|
1009
|
+
],
|
|
832
1010
|
});
|
|
833
1011
|
const rows = result.rows ?? [];
|
|
834
1012
|
return (input.newestFirst ? [...rows].reverse() : rows).map(mapCommentRow);
|
|
835
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* One page of a resource's comments, oldest first, and the cursor for the
|
|
1016
|
+
* next. Unlike a plain list, which stops at its limit, every comment is
|
|
1017
|
+
* reachable page by page.
|
|
1018
|
+
*/
|
|
1019
|
+
export async function queryReviewCommentPage(input) {
|
|
1020
|
+
const { cursor, ...query } = input;
|
|
1021
|
+
if (query.newestFirst) {
|
|
1022
|
+
throw new ReviewCursorError("Comment lists page oldest first only.");
|
|
1023
|
+
}
|
|
1024
|
+
const limit = clampLimit(query.limit);
|
|
1025
|
+
const queryKey = await reviewCommentQueryKey(query);
|
|
1026
|
+
// One more than the page, to know whether another follows.
|
|
1027
|
+
const rows = await selectReviewComments({
|
|
1028
|
+
...query,
|
|
1029
|
+
after: cursor ? decodeReviewCommentCursor(cursor, queryKey) : undefined,
|
|
1030
|
+
limit,
|
|
1031
|
+
}, 1);
|
|
1032
|
+
const comments = rows.slice(0, limit);
|
|
1033
|
+
const last = rows.length > limit ? comments[comments.length - 1] : undefined;
|
|
1034
|
+
return {
|
|
1035
|
+
comments,
|
|
1036
|
+
nextCursor: last ? encodeReviewCommentCursor(queryKey, last) : null,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const REVIEW_CURSOR_PREFIX = "c1~";
|
|
1040
|
+
/**
|
|
1041
|
+
* Names a list by what it holds: its resource, filters and reader, but not
|
|
1042
|
+
* its page size. A cursor carries it, so one from another list is refused
|
|
1043
|
+
* rather than silently skipping what that list holds before its position.
|
|
1044
|
+
*/
|
|
1045
|
+
async function reviewCommentQueryKey(query) {
|
|
1046
|
+
const identity = JSON.stringify({
|
|
1047
|
+
resourceType: query.resourceType,
|
|
1048
|
+
resourceId: query.resourceId,
|
|
1049
|
+
scope: query.bypassScope
|
|
1050
|
+
? null
|
|
1051
|
+
: {
|
|
1052
|
+
userEmail: query.scope.userEmail ?? null,
|
|
1053
|
+
orgId: query.scope.orgId ?? null,
|
|
1054
|
+
},
|
|
1055
|
+
includeResolved: query.includeResolved === true,
|
|
1056
|
+
includeDeleted: query.includeDeleted === true,
|
|
1057
|
+
// A filter left out drops its key, so "any" differs from `null`.
|
|
1058
|
+
targetId: query.targetId,
|
|
1059
|
+
surfaceId: query.surfaceId,
|
|
1060
|
+
rootOnly: query.rootOnly === true,
|
|
1061
|
+
unconsumedOnly: query.unconsumedOnly === true,
|
|
1062
|
+
resolutionTargets: query.resolutionTargets
|
|
1063
|
+
? [
|
|
1064
|
+
...new Set(query.resolutionTargets.map((target) => JSON.stringify(target))),
|
|
1065
|
+
].sort()
|
|
1066
|
+
: undefined,
|
|
1067
|
+
});
|
|
1068
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity));
|
|
1069
|
+
return Array.from(new Uint8Array(digest).slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* A cursor names its list, then the last comment of a page: its time and its
|
|
1073
|
+
* ID.
|
|
1074
|
+
*/
|
|
1075
|
+
function encodeReviewCommentCursor(queryKey, position) {
|
|
1076
|
+
return `${REVIEW_CURSOR_PREFIX}${queryKey}~${position.createdAt}~${position.id}`;
|
|
1077
|
+
}
|
|
1078
|
+
function decodeReviewCommentCursor(cursor, queryKey) {
|
|
1079
|
+
const listPrefix = `${REVIEW_CURSOR_PREFIX}${queryKey}~`;
|
|
1080
|
+
if (cursor.startsWith(REVIEW_CURSOR_PREFIX) &&
|
|
1081
|
+
!cursor.startsWith(listPrefix)) {
|
|
1082
|
+
throw new ReviewCursorError("This cursor is from another comment list. Start the list again.");
|
|
1083
|
+
}
|
|
1084
|
+
const rest = cursor.startsWith(listPrefix)
|
|
1085
|
+
? cursor.slice(listPrefix.length)
|
|
1086
|
+
: "";
|
|
1087
|
+
// The time never holds a `~`, so the first one after it starts the ID.
|
|
1088
|
+
const split = rest.indexOf("~");
|
|
1089
|
+
const createdAt = split > 0 ? rest.slice(0, split) : "";
|
|
1090
|
+
const id = split > 0 ? rest.slice(split + 1) : "";
|
|
1091
|
+
if (!id || Number.isNaN(Date.parse(createdAt))) {
|
|
1092
|
+
throw new ReviewCursorError("Not a comment list cursor.");
|
|
1093
|
+
}
|
|
1094
|
+
return { createdAt, id };
|
|
1095
|
+
}
|
|
836
1096
|
export async function getReviewThreadSummary(input) {
|
|
837
1097
|
await ensureReviewTables();
|
|
838
1098
|
const { clause, params } = input.bypassScope
|
|
@@ -860,6 +1120,13 @@ export async function getReviewThreadSummary(input) {
|
|
|
860
1120
|
filterParams.push(input.targetId);
|
|
861
1121
|
}
|
|
862
1122
|
}
|
|
1123
|
+
if (input.surfaceId === null) {
|
|
1124
|
+
filters.push("surface_id IS NULL");
|
|
1125
|
+
}
|
|
1126
|
+
else if (input.surfaceId !== undefined) {
|
|
1127
|
+
filters.push("surface_id = ?");
|
|
1128
|
+
filterParams.push(input.surfaceId);
|
|
1129
|
+
}
|
|
863
1130
|
const result = await getDbExec().execute({
|
|
864
1131
|
sql: `SELECT COUNT(DISTINCT thread_id) AS open_count,
|
|
865
1132
|
COUNT(DISTINCT CASE
|
|
@@ -913,30 +1180,110 @@ export async function getReviewThreadRoot(threadId, resource, scope, options = {
|
|
|
913
1180
|
const row = result.rows?.[0];
|
|
914
1181
|
return row ? mapCommentRow(row) : null;
|
|
915
1182
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1183
|
+
/**
|
|
1184
|
+
* Takes a live comment `FOR UPDATE`, so a change made from what was read here
|
|
1185
|
+
* can't lose a concurrent one.
|
|
1186
|
+
*/
|
|
1187
|
+
async function lockLiveReviewComment(client, id, resource) {
|
|
1188
|
+
// raw-sql-ok: the columns and resource clause are constant; values are bound.
|
|
1189
|
+
const result = await client.execute({
|
|
1190
|
+
sql: `SELECT ${commentColumns()}
|
|
1191
|
+
FROM agent_review_comments
|
|
1192
|
+
WHERE id = ?
|
|
1193
|
+
${resource ? "AND resource_type = ? AND resource_id = ?" : ""}
|
|
1194
|
+
AND deleted_at IS NULL
|
|
1195
|
+
FOR UPDATE`,
|
|
925
1196
|
args: [
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
input.commentId,
|
|
929
|
-
input.resourceType,
|
|
930
|
-
input.resourceId,
|
|
1197
|
+
id,
|
|
1198
|
+
...(resource ? [resource.resourceType, resource.resourceId] : []),
|
|
931
1199
|
],
|
|
932
1200
|
});
|
|
933
|
-
|
|
1201
|
+
const row = result.rows[0];
|
|
1202
|
+
return row ? mapCommentRow(row) : null;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Changes a comment's body, mentions or anchor. Checks the anchor against its
|
|
1206
|
+
* kind, refuses a stale `expectedRevision`, moves the revision on, and runs
|
|
1207
|
+
* the resource's hook. Returns null when there's no live comment to change.
|
|
1208
|
+
*/
|
|
1209
|
+
async function writeReviewCommentChange(client, id, input, resource, options) {
|
|
1210
|
+
const current = await lockLiveReviewComment(client, id, resource);
|
|
1211
|
+
if (!current)
|
|
1212
|
+
return null;
|
|
1213
|
+
const revision = current.revision ?? 1;
|
|
1214
|
+
if (options.expectedRevision !== undefined &&
|
|
1215
|
+
options.expectedRevision !== revision) {
|
|
1216
|
+
throw new ReviewCommentRevisionConflictError(id, revision);
|
|
1217
|
+
}
|
|
1218
|
+
const assignments = [];
|
|
1219
|
+
if (input.body !== undefined) {
|
|
1220
|
+
assignments.push(sql `body = ${sql.value(input.body)}`);
|
|
1221
|
+
}
|
|
1222
|
+
if (input.mentions !== undefined) {
|
|
1223
|
+
assignments.push(sql `mentions_json = ${sql.value(stringifyOptionalJson(input.mentions))}`);
|
|
1224
|
+
}
|
|
1225
|
+
if (input.anchor !== undefined) {
|
|
1226
|
+
// A moved anchor is written now, so at its kind's current version unless
|
|
1227
|
+
// the caller says otherwise.
|
|
1228
|
+
const anchor = normalizeReviewAnchor(current.resourceType, {
|
|
1229
|
+
anchor: input.anchor,
|
|
1230
|
+
// Clearing the anchor clears its kind too; null isn't parsed as one.
|
|
1231
|
+
anchorKind: input.anchorKind !== undefined
|
|
1232
|
+
? input.anchorKind
|
|
1233
|
+
: input.anchor === null
|
|
1234
|
+
? null
|
|
1235
|
+
: (current.anchorKind ?? null),
|
|
1236
|
+
anchorVersion: input.anchorVersion ?? null,
|
|
1237
|
+
});
|
|
1238
|
+
assignments.push(sql `anchor_json = ${sql.value(stringifyOptionalJson(anchor.anchor))}`, sql `anchor_kind = ${sql.value(anchor.anchorKind)}`, sql `anchor_version = ${sql.value(anchor.anchorVersion)}`);
|
|
1239
|
+
}
|
|
1240
|
+
if (!assignments.length)
|
|
1241
|
+
return current;
|
|
1242
|
+
// The row lock holds the revision inside a transaction. The update checks
|
|
1243
|
+
// it again, so a client without one can't write over a change made after
|
|
1244
|
+
// its read, whether or not the caller named a revision.
|
|
1245
|
+
const result = await client.execute(sql `UPDATE agent_review_comments
|
|
1246
|
+
SET ${sql.join(assignments, sql `, `)},
|
|
1247
|
+
revision = revision + 1,
|
|
1248
|
+
updated_at = ${sql.value(new Date().toISOString())}
|
|
1249
|
+
WHERE id = ${sql.value(id)} AND revision = ${sql.value(revision)}`);
|
|
1250
|
+
if (result.rowsAffected < 1) {
|
|
1251
|
+
const latest = await getReviewCommentByIdWithClient(client, id);
|
|
1252
|
+
throw new ReviewCommentRevisionConflictError(id, latest?.revision ?? 1);
|
|
1253
|
+
}
|
|
1254
|
+
const updated = await getReviewCommentByIdWithClient(client, id);
|
|
1255
|
+
if (!updated)
|
|
1256
|
+
throw new Error("Review comment not found after update");
|
|
1257
|
+
const changesContent = input.body !== undefined || input.mentions !== undefined;
|
|
1258
|
+
// An edit that also moves the anchor tells the hook both, so a hook keeping
|
|
1259
|
+
// anchor or surface state still hears of the move.
|
|
1260
|
+
const events = changesContent
|
|
1261
|
+
? input.anchor === undefined
|
|
1262
|
+
? ["edited"]
|
|
1263
|
+
: ["edited", "anchor-moved"]
|
|
1264
|
+
: ["anchor-moved"];
|
|
1265
|
+
for (const type of events) {
|
|
1266
|
+
await runReviewCommentWriteHook(updated.resourceType, { type, comment: updated, actorEmail: options.actorEmail ?? null }, client);
|
|
1267
|
+
}
|
|
1268
|
+
return updated;
|
|
934
1269
|
}
|
|
935
1270
|
export async function resolveReviewThread(threadId, resolvedBy, resource, resolutionNote, status = "resolved") {
|
|
936
1271
|
await ensureReviewTables();
|
|
937
1272
|
const client = getDbExec();
|
|
938
1273
|
const resolve = (tx) => resolveReviewThreadWithClient(tx, threadId, resolvedBy, resource, resolutionNote, status);
|
|
939
|
-
|
|
1274
|
+
if (client.transaction)
|
|
1275
|
+
return client.transaction(resolve);
|
|
1276
|
+
assertReviewWriteCanRollBack(...(resource
|
|
1277
|
+
? [resource.resourceType]
|
|
1278
|
+
: await getReviewThreadResourceTypes(client, threadId)));
|
|
1279
|
+
return resolve(client);
|
|
1280
|
+
}
|
|
1281
|
+
/** The resources a thread's live comments sit on; a thread ID can repeat. */
|
|
1282
|
+
async function getReviewThreadResourceTypes(client, threadId) {
|
|
1283
|
+
const { rows } = await client.execute(sql `SELECT DISTINCT resource_type
|
|
1284
|
+
FROM agent_review_comments
|
|
1285
|
+
WHERE thread_id = ${sql.value(threadId)} AND deleted_at IS NULL`);
|
|
1286
|
+
return rows.map((row) => String(row.resource_type));
|
|
940
1287
|
}
|
|
941
1288
|
export async function resolveReviewThreadWithClient(client, threadId, resolvedBy, resource, resolutionNote, status = "resolved") {
|
|
942
1289
|
if (status === "open" && resolutionNote !== undefined) {
|
|
@@ -950,6 +1297,7 @@ export async function resolveReviewThreadWithClient(client, threadId, resolvedBy
|
|
|
950
1297
|
const resourceClause = resource
|
|
951
1298
|
? "AND resource_type = ? AND resource_id = ?"
|
|
952
1299
|
: "";
|
|
1300
|
+
// raw-sql-ok: the resource clause is constant text; the ids are bound.
|
|
953
1301
|
const root = await client.execute({
|
|
954
1302
|
sql: `SELECT metadata_json
|
|
955
1303
|
FROM agent_review_comments
|
|
@@ -973,32 +1321,88 @@ export async function resolveReviewThreadWithClient(client, threadId, resolvedBy
|
|
|
973
1321
|
: resolutionNote === undefined
|
|
974
1322
|
? undefined
|
|
975
1323
|
: { ...(rootMetadata ?? {}), resolutionNote };
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1324
|
+
const noteChanges = nextRootMetadata !== undefined &&
|
|
1325
|
+
stringifyOptionalJson(nextRootMetadata) !==
|
|
1326
|
+
stringifyOptionalJson(rootMetadata);
|
|
1327
|
+
// Only comments not already in this state change, so resolving a resolved
|
|
1328
|
+
// thread again moves no revision and fires no hook; a new resolution note
|
|
1329
|
+
// still rewrites the first comment, keeping who resolved it and when.
|
|
1330
|
+
// A thread ID can repeat across resources, so a call that names none counts
|
|
1331
|
+
// each resource apart and runs each changed one's hook.
|
|
1332
|
+
// raw-sql-ok: the resource clause is constant text; the ids are bound.
|
|
1333
|
+
const counts = await client.execute({
|
|
1334
|
+
sql: `SELECT resource_type, resource_id, COUNT(*) AS live,
|
|
1335
|
+
COALESCE(SUM(CASE WHEN status <> ? THEN 1 ELSE 0 END), 0) AS changing
|
|
1336
|
+
FROM agent_review_comments
|
|
1337
|
+
WHERE thread_id = ? AND deleted_at IS NULL ${resourceClause}
|
|
1338
|
+
GROUP BY resource_type, resource_id
|
|
1339
|
+
ORDER BY resource_type, resource_id`,
|
|
987
1340
|
args: [
|
|
988
1341
|
status,
|
|
989
|
-
status,
|
|
990
|
-
resolvedBy ?? null,
|
|
991
|
-
status,
|
|
992
|
-
status === "resolved" ? now : null,
|
|
993
|
-
now,
|
|
994
|
-
...(nextRootMetadata === undefined
|
|
995
|
-
? []
|
|
996
|
-
: [stringifyOptionalJson(nextRootMetadata)]),
|
|
997
1342
|
threadId,
|
|
998
1343
|
...(resource ? [resource.resourceType, resource.resourceId] : []),
|
|
999
1344
|
],
|
|
1000
1345
|
});
|
|
1001
|
-
|
|
1346
|
+
const perResource = counts.rows.map((row) => ({
|
|
1347
|
+
resourceType: String(row.resource_type),
|
|
1348
|
+
resourceId: String(row.resource_id),
|
|
1349
|
+
live: Number(row.live),
|
|
1350
|
+
changing: Number(row.changing),
|
|
1351
|
+
}));
|
|
1352
|
+
const live = perResource.reduce((sum, row) => sum + row.live, 0);
|
|
1353
|
+
const changing = perResource.reduce((sum, row) => sum + row.changing, 0);
|
|
1354
|
+
if (changing > 0 || noteChanges) {
|
|
1355
|
+
const metadataAssignment = noteChanges
|
|
1356
|
+
? ", metadata_json = CASE WHEN parent_comment_id IS NULL THEN ? ELSE metadata_json END"
|
|
1357
|
+
: "";
|
|
1358
|
+
// raw-sql-ok: the clauses are constant text; every value is bound.
|
|
1359
|
+
await client.execute({
|
|
1360
|
+
sql: `UPDATE agent_review_comments
|
|
1361
|
+
SET status = ?,
|
|
1362
|
+
resolved_by = CASE
|
|
1363
|
+
WHEN status = ? THEN resolved_by
|
|
1364
|
+
WHEN ? = 'resolved' THEN ?
|
|
1365
|
+
ELSE NULL
|
|
1366
|
+
END,
|
|
1367
|
+
resolved_at = CASE
|
|
1368
|
+
WHEN status = ? THEN resolved_at
|
|
1369
|
+
WHEN ? = 'resolved' THEN ?
|
|
1370
|
+
ELSE NULL
|
|
1371
|
+
END,
|
|
1372
|
+
revision = revision + 1,
|
|
1373
|
+
updated_at = ?
|
|
1374
|
+
${metadataAssignment}
|
|
1375
|
+
WHERE thread_id = ? AND deleted_at IS NULL
|
|
1376
|
+
AND (status <> ?${noteChanges ? " OR parent_comment_id IS NULL" : ""})
|
|
1377
|
+
${resourceClause}`,
|
|
1378
|
+
args: [
|
|
1379
|
+
status,
|
|
1380
|
+
status,
|
|
1381
|
+
status,
|
|
1382
|
+
resolvedBy ?? null,
|
|
1383
|
+
status,
|
|
1384
|
+
status,
|
|
1385
|
+
status === "resolved" ? now : null,
|
|
1386
|
+
now,
|
|
1387
|
+
...(noteChanges ? [stringifyOptionalJson(nextRootMetadata)] : []),
|
|
1388
|
+
threadId,
|
|
1389
|
+
status,
|
|
1390
|
+
...(resource ? [resource.resourceType, resource.resourceId] : []),
|
|
1391
|
+
],
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
for (const changed of perResource) {
|
|
1395
|
+
if (changed.changing < 1)
|
|
1396
|
+
continue;
|
|
1397
|
+
await runReviewCommentWriteHook(changed.resourceType, {
|
|
1398
|
+
type: status === "resolved" ? "resolved" : "reopened",
|
|
1399
|
+
resourceType: changed.resourceType,
|
|
1400
|
+
resourceId: changed.resourceId,
|
|
1401
|
+
threadId,
|
|
1402
|
+
actorEmail: resolvedBy ?? null,
|
|
1403
|
+
}, client);
|
|
1404
|
+
}
|
|
1405
|
+
return live;
|
|
1002
1406
|
}
|
|
1003
1407
|
function withoutResolutionNote(metadata) {
|
|
1004
1408
|
if (!metadata || !("resolutionNote" in metadata)) {
|
|
@@ -1023,11 +1427,27 @@ export async function routeReviewThread(threadId, resolutionTarget, resource) {
|
|
|
1023
1427
|
}
|
|
1024
1428
|
async function routeReviewThreadWithClient(client, threadId, resolutionTarget, resource) {
|
|
1025
1429
|
const now = new Date().toISOString();
|
|
1430
|
+
// Every open first comment still counts as routed, but only one whose route
|
|
1431
|
+
// changes, or that the agent already took and is sent back to it, moves its
|
|
1432
|
+
// revision; a caller's next edit at the revision it read still goes through.
|
|
1026
1433
|
const result = await client.execute({
|
|
1027
1434
|
sql: `UPDATE agent_review_comments
|
|
1028
1435
|
SET resolution_target = ?,
|
|
1029
1436
|
consumed_at = CASE WHEN ? = 'agent' THEN NULL ELSE consumed_at END,
|
|
1030
|
-
|
|
1437
|
+
revision = CASE
|
|
1438
|
+
WHEN resolution_target IS NULL
|
|
1439
|
+
OR resolution_target <> ?
|
|
1440
|
+
OR (? = 'agent' AND consumed_at IS NOT NULL)
|
|
1441
|
+
THEN revision + 1
|
|
1442
|
+
ELSE revision
|
|
1443
|
+
END,
|
|
1444
|
+
updated_at = CASE
|
|
1445
|
+
WHEN resolution_target IS NULL
|
|
1446
|
+
OR resolution_target <> ?
|
|
1447
|
+
OR (? = 'agent' AND consumed_at IS NOT NULL)
|
|
1448
|
+
THEN ?
|
|
1449
|
+
ELSE updated_at
|
|
1450
|
+
END
|
|
1031
1451
|
WHERE thread_id = ?
|
|
1032
1452
|
AND resource_type = ?
|
|
1033
1453
|
AND resource_id = ?
|
|
@@ -1035,6 +1455,10 @@ async function routeReviewThreadWithClient(client, threadId, resolutionTarget, r
|
|
|
1035
1455
|
AND status = 'open'
|
|
1036
1456
|
AND deleted_at IS NULL`,
|
|
1037
1457
|
args: [
|
|
1458
|
+
resolutionTarget,
|
|
1459
|
+
resolutionTarget,
|
|
1460
|
+
resolutionTarget,
|
|
1461
|
+
resolutionTarget,
|
|
1038
1462
|
resolutionTarget,
|
|
1039
1463
|
resolutionTarget,
|
|
1040
1464
|
now,
|
|
@@ -1087,13 +1511,14 @@ export async function deleteReviewComment(id, deletedBy, options = {}) {
|
|
|
1087
1511
|
const client = getDbExec();
|
|
1088
1512
|
const deleteIn = async (tx) => {
|
|
1089
1513
|
// raw-sql-ok: the statement is constant text; the id is bound.
|
|
1090
|
-
const { rows:
|
|
1091
|
-
sql: `SELECT thread_id, resource_type, resource_id
|
|
1514
|
+
const { rows: targets } = await tx.execute({
|
|
1515
|
+
sql: `SELECT thread_id, resource_type, resource_id, parent_comment_id
|
|
1092
1516
|
FROM agent_review_comments
|
|
1093
|
-
WHERE id =
|
|
1517
|
+
WHERE id = ?`,
|
|
1094
1518
|
args: [id],
|
|
1095
1519
|
});
|
|
1096
|
-
const
|
|
1520
|
+
const target = targets[0];
|
|
1521
|
+
const root = target && target.parent_comment_id == null ? target : null;
|
|
1097
1522
|
if (root) {
|
|
1098
1523
|
// A reply still being written holds its root `FOR SHARE`. Taking every
|
|
1099
1524
|
// first comment in the thread `FOR UPDATE` first waits that out, so
|
|
@@ -1130,18 +1555,47 @@ export async function deleteReviewComment(id, deletedBy, options = {}) {
|
|
|
1130
1555
|
? comment.author_email
|
|
1131
1556
|
: null));
|
|
1132
1557
|
}
|
|
1133
|
-
|
|
1558
|
+
// The hook hears only of what this delete cleared; a comment deleted on
|
|
1559
|
+
// its own meanwhile already told it.
|
|
1560
|
+
const deletedIds = [];
|
|
1561
|
+
if ((await clearDeletedReviewComment(tx, id, actor, now)) > 0) {
|
|
1562
|
+
deletedIds.push(id);
|
|
1563
|
+
}
|
|
1134
1564
|
// Another comment with no parent can have its metadata changed while this
|
|
1135
1565
|
// runs, as resolving writes a note to each, so it gets the same rechecked
|
|
1136
1566
|
// update as the root.
|
|
1137
1567
|
for (const other of rest.filter((comment) => !comment.parent_comment_id)) {
|
|
1138
|
-
|
|
1568
|
+
const otherId = String(other.id);
|
|
1569
|
+
if ((await clearDeletedReviewComment(tx, otherId, actor, now)) > 0) {
|
|
1570
|
+
deletedIds.push(otherId);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
deletedIds.push(...(await clearDeletedReviewReplies(tx, rest.filter((comment) => comment.parent_comment_id), actor, now)));
|
|
1574
|
+
if (target && deletedIds.length > 0) {
|
|
1575
|
+
const resourceType = String(target.resource_type);
|
|
1576
|
+
await runReviewCommentWriteHook(resourceType, {
|
|
1577
|
+
type: "deleted",
|
|
1578
|
+
resourceType,
|
|
1579
|
+
resourceId: String(target.resource_id),
|
|
1580
|
+
threadId: String(target.thread_id),
|
|
1581
|
+
commentIds: deletedIds,
|
|
1582
|
+
actorEmail: actor,
|
|
1583
|
+
}, tx);
|
|
1139
1584
|
}
|
|
1140
|
-
|
|
1141
|
-
const deletedReplies = await clearDeletedReviewReplies(tx, replies, actor, now);
|
|
1142
|
-
return deleted + deletedReplies;
|
|
1585
|
+
return deletedIds.length;
|
|
1143
1586
|
};
|
|
1144
|
-
|
|
1587
|
+
if (client.transaction)
|
|
1588
|
+
return client.transaction(deleteIn);
|
|
1589
|
+
// A comment's resource never changes, so it can be read ahead of the delete.
|
|
1590
|
+
const resourceType = await getReviewCommentResourceType(client, id);
|
|
1591
|
+
if (resourceType !== null)
|
|
1592
|
+
assertReviewWriteCanRollBack(resourceType);
|
|
1593
|
+
return deleteIn(client);
|
|
1594
|
+
}
|
|
1595
|
+
async function getReviewCommentResourceType(client, id) {
|
|
1596
|
+
const { rows } = await client.execute(sql `SELECT resource_type FROM agent_review_comments WHERE id = ${sql.value(id)} LIMIT 1`);
|
|
1597
|
+
const row = rows[0];
|
|
1598
|
+
return row ? String(row.resource_type) : null;
|
|
1145
1599
|
}
|
|
1146
1600
|
/** A `type` or `kind` that marks a comment's metadata as a resolution note. */
|
|
1147
1601
|
const RESOLUTION_NOTE_MARKERS = new Set([
|
|
@@ -1223,6 +1677,7 @@ async function clearDeletedReviewComment(client, id, deletedBy, now) {
|
|
|
1223
1677
|
anchor_json = NULL,
|
|
1224
1678
|
metadata_json = ${metadata},
|
|
1225
1679
|
deleted_by = ${sql.value(deletedBy)},
|
|
1680
|
+
revision = revision + 1,
|
|
1226
1681
|
deleted_at = ${sql.value(now)},
|
|
1227
1682
|
updated_at = ${sql.value(now)}
|
|
1228
1683
|
WHERE id = ${sql.value(id)}
|
|
@@ -1241,10 +1696,10 @@ const DELETED_REPLIES_PER_STATEMENT = 1_000;
|
|
|
1241
1696
|
* metadata, read under the thread's lock, is still current here. A reply can
|
|
1242
1697
|
* still be deleted on its own in between, as that takes no thread lock, so
|
|
1243
1698
|
* only those still live are cleared, and one deleted meanwhile keeps who
|
|
1244
|
-
* deleted it and when.
|
|
1699
|
+
* deleted it and when. Returns the ids it cleared.
|
|
1245
1700
|
*/
|
|
1246
1701
|
async function clearDeletedReviewReplies(client, replies, deletedBy, now) {
|
|
1247
|
-
|
|
1702
|
+
const deleted = [];
|
|
1248
1703
|
for (let start = 0; start < replies.length; start += DELETED_REPLIES_PER_STATEMENT) {
|
|
1249
1704
|
const batch = replies.slice(start, start + DELETED_REPLIES_PER_STATEMENT);
|
|
1250
1705
|
const rewrites = batch.flatMap((reply) => {
|
|
@@ -1265,11 +1720,15 @@ async function clearDeletedReviewReplies(client, replies, deletedBy, now) {
|
|
|
1265
1720
|
anchor_json = NULL,
|
|
1266
1721
|
metadata_json = ${metadata},
|
|
1267
1722
|
deleted_by = ${sql.value(deletedBy)},
|
|
1723
|
+
revision = revision + 1,
|
|
1268
1724
|
deleted_at = ${sql.value(now)},
|
|
1269
1725
|
updated_at = ${sql.value(now)}
|
|
1270
1726
|
WHERE id IN ${sql.in(batch.map((reply) => String(reply.id)))}
|
|
1271
|
-
AND deleted_at IS NULL
|
|
1272
|
-
|
|
1727
|
+
AND deleted_at IS NULL
|
|
1728
|
+
RETURNING id`);
|
|
1729
|
+
for (const row of result.rows) {
|
|
1730
|
+
deleted.push(String(row.id));
|
|
1731
|
+
}
|
|
1273
1732
|
}
|
|
1274
1733
|
return deleted;
|
|
1275
1734
|
}
|
|
@@ -1288,41 +1747,17 @@ export async function listReviewThreadParticipantEmails(input) {
|
|
|
1288
1747
|
.map((row) => nullableString(row.author_email))
|
|
1289
1748
|
.filter((email) => Boolean(email));
|
|
1290
1749
|
}
|
|
1291
|
-
export async function updateReviewComment(id, input, resource) {
|
|
1750
|
+
export async function updateReviewComment(id, input, resource, options = {}) {
|
|
1292
1751
|
await ensureReviewTables();
|
|
1293
|
-
const
|
|
1294
|
-
const
|
|
1295
|
-
if (
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
if (
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1303
|
-
if (input.mentions !== undefined) {
|
|
1304
|
-
assignments.push("mentions_json = ?");
|
|
1305
|
-
args.push(stringifyOptionalJson(input.mentions));
|
|
1306
|
-
}
|
|
1307
|
-
if (!assignments.length)
|
|
1308
|
-
return getReviewCommentById(id, {}, { bypassScope: true });
|
|
1309
|
-
assignments.push("updated_at = ?");
|
|
1310
|
-
args.push(new Date().toISOString());
|
|
1311
|
-
const resourceClause = resource
|
|
1312
|
-
? " AND resource_type = ? AND resource_id = ?"
|
|
1313
|
-
: "";
|
|
1314
|
-
args.push(id);
|
|
1315
|
-
if (resource)
|
|
1316
|
-
args.push(resource.resourceType, resource.resourceId);
|
|
1317
|
-
const result = await getDbExec().execute({
|
|
1318
|
-
sql: `UPDATE agent_review_comments
|
|
1319
|
-
SET ${assignments.join(", ")}
|
|
1320
|
-
WHERE id = ? AND deleted_at IS NULL${resourceClause}`,
|
|
1321
|
-
args,
|
|
1322
|
-
});
|
|
1323
|
-
if ((result.rowsAffected ?? 0) < 1)
|
|
1324
|
-
return null;
|
|
1325
|
-
return getReviewCommentById(id, {}, { bypassScope: true });
|
|
1752
|
+
const client = getDbExec();
|
|
1753
|
+
const write = (tx) => writeReviewCommentChange(tx, id, input, resource, options);
|
|
1754
|
+
if (client.transaction)
|
|
1755
|
+
return client.transaction(write);
|
|
1756
|
+
// A comment's resource never changes, so it can be read ahead of the edit.
|
|
1757
|
+
const resourceType = resource?.resourceType ?? (await getReviewCommentResourceType(client, id));
|
|
1758
|
+
if (resourceType !== null)
|
|
1759
|
+
assertReviewWriteCanRollBack(resourceType);
|
|
1760
|
+
return write(client);
|
|
1326
1761
|
}
|
|
1327
1762
|
export async function consumeReviewFeedback(ids, consumedAt = new Date().toISOString(), resource) {
|
|
1328
1763
|
if (!ids.length) {
|
|
@@ -1350,8 +1785,15 @@ export async function consumeReviewFeedback(ids, consumedAt = new Date().toISOSt
|
|
|
1350
1785
|
ORDER BY id
|
|
1351
1786
|
FOR UPDATE`);
|
|
1352
1787
|
const result = await tx.execute(sql `UPDATE agent_review_comments
|
|
1353
|
-
SET consumed_at = ${sql.value(consumedAt)},
|
|
1354
|
-
|
|
1788
|
+
SET consumed_at = COALESCE(consumed_at, ${sql.value(consumedAt)}),
|
|
1789
|
+
revision = CASE
|
|
1790
|
+
WHEN consumed_at IS NULL THEN revision + 1
|
|
1791
|
+
ELSE revision
|
|
1792
|
+
END,
|
|
1793
|
+
updated_at = CASE
|
|
1794
|
+
WHEN consumed_at IS NULL THEN ${sql.value(consumedAt)}
|
|
1795
|
+
ELSE updated_at
|
|
1796
|
+
END
|
|
1355
1797
|
WHERE id IN ${sql.in(ids)}
|
|
1356
1798
|
${onResource}`);
|
|
1357
1799
|
return result.rowsAffected;
|
|
@@ -1455,10 +1897,79 @@ export async function getReviewStatus(resourceType, resourceId, scope, options =
|
|
|
1455
1897
|
const row = result.rows?.[0];
|
|
1456
1898
|
return row ? mapStatusRow(row) : null;
|
|
1457
1899
|
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Links a comment to the same comment or thread in another system, such as a
|
|
1902
|
+
* Notion comment or discussion, so an import can skip what it already copied.
|
|
1903
|
+
* Linking the same outside ID to the same comment again changes nothing;
|
|
1904
|
+
* linking it to another comment is refused. Links outlive a deleted comment,
|
|
1905
|
+
* so a later import doesn't bring it back.
|
|
1906
|
+
*/
|
|
1907
|
+
export async function linkReviewComment(input, client) {
|
|
1908
|
+
await ensureReviewTables();
|
|
1909
|
+
const db = client ?? getDbExec();
|
|
1910
|
+
const comment = await getReviewCommentByIdWithClient(db, input.commentId);
|
|
1911
|
+
if (!comment)
|
|
1912
|
+
throw new Error("Review comment not found");
|
|
1913
|
+
await db.execute(sql `INSERT INTO agent_review_comment_links
|
|
1914
|
+
(provider, external_kind, external_id, comment_id, resource_type,
|
|
1915
|
+
resource_id, created_at)
|
|
1916
|
+
VALUES (${sql.value(input.provider)}, ${sql.value(input.externalKind)},
|
|
1917
|
+
${sql.value(input.externalId)}, ${sql.value(input.commentId)},
|
|
1918
|
+
${sql.value(comment.resourceType)}, ${sql.value(comment.resourceId)},
|
|
1919
|
+
${sql.value(new Date().toISOString())})
|
|
1920
|
+
ON CONFLICT (provider, external_kind, external_id) DO NOTHING`);
|
|
1921
|
+
const link = await findReviewCommentLink(input, db);
|
|
1922
|
+
if (!link || link.commentId !== input.commentId) {
|
|
1923
|
+
throw new ReviewCommentLinkConflictError(input.provider, input.externalKind, input.externalId);
|
|
1924
|
+
}
|
|
1925
|
+
return link;
|
|
1926
|
+
}
|
|
1927
|
+
/** The comment an outside ID is linked to, if any. */
|
|
1928
|
+
export async function findReviewCommentLink(input, client) {
|
|
1929
|
+
await ensureReviewTables();
|
|
1930
|
+
const result = await (client ?? getDbExec()).execute(sql `SELECT provider,
|
|
1931
|
+
external_kind, external_id, comment_id, resource_type, resource_id,
|
|
1932
|
+
created_at
|
|
1933
|
+
FROM agent_review_comment_links
|
|
1934
|
+
WHERE provider = ${sql.value(input.provider)}
|
|
1935
|
+
AND external_kind = ${sql.value(input.externalKind)}
|
|
1936
|
+
AND external_id = ${sql.value(input.externalId)}`);
|
|
1937
|
+
const row = result.rows[0];
|
|
1938
|
+
return row ? mapLinkRow(row) : null;
|
|
1939
|
+
}
|
|
1940
|
+
/** Every outside link on these comments, optionally from one system. */
|
|
1941
|
+
export async function listReviewCommentLinks(commentIds, provider) {
|
|
1942
|
+
const ids = [...new Set(commentIds)];
|
|
1943
|
+
if (!ids.length)
|
|
1944
|
+
return [];
|
|
1945
|
+
await ensureReviewTables();
|
|
1946
|
+
const fromProvider = provider
|
|
1947
|
+
? sql `AND provider = ${sql.value(provider)}`
|
|
1948
|
+
: sql ``;
|
|
1949
|
+
const result = await getDbExec().execute(sql `SELECT provider, external_kind,
|
|
1950
|
+
external_id, comment_id, resource_type, resource_id, created_at
|
|
1951
|
+
FROM agent_review_comment_links
|
|
1952
|
+
WHERE comment_id IN ${sql.in(ids)}
|
|
1953
|
+
${fromProvider}
|
|
1954
|
+
ORDER BY comment_id, provider, external_kind, external_id`);
|
|
1955
|
+
return result.rows.map((row) => mapLinkRow(row));
|
|
1956
|
+
}
|
|
1957
|
+
function mapLinkRow(row) {
|
|
1958
|
+
return {
|
|
1959
|
+
provider: String(row.provider),
|
|
1960
|
+
externalKind: String(row.external_kind),
|
|
1961
|
+
externalId: String(row.external_id),
|
|
1962
|
+
commentId: String(row.comment_id),
|
|
1963
|
+
resourceType: String(row.resource_type),
|
|
1964
|
+
resourceId: String(row.resource_id),
|
|
1965
|
+
createdAt: String(row.created_at),
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1458
1968
|
export function __resetReviewInitForTests() {
|
|
1459
1969
|
reviewStore.reset();
|
|
1460
1970
|
}
|
|
1461
1971
|
function commentColumns() {
|
|
1972
|
+
// raw-sql-ok: a constant column list.
|
|
1462
1973
|
return [
|
|
1463
1974
|
"id",
|
|
1464
1975
|
"resource_type",
|
|
@@ -1466,9 +1977,13 @@ function commentColumns() {
|
|
|
1466
1977
|
"thread_id",
|
|
1467
1978
|
"parent_comment_id",
|
|
1468
1979
|
"target_id",
|
|
1980
|
+
"surface_id",
|
|
1469
1981
|
"kind",
|
|
1470
1982
|
"status",
|
|
1471
1983
|
"anchor_json",
|
|
1984
|
+
"anchor_kind",
|
|
1985
|
+
"anchor_version",
|
|
1986
|
+
"revision",
|
|
1472
1987
|
"body",
|
|
1473
1988
|
"author_email",
|
|
1474
1989
|
"author_name",
|
|
@@ -1527,9 +2042,15 @@ function mapCommentRow(row) {
|
|
|
1527
2042
|
threadId: String(row.thread_id),
|
|
1528
2043
|
parentCommentId: nullableString(row.parent_comment_id),
|
|
1529
2044
|
targetId: nullableString(row.target_id),
|
|
2045
|
+
surfaceId: nullableString(row.surface_id),
|
|
1530
2046
|
kind: normalizeKind(row.kind),
|
|
1531
2047
|
status: normalizeCommentStatus(row.status),
|
|
1532
2048
|
anchor: parseOptionalJson(row.anchor_json),
|
|
2049
|
+
anchorKind: nullableString(row.anchor_kind),
|
|
2050
|
+
anchorVersion: row.anchor_version === null || row.anchor_version === undefined
|
|
2051
|
+
? null
|
|
2052
|
+
: Number(row.anchor_version),
|
|
2053
|
+
revision: Number(row.revision ?? 1),
|
|
1533
2054
|
body: String(row.body),
|
|
1534
2055
|
authorEmail: nullableString(row.author_email),
|
|
1535
2056
|
authorName: nullableString(row.author_name),
|