@cliangdev/flux-plugin 0.3.1 → 0.4.0-dev.0892a21

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.
Files changed (32) hide show
  1. package/commands/dashboard.md +1 -1
  2. package/package.json +3 -1
  3. package/src/server/adapters/factory.ts +6 -28
  4. package/src/server/adapters/github/__tests__/criteria-deps.test.ts +579 -0
  5. package/src/server/adapters/github/__tests__/documents-stats.test.ts +789 -0
  6. package/src/server/adapters/github/__tests__/epic-task-crud.test.ts +1072 -0
  7. package/src/server/adapters/github/__tests__/foundation.test.ts +537 -0
  8. package/src/server/adapters/github/__tests__/index-store.test.ts +319 -0
  9. package/src/server/adapters/github/__tests__/prd-crud.test.ts +836 -0
  10. package/src/server/adapters/github/adapter.ts +1574 -0
  11. package/src/server/adapters/github/client.ts +34 -0
  12. package/src/server/adapters/github/config.ts +59 -0
  13. package/src/server/adapters/github/helpers/criteria.ts +157 -0
  14. package/src/server/adapters/github/helpers/index-store.ts +79 -0
  15. package/src/server/adapters/github/helpers/meta.ts +26 -0
  16. package/src/server/adapters/github/index.ts +5 -0
  17. package/src/server/adapters/github/mappers/epic.ts +21 -0
  18. package/src/server/adapters/github/mappers/index.ts +15 -0
  19. package/src/server/adapters/github/mappers/prd.ts +50 -0
  20. package/src/server/adapters/github/mappers/task.ts +37 -0
  21. package/src/server/adapters/github/types.ts +27 -0
  22. package/src/server/adapters/linear/adapter.ts +121 -105
  23. package/src/server/adapters/linear/client.ts +21 -14
  24. package/src/server/adapters/types.ts +1 -1
  25. package/src/server/index.ts +2 -0
  26. package/src/server/tools/__tests__/mcp-interface.test.ts +6 -0
  27. package/src/server/tools/__tests__/z-configure-github.test.ts +521 -0
  28. package/src/server/tools/__tests__/z-get-linear-url.test.ts +2 -2
  29. package/src/server/tools/__tests__/z-init-project.test.ts +168 -0
  30. package/src/server/tools/configure-github.ts +445 -0
  31. package/src/server/tools/index.ts +2 -1
  32. package/src/server/tools/init-project.ts +26 -12
