@cliangdev/flux-plugin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/agents/coder.md +150 -25
- package/bin/install.cjs +171 -16
- package/commands/breakdown.md +47 -10
- package/commands/dashboard.md +29 -0
- package/commands/flux.md +92 -12
- package/commands/implement.md +166 -17
- package/commands/linear.md +6 -5
- package/commands/prd.md +996 -82
- package/manifest.json +2 -1
- package/package.json +9 -11
- package/skills/flux-orchestrator/SKILL.md +11 -3
- package/skills/prd-writer/SKILL.md +761 -0
- package/skills/ux-ui-design/SKILL.md +346 -0
- package/skills/ux-ui-design/references/design-tokens.md +359 -0
- package/src/__tests__/version.test.ts +37 -0
- package/src/adapters/local/.gitkeep +0 -0
- package/src/dashboard/__tests__/api.test.ts +211 -0
- package/src/dashboard/browser.ts +35 -0
- package/src/dashboard/public/app.js +869 -0
- package/src/dashboard/public/index.html +90 -0
- package/src/dashboard/public/styles.css +807 -0
- package/src/dashboard/public/vendor/highlight.css +10 -0
- package/src/dashboard/public/vendor/highlight.min.js +8422 -0
- package/src/dashboard/public/vendor/marked.min.js +2210 -0
- package/src/dashboard/server.ts +296 -0
- package/src/dashboard/watchers.ts +83 -0
- package/src/server/__tests__/config.test.ts +163 -0
- package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
- package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
- package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
- package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
- package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
- package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
- package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
- package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
- package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
- package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
- package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
- package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
- package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
- package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
- package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
- package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
- package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
- package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
- package/src/server/adapters/factory.ts +90 -0
- package/src/server/adapters/index.ts +9 -0
- package/src/server/adapters/linear/adapter.ts +1141 -0
- package/src/server/adapters/linear/client.ts +169 -0
- package/src/server/adapters/linear/config.ts +152 -0
- package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
- package/src/server/adapters/linear/helpers/index.ts +7 -0
- package/src/server/adapters/linear/index.ts +16 -0
- package/src/server/adapters/linear/mappers/description.ts +136 -0
- package/src/server/adapters/linear/mappers/epic.ts +81 -0
- package/src/server/adapters/linear/mappers/index.ts +27 -0
- package/src/server/adapters/linear/mappers/prd.ts +178 -0
- package/src/server/adapters/linear/mappers/task.ts +82 -0
- package/src/server/adapters/linear/types.ts +264 -0
- package/src/server/adapters/local-adapter.ts +1009 -0
- package/src/server/adapters/types.ts +293 -0
- package/src/server/config.ts +73 -0
- package/src/server/db/__tests__/queries.test.ts +473 -0
- package/src/server/db/ids.ts +17 -0
- package/src/server/db/index.ts +69 -0
- package/src/server/db/queries.ts +142 -0
- package/src/server/db/refs.ts +60 -0
- package/src/server/db/schema.ts +97 -0
- package/src/server/db/sqlite.ts +10 -0
- package/src/server/index.ts +81 -0
- package/src/server/tools/__tests__/crud.test.ts +411 -0
- package/src/server/tools/__tests__/get-version.test.ts +27 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
- package/src/server/tools/__tests__/query.test.ts +405 -0
- package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
- package/src/server/tools/configure-linear.ts +373 -0
- package/src/server/tools/create-epic.ts +44 -0
- package/src/server/tools/create-prd.ts +40 -0
- package/src/server/tools/create-task.ts +47 -0
- package/src/server/tools/criteria.ts +50 -0
- package/src/server/tools/delete-entity.ts +76 -0
- package/src/server/tools/dependencies.ts +55 -0
- package/src/server/tools/get-entity.ts +240 -0
- package/src/server/tools/get-linear-url.ts +28 -0
- package/src/server/tools/get-stats.ts +52 -0
- package/src/server/tools/get-version.ts +20 -0
- package/src/server/tools/index.ts +158 -0
- package/src/server/tools/init-project.ts +108 -0
- package/src/server/tools/query-entities.ts +167 -0
- package/src/server/tools/render-status.ts +219 -0
- package/src/server/tools/update-entity.ts +140 -0
- package/src/server/tools/update-status.ts +166 -0
- package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
- package/src/server/utils/logger.ts +9 -0
- package/src/server/utils/mcp-response.ts +254 -0
- package/src/server/utils/status-transitions.ts +160 -0
- package/src/status-line/__tests__/status-line.test.ts +215 -0
- package/src/status-line/index.ts +147 -0
- package/src/utils/__tests__/chalk-import.test.ts +32 -0
- package/src/utils/__tests__/display.test.ts +97 -0
- package/src/utils/__tests__/status-renderer.test.ts +310 -0
- package/src/utils/display.ts +62 -0
- package/src/utils/status-renderer.ts +214 -0
- package/src/version.ts +5 -0
- package/dist/server/index.js +0 -87063
- package/skills/prd-template/SKILL.md +0 -242
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear SDK Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Wraps @linear/sdk with automatic retry logic for transient errors.
|
|
5
|
+
* - Rate limit errors (429) trigger exponential backoff
|
|
6
|
+
* - Network errors are retried with backoff
|
|
7
|
+
* - Other errors fail immediately
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { LinearClient as LinearSDK } from "@linear/sdk";
|
|
11
|
+
import type { LinearConfig } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom error class for Linear API errors.
|
|
15
|
+
*/
|
|
16
|
+
export class LinearApiError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
public readonly code: string,
|
|
20
|
+
public readonly statusCode?: number,
|
|
21
|
+
public readonly originalError?: Error,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "LinearApiError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options for configuring retry behavior.
|
|
30
|
+
*/
|
|
31
|
+
interface RetryOptions {
|
|
32
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
33
|
+
maxRetries?: number;
|
|
34
|
+
/** Base delay in milliseconds for exponential backoff (default: 1000) */
|
|
35
|
+
baseDelay?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* LinearClient wraps the Linear SDK with automatic retry logic.
|
|
40
|
+
*/
|
|
41
|
+
export class LinearClient {
|
|
42
|
+
private sdk: LinearSDK;
|
|
43
|
+
private maxRetries: number;
|
|
44
|
+
private baseDelay: number;
|
|
45
|
+
|
|
46
|
+
constructor(config: LinearConfig, options?: RetryOptions) {
|
|
47
|
+
this.sdk = new LinearSDK({ apiKey: config.apiKey });
|
|
48
|
+
this.maxRetries = options?.maxRetries ?? 3;
|
|
49
|
+
this.baseDelay = options?.baseDelay ?? 1000;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the underlying SDK for direct access when needed.
|
|
54
|
+
*/
|
|
55
|
+
get client(): LinearSDK {
|
|
56
|
+
return this.sdk;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Execute a request with automatic retry on transient errors.
|
|
61
|
+
* - 429 (rate limit) → exponential backoff with retry
|
|
62
|
+
* - Network errors → retry with backoff
|
|
63
|
+
* - 401 (unauthorized) → throw immediately with UNAUTHORIZED code
|
|
64
|
+
* - Other errors → throw immediately
|
|
65
|
+
*
|
|
66
|
+
* @param operation The async operation to execute
|
|
67
|
+
* @returns Promise resolving to the operation result
|
|
68
|
+
* @throws LinearApiError on failure
|
|
69
|
+
*/
|
|
70
|
+
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
|
71
|
+
let lastError: Error | undefined;
|
|
72
|
+
|
|
73
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
return await operation();
|
|
76
|
+
} catch (error: any) {
|
|
77
|
+
lastError = error;
|
|
78
|
+
|
|
79
|
+
// Check if error is retryable
|
|
80
|
+
const isRateLimited = error.status === 429;
|
|
81
|
+
const isNetworkError = this.isNetworkError(error);
|
|
82
|
+
const isUnauthorized = error.status === 401;
|
|
83
|
+
|
|
84
|
+
// Throw immediately for unauthorized errors
|
|
85
|
+
if (isUnauthorized) {
|
|
86
|
+
throw new LinearApiError(
|
|
87
|
+
"Unauthorized: Invalid API key",
|
|
88
|
+
"UNAUTHORIZED",
|
|
89
|
+
401,
|
|
90
|
+
error,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Throw immediately for non-retryable errors
|
|
95
|
+
if (!isRateLimited && !isNetworkError) {
|
|
96
|
+
throw new LinearApiError(
|
|
97
|
+
error.message || "Linear API error",
|
|
98
|
+
"API_ERROR",
|
|
99
|
+
error.status,
|
|
100
|
+
error,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If we've exhausted retries, throw with appropriate error code
|
|
105
|
+
if (attempt === this.maxRetries) {
|
|
106
|
+
if (isRateLimited) {
|
|
107
|
+
throw new LinearApiError(
|
|
108
|
+
`Rate limited after ${this.maxRetries} retries`,
|
|
109
|
+
"RATE_LIMITED",
|
|
110
|
+
429,
|
|
111
|
+
error,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
throw new LinearApiError(
|
|
115
|
+
`Network error after ${this.maxRetries} retries: ${error.message}`,
|
|
116
|
+
"NETWORK_ERROR",
|
|
117
|
+
undefined,
|
|
118
|
+
error,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Calculate delay with exponential backoff and jitter
|
|
123
|
+
const delay = this.calculateDelay(attempt);
|
|
124
|
+
await this.sleep(delay);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// This should never be reached, but TypeScript needs it
|
|
129
|
+
throw new LinearApiError(
|
|
130
|
+
"Unexpected error: max retries exceeded",
|
|
131
|
+
"API_ERROR",
|
|
132
|
+
undefined,
|
|
133
|
+
lastError,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if an error is a network error.
|
|
139
|
+
*/
|
|
140
|
+
private isNetworkError(error: any): boolean {
|
|
141
|
+
// Common network error codes
|
|
142
|
+
const networkErrorCodes = [
|
|
143
|
+
"ECONNRESET",
|
|
144
|
+
"ECONNREFUSED",
|
|
145
|
+
"ETIMEDOUT",
|
|
146
|
+
"ENOTFOUND",
|
|
147
|
+
"ENETUNREACH",
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
return networkErrorCodes.includes(error.code);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Calculate delay with exponential backoff and jitter.
|
|
155
|
+
* Formula: baseDelay * 2^attempt with ±10% randomization
|
|
156
|
+
*/
|
|
157
|
+
private calculateDelay(attempt: number): number {
|
|
158
|
+
const exponentialDelay = this.baseDelay * 2 ** attempt;
|
|
159
|
+
const jitter = exponentialDelay * 0.1 * (Math.random() * 2 - 1); // ±10%
|
|
160
|
+
return Math.floor(exponentialDelay + jitter);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sleep for the specified duration.
|
|
165
|
+
*/
|
|
166
|
+
private sleep(ms: number): Promise<void> {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Configuration Loader and Validator
|
|
3
|
+
*
|
|
4
|
+
* Manages Linear configuration stored in .flux/linear-config.json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { config } from "../../config.js";
|
|
9
|
+
import type { LinearConfig } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const LINEAR_CONFIG_FILE = "linear-config.json";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the path to the Linear config file.
|
|
15
|
+
*/
|
|
16
|
+
function getLinearConfigPath(): string {
|
|
17
|
+
return `${config.fluxPath}/${LINEAR_CONFIG_FILE}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if Linear config exists.
|
|
22
|
+
*/
|
|
23
|
+
export function linearConfigExists(): boolean {
|
|
24
|
+
return existsSync(getLinearConfigPath());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate LinearConfig object.
|
|
29
|
+
* @throws Error with clear message if invalid
|
|
30
|
+
*/
|
|
31
|
+
export function validateLinearConfig(input: unknown): LinearConfig {
|
|
32
|
+
// Check if input is an object
|
|
33
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
34
|
+
throw new Error("Invalid Linear config: must be an object");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const config = input as Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
// Validate apiKey
|
|
40
|
+
if (!config.apiKey) {
|
|
41
|
+
throw new Error("Invalid Linear config: apiKey is required");
|
|
42
|
+
}
|
|
43
|
+
if (typeof config.apiKey !== "string") {
|
|
44
|
+
throw new Error("Invalid Linear config: apiKey must be a string");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Validate teamId
|
|
48
|
+
if (!config.teamId) {
|
|
49
|
+
throw new Error("Invalid Linear config: teamId is required");
|
|
50
|
+
}
|
|
51
|
+
if (typeof config.teamId !== "string") {
|
|
52
|
+
throw new Error("Invalid Linear config: teamId must be a string");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate projectId
|
|
56
|
+
if (!config.projectId) {
|
|
57
|
+
throw new Error("Invalid Linear config: projectId is required");
|
|
58
|
+
}
|
|
59
|
+
if (typeof config.projectId !== "string") {
|
|
60
|
+
throw new Error("Invalid Linear config: projectId must be a string");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle defaultLabels - use defaults if not provided
|
|
64
|
+
const defaultLabels = {
|
|
65
|
+
prd: "prd",
|
|
66
|
+
epic: "epic",
|
|
67
|
+
task: "task",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (config.defaultLabels) {
|
|
71
|
+
if (
|
|
72
|
+
typeof config.defaultLabels !== "object" ||
|
|
73
|
+
config.defaultLabels === null ||
|
|
74
|
+
Array.isArray(config.defaultLabels)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error("Invalid Linear config: defaultLabels must be an object");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const labels = config.defaultLabels as Record<string, unknown>;
|
|
80
|
+
|
|
81
|
+
// Merge with defaults
|
|
82
|
+
if (labels.prd !== undefined) {
|
|
83
|
+
if (typeof labels.prd !== "string") {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"Invalid Linear config: defaultLabels.prd must be a string",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
defaultLabels.prd = labels.prd;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (labels.epic !== undefined) {
|
|
92
|
+
if (typeof labels.epic !== "string") {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Invalid Linear config: defaultLabels.epic must be a string",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
defaultLabels.epic = labels.epic;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (labels.task !== undefined) {
|
|
101
|
+
if (typeof labels.task !== "string") {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"Invalid Linear config: defaultLabels.task must be a string",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
defaultLabels.task = labels.task;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
apiKey: config.apiKey,
|
|
112
|
+
teamId: config.teamId,
|
|
113
|
+
projectId: config.projectId,
|
|
114
|
+
defaultLabels,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load Linear config from .flux/linear-config.json.
|
|
120
|
+
* @throws Error if config file doesn't exist
|
|
121
|
+
* @throws Error if config is invalid (missing required fields)
|
|
122
|
+
*/
|
|
123
|
+
export function loadLinearConfig(): LinearConfig {
|
|
124
|
+
const configPath = getLinearConfigPath();
|
|
125
|
+
|
|
126
|
+
if (!existsSync(configPath)) {
|
|
127
|
+
throw new Error("Linear config not found. Run configure_linear first.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
|
|
133
|
+
return validateLinearConfig(parsed);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Save Linear config to .flux/linear-config.json.
|
|
138
|
+
* Used by configure_linear MCP tool.
|
|
139
|
+
*/
|
|
140
|
+
export function saveLinearConfig(linearConfig: LinearConfig): void {
|
|
141
|
+
// Validate before saving
|
|
142
|
+
const validated = validateLinearConfig(linearConfig);
|
|
143
|
+
|
|
144
|
+
// Ensure .flux directory exists
|
|
145
|
+
const fluxPath = config.fluxPath;
|
|
146
|
+
if (!existsSync(fluxPath)) {
|
|
147
|
+
mkdirSync(fluxPath, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const configPath = getLinearConfigPath();
|
|
151
|
+
writeFileSync(configPath, JSON.stringify(validated, null, 2), "utf-8");
|
|
152
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Criteria Parser for Linear Adapter
|
|
3
|
+
*
|
|
4
|
+
* Parses acceptance criteria from issue descriptions and provides
|
|
5
|
+
* efficient operations for reading and updating criteria.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
export interface ParsedCriterion {
|
|
11
|
+
id: string; // Hash-based stable ID
|
|
12
|
+
text: string; // Criterion text
|
|
13
|
+
isMet: boolean; // Whether checkbox is checked
|
|
14
|
+
lineIndex: number; // Line number in description (for updates)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a stable ID for a criterion based on its text content.
|
|
19
|
+
* Uses first 8 chars of SHA-256 hash for brevity while maintaining uniqueness.
|
|
20
|
+
*/
|
|
21
|
+
export function generateCriteriaId(text: string): string {
|
|
22
|
+
const hash = createHash("sha256").update(text.trim()).digest("hex");
|
|
23
|
+
return `ac_${hash.substring(0, 8)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse acceptance criteria from a markdown description.
|
|
28
|
+
* Looks for checkbox items: `- [ ] text` or `- [x] text`
|
|
29
|
+
*
|
|
30
|
+
* Only parses items under "Acceptance Criteria" heading if present,
|
|
31
|
+
* otherwise parses all checkbox items in the document.
|
|
32
|
+
*/
|
|
33
|
+
export function parseCriteriaFromDescription(
|
|
34
|
+
description: string | undefined,
|
|
35
|
+
): ParsedCriterion[] {
|
|
36
|
+
if (!description) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lines = description.split("\n");
|
|
41
|
+
|
|
42
|
+
// First pass: check if AC section exists
|
|
43
|
+
let acSectionStart = -1;
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const trimmedLine = lines[i].trim().toLowerCase();
|
|
46
|
+
if (
|
|
47
|
+
trimmedLine.includes("acceptance criteria") &&
|
|
48
|
+
(trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
|
|
49
|
+
) {
|
|
50
|
+
acSectionStart = i;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Second pass: parse checkboxes
|
|
56
|
+
const criteria: ParsedCriterion[] = [];
|
|
57
|
+
let inACSection = acSectionStart === -1; // If no AC section, parse all
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i];
|
|
61
|
+
const trimmedLine = line.trim().toLowerCase();
|
|
62
|
+
|
|
63
|
+
// Check for AC section header
|
|
64
|
+
if (i === acSectionStart) {
|
|
65
|
+
inACSection = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If we're in AC section, check for next section header
|
|
70
|
+
if (acSectionStart !== -1 && i > acSectionStart && inACSection) {
|
|
71
|
+
if (
|
|
72
|
+
trimmedLine.startsWith("#") ||
|
|
73
|
+
(trimmedLine.startsWith("**") && trimmedLine.endsWith("**"))
|
|
74
|
+
) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Parse checkbox items only when in correct section
|
|
80
|
+
if (inACSection) {
|
|
81
|
+
const checkboxMatch = line.match(/^[\s]*[-*]\s*\[([ xX])\]\s*(.+)$/);
|
|
82
|
+
if (checkboxMatch) {
|
|
83
|
+
const isChecked = checkboxMatch[1].toLowerCase() === "x";
|
|
84
|
+
const text = checkboxMatch[2].trim();
|
|
85
|
+
|
|
86
|
+
criteria.push({
|
|
87
|
+
id: generateCriteriaId(text),
|
|
88
|
+
text,
|
|
89
|
+
isMet: isChecked,
|
|
90
|
+
lineIndex: i,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return criteria;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update a specific criterion's checked state in the description.
|
|
101
|
+
* Returns the updated description string.
|
|
102
|
+
*/
|
|
103
|
+
export function updateCriterionInDescription(
|
|
104
|
+
description: string,
|
|
105
|
+
criteriaId: string,
|
|
106
|
+
isMet: boolean,
|
|
107
|
+
): string {
|
|
108
|
+
const lines = description.split("\n");
|
|
109
|
+
const criteria = parseCriteriaFromDescription(description);
|
|
110
|
+
|
|
111
|
+
const criterion = criteria.find((c) => c.id === criteriaId);
|
|
112
|
+
if (!criterion) {
|
|
113
|
+
throw new Error(`Criterion not found: ${criteriaId}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Update the specific line
|
|
117
|
+
const line = lines[criterion.lineIndex];
|
|
118
|
+
const newCheckbox = isMet ? "[x]" : "[ ]";
|
|
119
|
+
const updatedLine = line.replace(/\[([ xX])\]/, newCheckbox);
|
|
120
|
+
lines[criterion.lineIndex] = updatedLine;
|
|
121
|
+
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Add a new criterion to the description.
|
|
127
|
+
* Appends to the Acceptance Criteria section if it exists,
|
|
128
|
+
* otherwise creates a new section at the end.
|
|
129
|
+
*/
|
|
130
|
+
export function addCriterionToDescription(
|
|
131
|
+
description: string | undefined,
|
|
132
|
+
criterionText: string,
|
|
133
|
+
): string {
|
|
134
|
+
if (!description) {
|
|
135
|
+
return `## Acceptance Criteria\n\n- [ ] ${criterionText}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lines = description.split("\n");
|
|
139
|
+
|
|
140
|
+
// Find the AC section
|
|
141
|
+
let acSectionStart = -1;
|
|
142
|
+
let acSectionEnd = lines.length;
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const trimmedLine = lines[i].trim().toLowerCase();
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
trimmedLine.includes("acceptance criteria") &&
|
|
149
|
+
(trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
|
|
150
|
+
) {
|
|
151
|
+
acSectionStart = i;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// If we're past the AC header, look for next section
|
|
156
|
+
if (acSectionStart !== -1 && i > acSectionStart) {
|
|
157
|
+
const line = lines[i].trim();
|
|
158
|
+
if (
|
|
159
|
+
line.startsWith("#") ||
|
|
160
|
+
(line.startsWith("**") && line.endsWith("**"))
|
|
161
|
+
) {
|
|
162
|
+
acSectionEnd = i;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Find the last checkbox in the AC section to insert after
|
|
169
|
+
let insertIndex = acSectionEnd;
|
|
170
|
+
if (acSectionStart !== -1) {
|
|
171
|
+
for (let i = acSectionEnd - 1; i > acSectionStart; i--) {
|
|
172
|
+
if (lines[i].match(/^[\s]*[-*]\s*\[([ xX])\]/)) {
|
|
173
|
+
insertIndex = i + 1;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// If no checkboxes found, insert after the header
|
|
178
|
+
if (insertIndex === acSectionEnd) {
|
|
179
|
+
insertIndex = acSectionStart + 1;
|
|
180
|
+
// Skip empty lines after header
|
|
181
|
+
while (insertIndex < lines.length && lines[insertIndex].trim() === "") {
|
|
182
|
+
insertIndex++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// No AC section exists, add one at the end
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push("## Acceptance Criteria");
|
|
189
|
+
lines.push("");
|
|
190
|
+
insertIndex = lines.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Insert the new criterion
|
|
194
|
+
lines.splice(insertIndex, 0, `- [ ] ${criterionText}`);
|
|
195
|
+
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Adapter Module
|
|
3
|
+
*
|
|
4
|
+
* Public exports for the Linear adapter.
|
|
5
|
+
* Only LinearAdapter is exposed; internal types remain private.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { LinearAdapter } from "./adapter.js";
|
|
9
|
+
export { LinearApiError, LinearClient } from "./client.js";
|
|
10
|
+
export {
|
|
11
|
+
linearConfigExists,
|
|
12
|
+
loadLinearConfig,
|
|
13
|
+
saveLinearConfig,
|
|
14
|
+
validateLinearConfig,
|
|
15
|
+
} from "./config.js";
|
|
16
|
+
export type { LinearConfig } from "./types.js";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description formatters for embedding acceptance criteria in Linear issue descriptions.
|
|
3
|
+
*
|
|
4
|
+
* Linear doesn't have native acceptance criteria fields, so we embed them
|
|
5
|
+
* as markdown checklists in the issue description.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface DescriptionInput {
|
|
9
|
+
description?: string;
|
|
10
|
+
acceptanceCriteria?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParsedDescription {
|
|
14
|
+
description: string;
|
|
15
|
+
acceptanceCriteria: Array<{
|
|
16
|
+
text: string;
|
|
17
|
+
isCompleted: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AC_SECTION_HEADER = "## Acceptance Criteria";
|
|
22
|
+
const AC_SECTION_REGEX = /^## Acceptance Criteria\s*$/m;
|
|
23
|
+
const AC_ITEM_REGEX = /^-\s*\[([^\]]*)\]\s*(.+)$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format description with embedded acceptance criteria checklist.
|
|
27
|
+
*
|
|
28
|
+
* @param input - Description and acceptance criteria to format
|
|
29
|
+
* @returns Formatted description with AC checklist
|
|
30
|
+
*/
|
|
31
|
+
export function formatDescription(input: DescriptionInput): string {
|
|
32
|
+
const { description = "", acceptanceCriteria = [] } = input;
|
|
33
|
+
|
|
34
|
+
// If no AC, return description as-is
|
|
35
|
+
if (!acceptanceCriteria || acceptanceCriteria.length === 0) {
|
|
36
|
+
return description;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove existing AC section if present
|
|
40
|
+
let baseDescription = description;
|
|
41
|
+
const acSectionMatch = baseDescription.match(AC_SECTION_REGEX);
|
|
42
|
+
if (acSectionMatch && acSectionMatch.index !== undefined) {
|
|
43
|
+
const acSectionIndex = acSectionMatch.index;
|
|
44
|
+
baseDescription = baseDescription.substring(0, acSectionIndex).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build AC checklist
|
|
48
|
+
const acLines = acceptanceCriteria.map((criterion) => `- [ ] ${criterion}`);
|
|
49
|
+
const acSection = `${AC_SECTION_HEADER}\n${acLines.join("\n")}`;
|
|
50
|
+
|
|
51
|
+
// Combine description and AC section
|
|
52
|
+
if (baseDescription) {
|
|
53
|
+
return `${baseDescription}\n\n${acSection}`;
|
|
54
|
+
}
|
|
55
|
+
return acSection;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse description to extract acceptance criteria.
|
|
60
|
+
*
|
|
61
|
+
* @param content - Description content with embedded AC
|
|
62
|
+
* @returns Parsed description and AC list
|
|
63
|
+
*/
|
|
64
|
+
export function parseDescription(content: string): ParsedDescription {
|
|
65
|
+
if (!content) {
|
|
66
|
+
return { description: "", acceptanceCriteria: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Find AC section
|
|
70
|
+
const acSectionMatch = content.match(AC_SECTION_REGEX);
|
|
71
|
+
if (!acSectionMatch || acSectionMatch.index === undefined) {
|
|
72
|
+
return { description: content.trim(), acceptanceCriteria: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const acSectionIndex = acSectionMatch.index;
|
|
76
|
+
const description = content.substring(0, acSectionIndex).trim();
|
|
77
|
+
|
|
78
|
+
// Extract AC items from section
|
|
79
|
+
const acSectionContent = content.substring(acSectionIndex);
|
|
80
|
+
const lines = acSectionContent.split("\n");
|
|
81
|
+
const acceptanceCriteria: Array<{ text: string; isCompleted: boolean }> = [];
|
|
82
|
+
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const match = line.match(AC_ITEM_REGEX);
|
|
85
|
+
if (match) {
|
|
86
|
+
const [, checkbox, text] = match;
|
|
87
|
+
const checkboxTrimmed = checkbox.trim().toLowerCase();
|
|
88
|
+
acceptanceCriteria.push({
|
|
89
|
+
text: text.trim(),
|
|
90
|
+
isCompleted: checkboxTrimmed === "x",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { description, acceptanceCriteria };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update completion status of AC in description.
|
|
100
|
+
*
|
|
101
|
+
* @param content - Description content with embedded AC
|
|
102
|
+
* @param acText - Text of the AC item to update
|
|
103
|
+
* @param isCompleted - New completion status
|
|
104
|
+
* @returns Updated description content
|
|
105
|
+
*/
|
|
106
|
+
export function updateAcCompletion(
|
|
107
|
+
content: string,
|
|
108
|
+
acText: string,
|
|
109
|
+
isCompleted: boolean,
|
|
110
|
+
): string {
|
|
111
|
+
// Find AC section
|
|
112
|
+
const acSectionMatch = content.match(AC_SECTION_REGEX);
|
|
113
|
+
if (!acSectionMatch) {
|
|
114
|
+
return content;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const checkbox = isCompleted ? "[x]" : "[ ]";
|
|
118
|
+
const lines = content.split("\n");
|
|
119
|
+
let updated = false;
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < lines.length; i++) {
|
|
122
|
+
const line = lines[i];
|
|
123
|
+
const match = line.match(AC_ITEM_REGEX);
|
|
124
|
+
if (match) {
|
|
125
|
+
const [, , text] = match;
|
|
126
|
+
// Exact match only to avoid partial matches
|
|
127
|
+
if (text.trim() === acText) {
|
|
128
|
+
lines[i] = `- ${checkbox} ${text.trim()}`;
|
|
129
|
+
updated = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return updated ? lines.join("\n") : content;
|
|
136
|
+
}
|