rootr-cli 0.34.0 → 0.35.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.ko.md +5 -3
- package/README.md +9 -1
- package/bin/rootr.js +48 -15
- package/lib/client.js +11 -4
- package/lib/issue-paging.js +44 -0
- package/lib/mcp-tools/issues.js +41 -13
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -482,7 +482,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
482
482
|
| 도구 | 설명 |
|
|
483
483
|
|---|---|
|
|
484
484
|
| `rootr_create_issue_tracker` | 이슈 트래커 생성 |
|
|
485
|
-
| `rootr_list_issues` | 이슈 목록(state/label/type/assignee/검색어 필터) |
|
|
485
|
+
| `rootr_list_issues` | 이슈 목록(state/label/type/assignee/검색어 필터). 나눠 받기: `limit`(1~200)·`cursor`(직전 `nextCursor`) → `nextCursor`·`total` |
|
|
486
486
|
| `rootr_create_issue` | 이슈 생성(labels/type/parentIssueId 지원) |
|
|
487
487
|
| `rootr_get_issue` | 이슈 상세 조회 |
|
|
488
488
|
| `rootr_update_issue` | 이슈 갱신(닫기: state=CLOSED + stateReason) |
|
|
@@ -490,12 +490,14 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
490
490
|
| `rootr_update_issue_tracker` | 트래커 설정 — **외부 참여**(`read` 보기만 · `login` 로그인한 사람만 작성 · `anyone` 누구나 작성), 라벨·유형 |
|
|
491
491
|
| `rootr_submit_public_issue` | **웹게시한** 트래커에 밖에서 이슈 올리기(공개 주소로, `asGuest`+`guestName` 이면 손님으로, 페이지 블록은 `blockId`, `files:[{filename, contentBase64}]` 첨부) |
|
|
492
492
|
| `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`files` 첨부, `read:true` 면 공개 댓글 읽기) |
|
|
493
|
-
| `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments) |
|
|
493
|
+
| `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments), `limit`·`cursor` 로 나눠 받기 |
|
|
494
494
|
|
|
495
495
|
CLI: `rootr issue tracker <트래커> --participation anyone` · `rootr issue list|create|comment …` ·
|
|
496
496
|
`rootr issue list <공개 주소> --q "결제" --label 버그` · `rootr issue submit <공개 주소> --title "…" --name <이름> --file 화면.png --file 로그.txt` ·
|
|
497
497
|
`rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]`.
|
|
498
|
-
|
|
498
|
+
목록은 **나눠 받습니다**(CLI 0.35+): 깃발이 없으면 예전처럼 최신 500건, `--limit 1~200` 은 한 페이지(맨 아래에 다음 `--cursor` 를 알려 줌),
|
|
499
|
+
`--all` 은 끝까지 이어 받습니다 — 예: `rootr issue list /지원/피드백 --state ALL --all --json`.
|
|
500
|
+
첨부는 그림(png·jpg·gif·webp)·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 75MB(손님 25MB)·글 하나에 10개까지입니다(확장자와 파일 첫 바이트가 맞아야 함).
|
|
499
501
|
외부 참여는 **트래커 설정**이라 페이지(PAGE)에 `issues` 블록으로 끼운 트래커도 같은 규칙을 따릅니다.
|
|
500
502
|
|
|
501
503
|
#### 웹훅 (webhooks:manage 스코프)
|
package/README.md
CHANGED
|
@@ -314,9 +314,17 @@ rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Button does nothing" -
|
|
|
314
314
|
rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Crash" --file shot.png --file app.log --name "Kim" # with files
|
|
315
315
|
rootr issue reply https://rootr.io/ko/p/AbCd123 12 "Same here" --name "Lee"
|
|
316
316
|
rootr issue list https://rootr.io/ko/p/AbCd123 --q "payment" --label bug --sort comments # search a published tracker
|
|
317
|
+
rootr issue list /support/feedback --state ALL --limit 100 # one page; the last line prints the next --cursor
|
|
318
|
+
rootr issue list /support/feedback --state ALL --limit 100 --cursor <next cursor>
|
|
319
|
+
rootr issue list /support/feedback --state ALL --all --json # every issue, page by page (200 at a time)
|
|
317
320
|
```
|
|
318
321
|
|
|
319
|
-
|
|
322
|
+
Issue lists are **paged** (CLI 0.35+): without flags you get the newest 500 as before; `--limit 1..200` gives
|
|
323
|
+
one page and prints `--cursor <…>` for the next, `--all` follows the cursors to the end. MCP
|
|
324
|
+
`rootr_list_issues` / `rootr_search_public_issues` take `limit` and `cursor` and return `nextCursor` (null on the
|
|
325
|
+
last page) and `total`.
|
|
326
|
+
|
|
327
|
+
Outside attachments: images (png·jpg·gif·webp), pdf, txt·csv·log, docx·xlsx·pptx, zip — 75MB per file signed in (25MB as a guest), 10 per
|
|
320
328
|
issue/comment; the extension and the file's first bytes must agree. MCP: `rootr_submit_public_issue` /
|
|
321
329
|
`rootr_comment_public_issue` take `files: [{ filename, contentBase64 }]` (contents, never a path), and
|
|
322
330
|
`rootr_search_public_issues` searches a published tracker the way a visitor does.
|
package/bin/rootr.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { setGlobalDocMailSink } from '../lib/context.js';
|
|
19
19
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
20
20
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
21
|
+
import { issuePageArgs, collectIssuePages } from '../lib/issue-paging.js';
|
|
21
22
|
import { domainPaymentHelp, slotLine } from '../lib/domain-billing.js';
|
|
22
23
|
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -89,11 +90,12 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
89
90
|
rootr issue tracker <트래커> [--participation read|login|anyone] [--json] 트래커 설정 보기·외부 참여 바꾸기(MANAGE)
|
|
90
91
|
rootr issue list <트래커|공개 주소> [--state OPEN|CLOSED|ALL] [--q <검색어>] [--label <라벨>]… [--sort newest|oldest|comments] [--json]
|
|
91
92
|
이슈 목록·검색(공개 주소면 밖에서 — 제목·본문·#번호·쓴 사람, 페이지 블록은 --block)
|
|
93
|
+
[--limit <1~200>] [--cursor <다음 커서>] [--all] 나눠 받기 — 깃발 없으면 최신 500건, --all 은 끝까지
|
|
92
94
|
rootr issue create <트래커> --title "<제목>" [--body "<본문>"] 이슈 올리기(내 계정으로)
|
|
93
95
|
rootr issue comment <트래커> <#번호> "<댓글>" 이슈에 댓글
|
|
94
96
|
rootr issue submit <공개 주소> --title "<제목>" [--body …] [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>]
|
|
95
97
|
웹게시한 트래커에 밖에서 이슈 올리기(--name 이면 키 없이 손님으로, 페이지 블록은 --block)
|
|
96
|
-
--file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일
|
|
98
|
+
--file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 75MB(손님 25MB)·글 하나에 10개
|
|
97
99
|
rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>] 웹게시한 트래커 이슈에 댓글
|
|
98
100
|
rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
|
|
99
101
|
rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
|
|
@@ -245,11 +247,12 @@ usage:
|
|
|
245
247
|
rootr issue tracker <tracker> [--participation read|login|anyone] [--json] show tracker settings / change outside participation (MANAGE)
|
|
246
248
|
rootr issue list <tracker|public URL> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <name>]… [--sort newest|oldest|comments] [--json]
|
|
247
249
|
list/search issues (a public URL searches a PUBLISHED tracker from outside — title, body, #number, author; --block for a page)
|
|
250
|
+
[--limit <1-200>] [--cursor <next cursor>] [--all] paging — no flag = newest 500, --all = everything
|
|
248
251
|
rootr issue create <tracker> --title "<title>" [--body "<body>"] file an issue (as your account)
|
|
249
252
|
rootr issue comment <tracker> <#number> "<comment>" comment on an issue
|
|
250
253
|
rootr issue submit <public URL> --title "<title>" [--body …] [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>]
|
|
251
254
|
file an issue on a PUBLISHED tracker from outside (--name = as a guest, no key; --block for a tracker on a page)
|
|
252
|
-
--file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip,
|
|
255
|
+
--file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip, 75MB per file (guest 25MB), 10 per issue/comment
|
|
253
256
|
rootr issue reply <public URL> <#number> "<comment>" [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>] comment on an issue of a published tracker
|
|
254
257
|
rootr booking show <table> show visitor booking settings
|
|
255
258
|
rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
|
|
@@ -2216,6 +2219,23 @@ function issuesOf(res) {
|
|
|
2216
2219
|
return Array.isArray(res) ? res : Array.isArray(res?.issues) ? res.issues : [];
|
|
2217
2220
|
}
|
|
2218
2221
|
|
|
2222
|
+
/** 목록 아래 한 줄 — 몇 건 중 몇 건, 다음 페이지 받는 법. */
|
|
2223
|
+
function issuePageFooter(res, shown) {
|
|
2224
|
+
const total = typeof res?.total === 'number' ? res.total : res?.truncated?.total;
|
|
2225
|
+
if (res?.nextCursor) {
|
|
2226
|
+
console.log(
|
|
2227
|
+
tr(
|
|
2228
|
+
`… 전체 ${total ?? '?'}건 중 ${shown}건. 다음 페이지: 같은 조건에 --cursor ${res.nextCursor} (끝까지: --all)`,
|
|
2229
|
+
`… ${shown} of ${total ?? '?'}. Next page: same filters plus --cursor ${res.nextCursor} (everything: --all)`,
|
|
2230
|
+
),
|
|
2231
|
+
);
|
|
2232
|
+
} else if (res?.truncated) {
|
|
2233
|
+
console.log(tr(`… ${res.truncated.total}건 중 ${res.truncated.shown}건 (나머지는 --limit/--cursor 또는 --all)`, `… ${res.truncated.shown} of ${res.truncated.total} (rest: --limit/--cursor or --all)`));
|
|
2234
|
+
} else if (typeof total === 'number' && total > shown) {
|
|
2235
|
+
console.log(tr(`… 전체 ${total}건 중 ${shown}건`, `… ${shown} of ${total}`));
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2219
2239
|
function issueNumberOf(raw) {
|
|
2220
2240
|
const n = Number(String(raw ?? '').replace(/^#/, ''));
|
|
2221
2241
|
if (!Number.isSafeInteger(n) || n < 1) throw new Error(tr(`이슈 번호가 아닙니다: ${raw}`, `Not an issue number: ${raw}`));
|
|
@@ -2238,7 +2258,7 @@ function publicIssueFilesOf(args) {
|
|
|
2238
2258
|
}
|
|
2239
2259
|
const data = readFileSync(p);
|
|
2240
2260
|
if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) {
|
|
2241
|
-
throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에
|
|
2261
|
+
throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에 75MB까지`, `File too large: ${filename} — 75MB per file`));
|
|
2242
2262
|
}
|
|
2243
2263
|
return { filename, mimeType, data };
|
|
2244
2264
|
});
|
|
@@ -2283,19 +2303,26 @@ async function cmdIssue(args) {
|
|
|
2283
2303
|
}
|
|
2284
2304
|
if (sub === 'list') {
|
|
2285
2305
|
const target = args._[1];
|
|
2286
|
-
if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--json]'));
|
|
2306
|
+
if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--limit <1~200>] [--cursor <다음 커서>] [--all] [--json]'));
|
|
2307
|
+
const pg = issuePageArgs(args);
|
|
2287
2308
|
// ISSUE-PUBLIC-SEARCH — 공개 주소(…/p/{slug})나 --block 이면 웹게시한 트래커를 **밖에서** 찾는다(키 없이, 서버 검색).
|
|
2288
2309
|
if (/\/p\//.test(String(target)) || typeof args.block === 'string') {
|
|
2289
2310
|
const slug = publicSlugOf(target);
|
|
2290
2311
|
const pass = typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined;
|
|
2291
|
-
const res = await
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2312
|
+
const res = await collectIssuePages(
|
|
2313
|
+
({ limit, cursor }) =>
|
|
2314
|
+
client.searchPublicIssues(slug, {
|
|
2315
|
+
blockId: typeof args.block === 'string' ? args.block : undefined,
|
|
2316
|
+
q: typeof args.q === 'string' ? args.q : undefined,
|
|
2317
|
+
state: typeof args.state === 'string' ? args.state : undefined,
|
|
2318
|
+
labels: args.__all.label || [],
|
|
2319
|
+
sort: typeof args.sort === 'string' ? args.sort : undefined,
|
|
2320
|
+
pass,
|
|
2321
|
+
limit,
|
|
2322
|
+
cursor,
|
|
2323
|
+
}),
|
|
2324
|
+
pg,
|
|
2325
|
+
);
|
|
2299
2326
|
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2300
2327
|
const list = res?.issues || [];
|
|
2301
2328
|
if (!list.length) return console.log(tr('조건에 맞는 이슈가 없습니다.', 'No matching issues.'));
|
|
@@ -2304,17 +2331,23 @@ async function cmdIssue(args) {
|
|
|
2304
2331
|
const labels = (i.labels || []).map((l) => l.name).join(',');
|
|
2305
2332
|
console.log(`#${i.number}\t${i.state}\t${i.title}${labels ? `\t[${labels}]` : ''}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
|
|
2306
2333
|
}
|
|
2307
|
-
|
|
2334
|
+
issuePageFooter(res, list.length);
|
|
2308
2335
|
return;
|
|
2309
2336
|
}
|
|
2310
2337
|
const id = await resolveNodeId(client, target);
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2338
|
+
const res = await collectIssuePages(
|
|
2339
|
+
({ limit, cursor }) => client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined, limit, cursor }),
|
|
2340
|
+
pg,
|
|
2341
|
+
);
|
|
2342
|
+
const list = issuesOf(res);
|
|
2343
|
+
// --json: 깃발 없이는 예전처럼 배열(스크립트 호환), --all 도 배열(전부). --limit/--cursor 면 다음 커서가 필요하니 { issues, nextCursor, total }.
|
|
2344
|
+
if (args.json) return console.log(JSON.stringify(pg.paged && !pg.all ? { issues: list, nextCursor: res?.nextCursor ?? null, total: res?.total } : list, null, 2));
|
|
2313
2345
|
if (!list.length) return console.log(tr('이슈가 없습니다.', 'No issues.'));
|
|
2314
2346
|
for (const i of list) {
|
|
2315
2347
|
const who = i.guestName ? tr(`${i.guestName} (외부)`, `${i.guestName} (guest)`) : i.author?.name ?? '';
|
|
2316
2348
|
console.log(`#${i.number}\t${i.state}\t${i.title}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
|
|
2317
2349
|
}
|
|
2350
|
+
issuePageFooter(res, list.length);
|
|
2318
2351
|
return;
|
|
2319
2352
|
}
|
|
2320
2353
|
if (sub === 'create') {
|
package/lib/client.js
CHANGED
|
@@ -119,7 +119,8 @@ function hintFor(status, body) {
|
|
|
119
119
|
* ISSUE-PUBLIC-ATTACH — 공개 이슈트래커 첨부의 안전선(서버 issue-public-attachments.ts 와 같은 값). 서버가 다시 판정하지만, 보내기 전에
|
|
120
120
|
* 걸러 큰 파일을 헛되이 싣지 않게 한다.
|
|
121
121
|
*/
|
|
122
|
-
|
|
122
|
+
/** 계정으로 올릴 때의 상한(서버와 같음, 손님은 25MB — 서버가 가른다). */
|
|
123
|
+
export const PUBLIC_ISSUE_FILE_MAX_BYTES = 75 * 1024 * 1024;
|
|
123
124
|
export const PUBLIC_ISSUE_FILES_PER_POST = 10;
|
|
124
125
|
const PUBLIC_ISSUE_MIME = {
|
|
125
126
|
png: 'image/png',
|
|
@@ -1037,19 +1038,22 @@ export class RootrClient {
|
|
|
1037
1038
|
* ISSUE-PUBLIC-SEARCH — 공개 트래커(또는 페이지 블록)의 목록을 서버에서 찾는다. `q` 는 제목·본문·번호(#12)·(이름을 싣는 모드면) 쓴 사람,
|
|
1038
1039
|
* `labels` 는 이름(모두 가진 것), `state` OPEN|CLOSED, `sort` newest|oldest|comments. 키 없이 부른다(공개 읽기).
|
|
1039
1040
|
*/
|
|
1040
|
-
async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass } = {}) {
|
|
1041
|
+
async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass, limit, cursor } = {}) {
|
|
1041
1042
|
const qs = new URLSearchParams();
|
|
1042
1043
|
if (q) qs.set('q', q);
|
|
1043
1044
|
if (state && String(state).toUpperCase() !== 'ALL') qs.set('state', String(state).toUpperCase());
|
|
1044
1045
|
for (const l of [].concat(labels || [])) if (l) qs.append('label', l);
|
|
1045
1046
|
if (sort) qs.set('sort', sort);
|
|
1047
|
+
// ISSUE-PAGING — limit(1~200)·cursor(직전 응답 nextCursor). 둘 다 없으면 서버가 예전처럼 500건.
|
|
1048
|
+
if (limit !== undefined && limit !== null) qs.set('limit', String(limit));
|
|
1049
|
+
if (cursor) qs.set('cursor', cursor);
|
|
1046
1050
|
if (pass) qs.set('pass', pass);
|
|
1047
1051
|
const tail = qs.toString();
|
|
1048
1052
|
return this.request(`${this._publicIssuesBase(slug, blockId)}${tail ? `?${tail}` : ''}`, { noAuth: true });
|
|
1049
1053
|
}
|
|
1050
1054
|
|
|
1051
1055
|
/**
|
|
1052
|
-
* ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트).
|
|
1056
|
+
* ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트). 크기(계정 75MB·손님 25MB)·허용 형식은 서버가 판정한다.
|
|
1053
1057
|
* 돌려받은 `markdown` 을 이슈·댓글 본문 끝에 붙여 보낸다(`withPublicAttachments`).
|
|
1054
1058
|
*/
|
|
1055
1059
|
async uploadPublicIssueFile(slug, { blockId, filename, mimeType, data, guestName, pass, asGuest } = {}) {
|
|
@@ -1072,13 +1076,16 @@ export class RootrClient {
|
|
|
1072
1076
|
return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}${qs}`, { noAuth: true });
|
|
1073
1077
|
}
|
|
1074
1078
|
|
|
1075
|
-
async listIssues(trackerId, { state, label, type, assigneeId, q } = {}) {
|
|
1079
|
+
async listIssues(trackerId, { state, label, type, assigneeId, q, limit, cursor } = {}) {
|
|
1076
1080
|
const params = new URLSearchParams();
|
|
1077
1081
|
if (state) params.set('state', state);
|
|
1078
1082
|
if (label) params.set('label', label);
|
|
1079
1083
|
if (type) params.set('type', type);
|
|
1080
1084
|
if (assigneeId) params.set('assigneeId', assigneeId);
|
|
1081
1085
|
if (q) params.set('q', q);
|
|
1086
|
+
// ISSUE-PAGING — 응답 { issues, nextCursor, total }. 둘 다 없으면 예전처럼 최신 500개.
|
|
1087
|
+
if (limit !== undefined && limit !== null) params.set('limit', String(limit));
|
|
1088
|
+
if (cursor) params.set('cursor', cursor);
|
|
1082
1089
|
const qs = params.toString();
|
|
1083
1090
|
return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}/issues${qs ? `?${qs}` : ''}`);
|
|
1084
1091
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { t as tr } from './i18n.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ISSUE-PAGING — `--limit <1~200>` · `--cursor <다음 커서>` · `--all`(끝까지). 깃발이 없으면 예전처럼 한 번(서버가 최신 500개).
|
|
5
|
+
* `--all` 은 200개씩 nextCursor 가 없을 때까지 받는다. 옛 서버(페이지 모르는)는 nextCursor 를 안 주므로 한 번에 멈춘다.
|
|
6
|
+
*/
|
|
7
|
+
export function issuePageArgs(args) {
|
|
8
|
+
const out = { all: args.all === true, limit: undefined, cursor: undefined };
|
|
9
|
+
if (args.limit !== undefined) {
|
|
10
|
+
const n = Number(args.limit);
|
|
11
|
+
if (args.limit === true || !Number.isInteger(n) || n < 1 || n > 200) {
|
|
12
|
+
throw new Error(tr('--limit 은 1~200 사이 정수입니다(예: --limit 100). 끝까지 받으려면 --all.', '--limit must be an integer 1–200 (e.g. --limit 100). Use --all to fetch everything.'));
|
|
13
|
+
}
|
|
14
|
+
out.limit = n;
|
|
15
|
+
}
|
|
16
|
+
if (args.cursor !== undefined) {
|
|
17
|
+
if (typeof args.cursor !== 'string' || !args.cursor) throw new Error(tr('--cursor 에는 직전 결과의 다음 커서를 넣으세요.', '--cursor needs the previous result\'s next cursor.'));
|
|
18
|
+
out.cursor = args.cursor;
|
|
19
|
+
}
|
|
20
|
+
out.paged = out.all || out.limit !== undefined || out.cursor !== undefined;
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 페이지를 이어 받는다. fetchPage({limit, cursor}) → { issues, nextCursor?, total?, truncated? }. */
|
|
25
|
+
export async function collectIssuePages(fetchPage, { all, limit, cursor }) {
|
|
26
|
+
if (!all) return fetchPage({ limit, cursor });
|
|
27
|
+
const size = limit ?? 200;
|
|
28
|
+
let cur = cursor;
|
|
29
|
+
let last = null;
|
|
30
|
+
const issues = [];
|
|
31
|
+
for (let guard = 0; guard < 100_000; guard++) {
|
|
32
|
+
const res = await fetchPage({ limit: size, cursor: cur });
|
|
33
|
+
issues.push(...(Array.isArray(res?.issues) ? res.issues : Array.isArray(res) ? res : []));
|
|
34
|
+
last = res;
|
|
35
|
+
const next = res?.nextCursor;
|
|
36
|
+
if (!next || next === cur) break;
|
|
37
|
+
cur = next;
|
|
38
|
+
}
|
|
39
|
+
const out = { ...(last && !Array.isArray(last) ? last : {}), issues, nextCursor: null };
|
|
40
|
+
delete out.truncated;
|
|
41
|
+
if (typeof out.total !== 'number') out.total = issues.length;
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
package/lib/mcp-tools/issues.js
CHANGED
|
@@ -31,6 +31,26 @@ function slugOf(target) {
|
|
|
31
31
|
return m ? decodeURIComponent(m[1]) : t;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* ISSUE-PAGING — 다음 페이지가 있으면 에이전트가 놓치지 않게 한 줄 안내를 붙인다(서버 응답은 그대로 두고 `next` 만 더한다).
|
|
36
|
+
* 옛 서버(페이지 모르는)는 nextCursor 가 없어 안내도 없다 — 대신 truncated 가 있으면 그걸로 알린다.
|
|
37
|
+
*/
|
|
38
|
+
export function withPageHint(result) {
|
|
39
|
+
if (!result || typeof result !== 'object' || !Array.isArray(result.issues)) return result;
|
|
40
|
+
const n = result.issues.length;
|
|
41
|
+
if (result.nextCursor) {
|
|
42
|
+
const total = typeof result.total === 'number' ? ` of ${result.total}` : '';
|
|
43
|
+
return {
|
|
44
|
+
...result,
|
|
45
|
+
next: `More issues remain (this page ${n}${total}). Call again with cursor="${result.nextCursor}" and the same filters to get the next page.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (result.truncated && result.nextCursor === undefined) {
|
|
49
|
+
return { ...result, next: `Only ${result.truncated.shown} of ${result.truncated.total} returned — pass limit (1–200) and then cursor=<nextCursor> to page through the rest.` };
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
/** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
|
|
35
55
|
export function registerIssueTools(server) {
|
|
36
56
|
server.registerTool(
|
|
@@ -68,8 +88,11 @@ export function registerIssueTools(server) {
|
|
|
68
88
|
{
|
|
69
89
|
title: 'List issues in a tracker',
|
|
70
90
|
description:
|
|
71
|
-
'List issues in a Rootr (루터) issue tracker, optionally filtered by state (OPEN/CLOSED/ALL),
|
|
72
|
-
'type id, or a text query.
|
|
91
|
+
'List issues in a Rootr (루터) issue tracker, newest number first, optionally filtered by state (OPEN/CLOSED/ALL), ' +
|
|
92
|
+
'label id, type id, or a text query. Paged: pass `limit` (1–200) and, for the next page, `cursor` = the previous ' +
|
|
93
|
+
'result\'s `nextCursor` (keep the same filters). Without limit/cursor you get the newest 500. Returns ' +
|
|
94
|
+
'{ issues, nextCursor, total } — `nextCursor` null means the last page; `total` = all issues matching the filters. ' +
|
|
95
|
+
'Use rootr_get_issue for full details of one issue.',
|
|
73
96
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
74
97
|
inputSchema: {
|
|
75
98
|
trackerId: z.string().describe(`Issue tracker node id${NODE_TARGET_HINT}`),
|
|
@@ -77,14 +100,16 @@ export function registerIssueTools(server) {
|
|
|
77
100
|
q: z.string().optional().describe('Free-text search within issues'),
|
|
78
101
|
label: z.string().optional().describe('Label id to filter by'),
|
|
79
102
|
type: z.string().optional().describe('Issue type id to filter by'),
|
|
103
|
+
limit: z.number().int().min(1).max(200).optional().describe('Page size 1–200 (omit with cursor too = newest 500, as before)'),
|
|
104
|
+
cursor: z.string().max(400).optional().describe('`nextCursor` from the previous call, unchanged (same filters) — next page'),
|
|
80
105
|
},
|
|
81
106
|
},
|
|
82
|
-
async ({ trackerId, state, q, label, type }) => {
|
|
107
|
+
async ({ trackerId, state, q, label, type, limit, cursor }) => {
|
|
83
108
|
try {
|
|
84
109
|
const client = makeClient();
|
|
85
110
|
trackerId = await resolveNodeId(client, trackerId);
|
|
86
|
-
const result = await client.listIssues(trackerId, { state, q, label, type });
|
|
87
|
-
return jsonResult(result);
|
|
111
|
+
const result = await client.listIssues(trackerId, { state, q, label, type, limit, cursor });
|
|
112
|
+
return jsonResult(withPageHint(result));
|
|
88
113
|
} catch (err) {
|
|
89
114
|
return errorResult(err);
|
|
90
115
|
}
|
|
@@ -264,7 +289,7 @@ export function registerIssueTools(server) {
|
|
|
264
289
|
/**
|
|
265
290
|
* ISSUE-PUBLIC-ATTACH — 공개 이슈·댓글에 붙일 파일. **내용(base64)만** 받는다 — 파일 경로는 받지 않는다: 원격 커넥터는 이 도구를 API 서버
|
|
266
291
|
* 안에서 돌리므로 경로를 읽으면 서버 파일을 남의 공개 트래커로 내보내는 구멍이 된다(rootr-remote-mcp-runs-in-api-process). 로컬에서도
|
|
267
|
-
* 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와
|
|
292
|
+
* 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와 같다 — 계정 키로 부르면 75MB, 손님이면 25MB, 글 하나에 10개.
|
|
268
293
|
*/
|
|
269
294
|
const PUBLIC_FILES = z
|
|
270
295
|
.array(
|
|
@@ -273,14 +298,14 @@ export function registerIssueTools(server) {
|
|
|
273
298
|
contentBase64: z
|
|
274
299
|
.string()
|
|
275
300
|
.max(Math.ceil(PUBLIC_ISSUE_FILE_MAX_BYTES / 3) * 4 + 4)
|
|
276
|
-
.describe('The file bytes, base64-encoded (≤
|
|
301
|
+
.describe('The file bytes, base64-encoded (≤75MB decoded with an account key; ≤25MB as a guest)'),
|
|
277
302
|
mimeType: z.string().max(120).optional().describe('Optional — guessed from the extension'),
|
|
278
303
|
}),
|
|
279
304
|
)
|
|
280
305
|
.max(PUBLIC_ISSUE_FILES_PER_POST)
|
|
281
306
|
.optional()
|
|
282
307
|
.describe(
|
|
283
|
-
`Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤
|
|
308
|
+
`Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤75MB each with an account key, ≤25MB as a guest): ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}. ` +
|
|
284
309
|
'Images show inside the text, other files as a download link. svg/html/executables/video are refused, and the file\'s ' +
|
|
285
310
|
'first bytes must match its extension. Content only — no file paths.',
|
|
286
311
|
);
|
|
@@ -291,7 +316,7 @@ export function registerIssueTools(server) {
|
|
|
291
316
|
for (const f of files || []) {
|
|
292
317
|
const data = Buffer.from(f.contentBase64, 'base64');
|
|
293
318
|
if (!data.length) throw new RootrApiError(`빈 파일입니다: ${f.filename}`);
|
|
294
|
-
if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에
|
|
319
|
+
if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에 75MB까지`);
|
|
295
320
|
const mimeType = f.mimeType || publicIssueMimeOf(f.filename);
|
|
296
321
|
if (!mimeType) {
|
|
297
322
|
throw new RootrApiError(`이 형식은 올릴 수 없습니다: ${f.filename}`, { hint: `허용: ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}` });
|
|
@@ -311,8 +336,9 @@ export function registerIssueTools(server) {
|
|
|
311
336
|
'what an outside visitor sees. `q` matches title, body, number ("#12" or "12") and, when the tracker shows authors ' +
|
|
312
337
|
'(participation login/anyone), the author name; `labels` = label names (issues having all of them); `state` OPEN|CLOSED; ' +
|
|
313
338
|
'`sort` newest (default) | oldest | comments. Returns { participation, issues:[{number,title,state,body,labels,createdAt,' +
|
|
314
|
-
'closedAt,commentCount,author}], truncated? }
|
|
315
|
-
'
|
|
339
|
+
'closedAt,commentCount,author}], truncated?, nextCursor?, total? }. Paged: `limit` (1–200) and `cursor` = the previous ' +
|
|
340
|
+
'result\'s `nextCursor` (same q/state/labels/sort); without them you get up to 500 (truncated tells you there were more). ' +
|
|
341
|
+
'No key needed. For a tracker in YOUR workspace prefer rootr_list_issues (internal fields).',
|
|
316
342
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
317
343
|
inputSchema: {
|
|
318
344
|
url: PUBLIC_TARGET.url,
|
|
@@ -322,14 +348,16 @@ export function registerIssueTools(server) {
|
|
|
322
348
|
state: z.enum(['OPEN', 'CLOSED', 'ALL']).optional().describe('Default ALL'),
|
|
323
349
|
labels: z.array(z.string().max(100)).max(20).optional().describe('Label names — issues having all of them'),
|
|
324
350
|
sort: z.enum(['newest', 'oldest', 'comments']).optional(),
|
|
351
|
+
limit: z.number().int().min(1).max(200).optional().describe('Page size 1–200'),
|
|
352
|
+
cursor: z.string().max(400).optional().describe('`nextCursor` from the previous call, unchanged (same q/state/labels/sort)'),
|
|
325
353
|
},
|
|
326
354
|
},
|
|
327
|
-
async ({ url, blockId, password, q, state, labels, sort }) => {
|
|
355
|
+
async ({ url, blockId, password, q, state, labels, sort, limit, cursor }) => {
|
|
328
356
|
try {
|
|
329
357
|
const client = makeClient();
|
|
330
358
|
const slug = slugOf(url);
|
|
331
359
|
const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
|
|
332
|
-
return jsonResult(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass }));
|
|
360
|
+
return jsonResult(withPageHint(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass, limit, cursor })));
|
|
333
361
|
} catch (err) {
|
|
334
362
|
return errorResult(err);
|
|
335
363
|
}
|