@@ -0,0 +1,34 @@
1
+ import { graphql } from "@octokit/graphql";
2
+ import { Octokit } from "@octokit/rest";
3
+ import type { GitHubConfig } from "./types.js";
4
+
5
+ export class GitHubClient {
6
+ readonly rest: Octokit;
7
+ private gql: ReturnType<typeof graphql.defaults>;
8
+
9
+ constructor(config: GitHubConfig) {
10
+ this.rest = new Octokit({ auth: config.token });
11
+ this.gql = graphql.defaults({
12
+ headers: {
13
+ authorization: `token ${config.token}`,
14
+ },
15
+ });
16
+ }
17
+
18
+ async graphql<T>(
19
+ query: string,
20
+ variables?: Record<string, unknown>,
21
+ ): Promise<T> {
22
+ try {
23
+ return await this.gql<T>(query, variables);
24
+ } catch (error: unknown) {
25
+ const err = error as { status?: number };
26
+ if (err.status === 401 || err.status === 403) {
27
+ throw new Error(
28
+ `GitHub API auth error: token may lack 'repo' scope (HTTP ${err.status})`,
29
+ );
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { config } from "../../config.js";
3
+ import type { GitHubConfig } from "./types.js";
4
+
5
+ export function githubConfigExists(): boolean {
6
+ if (!existsSync(config.projectJsonPath)) {
7
+ return false;
8
+ }
9
+
10
+ try {
11
+ const content = readFileSync(config.projectJsonPath, "utf-8");
12
+ const project = JSON.parse(content);
13
+ return project?.adapter?.type === "github";
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function loadGithubConfig(): GitHubConfig {
20
+ if (!existsSync(config.projectJsonPath)) {
21
+ throw new Error(
22
+ "GitHub config not found: project.json missing. Run project init first.",
23
+ );
24
+ }
25
+
26
+ const content = readFileSync(config.projectJsonPath, "utf-8");
27
+ const project = JSON.parse(content);
28
+ const cfg = project?.adapter?.config;
29
+
30
+ if (!cfg || typeof cfg !== "object") {
31
+ throw new Error(
32
+ "GitHub config not found in project.json: adapter.config is missing.",
33
+ );
34
+ }
35
+
36
+ if (!cfg.token || typeof cfg.token !== "string") {
37
+ throw new Error("Invalid GitHub config: token is required");
38
+ }
39
+ if (!cfg.owner || typeof cfg.owner !== "string") {
40
+ throw new Error("Invalid GitHub config: owner is required");
41
+ }
42
+ if (!cfg.repo || typeof cfg.repo !== "string") {
43
+ throw new Error("Invalid GitHub config: repo is required");
44
+ }
45
+ if (!cfg.projectId || typeof cfg.projectId !== "string") {
46
+ throw new Error("Invalid GitHub config: projectId is required");
47
+ }
48
+ if (!cfg.refPrefix || typeof cfg.refPrefix !== "string") {
49
+ throw new Error("Invalid GitHub config: refPrefix is required");
50
+ }
51
+
52
+ return {
53
+ token: cfg.token,
54
+ owner: cfg.owner,
55
+ repo: cfg.repo,
56
+ projectId: cfg.projectId,
57
+ refPrefix: cfg.refPrefix,
58
+ };
59
+ }
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface ParsedCriterion {
4
+ id: string;
5
+ text: string;
6
+ isMet: boolean;
7
+ lineIndex: number;
8
+ }
9
+
10
+ export function generateCriteriaId(text: string): string {
11
+ const hash = createHash("sha256").update(text.trim()).digest("hex");
12
+ return `ac_${hash.substring(0, 8)}`;
13
+ }
14
+
15
+ export function parseCriteriaFromDescription(
16
+ description: string | undefined,
17
+ ): ParsedCriterion[] {
18
+ if (!description) {
19
+ return [];
20
+ }
21
+
22
+ const lines = description.split("\n");
23
+
24
+ let acSectionStart = -1;
25
+ for (let i = 0; i < lines.length; i++) {
26
+ const trimmedLine = lines[i].trim().toLowerCase();
27
+ if (
28
+ trimmedLine.includes("acceptance criteria") &&
29
+ (trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
30
+ ) {
31
+ acSectionStart = i;
32
+ break;
33
+ }
34
+ }
35
+
36
+ const criteria: ParsedCriterion[] = [];
37
+ let inACSection = acSectionStart === -1;
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ const trimmedLine = line.trim().toLowerCase();
42
+
43
+ if (i === acSectionStart) {
44
+ inACSection = true;
45
+ continue;
46
+ }
47
+
48
+ if (acSectionStart !== -1 && i > acSectionStart && inACSection) {
49
+ if (
50
+ trimmedLine.startsWith("#") ||
51
+ (trimmedLine.startsWith("**") && trimmedLine.endsWith("**"))
52
+ ) {
53
+ break;
54
+ }
55
+ }
56
+
57
+ if (inACSection) {
58
+ const checkboxMatch = line.match(/^[\s]*[-*]\s*\[([ xX])\]\s*(.+)$/);
59
+ if (checkboxMatch) {
60
+ const isChecked = checkboxMatch[1].toLowerCase() === "x";
61
+ const text = checkboxMatch[2].trim();
62
+
63
+ criteria.push({
64
+ id: generateCriteriaId(text),
65
+ text,
66
+ isMet: isChecked,
67
+ lineIndex: i,
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ return criteria;
74
+ }
75
+
76
+ export function updateCriterionInDescription(
77
+ description: string,
78
+ criteriaId: string,
79
+ isMet: boolean,
80
+ ): string {
81
+ const lines = description.split("\n");
82
+ const criteria = parseCriteriaFromDescription(description);
83
+
84
+ const criterion = criteria.find((c) => c.id === criteriaId);
85
+ if (!criterion) {
86
+ throw new Error(`Criterion not found: ${criteriaId}`);
87
+ }
88
+
89
+ const line = lines[criterion.lineIndex];
90
+ const newCheckbox = isMet ? "[x]" : "[ ]";
91
+ const updatedLine = line.replace(/\[([ xX])\]/, newCheckbox);
92
+ lines[criterion.lineIndex] = updatedLine;
93
+
94
+ return lines.join("\n");
95
+ }
96
+
97
+ export function addCriterionToDescription(
98
+ description: string | undefined,
99
+ criterionText: string,
100
+ ): string {
101
+ if (!description) {
102
+ return `## Acceptance Criteria\n\n- [ ] ${criterionText}`;
103
+ }
104
+
105
+ const lines = description.split("\n");
106
+
107
+ let acSectionStart = -1;
108
+ let acSectionEnd = lines.length;
109
+
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const trimmedLine = lines[i].trim().toLowerCase();
112
+
113
+ if (
114
+ trimmedLine.includes("acceptance criteria") &&
115
+ (trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
116
+ ) {
117
+ acSectionStart = i;
118
+ continue;
119
+ }
120
+
121
+ if (acSectionStart !== -1 && i > acSectionStart) {
122
+ const line = lines[i].trim();
123
+ if (
124
+ line.startsWith("#") ||
125
+ (line.startsWith("**") && line.endsWith("**"))
126
+ ) {
127
+ acSectionEnd = i;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+
133
+ let insertIndex = -1;
134
+ if (acSectionStart !== -1) {
135
+ for (let i = acSectionEnd - 1; i > acSectionStart; i--) {
136
+ if (lines[i].match(/^[\s]*[-*]\s*\[([ xX])\]/)) {
137
+ insertIndex = i + 1;
138
+ break;
139
+ }
140
+ }
141
+ if (insertIndex === -1) {
142
+ insertIndex = acSectionStart + 1;
143
+ while (insertIndex < lines.length && lines[insertIndex].trim() === "") {
144
+ insertIndex++;
145
+ }
146
+ }
147
+ } else {
148
+ lines.push("");
149
+ lines.push("## Acceptance Criteria");
150
+ lines.push("");
151
+ insertIndex = lines.length;
152
+ }
153
+
154
+ lines.splice(insertIndex, 0, `- [ ] ${criterionText}`);
155
+
156
+ return lines.join("\n");
157
+ }
@@ -0,0 +1,79 @@
1
+ import type { GitHubClient } from "../client.js";
2
+
3
+ export type RefIndex = Record<string, number>;
4
+
5
+ const INDEX_PATH = ".flux/github-index.json";
6
+
7
+ async function fetchCurrentFile(
8
+ client: GitHubClient,
9
+ owner: string,
10
+ repo: string,
11
+ ): Promise<{ sha: string; content: string } | null> {
12
+ try {
13
+ const response = await client.rest.repos.getContent({
14
+ owner,
15
+ repo,
16
+ path: INDEX_PATH,
17
+ });
18
+ const data = response.data as {
19
+ sha: string;
20
+ content: string;
21
+ encoding: string;
22
+ };
23
+ return { sha: data.sha, content: data.content };
24
+ } catch (error: unknown) {
25
+ if ((error as { status?: number }).status === 404) {
26
+ return null;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ export async function readIndex(
33
+ client: GitHubClient,
34
+ owner: string,
35
+ repo: string,
36
+ ): Promise<RefIndex> {
37
+ const file = await fetchCurrentFile(client, owner, repo);
38
+ if (file === null) {
39
+ return {};
40
+ }
41
+ const json = Buffer.from(file.content, "base64").toString("utf-8");
42
+ return JSON.parse(json) as RefIndex;
43
+ }
44
+
45
+ export async function writeIndex(
46
+ client: GitHubClient,
47
+ owner: string,
48
+ repo: string,
49
+ index: RefIndex,
50
+ ): Promise<void> {
51
+ const existing = await fetchCurrentFile(client, owner, repo);
52
+
53
+ const params: Record<string, unknown> = {
54
+ owner,
55
+ repo,
56
+ path: INDEX_PATH,
57
+ message: "chore: update flux ref index",
58
+ content: Buffer.from(JSON.stringify(index, null, 2)).toString("base64"),
59
+ };
60
+
61
+ if (existing !== null) {
62
+ params.sha = existing.sha;
63
+ }
64
+
65
+ try {
66
+ await client.rest.repos.createOrUpdateFileContents(
67
+ params as Parameters<
68
+ typeof client.rest.repos.createOrUpdateFileContents
69
+ >[0],
70
+ );
71
+ } catch (error: unknown) {
72
+ if ((error as { status?: number }).status === 409) {
73
+ throw new Error(
74
+ "Index conflict: another operation modified the index concurrently. Retry the operation.",
75
+ );
76
+ }
77
+ throw error;
78
+ }
79
+ }
@@ -0,0 +1,26 @@
1
+ export interface FluxMeta {
2
+ ref: string;
3
+ tag?: string;
4
+ prd_ref?: string;
5
+ epic_ref?: string;
6
+ priority?: string;
7
+ dependencies?: string[];
8
+ }
9
+
10
+ export function encodeMeta(meta: FluxMeta): string {
11
+ return `<!-- flux-meta\n${JSON.stringify(meta)}\n-->`;
12
+ }
13
+
14
+ export function decodeMeta(body: string): FluxMeta | null {
15
+ const match = body.match(/<!--\s*flux-meta\n([\s\S]*?)\n-->/);
16
+ if (!match) return null;
17
+ try {
18
+ return JSON.parse(match[1]) as FluxMeta;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function extractDescription(body: string): string {
25
+ return body.replace(/<!--\s*flux-meta[\s\S]*?-->/g, "").trim();
26
+ }
@@ -0,0 +1,5 @@
1
+ export { GitHubAdapter } from "./adapter.js";
2
+ export { GitHubClient } from "./client.js";
3
+ export { githubConfigExists, loadGithubConfig } from "./config.js";
4
+ export type { GitHubConfig } from "./types.js";
5
+ export { GITHUB_LABELS } from "./types.js";
@@ -0,0 +1,21 @@
1
+ import type { EpicStatus } from "../../types.js";
2
+ import { GITHUB_LABELS } from "../types.js";
3
+
4
+ const STATUS_LABEL_MAP: Record<EpicStatus, string | null> = {
5
+ PENDING: null,
6
+ IN_PROGRESS: GITHUB_LABELS.STATUS_IN_PROGRESS,
7
+ COMPLETED: null,
8
+ };
9
+
10
+ export function epicStatusToLabel(status: EpicStatus): string | null {
11
+ return STATUS_LABEL_MAP[status];
12
+ }
13
+
14
+ export function labelToEpicStatus(
15
+ labels: string[],
16
+ isClosed: boolean,
17
+ ): EpicStatus {
18
+ if (isClosed) return "COMPLETED";
19
+ if (labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS)) return "IN_PROGRESS";
20
+ return "PENDING";
21
+ }
@@ -0,0 +1,15 @@
1
+ export { epicStatusToLabel, labelToEpicStatus } from "./epic.js";
2
+ export {
3
+ getAllStatusLabels,
4
+ labelToPrdStatus,
5
+ labelToTag,
6
+ prdStatusToLabel,
7
+ tagToLabel,
8
+ } from "./prd.js";
9
+
10
+ export {
11
+ labelToPriority,
12
+ labelToTaskStatus,
13
+ priorityToLabel,
14
+ taskStatusToLabel,
15
+ } from "./task.js";
@@ -0,0 +1,50 @@
1
+ import type { PrdStatus } from "../../types.js";
2
+ import { GITHUB_LABELS } from "../types.js";
3
+
4
+ const STATUS_LABEL_MAP: Record<PrdStatus, string | null> = {
5
+ DRAFT: GITHUB_LABELS.STATUS_DRAFT,
6
+ PENDING_REVIEW: GITHUB_LABELS.STATUS_PENDING_REVIEW,
7
+ REVIEWED: GITHUB_LABELS.STATUS_REVIEWED,
8
+ APPROVED: GITHUB_LABELS.STATUS_APPROVED,
9
+ BREAKDOWN_READY: GITHUB_LABELS.STATUS_BREAKDOWN_READY,
10
+ COMPLETED: GITHUB_LABELS.STATUS_COMPLETED,
11
+ ARCHIVED: null,
12
+ };
13
+
14
+ const ALL_STATUS_LABELS = new Set(
15
+ Object.values(STATUS_LABEL_MAP).filter(Boolean) as string[],
16
+ );
17
+
18
+ export function prdStatusToLabel(status: PrdStatus): string | null {
19
+ return STATUS_LABEL_MAP[status];
20
+ }
21
+
22
+ export function labelToPrdStatus(
23
+ labels: string[],
24
+ isClosed: boolean,
25
+ ): PrdStatus {
26
+ if (isClosed) return "ARCHIVED";
27
+
28
+ if (labels.includes(GITHUB_LABELS.STATUS_COMPLETED)) return "COMPLETED";
29
+ if (labels.includes(GITHUB_LABELS.STATUS_BREAKDOWN_READY))
30
+ return "BREAKDOWN_READY";
31
+ if (labels.includes(GITHUB_LABELS.STATUS_APPROVED)) return "APPROVED";
32
+ if (labels.includes(GITHUB_LABELS.STATUS_REVIEWED)) return "REVIEWED";
33
+ if (labels.includes(GITHUB_LABELS.STATUS_PENDING_REVIEW))
34
+ return "PENDING_REVIEW";
35
+
36
+ return "DRAFT";
37
+ }
38
+
39
+ export function tagToLabel(tag: string): string {
40
+ return `${GITHUB_LABELS.TAG_PREFIX}${tag}`;
41
+ }
42
+
43
+ export function labelToTag(labels: string[]): string | undefined {
44
+ const tagLabel = labels.find((l) => l.startsWith(GITHUB_LABELS.TAG_PREFIX));
45
+ return tagLabel ? tagLabel.slice(GITHUB_LABELS.TAG_PREFIX.length) : undefined;
46
+ }
47
+
48
+ export function getAllStatusLabels(): string[] {
49
+ return [...ALL_STATUS_LABELS];
50
+ }
@@ -0,0 +1,37 @@
1
+ import type { Priority, TaskStatus } from "../../types.js";
2
+ import { GITHUB_LABELS } from "../types.js";
3
+
4
+ const TASK_STATUS_LABEL_MAP: Record<TaskStatus, string | null> = {
5
+ PENDING: null,
6
+ IN_PROGRESS: GITHUB_LABELS.STATUS_IN_PROGRESS,
7
+ COMPLETED: null,
8
+ };
9
+
10
+ export function taskStatusToLabel(status: TaskStatus): string | null {
11
+ return TASK_STATUS_LABEL_MAP[status];
12
+ }
13
+
14
+ export function labelToTaskStatus(
15
+ labels: string[],
16
+ isClosed: boolean,
17
+ ): TaskStatus {
18
+ if (isClosed) return "COMPLETED";
19
+ if (labels.includes(GITHUB_LABELS.STATUS_IN_PROGRESS)) return "IN_PROGRESS";
20
+ return "PENDING";
21
+ }
22
+
23
+ const PRIORITY_LABEL_MAP: Record<Priority, string> = {
24
+ LOW: GITHUB_LABELS.PRIORITY_LOW,
25
+ MEDIUM: GITHUB_LABELS.PRIORITY_MEDIUM,
26
+ HIGH: GITHUB_LABELS.PRIORITY_HIGH,
27
+ };
28
+
29
+ export function priorityToLabel(priority: Priority): string {
30
+ return PRIORITY_LABEL_MAP[priority];
31
+ }
32
+
33
+ export function labelToPriority(labels: string[]): Priority {
34
+ if (labels.includes(GITHUB_LABELS.PRIORITY_HIGH)) return "HIGH";
35
+ if (labels.includes(GITHUB_LABELS.PRIORITY_LOW)) return "LOW";
36
+ return "MEDIUM";
37
+ }
@@ -0,0 +1,27 @@
1
+ export interface GitHubConfig {
2
+ token: string;
3
+ owner: string;
4
+ repo: string;
5
+ projectId: string;
6
+ refPrefix: string;
7
+ }
8
+
9
+ export const GITHUB_LABELS = {
10
+ ENTITY_PRD: "flux:prd",
11
+ ENTITY_EPIC: "flux:epic",
12
+ ENTITY_TASK: "flux:task",
13
+
14
+ STATUS_DRAFT: "status:draft",
15
+ STATUS_PENDING_REVIEW: "status:pending-review",
16
+ STATUS_REVIEWED: "status:reviewed",
17
+ STATUS_APPROVED: "status:approved",
18
+ STATUS_BREAKDOWN_READY: "status:breakdown-ready",
19
+ STATUS_COMPLETED: "status:completed",
20
+ STATUS_IN_PROGRESS: "status:in-progress",
21
+
22
+ PRIORITY_LOW: "priority:low",
23
+ PRIORITY_MEDIUM: "priority:medium",
24
+ PRIORITY_HIGH: "priority:high",
25
+
26
+ TAG_PREFIX: "tag:",
27
+ } as const;