canopycms 0.6.0 → 0.7.1
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/CHANGELOG.md +188 -47
- package/dist/api/branch-review.d.ts +11 -1
- package/dist/api/branch-review.js +96 -18
- package/dist/api/branch-status.d.ts +26 -2
- package/dist/api/branch-status.js +38 -2
- package/dist/api/branch-withdraw.js +4 -7
- package/dist/api/branch.d.ts +2 -0
- package/dist/api/client.d.ts +3 -3
- package/dist/api/client.js +4 -4
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/comments.js +4 -0
- package/dist/api/content.d.ts +2 -2
- package/dist/api/github-sync.d.ts +4 -0
- package/dist/api/github-sync.js +38 -6
- package/dist/api/pr-comment-sync.d.ts +18 -0
- package/dist/api/pr-comment-sync.js +77 -0
- package/dist/api/request-deadline.d.ts +15 -7
- package/dist/api/request-deadline.js +15 -7
- package/dist/api/schema.d.ts +16 -16
- package/dist/authorization/review-role.d.ts +33 -0
- package/dist/authorization/review-role.js +17 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/cli/cli.js +699 -130
- package/dist/cli/generate-ai-content.js +524 -59
- package/dist/cli/github-app-output.d.ts +34 -0
- package/dist/cli/github-app-output.js +85 -0
- package/dist/cli/init-github-app.d.ts +18 -0
- package/dist/cli/init-github-app.js +66 -58
- package/dist/cli/init.js +10 -1
- package/dist/comment-store.d.ts +5 -0
- package/dist/comment-store.js +24 -0
- package/dist/config/helpers.js +1 -3
- package/dist/config/schemas/collection.d.ts +2 -2
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -1
- package/dist/config/schemas/url.d.ts +6 -0
- package/dist/config/schemas/url.js +16 -0
- package/dist/config/types.d.ts +12 -1
- package/dist/content-id-index.d.ts +9 -0
- package/dist/content-id-index.js +31 -0
- package/dist/editor/BranchManager.d.ts +3 -0
- package/dist/editor/BranchManager.js +75 -44
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditorPage.js +3 -4
- package/dist/editor/Editor.js +47 -8
- package/dist/editor/branch-lock.d.ts +7 -0
- package/dist/editor/branch-lock.js +9 -2
- package/dist/editor/client-reference-resolver.d.ts +5 -3
- package/dist/editor/client-reference-resolver.js +10 -5
- package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
- package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
- package/dist/editor/components/EditorHeader.d.ts +10 -4
- package/dist/editor/components/EditorHeader.js +108 -28
- package/dist/editor/components/EntryHeader.d.ts +5 -1
- package/dist/editor/components/EntryHeader.js +3 -1
- package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
- package/dist/editor/components/ReasonedMenuItem.js +2 -2
- package/dist/editor/components/ReviewDialogs.d.ts +19 -0
- package/dist/editor/components/ReviewDialogs.js +41 -0
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/fields/MarkdownField.js +1 -1
- package/dist/editor/hooks/create-branch-request.d.ts +6 -2
- package/dist/editor/hooks/create-branch-request.js +7 -2
- package/dist/editor/hooks/useBranchManager.d.ts +7 -2
- package/dist/editor/hooks/useBranchManager.js +252 -37
- package/dist/editor-url.d.ts +20 -0
- package/dist/editor-url.js +71 -0
- package/dist/git-manager.js +10 -6
- package/dist/github-service.d.ts +2 -2
- package/dist/github-service.js +4 -2
- package/dist/pr-comments.d.ts +33 -0
- package/dist/pr-comments.js +41 -0
- package/dist/schema/schema-store.d.ts +8 -8
- package/dist/services.d.ts +2 -17
- package/dist/services.js +38 -1
- package/dist/settings-workspace.d.ts +21 -7
- package/dist/settings-workspace.js +91 -16
- package/dist/submission-attribution.d.ts +38 -1
- package/dist/submission-attribution.js +197 -18
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/content-serialize.js +7 -6
- package/dist/utils/markdown-body-splice.d.ts +15 -10
- package/dist/utils/markdown-body-splice.js +71 -28
- package/dist/utils/markdown-export-options.d.ts +13 -0
- package/dist/utils/markdown-export-options.js +13 -0
- package/dist/utils/markdown-restyle.d.ts +58 -0
- package/dist/utils/markdown-restyle.js +482 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/bundle-header.d.ts +13 -9
- package/dist/worker/bundle-header.js +17 -11
- package/dist/worker/git-process.d.ts +2 -2
- package/dist/worker/git-sync.js +5 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +3 -0
- package/dist/worker/github-gateway-wire.js +1 -0
- package/dist/worker/github-gateway.d.ts +1 -1
- package/dist/worker/github-transfer.js +72 -2
- package/dist/worker/task-runner.js +53 -3
- package/package.json +2 -2
- package/dist/editor/fields/markdown-export-options.d.ts +0 -2
- package/dist/editor/fields/markdown-export-options.js +0 -8
|
@@ -1,43 +1,121 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { branchParamSchema } from './validators.js';
|
|
2
3
|
import { toBranchListItem } from './branch.js';
|
|
3
4
|
import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
4
5
|
import { defineEndpoint } from './route-builder.js';
|
|
5
6
|
import { syncConvertToDraft } from './github-sync.js';
|
|
6
|
-
|
|
7
|
+
import { syncPrComments } from './pr-comment-sync.js';
|
|
8
|
+
import { CommentStore } from '../comment-store.js';
|
|
9
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
10
|
+
import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
|
|
11
|
+
import { getBranchProtection } from '../authorization/index.js';
|
|
12
|
+
/** Optional on the wire: a bodiless request means no note. */
|
|
13
|
+
const requestChangesBodySchema = z
|
|
14
|
+
.object({ note: z.string().trim().max(REQUEST_CHANGES_NOTE_MAX_LENGTH).optional() })
|
|
15
|
+
.optional();
|
|
16
|
+
const NOTE_NOT_SAVED_WARNING = "Changes were requested, but your note couldn't be saved.";
|
|
17
|
+
/**
|
|
18
|
+
* The 409 for a branch that left review before this request arrived, because the reviewer's page
|
|
19
|
+
* was stale. `action` finishes "so ..." for any status that has no wording of its own.
|
|
20
|
+
*/
|
|
21
|
+
const staleStatusError = (status, action) => status === 'editing'
|
|
22
|
+
? "This branch is no longer in review: it's back with its editor."
|
|
23
|
+
: status === undefined
|
|
24
|
+
? `This branch's status can't be read, so ${action}.`
|
|
25
|
+
: `This branch is ${status}, so ${action}.`;
|
|
26
|
+
const canRequestChangesFrom = (status) => status === 'submitted' || status === 'approved';
|
|
27
|
+
const requestChangesConflict = (status) => ({
|
|
28
|
+
ok: false,
|
|
29
|
+
status: 409,
|
|
30
|
+
error: staleStatusError(status, "changes can't be requested"),
|
|
31
|
+
});
|
|
32
|
+
const approveConflict = (status) => ({
|
|
33
|
+
ok: false,
|
|
34
|
+
status: 409,
|
|
35
|
+
error: status === 'approved'
|
|
36
|
+
? 'This branch is already approved.'
|
|
37
|
+
: staleStatusError(status, "it can't be approved"),
|
|
38
|
+
});
|
|
39
|
+
const requestChangesHandler = async (gc, ctx, req, _params, body) => {
|
|
7
40
|
const { branchContext } = gc;
|
|
8
|
-
if (branchContext.branch.status
|
|
9
|
-
return
|
|
10
|
-
ok: false,
|
|
11
|
-
status: 400,
|
|
12
|
-
error: `Cannot request changes on branch with status '${branchContext.branch.status}'. Only 'submitted' branches can have changes requested.`,
|
|
13
|
-
};
|
|
41
|
+
if (!canRequestChangesFrom(branchContext.branch.status)) {
|
|
42
|
+
return requestChangesConflict(branchContext.branch.status);
|
|
14
43
|
}
|
|
15
44
|
await syncConvertToDraft(ctx, branchContext);
|
|
16
45
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
17
|
-
|
|
18
|
-
|
|
46
|
+
// The status the guard read can be stale by now, so the save checks the status on disk, in the
|
|
47
|
+
// same step that writes. A branch that left review meanwhile is refused, and the draft
|
|
48
|
+
// conversion above is left as it is: withdraw converts the pull request to a draft too, and a
|
|
49
|
+
// merged one cannot be converted.
|
|
50
|
+
let found;
|
|
51
|
+
const updated = await meta.saveIf({ branch: { name: branchContext.branch.name, status: 'editing' } }, (existing) => {
|
|
52
|
+
found = existing?.branch.status;
|
|
53
|
+
return canRequestChangesFrom(found);
|
|
19
54
|
});
|
|
20
|
-
|
|
55
|
+
if (!updated)
|
|
56
|
+
return requestChangesConflict(found);
|
|
57
|
+
// The note is written after the status change so a failed write never blocks the branch from
|
|
58
|
+
// returning to editing; the caller is warned instead.
|
|
59
|
+
const note = body?.note;
|
|
60
|
+
let warning;
|
|
61
|
+
if (note) {
|
|
62
|
+
try {
|
|
63
|
+
await new CommentStore(branchContext.branchRoot).addComment({
|
|
64
|
+
userId: req.user.userId,
|
|
65
|
+
text: `Changes requested: ${note}`,
|
|
66
|
+
type: 'branch',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`CanopyCMS: Failed to save the request-changes note for ${branchContext.branch.name}:`, getErrorMessage(err));
|
|
71
|
+
warning = NOTE_NOT_SAVED_WARNING;
|
|
72
|
+
}
|
|
73
|
+
if (!warning)
|
|
74
|
+
await syncPrComments(ctx, { ...branchContext, branch: updated.branch });
|
|
75
|
+
}
|
|
21
76
|
return {
|
|
22
77
|
ok: true,
|
|
23
78
|
status: 200,
|
|
24
|
-
data: {
|
|
79
|
+
data: {
|
|
80
|
+
branch: toBranchListItem(ctx.services.config, updated.branch, req.user),
|
|
81
|
+
...(warning && { warning }),
|
|
82
|
+
},
|
|
25
83
|
};
|
|
26
84
|
};
|
|
27
85
|
const approveBranchHandler = async (gc, ctx, req, _params) => {
|
|
28
86
|
const { branchContext } = gc;
|
|
29
|
-
|
|
87
|
+
// Approve is a CanopyCMS status only. The CMS authors its pull requests and GitHub refuses
|
|
88
|
+
// an author's approval review, so nothing is sent to GitHub.
|
|
89
|
+
if (branchContext.branch.createdBy === req.user.userId) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
status: 403,
|
|
93
|
+
error: "You can't approve a branch you created. Ask another reviewer to approve it.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Request changes stays open on the base branch, as Withdraw does, to recover one stuck in review.
|
|
97
|
+
const { isProtected } = getBranchProtection(ctx.services.config, branchContext.branch.name, branchContext.branch.baseBranch);
|
|
98
|
+
if (isProtected) {
|
|
99
|
+
return { ok: false, status: 400, error: "The base branch can't be approved." };
|
|
100
|
+
}
|
|
101
|
+
if (branchContext.branch.status !== 'submitted')
|
|
102
|
+
return approveConflict(branchContext.branch.status);
|
|
103
|
+
if (branchContext.branch.pullRequestState === 'closed') {
|
|
30
104
|
return {
|
|
31
105
|
ok: false,
|
|
32
106
|
status: 400,
|
|
33
|
-
error:
|
|
107
|
+
error: 'The pull request was closed on GitHub, so this branch cannot be approved. Withdraw it from review, then submit it again to open a new one.',
|
|
34
108
|
};
|
|
35
109
|
}
|
|
36
110
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
37
|
-
|
|
38
|
-
|
|
111
|
+
// As for request changes, the status on disk decides, in the step that writes.
|
|
112
|
+
let found;
|
|
113
|
+
const updated = await meta.saveIf({ branch: { name: branchContext.branch.name, status: 'approved' } }, (existing) => {
|
|
114
|
+
found = existing?.branch.status;
|
|
115
|
+
return found === 'submitted';
|
|
39
116
|
});
|
|
40
|
-
|
|
117
|
+
if (!updated)
|
|
118
|
+
return approveConflict(found);
|
|
41
119
|
return {
|
|
42
120
|
ok: true,
|
|
43
121
|
status: 200,
|
|
@@ -50,8 +128,8 @@ export const requestChanges = defineEndpoint({
|
|
|
50
128
|
method: 'POST',
|
|
51
129
|
path: '/:branch/request-changes',
|
|
52
130
|
params: branchParamSchema,
|
|
53
|
-
|
|
54
|
-
|
|
131
|
+
body: requestChangesBodySchema,
|
|
132
|
+
bodyType: 'RequestChangesBody',
|
|
55
133
|
responseType: 'BranchListItemResponse',
|
|
56
134
|
response: {},
|
|
57
135
|
defaultMockData: {
|
|
@@ -3,6 +3,7 @@ import type { ApiResponse } from './types.js';
|
|
|
3
3
|
import { type BranchListItem, type BranchListItemResponse } from './branch.js';
|
|
4
4
|
import type { StampedEntry } from '../stamp-on-publish.js';
|
|
5
5
|
export type { BranchMergeResponse } from './branch-merge.js';
|
|
6
|
+
export type { RequestChangesBody } from './branch-review.js';
|
|
6
7
|
/**
|
|
7
8
|
* A submit's answer: the branch, plus the entries the submit stamped (stamp-on-publish.ts) when
|
|
8
9
|
* there were any, so the editor can carry its open forms and drafts onto their new versions.
|
|
@@ -11,6 +12,16 @@ export type SubmitBranchResponse = ApiResponse<{
|
|
|
11
12
|
branch: BranchListItem;
|
|
12
13
|
stamped?: StampedEntry[];
|
|
13
14
|
}>;
|
|
15
|
+
declare const submitBranchBodySchema: z.ZodOptional<z.ZodObject<{
|
|
16
|
+
/** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
|
|
17
|
+
editorUrl: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
18
|
+
}, "strip", z.ZodTypeAny, {
|
|
19
|
+
editorUrl?: string | undefined;
|
|
20
|
+
}, {
|
|
21
|
+
editorUrl?: unknown;
|
|
22
|
+
}>>;
|
|
23
|
+
/** The submit request body. */
|
|
24
|
+
export type SubmitBranchBody = z.infer<typeof submitBranchBodySchema>;
|
|
14
25
|
export declare const WORKFLOW_ROUTES: {
|
|
15
26
|
readonly getStatus: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
16
27
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
@@ -33,7 +44,14 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
33
44
|
branch: string & {
|
|
34
45
|
readonly __brand: "BranchName";
|
|
35
46
|
};
|
|
36
|
-
}>,
|
|
47
|
+
}>, z.ZodOptional<z.ZodObject<{
|
|
48
|
+
/** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
|
|
49
|
+
editorUrl: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
editorUrl?: string | undefined;
|
|
52
|
+
}, {
|
|
53
|
+
editorUrl?: unknown;
|
|
54
|
+
}>>, SubmitBranchResponse>;
|
|
37
55
|
readonly withdraw: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
38
56
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
39
57
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -55,7 +73,13 @@ export declare const WORKFLOW_ROUTES: {
|
|
|
55
73
|
branch: string & {
|
|
56
74
|
readonly __brand: "BranchName";
|
|
57
75
|
};
|
|
58
|
-
}>,
|
|
76
|
+
}>, z.ZodOptional<z.ZodObject<{
|
|
77
|
+
note: z.ZodOptional<z.ZodString>;
|
|
78
|
+
}, "strip", z.ZodTypeAny, {
|
|
79
|
+
note?: string | undefined;
|
|
80
|
+
}, {
|
|
81
|
+
note?: string | undefined;
|
|
82
|
+
}>>, BranchListItemResponse>;
|
|
59
83
|
readonly approve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
60
84
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
|
61
85
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { branchParamSchema } from './validators.js';
|
|
2
3
|
import { toBranchListItem } from './branch.js';
|
|
3
4
|
import { getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
@@ -13,6 +14,7 @@ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
|
13
14
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
14
15
|
import { NothingToSubmitError } from '../services.js';
|
|
15
16
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
17
|
+
import { resolveEditorUrl } from '../editor-url.js';
|
|
16
18
|
const getBranchStatusHandler = async (gc, ctx, req, _params) => {
|
|
17
19
|
const { branchContext } = gc;
|
|
18
20
|
return {
|
|
@@ -21,7 +23,33 @@ const getBranchStatusHandler = async (gc, ctx, req, _params) => {
|
|
|
21
23
|
data: { branch: toBranchListItem(ctx.services.config, branchContext.branch, req.user) },
|
|
22
24
|
};
|
|
23
25
|
};
|
|
24
|
-
const
|
|
26
|
+
const submitBranchBodySchema = z
|
|
27
|
+
.object({
|
|
28
|
+
/** The editor page's URL (`location.origin + location.pathname`), for the PR body's links. */
|
|
29
|
+
// Any value but a usable URL costs only the links (editor-url.ts), never the submit.
|
|
30
|
+
editorUrl: z.string().optional().catch(undefined),
|
|
31
|
+
})
|
|
32
|
+
.optional();
|
|
33
|
+
/**
|
|
34
|
+
* The editor URL the PR body links to: `config.editorUrl`, else the URL the editor reported
|
|
35
|
+
* when the request was addressed to its host (editor-url.ts). A reported URL that fails that
|
|
36
|
+
* check is logged once per submit, since every link then goes missing.
|
|
37
|
+
*/
|
|
38
|
+
function submitEditorUrl(ctx, req, claimed) {
|
|
39
|
+
const configured = ctx.services.config.editorUrl;
|
|
40
|
+
const resolved = resolveEditorUrl({
|
|
41
|
+
configured,
|
|
42
|
+
claimed,
|
|
43
|
+
header: req.rawRequest ? (name) => req.rawRequest?.header(name) ?? null : undefined,
|
|
44
|
+
});
|
|
45
|
+
if (!resolved && !configured && claimed) {
|
|
46
|
+
console.warn('CanopyCMS: The pull request body has no editor links: the editor URL the browser reported ' +
|
|
47
|
+
'is not on the host the request was addressed to (X-Forwarded-Host, else Host). Set ' +
|
|
48
|
+
'editorUrl in the CanopyCMS config to the editor page URL.');
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
|
|
25
53
|
const { branchContext } = gc;
|
|
26
54
|
// Check if user can perform workflow actions (creator OR ACL access). isProtectedBranch
|
|
27
55
|
// is passed for defense-in-depth (disables the system-branch grant); the 'submittableBranch'
|
|
@@ -61,10 +89,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
61
89
|
const { authPlugin } = ctx;
|
|
62
90
|
let changedPaths;
|
|
63
91
|
let editors;
|
|
92
|
+
let entryPaths;
|
|
64
93
|
let stamped;
|
|
65
94
|
try {
|
|
66
95
|
;
|
|
67
|
-
({ changedPaths, editors, stamped } = await ctx.services.submitBranch({
|
|
96
|
+
({ changedPaths, editors, entryPaths, stamped } = await ctx.services.submitBranch({
|
|
68
97
|
context: branchContext,
|
|
69
98
|
submitter,
|
|
70
99
|
lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
|
|
@@ -123,10 +152,13 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
123
152
|
}
|
|
124
153
|
// Create or update PR (sync via githubService, or async via task queue)
|
|
125
154
|
const submittedAt = new Date().toISOString();
|
|
155
|
+
const editorUrl = submitEditorUrl(ctx, req, body?.editorUrl);
|
|
126
156
|
const prResult = await syncSubmitPr(ctx, branchContext, {
|
|
127
157
|
submitter,
|
|
128
158
|
editors,
|
|
129
159
|
changedPaths,
|
|
160
|
+
entryPaths,
|
|
161
|
+
editorUrl,
|
|
130
162
|
submittedAt,
|
|
131
163
|
});
|
|
132
164
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
@@ -149,6 +181,8 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
149
181
|
status: 'submitted',
|
|
150
182
|
submittedAt,
|
|
151
183
|
...pushed,
|
|
184
|
+
// Only a request-derived URL: config.editorUrl is read live by every later re-render.
|
|
185
|
+
...(editorUrl && !ctx.services.config.editorUrl ? { editorUrl } : {}),
|
|
152
186
|
syncFailureReason: prResult.syncFailureReason,
|
|
153
187
|
pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
|
|
154
188
|
pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
|
|
@@ -196,6 +230,8 @@ const submitBranchForMerge = defineEndpoint({
|
|
|
196
230
|
method: 'POST',
|
|
197
231
|
path: '/:branch/submit',
|
|
198
232
|
params: branchParamSchema,
|
|
233
|
+
body: submitBranchBodySchema,
|
|
234
|
+
bodyType: 'SubmitBranchBody',
|
|
199
235
|
responseType: 'SubmitBranchResponse',
|
|
200
236
|
response: {},
|
|
201
237
|
defaultMockData: {
|
|
@@ -22,13 +22,10 @@ const withdrawBranchHandler = async (gc, ctx, req, _params) => {
|
|
|
22
22
|
error: 'You do not have permission to withdraw this branch from review.',
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
// 'approved' is accepted alongside 'submitted' because withdraw is
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// discarded the approval and is now refused by the submit status gate
|
|
30
|
-
// (api/branch-status.ts). Without this, closing that hole would seal the dead
|
|
31
|
-
// end completely, leaving deletion as the only way out.
|
|
25
|
+
// 'approved' is accepted alongside 'submitted' because withdraw is the author's
|
|
26
|
+
// way back from either: the editor offers an author no Request changes. Every
|
|
27
|
+
// non-'editing' status blocks content writes, and a raw workflow.submit would
|
|
28
|
+
// discard the approval, which the submit status gate refuses (api/branch-status.ts).
|
|
32
29
|
//
|
|
33
30
|
// 'archived' stays refused: the PR is merged and there is nothing to return to.
|
|
34
31
|
const status = branchContext.branch.status;
|
package/dist/api/branch.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface BranchListItem extends BranchMetadata {
|
|
|
57
57
|
*/
|
|
58
58
|
export type BranchListItemResponse = ApiResponse<{
|
|
59
59
|
branch: BranchListItem;
|
|
60
|
+
/** Set when the request succeeded but a secondary effect failed; show it to the user. */
|
|
61
|
+
warning?: string;
|
|
60
62
|
}>;
|
|
61
63
|
/** Response type for listing branches */
|
|
62
64
|
export type BranchListResponse = ApiResponse<{
|
package/dist/api/client.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { type RequestOptions } from './request-deadline.js';
|
|
|
9
9
|
export { isApiTimeoutError, WRITE_TIMED_OUT_MESSAGE, type RequestOptions } from './request-deadline.js';
|
|
10
10
|
import type { OperatingMode } from '../operating-mode/types.js';
|
|
11
11
|
import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
12
|
-
import type { BranchMergeResponse, SubmitBranchResponse } from './branch-status.js';
|
|
12
|
+
import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
|
|
13
13
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
14
14
|
import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
|
|
15
15
|
import type { ReferenceOptionsResponse } from './reference-options.js';
|
|
@@ -82,7 +82,7 @@ export declare class CanopyApiClient {
|
|
|
82
82
|
/** POST /:branch/withdraw */
|
|
83
83
|
withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
84
84
|
/** POST /:branch/request-changes */
|
|
85
|
-
requestChanges: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
85
|
+
requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
86
86
|
/** POST /:branch/approve */
|
|
87
87
|
approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
88
88
|
/** POST /:branch/mark-merged */
|
|
@@ -90,7 +90,7 @@ export declare class CanopyApiClient {
|
|
|
90
90
|
/** GET /:branch/status */
|
|
91
91
|
getStatus: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
|
|
92
92
|
/** POST /:branch/submit */
|
|
93
|
-
submit: (params: Record<string, string>, options?: RequestOptions) => Promise<SubmitBranchResponse>;
|
|
93
|
+
submit: (params: Record<string, string>, body: SubmitBranchBody, options?: RequestOptions) => Promise<SubmitBranchResponse>;
|
|
94
94
|
};
|
|
95
95
|
readonly comments: {
|
|
96
96
|
/** GET /:branch/comments */
|
package/dist/api/client.js
CHANGED
|
@@ -47,8 +47,8 @@ export class CanopyApiClient {
|
|
|
47
47
|
return this.request('POST', this.buildPath('/:branch/withdraw', params), undefined, 'standard', options);
|
|
48
48
|
},
|
|
49
49
|
/** POST /:branch/request-changes */
|
|
50
|
-
requestChanges: (params, options) => {
|
|
51
|
-
return this.request('POST', this.buildPath('/:branch/request-changes', params),
|
|
50
|
+
requestChanges: (params, body, options) => {
|
|
51
|
+
return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
|
|
52
52
|
},
|
|
53
53
|
/** POST /:branch/approve */
|
|
54
54
|
approve: (params, options) => {
|
|
@@ -63,8 +63,8 @@ export class CanopyApiClient {
|
|
|
63
63
|
return this.request('GET', this.buildPath('/:branch/status', params), undefined, 'standard', options);
|
|
64
64
|
},
|
|
65
65
|
/** POST /:branch/submit */
|
|
66
|
-
submit: (params, options) => {
|
|
67
|
-
return this.request('POST', this.buildPath('/:branch/submit', params),
|
|
66
|
+
submit: (params, body, options) => {
|
|
67
|
+
return this.request('POST', this.buildPath('/:branch/submit', params), body, 'long', options);
|
|
68
68
|
},
|
|
69
69
|
};
|
|
70
70
|
this.comments = {
|
package/dist/api/comments.d.ts
CHANGED
|
@@ -60,16 +60,16 @@ export declare const COMMENT_ROUTES: {
|
|
|
60
60
|
}, "strip", z.ZodTypeAny, {
|
|
61
61
|
type: "branch" | "entry" | "field";
|
|
62
62
|
text: string;
|
|
63
|
-
entryPath?: LogicalPath | undefined;
|
|
64
63
|
threadId?: string | undefined;
|
|
65
64
|
itemKeys?: string[] | undefined;
|
|
65
|
+
entryPath?: LogicalPath | undefined;
|
|
66
66
|
canopyPath?: string | undefined;
|
|
67
67
|
}, {
|
|
68
68
|
type: "branch" | "entry" | "field";
|
|
69
69
|
text: string;
|
|
70
|
-
entryPath?: LogicalPath | undefined;
|
|
71
70
|
threadId?: string | undefined;
|
|
72
71
|
itemKeys?: string[] | undefined;
|
|
72
|
+
entryPath?: LogicalPath | undefined;
|
|
73
73
|
canopyPath?: string | undefined;
|
|
74
74
|
}>, AddCommentResponse>;
|
|
75
75
|
readonly resolve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
package/dist/api/comments.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isReviewer } from '../authorization/index.js';
|
|
|
6
6
|
import { parseLogicalPath } from '../paths/validation.js';
|
|
7
7
|
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
8
8
|
import { defineEndpoint } from './route-builder.js';
|
|
9
|
+
import { syncPrComments } from './pr-comment-sync.js';
|
|
9
10
|
import { isItemKey } from '../item-keys.js';
|
|
10
11
|
import { parseCanopyPath } from '../editor/canopy-path.js';
|
|
11
12
|
import { branchParamSchema, branchNameSchema, logicalPathSchema } from './validators.js';
|
|
@@ -141,6 +142,7 @@ const addCommentHandler = async (gc, ctx, req, _params, body) => {
|
|
|
141
142
|
if (!reply) {
|
|
142
143
|
return { ok: false, status: 404, error: 'Thread not found' };
|
|
143
144
|
}
|
|
145
|
+
await syncPrComments(ctx, branchContext);
|
|
144
146
|
return { ok: true, status: 201, data: reply };
|
|
145
147
|
}
|
|
146
148
|
if (!canRead(body.entryPath)) {
|
|
@@ -154,6 +156,7 @@ const addCommentHandler = async (gc, ctx, req, _params, body) => {
|
|
|
154
156
|
canopyPath: body.canopyPath,
|
|
155
157
|
itemKeys: body.itemKeys,
|
|
156
158
|
});
|
|
159
|
+
await syncPrComments(ctx, branchContext);
|
|
157
160
|
return { ok: true, status: 201, data: result };
|
|
158
161
|
};
|
|
159
162
|
const resolveCommentHandler = async (gc, ctx, req, params) => {
|
|
@@ -181,6 +184,7 @@ const resolveCommentHandler = async (gc, ctx, req, params) => {
|
|
|
181
184
|
if (!resolved) {
|
|
182
185
|
return { ok: false, status: 404, error: 'Thread not found' };
|
|
183
186
|
}
|
|
187
|
+
await syncPrComments(ctx, branchContext);
|
|
184
188
|
return { ok: true, status: 200, data: { resolved: true } };
|
|
185
189
|
};
|
|
186
190
|
// Deliberately no 'writableBranch' guard on any endpoint below: comments live
|
package/dist/api/content.d.ts
CHANGED
|
@@ -103,12 +103,12 @@ export declare const CONTENT_ROUTES: {
|
|
|
103
103
|
body: z.ZodOptional<z.ZodString>;
|
|
104
104
|
expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
105
105
|
}, "strip", z.ZodTypeAny, {
|
|
106
|
-
format: "
|
|
106
|
+
format: "md" | "mdx" | "json" | "yaml";
|
|
107
107
|
data?: Record<string, unknown> | undefined;
|
|
108
108
|
expectedVersion?: number | null | undefined;
|
|
109
109
|
body?: string | undefined;
|
|
110
110
|
}, {
|
|
111
|
-
format: "
|
|
111
|
+
format: "md" | "mdx" | "json" | "yaml";
|
|
112
112
|
data?: Record<string, unknown> | undefined;
|
|
113
113
|
expectedVersion?: number | null | undefined;
|
|
114
114
|
body?: string | undefined;
|
|
@@ -21,6 +21,10 @@ export interface SubmissionRecord {
|
|
|
21
21
|
/** Every recorded editor of the branch; the PR body lists those other than the submitter. */
|
|
22
22
|
editors?: readonly SubmissionEditor[];
|
|
23
23
|
changedPaths: readonly string[];
|
|
24
|
+
/** The entry logical path of each changed path that is an entry, for its editor link. */
|
|
25
|
+
entryPaths?: ReadonlyMap<string, string>;
|
|
26
|
+
/** The editor page URL the PR body links to (editor-url.ts resolveEditorUrl). */
|
|
27
|
+
editorUrl?: string;
|
|
24
28
|
/** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
|
|
25
29
|
submittedAt?: string;
|
|
26
30
|
}
|
package/dist/api/github-sync.js
CHANGED
|
@@ -6,6 +6,19 @@ import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
|
6
6
|
import { buildPrSection, mergePrSection } from '../submission-attribution.js';
|
|
7
7
|
import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
|
|
8
8
|
import { baseBranchOf } from '../utils/base-branch.js';
|
|
9
|
+
import { CommentStore } from '../comment-store.js';
|
|
10
|
+
import { prBodyLockKey, refreshCommentsBlock } from '../pr-comments.js';
|
|
11
|
+
import { withLock } from '../utils/async-mutex.js';
|
|
12
|
+
/** The branch's comment threads for the PR body, or null (said so in the body) if unreadable. */
|
|
13
|
+
async function readThreadsOrWarn(context) {
|
|
14
|
+
try {
|
|
15
|
+
return await new CommentStore(context.branchRoot).listThreadsStrict();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
console.warn(`CanopyCMS: Could not read the editor comments of ${context.branch.name} for its PR:`, getErrorMessage(err));
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
11
24
|
*
|
|
@@ -16,11 +29,16 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
16
29
|
const { githubService } = ctx.services;
|
|
17
30
|
const mode = ctx.services.config.mode;
|
|
18
31
|
const prTitle = context.branch.title || `Submit ${context.branch.name}`;
|
|
32
|
+
const threads = await readThreadsOrWarn(context);
|
|
19
33
|
const prSection = buildPrSection({
|
|
20
34
|
description: context.branch.description,
|
|
21
35
|
submitter: submission.submitter,
|
|
22
36
|
editors: submission.editors,
|
|
23
37
|
changedPaths: submission.changedPaths,
|
|
38
|
+
branch: context.branch.name,
|
|
39
|
+
editorUrl: submission.editorUrl,
|
|
40
|
+
entryPaths: submission.entryPaths,
|
|
41
|
+
threads,
|
|
24
42
|
});
|
|
25
43
|
// Target the fork point recorded at branch creation when available.
|
|
26
44
|
const baseBranch = context.branch.baseBranch ?? baseBranchOf(ctx.services.config);
|
|
@@ -40,12 +58,24 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
40
58
|
if (githubService) {
|
|
41
59
|
try {
|
|
42
60
|
if (context.branch.pullRequestNumber) {
|
|
61
|
+
const prNumber = context.branch.pullRequestNumber;
|
|
43
62
|
// Read before writing: the update keeps the human text around the
|
|
44
63
|
// canopycms section, and the same read says whether the PR is a draft.
|
|
45
|
-
const pr = await
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const pr = await withLock(prBodyLockKey(context.branchRoot), async () => {
|
|
65
|
+
const current = await githubService.getPullRequest(prNumber);
|
|
66
|
+
// Re-read under the lock: a comment synced since the read above has the newer block.
|
|
67
|
+
const section = await refreshCommentsBlock({
|
|
68
|
+
branchRoot: context.branchRoot,
|
|
69
|
+
branch: context.branch.name,
|
|
70
|
+
editorUrl: submission.editorUrl,
|
|
71
|
+
section: prSection,
|
|
72
|
+
warn: (message) => console.warn(`CanopyCMS: ${message}`),
|
|
73
|
+
});
|
|
74
|
+
await githubService.updatePullRequest(prNumber, {
|
|
75
|
+
title: prTitle,
|
|
76
|
+
body: mergePrSection(current.body, section),
|
|
77
|
+
});
|
|
78
|
+
return current;
|
|
49
79
|
});
|
|
50
80
|
// Best-effort draft->ready conversion. This branch updates a known
|
|
51
81
|
// PR number directly (not through createOrUpdatePR), so it doesn't
|
|
@@ -83,14 +113,14 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
83
113
|
// shared createOrUpdatePullRequest helper (github-service.ts), which
|
|
84
114
|
// treats conversion as best-effort so a permissions-limited token
|
|
85
115
|
// can't fail this submit.
|
|
86
|
-
const result = await githubService.createOrUpdatePR({
|
|
116
|
+
const result = await withLock(prBodyLockKey(context.branchRoot), () => githubService.createOrUpdatePR({
|
|
87
117
|
head: context.branch.name,
|
|
88
118
|
base: baseBranch,
|
|
89
119
|
title: prTitle,
|
|
90
120
|
body: prSection,
|
|
91
121
|
markReadyIfDraft: true,
|
|
92
122
|
mergeSectionIntoBody: true,
|
|
93
|
-
});
|
|
123
|
+
}));
|
|
94
124
|
return {
|
|
95
125
|
prUrl: result.url,
|
|
96
126
|
prNumber: result.number,
|
|
@@ -127,6 +157,8 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
127
157
|
title: prTitle,
|
|
128
158
|
body: prSection,
|
|
129
159
|
mergeSectionIntoBody: true,
|
|
160
|
+
// The worker re-renders the comment block from comments.json when the task runs.
|
|
161
|
+
...(submission.editorUrl ? { editorUrl: submission.editorUrl } : {}),
|
|
130
162
|
baseBranch,
|
|
131
163
|
pullRequestNumber: context.branch.pullRequestNumber,
|
|
132
164
|
// A content submit is an explicit "ready for review" action: convert a
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApiContext } from './types.js';
|
|
2
|
+
import type { BranchContext } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Brings the PR's editor-comment summary up to date after a comment is added, replied to or
|
|
5
|
+
* resolved. Best-effort: a failure is logged and never fails the comment request.
|
|
6
|
+
*
|
|
7
|
+
* Direct path: read the PR body, replace the block, write it back, within
|
|
8
|
+
* DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the comment request.
|
|
9
|
+
* GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
|
|
10
|
+
* orders this process's writers (docs/concurrency.md).
|
|
11
|
+
*
|
|
12
|
+
* Queued path: one pending task per branch and PR, rendered by the worker from comments.json
|
|
13
|
+
* when it runs, so a burst of comments costs one update. The comment was written before this
|
|
14
|
+
* call and a pending task reads comments.json only after it leaves pending, so its run carries
|
|
15
|
+
* the change this call skipped; a task already running doesn't count, so a later change gets
|
|
16
|
+
* a run of its own.
|
|
17
|
+
*/
|
|
18
|
+
export declare function syncPrComments(ctx: ApiContext, context: BranchContext): Promise<void>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
2
|
+
import { SYNC_PR_COMMENTS_ACTION, prBodyLockKey, renderPrCommentsBody } from '../pr-comments.js';
|
|
3
|
+
import { isApiTimeoutError, runWithDeadline } from './request-deadline.js';
|
|
4
|
+
import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
|
|
5
|
+
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
6
|
+
import { withLock } from '../utils/async-mutex.js';
|
|
7
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
8
|
+
/** A PR the comment summary is still worth updating on: one exists and is not merged or closed. */
|
|
9
|
+
function openPullRequest(context) {
|
|
10
|
+
const { pullRequestNumber, pullRequestState, status } = context.branch;
|
|
11
|
+
if (!pullRequestNumber || status === 'archived')
|
|
12
|
+
return undefined;
|
|
13
|
+
return pullRequestState === 'merged' || pullRequestState === 'closed'
|
|
14
|
+
? undefined
|
|
15
|
+
: pullRequestNumber;
|
|
16
|
+
}
|
|
17
|
+
/** How long a comment request waits on GitHub for its summary update, lock wait included. */
|
|
18
|
+
const DIRECT_SYNC_TIMEOUT_MS = 10_000;
|
|
19
|
+
/**
|
|
20
|
+
* Brings the PR's editor-comment summary up to date after a comment is added, replied to or
|
|
21
|
+
* resolved. Best-effort: a failure is logged and never fails the comment request.
|
|
22
|
+
*
|
|
23
|
+
* Direct path: read the PR body, replace the block, write it back, within
|
|
24
|
+
* DIRECT_SYNC_TIMEOUT_MS so a slow or rate-limited GitHub never holds the comment request.
|
|
25
|
+
* GitHub's PR update takes no expected version; the per-branch lock (prBodyLockKey) only
|
|
26
|
+
* orders this process's writers (docs/concurrency.md).
|
|
27
|
+
*
|
|
28
|
+
* Queued path: one pending task per branch and PR, rendered by the worker from comments.json
|
|
29
|
+
* when it runs, so a burst of comments costs one update. The comment was written before this
|
|
30
|
+
* call and a pending task reads comments.json only after it leaves pending, so its run carries
|
|
31
|
+
* the change this call skipped; a task already running doesn't count, so a later change gets
|
|
32
|
+
* a run of its own.
|
|
33
|
+
*/
|
|
34
|
+
export async function syncPrComments(ctx, context) {
|
|
35
|
+
const prNumber = openPullRequest(context);
|
|
36
|
+
if (prNumber === undefined)
|
|
37
|
+
return;
|
|
38
|
+
if (!clientOperatingStrategy(ctx.services.config.mode).supportsPullRequests())
|
|
39
|
+
return;
|
|
40
|
+
const branch = context.branch.name;
|
|
41
|
+
const editorUrl = ctx.services.config.editorUrl ?? context.branch.editorUrl;
|
|
42
|
+
try {
|
|
43
|
+
const { githubService } = ctx.services;
|
|
44
|
+
if (githubService) {
|
|
45
|
+
await runWithDeadline((signal) => withLock(prBodyLockKey(context.branchRoot), async () => {
|
|
46
|
+
const pr = await githubService.getPullRequest(prNumber, signal);
|
|
47
|
+
if (pr.state !== 'open')
|
|
48
|
+
return;
|
|
49
|
+
const body = await renderPrCommentsBody({
|
|
50
|
+
branchRoot: context.branchRoot,
|
|
51
|
+
branch,
|
|
52
|
+
editorUrl,
|
|
53
|
+
body: pr.body,
|
|
54
|
+
});
|
|
55
|
+
if (body !== undefined)
|
|
56
|
+
await githubService.updatePullRequest(prNumber, { body }, signal);
|
|
57
|
+
}), { method: 'PATCH', path: `pull request #${prNumber}`, timeoutMs: DIRECT_SYNC_TIMEOUT_MS });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const taskDir = getTaskQueueDir(ctx.services.config);
|
|
61
|
+
const pending = await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER);
|
|
62
|
+
const queued = pending.some((t) => t.action === SYNC_PR_COMMENTS_ACTION &&
|
|
63
|
+
t.payload.branch === branch &&
|
|
64
|
+
t.payload.pullRequestNumber === prNumber);
|
|
65
|
+
if (queued)
|
|
66
|
+
return;
|
|
67
|
+
await enqueueTask(taskDir, {
|
|
68
|
+
action: SYNC_PR_COMMENTS_ACTION,
|
|
69
|
+
payload: { branch, pullRequestNumber: prNumber, ...(editorUrl ? { editorUrl } : {}) },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.warn(`CanopyCMS: Could not update the editor comments on PR #${prNumber} for ${branch}:`, isApiTimeoutError(err)
|
|
74
|
+
? `GitHub did not answer within ${DIRECT_SYNC_TIMEOUT_MS / 1000} s`
|
|
75
|
+
: getErrorMessage(err));
|
|
76
|
+
}
|
|
77
|
+
}
|