@omnicoreos/planka-mcp 0.2.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/CHANGELOG.md +86 -0
- package/CONTRIBUTING.md +37 -0
- package/CREDITS.md +39 -0
- package/LICENSE +22 -0
- package/README.es.md +213 -0
- package/README.md +213 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +281 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +226 -0
- package/dist/errors.js.map +1 -0
- package/dist/identity.generated.d.ts +3 -0
- package/dist/identity.generated.d.ts.map +1 -0
- package/dist/identity.generated.js +4 -0
- package/dist/identity.generated.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/attachments.d.ts +34 -0
- package/dist/operations/attachments.d.ts.map +1 -0
- package/dist/operations/attachments.js +89 -0
- package/dist/operations/attachments.js.map +1 -0
- package/dist/operations/board-id.d.ts +9 -0
- package/dist/operations/board-id.d.ts.map +1 -0
- package/dist/operations/board-id.js +86 -0
- package/dist/operations/board-id.js.map +1 -0
- package/dist/operations/boards.d.ts +143 -0
- package/dist/operations/boards.d.ts.map +1 -0
- package/dist/operations/boards.js +391 -0
- package/dist/operations/boards.js.map +1 -0
- package/dist/operations/cards.d.ts +36 -0
- package/dist/operations/cards.d.ts.map +1 -0
- package/dist/operations/cards.js +81 -0
- package/dist/operations/cards.js.map +1 -0
- package/dist/operations/comments.d.ts +28 -0
- package/dist/operations/comments.d.ts.map +1 -0
- package/dist/operations/comments.js +53 -0
- package/dist/operations/comments.js.map +1 -0
- package/dist/operations/labels.d.ts +60 -0
- package/dist/operations/labels.d.ts.map +1 -0
- package/dist/operations/labels.js +146 -0
- package/dist/operations/labels.js.map +1 -0
- package/dist/operations/lists.d.ts +15 -0
- package/dist/operations/lists.d.ts.map +1 -0
- package/dist/operations/lists.js +40 -0
- package/dist/operations/lists.js.map +1 -0
- package/dist/operations/projects.d.ts +24 -0
- package/dist/operations/projects.d.ts.map +1 -0
- package/dist/operations/projects.js +61 -0
- package/dist/operations/projects.js.map +1 -0
- package/dist/operations/tasks.d.ts +30 -0
- package/dist/operations/tasks.d.ts.map +1 -0
- package/dist/operations/tasks.js +111 -0
- package/dist/operations/tasks.js.map +1 -0
- package/dist/schemas/entities.d.ts +389 -0
- package/dist/schemas/entities.d.ts.map +1 -0
- package/dist/schemas/entities.js +202 -0
- package/dist/schemas/entities.js.map +1 -0
- package/dist/schemas/requests.d.ts +539 -0
- package/dist/schemas/requests.d.ts.map +1 -0
- package/dist/schemas/requests.js +187 -0
- package/dist/schemas/requests.js.map +1 -0
- package/dist/schemas/responses.d.ts +2626 -0
- package/dist/schemas/responses.d.ts.map +1 -0
- package/dist/schemas/responses.js +75 -0
- package/dist/schemas/responses.js.map +1 -0
- package/dist/tools/attachments.d.ts +270 -0
- package/dist/tools/attachments.d.ts.map +1 -0
- package/dist/tools/attachments.js +247 -0
- package/dist/tools/attachments.js.map +1 -0
- package/dist/tools/cards.d.ts +401 -0
- package/dist/tools/cards.d.ts.map +1 -0
- package/dist/tools/cards.js +368 -0
- package/dist/tools/cards.js.map +1 -0
- package/dist/tools/comments.d.ts +217 -0
- package/dist/tools/comments.d.ts.map +1 -0
- package/dist/tools/comments.js +208 -0
- package/dist/tools/comments.js.map +1 -0
- package/dist/tools/index.d.ts +1293 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +62 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/labels.d.ts +214 -0
- package/dist/tools/labels.d.ts.map +1 -0
- package/dist/tools/labels.js +302 -0
- package/dist/tools/labels.js.map +1 -0
- package/dist/tools/lists.d.ts +117 -0
- package/dist/tools/lists.d.ts.map +1 -0
- package/dist/tools/lists.js +178 -0
- package/dist/tools/lists.js.map +1 -0
- package/dist/tools/navigation.d.ts +106 -0
- package/dist/tools/navigation.d.ts.map +1 -0
- package/dist/tools/navigation.js +151 -0
- package/dist/tools/navigation.js.map +1 -0
- package/dist/tools/queries.d.ts +380 -0
- package/dist/tools/queries.d.ts.map +1 -0
- package/dist/tools/queries.js +256 -0
- package/dist/tools/queries.js.map +1 -0
- package/dist/tools/tasks.d.ts +223 -0
- package/dist/tools/tasks.d.ts.map +1 -0
- package/dist/tools/tasks.js +169 -0
- package/dist/tools/tasks.js.map +1 -0
- package/docs/planka-2x-gotchas.md +192 -0
- package/docs/tools.md +659 -0
- package/docs/troubleshooting.md +138 -0
- package/package.json +71 -0
- package/project.identity.json +7 -0
- package/scripts/bootstrap-board.mjs +160 -0
- package/scripts/lib/planka-api.mjs +244 -0
- package/scripts/lib/workflow-template.mjs +26 -0
- package/scripts/setup.mjs +236 -0
- package/scripts/setup.sh +48 -0
- package/scripts/sync-identity.mjs +57 -0
- package/server.json +43 -0
- package/tests/smoke/planka-smoke.mjs +920 -0
- package/workflow/README.md +232 -0
- package/workflow/board-template.md +115 -0
- package/workflow/skills/planka-close-card/SKILL.md +81 -0
- package/workflow/skills/planka-orchestrator/SKILL.md +126 -0
- package/workflow/worktrees/README.md +104 -0
- package/workflow/worktrees/wt.conf.example +24 -0
- package/workflow/worktrees/wt.sh +345 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Match the symptom first. Each entry gives the observed behavior, the real cause, and
|
|
4
|
+
the fix.
|
|
5
|
+
|
|
6
|
+
## “The agent says the card has no comments, but I can see comments”
|
|
7
|
+
|
|
8
|
+
**Cause:** the old MCP reads `included.comments` from the card response. Planka 2.x
|
|
9
|
+
does not populate it, so HTTP succeeds with zero items.
|
|
10
|
+
|
|
11
|
+
**Fix:** make sure Claude Code runs this fork, restart Claude Code, and call
|
|
12
|
+
`planka_get_comments` again. Run the opt-in smoke test if the result is still
|
|
13
|
+
suspicious; it compares MCP output to `GET /api/cards/:id/comments`.
|
|
14
|
+
|
|
15
|
+
## “It says the label was added, but the card has no label”
|
|
16
|
+
|
|
17
|
+
**Cause:** the old tool schema knows `addLabelIds` but silently drops `labelIds`.
|
|
18
|
+
|
|
19
|
+
**Fix:** use this fork. It accepts both names and re-reads the card. Treat any result
|
|
20
|
+
with a non-empty `failed` array as an error.
|
|
21
|
+
|
|
22
|
+
## “Removing a label returns 404, or the label remains”
|
|
23
|
+
|
|
24
|
+
**Cause:** Planka 2.x deletes by `labelId:<labelId>` criteria, not by the ID of the
|
|
25
|
+
card-label junction record.
|
|
26
|
+
|
|
27
|
+
**Fix:** upgrade to this fork and pass the board label ID in `removeLabelIds`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"cardId": "<CARD_ID>",
|
|
32
|
+
"removeLabelIds": ["<LABEL_ID>"]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## “`planka_get_board` fails because one color is invalid”
|
|
37
|
+
|
|
38
|
+
**Cause:** the old response schema has a closed color enum. One newer Planka color
|
|
39
|
+
causes Zod to reject the entire board.
|
|
40
|
+
|
|
41
|
+
**Fix:** use this fork. Read paths accept backend color strings. Write paths still
|
|
42
|
+
validate known colors.
|
|
43
|
+
|
|
44
|
+
## “My credential is expired,” but the password works in the browser
|
|
45
|
+
|
|
46
|
+
**Cause:** `http://` redirects to `https://`. The authentication POST body can be
|
|
47
|
+
lost in the redirect, and the final response is HTML instead of the expected JSON
|
|
48
|
+
token.
|
|
49
|
+
|
|
50
|
+
**Fix:** set `PLANKA_BASE_URL` to the final HTTPS origin:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export PLANKA_BASE_URL="https://planka.example.com"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Run `./scripts/setup.sh` again. It validates `POST /api/access-tokens` before writing
|
|
57
|
+
anything and calls out redirect/HTML responses directly.
|
|
58
|
+
|
|
59
|
+
## “Board not found” while creating a card, list, or label
|
|
60
|
+
|
|
61
|
+
**Cause:** Planka can answer 404 when the agent user lacks write permission. Instance
|
|
62
|
+
administrator access alone is not the same as board write access.
|
|
63
|
+
|
|
64
|
+
**Fix:** give the agent user both:
|
|
65
|
+
|
|
66
|
+
1. project manager access when it must create boards or manage project-level state;
|
|
67
|
+
2. board membership with role `editor` for card, list, label, task, and comment
|
|
68
|
+
writes.
|
|
69
|
+
|
|
70
|
+
The board-membership route in Planka 2.x is:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
POST /api/boards/<BOARD_ID>/board-memberships
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
with a body containing `userId` and `role: "editor"`.
|
|
77
|
+
|
|
78
|
+
## “Creating a list returns `E_MISSING_OR_INVALID_PARAMS`”
|
|
79
|
+
|
|
80
|
+
**Cause:** Planka 2.x requires `type` as well as `name` and `position`. Older MCP
|
|
81
|
+
builds omit it.
|
|
82
|
+
|
|
83
|
+
**Fix:** use this fork. `planka_manage_lists` sends `type: "active"` by default and
|
|
84
|
+
also accepts `closed`.
|
|
85
|
+
|
|
86
|
+
## “The MCP tools do not appear in Claude Code”
|
|
87
|
+
|
|
88
|
+
**Cause:** MCP configuration is loaded at session startup. A project-scoped
|
|
89
|
+
`.mcp.json` may also be waiting for workspace trust and explicit approval.
|
|
90
|
+
|
|
91
|
+
**Fix:** close and restart Claude Code, approve the project when prompted, then run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude mcp list
|
|
95
|
+
claude mcp get planka
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Inside Claude Code, `/mcp` shows connection details. A project server waiting for
|
|
99
|
+
approval appears as pending rather than connected.
|
|
100
|
+
|
|
101
|
+
## “`claude mcp add` says the server already exists”
|
|
102
|
+
|
|
103
|
+
**Cause:** a previous local or user-scoped entry has the same name.
|
|
104
|
+
|
|
105
|
+
**Fix:** `setup.sh` removes and recreates the user-scoped `planka` entry
|
|
106
|
+
idempotently. To do it manually:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
claude mcp remove planka --scope user
|
|
110
|
+
claude mcp add --scope user --transport stdio planka -- \
|
|
111
|
+
"$HOME/.local/bin/planka-mcp"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## “The smoke test says it was skipped”
|
|
115
|
+
|
|
116
|
+
**Cause:** the real smoke test is opt-in and one of its four environment variables is
|
|
117
|
+
missing.
|
|
118
|
+
|
|
119
|
+
**Fix:** set all required values and choose a writable board:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export PLANKA_BASE_URL="https://planka.example.com"
|
|
123
|
+
export PLANKA_AGENT_EMAIL="agent@example.com"
|
|
124
|
+
export PLANKA_AGENT_PASSWORD="<YOUR_PASSWORD>"
|
|
125
|
+
export PLANKA_SMOKE_BOARD_ID="<BOARD_ID>"
|
|
126
|
+
npm run test:smoke
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The test creates and deletes a temporary card. Do not point it at a board where the
|
|
130
|
+
agent user must be read-only.
|
|
131
|
+
|
|
132
|
+
## “Authentication returns HTTP 502 or another HTML error page”
|
|
133
|
+
|
|
134
|
+
**Cause:** the reverse proxy or Planka service is unavailable. This is different from
|
|
135
|
+
an invalid JSON token response even though both can look like HTML to a client.
|
|
136
|
+
|
|
137
|
+
**Fix:** restore the Planka service, verify the final HTTPS URL in a browser, and
|
|
138
|
+
rerun setup. The setup script does not write configuration after failed validation.
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omnicoreos/planka-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A Planka 2.x MCP server with verified writes and an optional agent workflow",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"planka-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"docs",
|
|
17
|
+
"scripts",
|
|
18
|
+
"tests/smoke",
|
|
19
|
+
"workflow",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"CONTRIBUTING.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"CREDITS.md",
|
|
24
|
+
"README.md",
|
|
25
|
+
"README.es.md",
|
|
26
|
+
"project.identity.json",
|
|
27
|
+
"server.json"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"sync:identity": "node scripts/sync-identity.mjs",
|
|
31
|
+
"check:identity": "node scripts/sync-identity.mjs --check",
|
|
32
|
+
"build": "npm run check:identity && tsc",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"test:smoke": "npm run build && node tests/smoke/planka-smoke.mjs",
|
|
36
|
+
"lint": "tsc --noEmit",
|
|
37
|
+
"prepublishOnly": "npm run build && npm test"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"mcp",
|
|
41
|
+
"mcp-server",
|
|
42
|
+
"model-context-protocol",
|
|
43
|
+
"planka",
|
|
44
|
+
"kanban",
|
|
45
|
+
"claude-code",
|
|
46
|
+
"ai-agent"
|
|
47
|
+
],
|
|
48
|
+
"author": "planka-mcp contributors",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/omnicoreos/planka-mcp.git"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/omnicoreos/planka-mcp/issues"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/omnicoreos/planka-mcp#readme",
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18.0.0"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
63
|
+
"zod": "^3.25.76"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^18.19.130",
|
|
67
|
+
"typescript": "^5.9.3",
|
|
68
|
+
"vitest": "^3.2.7"
|
|
69
|
+
},
|
|
70
|
+
"mcpName": "io.github.omnicoreos/planka-mcp"
|
|
71
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
PlankaApi,
|
|
6
|
+
PlankaApiError,
|
|
7
|
+
boardEntities,
|
|
8
|
+
} from "./lib/planka-api.mjs";
|
|
9
|
+
import {
|
|
10
|
+
WORKFLOW_LABELS,
|
|
11
|
+
WORKFLOW_LISTS,
|
|
12
|
+
nextPosition,
|
|
13
|
+
} from "./lib/workflow-template.mjs";
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const args = { force: false, json: false };
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const arg = argv[index];
|
|
19
|
+
if (arg === "--force") args.force = true;
|
|
20
|
+
else if (arg === "--json") args.json = true;
|
|
21
|
+
else if (arg === "--board-id") args.boardId = argv[++index];
|
|
22
|
+
else if (arg === "--project-id") args.projectId = argv[++index];
|
|
23
|
+
else if (arg === "--board-name") args.boardName = argv[++index];
|
|
24
|
+
else if (arg === "--help" || arg === "-h") args.help = true;
|
|
25
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
26
|
+
}
|
|
27
|
+
return args;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usage() {
|
|
31
|
+
return `Usage:
|
|
32
|
+
node scripts/bootstrap-board.mjs --board-id <id>
|
|
33
|
+
node scripts/bootstrap-board.mjs --project-id <id> --board-name "Agent Work"
|
|
34
|
+
|
|
35
|
+
Environment:
|
|
36
|
+
PLANKA_BASE_URL, PLANKA_AGENT_EMAIL, PLANKA_AGENT_PASSWORD
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--force Add the template even when the board already has unrelated content
|
|
40
|
+
--json Print the result as JSON`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function bootstrapBoard(
|
|
44
|
+
api,
|
|
45
|
+
{ boardId, projectId, boardName = "Agent Work", force = false } = {}
|
|
46
|
+
) {
|
|
47
|
+
let createdBoard = false;
|
|
48
|
+
if (!boardId) {
|
|
49
|
+
if (!projectId) {
|
|
50
|
+
throw new Error("Pass either boardId or projectId");
|
|
51
|
+
}
|
|
52
|
+
const created = await api.createBoard(projectId, boardName);
|
|
53
|
+
boardId = created?.item?.id;
|
|
54
|
+
if (!boardId) throw new Error("Planka did not return the new board id");
|
|
55
|
+
createdBoard = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let state = boardEntities(await api.getBoard(boardId));
|
|
59
|
+
const templateListNames = new Set(WORKFLOW_LISTS.map((item) => item.name));
|
|
60
|
+
const matchingLists = state.lists.filter((item) =>
|
|
61
|
+
templateListNames.has(item.name)
|
|
62
|
+
);
|
|
63
|
+
const unrelatedLists = state.lists.filter(
|
|
64
|
+
(item) => item.name && !templateListNames.has(item.name)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
!force &&
|
|
69
|
+
matchingLists.length === 0 &&
|
|
70
|
+
(unrelatedLists.length > 0 || state.cards.length > 0)
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"This board is not empty and does not look bootstrapped. Use --force only if you intentionally want to add the workflow alongside existing content."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const createdLists = [];
|
|
78
|
+
const existingListNames = new Set(state.lists.map((item) => item.name));
|
|
79
|
+
for (const [index, definition] of WORKFLOW_LISTS.entries()) {
|
|
80
|
+
if (existingListNames.has(definition.name)) continue;
|
|
81
|
+
const response = await api.createList(
|
|
82
|
+
boardId,
|
|
83
|
+
definition.name,
|
|
84
|
+
nextPosition(index),
|
|
85
|
+
definition.type
|
|
86
|
+
);
|
|
87
|
+
createdLists.push(response.item);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
state = boardEntities(await api.getBoard(boardId));
|
|
91
|
+
const createdLabels = [];
|
|
92
|
+
const existingLabelNames = new Set(state.labels.map((item) => item.name));
|
|
93
|
+
for (const [index, definition] of WORKFLOW_LABELS.entries()) {
|
|
94
|
+
if (existingLabelNames.has(definition.name)) continue;
|
|
95
|
+
const response = await api.createLabel(
|
|
96
|
+
boardId,
|
|
97
|
+
definition.name,
|
|
98
|
+
definition.color,
|
|
99
|
+
nextPosition(index)
|
|
100
|
+
);
|
|
101
|
+
createdLabels.push(response.item);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const verified = boardEntities(await api.getBoard(boardId));
|
|
105
|
+
const missingLists = WORKFLOW_LISTS.filter(
|
|
106
|
+
(definition) =>
|
|
107
|
+
!verified.lists.some(
|
|
108
|
+
(item) =>
|
|
109
|
+
item.name === definition.name &&
|
|
110
|
+
(!item.type || item.type === definition.type)
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
const missingLabels = WORKFLOW_LABELS.filter(
|
|
114
|
+
(definition) =>
|
|
115
|
+
!verified.labels.some((item) => item.name === definition.name)
|
|
116
|
+
);
|
|
117
|
+
if (missingLists.length || missingLabels.length) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Bootstrap verification failed. Missing lists: ${missingLists.map((item) => item.name).join(", ") || "none"}; missing labels: ${missingLabels.map((item) => item.name).join(", ") || "none"}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
boardId,
|
|
125
|
+
boardName: verified.board?.name || boardName,
|
|
126
|
+
createdBoard,
|
|
127
|
+
createdLists: createdLists.map((item) => item.id),
|
|
128
|
+
createdLabels: createdLabels.map((item) => item.id),
|
|
129
|
+
listCount: WORKFLOW_LISTS.length,
|
|
130
|
+
labelCount: WORKFLOW_LABELS.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
const args = parseArgs(process.argv.slice(2));
|
|
136
|
+
if (args.help) {
|
|
137
|
+
console.log(usage());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const api = await PlankaApi.fromCredentials({
|
|
142
|
+
baseUrl: process.env.PLANKA_BASE_URL,
|
|
143
|
+
email: process.env.PLANKA_AGENT_EMAIL,
|
|
144
|
+
password: process.env.PLANKA_AGENT_PASSWORD,
|
|
145
|
+
});
|
|
146
|
+
const result = await bootstrapBoard(api, args);
|
|
147
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
148
|
+
else {
|
|
149
|
+
console.log(`Workflow ready on board: ${result.boardName} (${result.boardId})`);
|
|
150
|
+
console.log(`Created ${result.createdLists.length} list(s) and ${result.createdLabels.length} label(s).`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
155
|
+
main().catch((error) => {
|
|
156
|
+
const prefix = error instanceof PlankaApiError ? "Planka API error" : "Error";
|
|
157
|
+
console.error(`${prefix}: ${error.message}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
export class PlankaApiError extends Error {
|
|
2
|
+
constructor(message, { status = 0, body = null } = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "PlankaApiError";
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.body = body;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeBaseUrl(value) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(value);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new PlankaApiError(`Invalid Planka URL: ${value}`);
|
|
16
|
+
}
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
18
|
+
throw new PlankaApiError("PLANKA_BASE_URL must use http:// or https://");
|
|
19
|
+
}
|
|
20
|
+
return url.toString().replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function parseResponse(response, context) {
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
let body = null;
|
|
26
|
+
if (text) {
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
const looksLikeHtml = /<\s*(?:!doctype|html|head|body)\b/i.test(text);
|
|
31
|
+
const hint = looksLikeHtml
|
|
32
|
+
? " The server returned HTML instead of JSON. If the URL starts with http://, configure the final https:// URL directly; redirects can drop an authentication POST body."
|
|
33
|
+
: " The response was not valid JSON.";
|
|
34
|
+
throw new PlankaApiError(`${context} failed.${hint}`, {
|
|
35
|
+
status: response.status,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const detail =
|
|
42
|
+
body && typeof body === "object"
|
|
43
|
+
? body.message || body.code || JSON.stringify(body)
|
|
44
|
+
: response.statusText;
|
|
45
|
+
throw new PlankaApiError(
|
|
46
|
+
`${context} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
|
|
47
|
+
{ status: response.status, body }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function login({ baseUrl, email, password, fetchImpl = fetch }) {
|
|
54
|
+
const normalized = normalizeBaseUrl(baseUrl);
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetchImpl(`${normalized}/api/access-tokens`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify({ emailOrUsername: email, password }),
|
|
61
|
+
redirect: "manual",
|
|
62
|
+
signal: AbortSignal.timeout(30000),
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof PlankaApiError) throw error;
|
|
66
|
+
throw new PlankaApiError(
|
|
67
|
+
`Could not connect to Planka at ${normalized}: ${error instanceof Error ? error.message : String(error)}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (response.status >= 300 && response.status < 400) {
|
|
72
|
+
const destination = response.headers.get("location");
|
|
73
|
+
throw new PlankaApiError(
|
|
74
|
+
`Authentication was redirected${destination ? ` to ${destination}` : ""}. Use the final https:// Planka URL directly; a 301/302 can drop the POST body.` ,
|
|
75
|
+
{ status: response.status }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = await parseResponse(response, "Authentication");
|
|
80
|
+
if (!body || typeof body.item !== "string") {
|
|
81
|
+
throw new PlankaApiError(
|
|
82
|
+
"Authentication returned JSON, but not a Planka access token. Check the base URL and credentials."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { baseUrl: normalized, token: body.item };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class PlankaApi {
|
|
89
|
+
constructor({ baseUrl, token, fetchImpl = fetch }) {
|
|
90
|
+
this.baseUrl = normalizeBaseUrl(baseUrl);
|
|
91
|
+
this.token = token;
|
|
92
|
+
this.fetchImpl = fetchImpl;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static async fromCredentials(credentials) {
|
|
96
|
+
const session = await login(credentials);
|
|
97
|
+
return new PlankaApi({
|
|
98
|
+
...session,
|
|
99
|
+
fetchImpl: credentials.fetchImpl,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async request(method, path, body) {
|
|
104
|
+
const headers = { Authorization: `Bearer ${this.token}` };
|
|
105
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
106
|
+
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
110
|
+
method,
|
|
111
|
+
headers,
|
|
112
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
113
|
+
redirect: "manual",
|
|
114
|
+
signal: AbortSignal.timeout(30000),
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new PlankaApiError(
|
|
118
|
+
`${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (response.status >= 300 && response.status < 400) {
|
|
123
|
+
throw new PlankaApiError(
|
|
124
|
+
`${method} ${path} was redirected. Configure the final https:// URL directly.`,
|
|
125
|
+
{ status: response.status }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (response.status === 204) return null;
|
|
129
|
+
return parseResponse(response, `${method} ${path}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get(path) {
|
|
133
|
+
return this.request("GET", path);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
post(path, body) {
|
|
137
|
+
return this.request("POST", path, body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
patch(path, body) {
|
|
141
|
+
return this.request("PATCH", path, body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
delete(path) {
|
|
145
|
+
return this.request("DELETE", path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getProjects() {
|
|
149
|
+
return this.get("/api/projects");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getBoard(boardId) {
|
|
153
|
+
return this.get(`/api/boards/${boardId}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
createBoard(projectId, name, position = 65536) {
|
|
157
|
+
return this.post(`/api/projects/${projectId}/boards`, { name, position });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
createList(boardId, name, position, type = "active") {
|
|
161
|
+
return this.post(`/api/boards/${boardId}/lists`, {
|
|
162
|
+
name,
|
|
163
|
+
position,
|
|
164
|
+
type,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
createLabel(boardId, name, color, position) {
|
|
169
|
+
return this.post(`/api/boards/${boardId}/labels`, {
|
|
170
|
+
name,
|
|
171
|
+
color,
|
|
172
|
+
position,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
deleteLabel(labelId) {
|
|
177
|
+
return this.delete(`/api/labels/${labelId}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
createCard(listId, name, description = "") {
|
|
181
|
+
return this.post(`/api/lists/${listId}/cards`, {
|
|
182
|
+
name,
|
|
183
|
+
description,
|
|
184
|
+
position: 65536,
|
|
185
|
+
type: "project",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
deleteCard(cardId) {
|
|
190
|
+
return this.delete(`/api/cards/${cardId}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getComments(cardId) {
|
|
194
|
+
return this.get(`/api/cards/${cardId}/comments`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getCard(cardId) {
|
|
198
|
+
return this.get(`/api/cards/${cardId}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
deleteList(listId) {
|
|
202
|
+
return this.delete(`/api/lists/${listId}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
deleteAttachment(attachmentId) {
|
|
206
|
+
return this.delete(`/api/attachments/${attachmentId}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Authenticated binary download. Planka 2.0 serves attachment contents with
|
|
211
|
+
* the token as a Bearer header or as an `accessToken` cookie depending on
|
|
212
|
+
* the deployment, so send both. Accepts an absolute URL or a relative path.
|
|
213
|
+
*/
|
|
214
|
+
async getBinary(urlOrPath) {
|
|
215
|
+
const url = urlOrPath.startsWith("http")
|
|
216
|
+
? urlOrPath
|
|
217
|
+
: `${this.baseUrl}${urlOrPath}`;
|
|
218
|
+
const response = await this.fetchImpl(url, {
|
|
219
|
+
headers: {
|
|
220
|
+
Authorization: `Bearer ${this.token}`,
|
|
221
|
+
Cookie: `accessToken=${this.token}`,
|
|
222
|
+
},
|
|
223
|
+
redirect: "manual",
|
|
224
|
+
signal: AbortSignal.timeout(30000),
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
throw new PlankaApiError(`GET ${url} failed: HTTP ${response.status}`, {
|
|
228
|
+
status: response.status,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function boardEntities(payload) {
|
|
236
|
+
const included = payload?.included || {};
|
|
237
|
+
return {
|
|
238
|
+
board: payload?.item,
|
|
239
|
+
lists: Array.isArray(included.lists) ? included.lists : [],
|
|
240
|
+
cards: Array.isArray(included.cards) ? included.cards : [],
|
|
241
|
+
labels: Array.isArray(included.labels) ? included.labels : [],
|
|
242
|
+
cardLabels: Array.isArray(included.cardLabels) ? included.cardLabels : [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const WORKFLOW_LISTS = [
|
|
2
|
+
{ name: "Pending", type: "active" },
|
|
3
|
+
{ name: "Needs your decision", type: "active" },
|
|
4
|
+
{ name: "In worktree", type: "active" },
|
|
5
|
+
{ name: "Test it", type: "active" },
|
|
6
|
+
{ name: "Review in main", type: "active" },
|
|
7
|
+
{ name: "Merged", type: "closed" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export const WORKFLOW_LABELS = [
|
|
11
|
+
{ name: "needs-decision", color: "apricot-red" },
|
|
12
|
+
{ name: "decided", color: "modern-green" },
|
|
13
|
+
{ name: "tested-ok", color: "french-coast" },
|
|
14
|
+
{ name: "fast-track", color: "sunny-grass" },
|
|
15
|
+
{ name: "standard", color: "morning-sky" },
|
|
16
|
+
{ name: "serial", color: "red-burgundy" },
|
|
17
|
+
{ name: "zone:frontend", color: "pink-tulip" },
|
|
18
|
+
{ name: "zone:backend", color: "lagoon-blue" },
|
|
19
|
+
{ name: "zone:data", color: "egg-yellow" },
|
|
20
|
+
{ name: "zone:infra", color: "dark-granite" },
|
|
21
|
+
{ name: "zone:shared-ui", color: "sweet-lilac" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function nextPosition(index) {
|
|
25
|
+
return (index + 1) * 65536;
|
|
26
|
+
}
|