# Conflicts: # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDocumentAnchorSelector,
|
||||
projectMarkdownToText,
|
||||
remapDocumentAnchor,
|
||||
resolveProjectionRange,
|
||||
verifyDocumentAnchorSelector,
|
||||
} from "./document-anchors.js";
|
||||
|
||||
function selectorFor(markdown: string, quote: string) {
|
||||
const projection = projectMarkdownToText(markdown);
|
||||
const start = projection.text.indexOf(quote);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
const range = resolveProjectionRange(projection, start, start + quote.length);
|
||||
expect(range).not.toBeNull();
|
||||
return createDocumentAnchorSelector(projection, range!);
|
||||
}
|
||||
|
||||
describe("document text projection", () => {
|
||||
it("projects markdown into normalized rendered text with source ranges", () => {
|
||||
const markdown = [
|
||||
"# Heading",
|
||||
"",
|
||||
"- Ship **bold** [link text](https://example.com) and `code span`.",
|
||||
"| Name | Value |",
|
||||
"| --- | --- |",
|
||||
"| Alpha | Beta |",
|
||||
].join("\n");
|
||||
|
||||
const projection = projectMarkdownToText(markdown);
|
||||
|
||||
expect(projection.text).toContain("Heading");
|
||||
expect(projection.text).toContain("Ship bold link text and code span.");
|
||||
expect(projection.text).toContain("Name Value");
|
||||
expect(projection.text).toContain("Alpha Beta");
|
||||
expect(projection.text).not.toContain("https://example.com");
|
||||
expect(projection.positions).toHaveLength(projection.text.length);
|
||||
|
||||
const linkStart = projection.text.indexOf("link text");
|
||||
const range = resolveProjectionRange(projection, linkStart, linkStart + "link text".length);
|
||||
expect(range?.markdownStart).toBe(markdown.indexOf("link text"));
|
||||
expect(range?.markdownEnd).toBe(markdown.indexOf("link text") + "link text".length);
|
||||
});
|
||||
|
||||
it("normalizes whitespace while retaining markdown offsets", () => {
|
||||
const markdown = "First line\n\nSecond\t\tline";
|
||||
const projection = projectMarkdownToText(markdown);
|
||||
|
||||
expect(projection.text).toBe("First line Second line");
|
||||
const range = resolveProjectionRange(projection, projection.text.indexOf("Second"), projection.text.length);
|
||||
expect(range?.markdownStart).toBe(markdown.indexOf("Second"));
|
||||
expect(range?.markdownEnd).toBe(markdown.length);
|
||||
});
|
||||
|
||||
it("preserves non-link punctuation", () => {
|
||||
const markdown = "Keep (parenthetical) [plain brackets] visible.";
|
||||
const projection = projectMarkdownToText(markdown);
|
||||
|
||||
expect(projection.text).toBe("Keep (parenthetical) [plain brackets] visible.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document anchor verification and remapping", () => {
|
||||
it("verifies a selector against its base revision", () => {
|
||||
const markdown = "Intro text with **selected text** inside.";
|
||||
const selector = selectorFor(markdown, "selected text");
|
||||
|
||||
const result = verifyDocumentAnchorSelector({ markdown, selector });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.anchor?.selectedText).toBe("selected text");
|
||||
expect(result.anchor?.markdownStart).toBe(markdown.indexOf("selected text"));
|
||||
});
|
||||
|
||||
it("remaps exact anchors after surrounding text moves", () => {
|
||||
const selector = selectorFor("Alpha paragraph.\n\nTarget sentence here.\n\nOmega paragraph.", "Target sentence here.");
|
||||
const previousAnchor = {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: selector.quote.prefix,
|
||||
suffixText: selector.quote.suffix,
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
|
||||
const result = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: "Omega paragraph.\n\nAlpha paragraph.\n\nTarget sentence here.",
|
||||
});
|
||||
|
||||
expect(result.anchorState).toBe("active");
|
||||
expect(result.confidence).toBe("exact");
|
||||
expect(result.anchor?.selectedText).toBe("Target sentence here.");
|
||||
});
|
||||
|
||||
it("uses context and proximity to disambiguate duplicate quotes", () => {
|
||||
const selector = selectorFor("One apple near the start.\n\nTwo apple near the end.", "apple");
|
||||
const previousAnchor = {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: selector.quote.prefix,
|
||||
suffixText: selector.quote.suffix,
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
|
||||
const result = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: "Zero apple elsewhere.\n\nOne apple near the start.\n\nTwo apple near the end.",
|
||||
});
|
||||
|
||||
expect(result.anchorState).toBe("active");
|
||||
expect(result.confidence).toBe("duplicate");
|
||||
expect(result.anchor?.prefixText).toContain("One");
|
||||
});
|
||||
|
||||
it("marks duplicate anchors ambiguous when context cannot distinguish them", () => {
|
||||
const selector = selectorFor("apple apple", "apple");
|
||||
const previousAnchor = {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: "",
|
||||
suffixText: "",
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
|
||||
const result = remapDocumentAnchor({ previousAnchor, nextMarkdown: "apple apple" });
|
||||
|
||||
expect(result.anchorState).toBe("stale");
|
||||
expect(result.confidence).toBe("ambiguous");
|
||||
});
|
||||
|
||||
it("keeps edited anchors as stale fuzzy matches", () => {
|
||||
const selector = selectorFor("We rely on an important launch assumption for scope.", "important launch assumption");
|
||||
const previousAnchor = {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: selector.quote.prefix,
|
||||
suffixText: selector.quote.suffix,
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
|
||||
const result = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: "We rely on an important product launch assumption for scope.",
|
||||
});
|
||||
|
||||
expect(result.anchorState).toBe("stale");
|
||||
expect(result.confidence).toBe("fuzzy");
|
||||
expect(result.anchor?.selectedText).toBe("important product launch assumption");
|
||||
});
|
||||
|
||||
it("marks deleted anchors orphaned and allows future remapping from the latest known anchor", () => {
|
||||
const selector = selectorFor("Keep this reviewed phrase in mind.", "reviewed phrase");
|
||||
const previousAnchor = {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: selector.quote.prefix,
|
||||
suffixText: selector.quote.suffix,
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
|
||||
const missing = remapDocumentAnchor({ previousAnchor, nextMarkdown: "The target disappeared." });
|
||||
const recovered = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: "The target came back: reviewed phrase.",
|
||||
});
|
||||
|
||||
expect(missing.anchorState).toBe("orphaned");
|
||||
expect(missing.confidence).toBe("missing");
|
||||
expect(missing.anchor).toBeNull();
|
||||
expect(recovered.anchorState).toBe("active");
|
||||
expect(recovered.anchor?.selectedText).toBe("reviewed phrase");
|
||||
});
|
||||
});
|
||||
@@ -473,6 +473,12 @@ export type {
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
AcceptedPlanDecompositionResult,
|
||||
AcceptedPlanDecompositionChildIssue,
|
||||
AcceptedPlanDecompositionSummary,
|
||||
IssueThreadInteractionBase,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
@@ -868,6 +874,7 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueInputSchema,
|
||||
createChildIssueSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
resolveCreateIssueStatusDefault,
|
||||
createIssueLabelSchema,
|
||||
issueBlockedInboxAttentionSchema,
|
||||
@@ -936,6 +943,7 @@ export {
|
||||
releaseIssueTreeHoldSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateAcceptedPlanDecomposition,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type ResolveIssueRecoveryAction,
|
||||
|
||||
@@ -238,6 +238,12 @@ export type {
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
AcceptedPlanDecompositionResult,
|
||||
AcceptedPlanDecompositionChildIssue,
|
||||
AcceptedPlanDecompositionSummary,
|
||||
IssueThreadInteractionBase,
|
||||
SuggestTasksInteraction,
|
||||
AskUserQuestionsInteraction,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
||||
export interface InstanceExperimentalSettings {
|
||||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
|
||||
@@ -129,6 +129,71 @@ export interface LegacyPlanDocument {
|
||||
source: "issue_description";
|
||||
}
|
||||
|
||||
export type AcceptedPlanDecompositionStatus = "in_flight" | "completed";
|
||||
|
||||
export interface AcceptedPlanDecompositionChild {
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
goalId?: string | null;
|
||||
blockedByIssueIds?: string[];
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: IssueStatus;
|
||||
workMode: IssueWorkMode;
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
requestDepth?: number;
|
||||
billingCode?: string | null;
|
||||
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
||||
executionPolicy?: IssueExecutionPolicy | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||
labelIds?: string[];
|
||||
acceptanceCriteria?: string[];
|
||||
blockParentUntilDone?: boolean;
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecomposition {
|
||||
id: string;
|
||||
companyId: string;
|
||||
sourceIssueId: string;
|
||||
acceptedPlanRevisionId: string;
|
||||
acceptedInteractionId: string | null;
|
||||
status: AcceptedPlanDecompositionStatus;
|
||||
requestFingerprint: string;
|
||||
requestedChildCount: number;
|
||||
childIssueIds: string[];
|
||||
ownerAgentId: string | null;
|
||||
ownerUserId: string | null;
|
||||
ownerRunId: string | null;
|
||||
completedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecompositionResult {
|
||||
decomposition: AcceptedPlanDecomposition;
|
||||
childIssueIds: string[];
|
||||
newlyCreatedChildIssueIds: string[];
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecompositionChildIssue {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface AcceptedPlanDecompositionSummary extends AcceptedPlanDecomposition {
|
||||
acceptedPlanRevisionNumber: number | null;
|
||||
childIssues: AcceptedPlanDecompositionChildIssue[];
|
||||
}
|
||||
|
||||
export interface IssueRelationIssueSummary {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
|
||||
@@ -38,8 +38,24 @@ import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
|
||||
/**
|
||||
* A JSON Schema object used for plugin config schemas and tool parameter schemas.
|
||||
* Plugins provide these as plain JSON Schema compatible objects.
|
||||
*
|
||||
* The Paperclip extension keywords below are recognised by the Paperclip UI
|
||||
* but are otherwise ignored by standard JSON Schema validators.
|
||||
*/
|
||||
export type JsonSchema = Record<string, unknown>;
|
||||
export type JsonSchema = {
|
||||
/**
|
||||
* When true, the Paperclip config UI hides this property behind an
|
||||
* "Advanced options" disclosure. Defaults to false (always visible).
|
||||
*/
|
||||
"x-paperclip-advanced"?: boolean;
|
||||
/**
|
||||
* Optional sub-section heading used to group advanced properties inside
|
||||
* the disclosure (e.g. "SSH access", "VM resources"). Ignored when
|
||||
* `x-paperclip-advanced` is not true.
|
||||
*/
|
||||
"x-paperclip-group"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type {
|
||||
PluginDatabaseCoreReadTable,
|
||||
|
||||
@@ -2,7 +2,8 @@ export type WorkspaceOperationPhase =
|
||||
| "worktree_prepare"
|
||||
| "workspace_provision"
|
||||
| "workspace_teardown"
|
||||
| "worktree_cleanup";
|
||||
| "worktree_cleanup"
|
||||
| "workspace_finalize";
|
||||
|
||||
export type WorkspaceOperationStatus = "running" | "succeeded" | "failed" | "skipped";
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
catalogSkillFileDetailSchema,
|
||||
catalogSkillListQuerySchema,
|
||||
companySkillAuditResultSchema,
|
||||
companySkillInstallCatalogResultSchema,
|
||||
companySkillInstallCatalogSchema,
|
||||
companySkillInstallUpdateSchema,
|
||||
companySkillResetSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
} from "./company-skill.js";
|
||||
|
||||
const catalogSkill = {
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "review",
|
||||
name: "review",
|
||||
description: "Review code",
|
||||
path: "catalog/bundled/software-development/review",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["review"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }],
|
||||
contentHash: "sha256:abc",
|
||||
};
|
||||
|
||||
const companySkill = {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
companyId: "00000000-0000-4000-8000-000000000002",
|
||||
key: catalogSkill.key,
|
||||
slug: catalogSkill.slug,
|
||||
name: catalogSkill.name,
|
||||
description: catalogSkill.description,
|
||||
markdown: "# Review\n",
|
||||
sourceType: "catalog",
|
||||
sourceLocator: "/tmp/review",
|
||||
sourceRef: catalogSkill.contentHash,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: {
|
||||
sourceKind: "catalog",
|
||||
catalogId: catalogSkill.id,
|
||||
originHash: catalogSkill.contentHash,
|
||||
},
|
||||
createdAt: "2026-05-26T00:00:00.000Z",
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("company skill catalog validators", () => {
|
||||
it("accepts catalog list and install request shapes", () => {
|
||||
expect(catalogSkillListQuerySchema.parse({
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
q: "review",
|
||||
})).toEqual({
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
q: "review",
|
||||
});
|
||||
|
||||
expect(companySkillInstallCatalogSchema.parse({
|
||||
catalogSkillId: catalogSkill.id,
|
||||
slug: "team-review",
|
||||
force: true,
|
||||
})).toEqual({
|
||||
catalogSkillId: catalogSkill.id,
|
||||
slug: "team-review",
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid catalog filter and install payloads", () => {
|
||||
expect(() => catalogSkillListQuerySchema.parse({ kind: "external" })).toThrow();
|
||||
expect(() => companySkillInstallCatalogSchema.parse({ force: true })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts catalog file and install result responses", () => {
|
||||
expect(catalogSkillFileDetailSchema.parse({
|
||||
catalogSkillId: catalogSkill.id,
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content: "# Review\n",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
})).toMatchObject({
|
||||
catalogSkillId: catalogSkill.id,
|
||||
path: "SKILL.md",
|
||||
});
|
||||
|
||||
expect(companySkillInstallCatalogResultSchema.parse({
|
||||
action: "created",
|
||||
skill: companySkill,
|
||||
catalogSkill,
|
||||
warnings: [],
|
||||
})).toMatchObject({
|
||||
action: "created",
|
||||
skill: {
|
||||
key: catalogSkill.key,
|
||||
sourceType: "catalog",
|
||||
},
|
||||
catalogSkill: {
|
||||
id: catalogSkill.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts update status, audit, update, and reset contract shapes", () => {
|
||||
expect(companySkillUpdateStatusSchema.parse({
|
||||
supported: true,
|
||||
reason: null,
|
||||
trackingRef: catalogSkill.id,
|
||||
currentRef: "sha256:old",
|
||||
latestRef: catalogSkill.contentHash,
|
||||
hasUpdate: true,
|
||||
installedHash: "sha256:installed",
|
||||
originHash: catalogSkill.contentHash,
|
||||
userModifiedAt: "2026-05-26T00:00:00.000Z",
|
||||
updateHoldReason: "local_modifications",
|
||||
auditVerdict: "warning",
|
||||
auditCodes: ["local_modifications"],
|
||||
})).toMatchObject({
|
||||
supported: true,
|
||||
updateHoldReason: "local_modifications",
|
||||
auditVerdict: "warning",
|
||||
});
|
||||
|
||||
expect(companySkillAuditResultSchema.parse({
|
||||
skillId: companySkill.id,
|
||||
installedHash: "sha256:installed",
|
||||
originHash: catalogSkill.contentHash,
|
||||
verdict: "fail",
|
||||
codes: ["remote_fetch_exec"],
|
||||
findings: [{
|
||||
code: "remote_fetch_exec",
|
||||
severity: "error",
|
||||
message: "Remote-fetch or dynamic execution pattern is not allowed.",
|
||||
path: "SKILL.md",
|
||||
}],
|
||||
scannedAt: "2026-05-26T00:00:00.000Z",
|
||||
scanVersion: "skills-audit-v1",
|
||||
})).toMatchObject({
|
||||
verdict: "fail",
|
||||
codes: ["remote_fetch_exec"],
|
||||
});
|
||||
|
||||
expect(companySkillInstallUpdateSchema.parse(undefined)).toEqual({});
|
||||
expect(companySkillInstallUpdateSchema.parse({ force: true })).toEqual({ force: true });
|
||||
expect(companySkillResetSchema.parse(undefined)).toEqual({});
|
||||
expect(companySkillResetSchema.parse({ force: true })).toEqual({ force: true });
|
||||
});
|
||||
});
|
||||
@@ -186,6 +186,7 @@ export {
|
||||
createIssueSchema,
|
||||
createIssueInputSchema,
|
||||
createChildIssueSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
resolveCreateIssueStatusDefault,
|
||||
createIssueLabelSchema,
|
||||
issueBlockedInboxAttentionSchema,
|
||||
@@ -237,6 +238,7 @@ export {
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateAcceptedPlanDecomposition,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||
|
||||
@@ -412,6 +412,13 @@ export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBa
|
||||
|
||||
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
|
||||
|
||||
export const createAcceptedPlanDecompositionSchema = z.object({
|
||||
acceptedPlanRevisionId: z.string().uuid(),
|
||||
children: z.array(createChildIssueSchema).min(1).max(25),
|
||||
});
|
||||
|
||||
export type CreateAcceptedPlanDecomposition = z.infer<typeof createAcceptedPlanDecompositionSchema>;
|
||||
|
||||
export const createIssueLabelSchema = z.object({
|
||||
name: z.string().trim().min(1).max(48),
|
||||
color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
|
||||
|
||||
Reference in New Issue
Block a user