@ryuu-reinzz/ryuu-cli 0.4.3 → 0.5.1

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 (48) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +26 -0
  3. package/docs/drive.md +484 -0
  4. package/docs/github.md +464 -0
  5. package/package.json +4 -1
  6. package/plugins/github/README.md +151 -0
  7. package/plugins/github/api.js +78 -0
  8. package/plugins/github/auth.js +45 -0
  9. package/plugins/github/backend.js +191 -0
  10. package/plugins/github/command.js +307 -0
  11. package/plugins/github/config.js +209 -0
  12. package/plugins/github/credentials.js +161 -0
  13. package/plugins/github/errors.js +49 -0
  14. package/plugins/github/index.js +41 -0
  15. package/plugins/github/manager.js +613 -0
  16. package/plugins/github/tool-helpers.js +259 -0
  17. package/plugins/github/tools-account.js +64 -0
  18. package/plugins/github/tools-actions.js +126 -0
  19. package/plugins/github/tools-issues.js +214 -0
  20. package/plugins/github/tools-pulls.js +267 -0
  21. package/plugins/github/tools-releases.js +87 -0
  22. package/plugins/github/tools-repos.js +392 -0
  23. package/plugins/github/tools.js +25 -0
  24. package/plugins/google-drive/auth.js +57 -0
  25. package/plugins/google-drive/backup.js +282 -0
  26. package/plugins/google-drive/command.js +266 -0
  27. package/plugins/google-drive/config.js +252 -0
  28. package/plugins/google-drive/credentials.js +171 -0
  29. package/plugins/google-drive/drive-api.js +304 -0
  30. package/plugins/google-drive/index.js +26 -0
  31. package/plugins/google-drive/manager.js +507 -0
  32. package/plugins/google-drive/oauth-backend.js +161 -0
  33. package/plugins/google-drive/secret.js +50 -0
  34. package/plugins/google-drive/token-client.js +80 -0
  35. package/plugins/google-drive/tool-helpers.js +46 -0
  36. package/plugins/google-drive/tools-backup.js +204 -0
  37. package/plugins/google-drive/tools-files.js +315 -0
  38. package/plugins/google-drive/tools.js +13 -0
  39. package/plugins/google-drive/zip-reader.js +217 -0
  40. package/plugins/google-drive/zip-writer.js +204 -0
  41. package/plugins/index.js +58 -0
  42. package/src/agent/agent.js +12 -2
  43. package/src/cli/app.js +16 -0
  44. package/src/cli/commands.js +44 -0
  45. package/src/cli/markdown.js +10 -5
  46. package/src/cli/renderer.js +38 -5
  47. package/src/cli/tui.js +80 -9
  48. package/src/tools/registry.js +13 -0
@@ -0,0 +1,259 @@
1
+ import { ValidationError, RyuuError } from '../../src/utils/errors.js';
2
+ import { GitHubPluginError, GITHUB_CODES } from './errors.js';
3
+
4
+ /**
5
+ * Pembantu bersama untuk definisi tool GitHub.
6
+ *
7
+ * Tujuannya bukan hanya validasi, tetapi juga membatasi data yang dikirim ke
8
+ * model: setiap list dipotong dan setiap body panjang diringkas.
9
+ */
10
+
11
+ export const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
12
+ export const REPO_RE = /^[A-Za-z0-9._-]{1,100}$/;
13
+
14
+ /** Validasi owner/repo dari argumen tool dan kembalikan prefix path API. */
15
+ export function repoBase(args = {}) {
16
+ const owner = String(args.owner ?? '').trim();
17
+ const repo = String(args.repo ?? '').trim();
18
+ if (!OWNER_RE.test(owner)) {
19
+ throw new ValidationError('owner tidak valid: gunakan nama pengguna/organisasi GitHub (huruf, angka, tanda hubung).');
20
+ }
21
+ if (!REPO_RE.test(repo) || repo === '.' || repo === '..') {
22
+ throw new ValidationError('repo tidak valid: gunakan nama repository tanpa spasi atau garis miring.');
23
+ }
24
+ return `/repos/${owner}/${repo}`;
25
+ }
26
+
27
+ /** Nama branch/tag; menolak path traversal. */
28
+ export function refSegment(value, field = 'ref') {
29
+ const text = String(value ?? '').trim();
30
+ if (!text || text.length > 200 || text.includes('..') || text.includes('\\')) {
31
+ throw new ValidationError(`${field} tidak valid.`);
32
+ }
33
+ return text.split('/').map((part) => encodeURIComponent(part)).join('/');
34
+ }
35
+
36
+ /** Path file relatif di dalam repository. */
37
+ export function contentPath(value) {
38
+ const text = String(value ?? '').trim();
39
+ if (!text || text.length > 400 || text.startsWith('/') || text.includes('\\') || text.includes('\0')) {
40
+ throw new ValidationError('path file tidak valid.');
41
+ }
42
+ if (text.split('/').some((part) => part === '..' || part === '.' || part.length === 0)) {
43
+ throw new ValidationError('path file tidak boleh memuat segmen "." atau "..".');
44
+ }
45
+ return text.split('/').map((part) => encodeURIComponent(part)).join('/');
46
+ }
47
+
48
+ /** Argumen pagination yang aman. */
49
+ export function pageQuery(args = {}, { perPage = 30, max = 100 } = {}) {
50
+ const requested = Number(args.per_page);
51
+ const per_page = Number.isInteger(requested) ? Math.min(max, Math.max(1, requested)) : perPage;
52
+ const page = Number.isInteger(Number(args.page)) && Number(args.page) > 0 ? Number(args.page) : 1;
53
+ return { per_page, page };
54
+ }
55
+
56
+ /** Batasi jumlah item yang dikirim ke model. */
57
+ export function limitItems(items, max = 50) {
58
+ const list = Array.isArray(items) ? items : [];
59
+ return list.slice(0, max);
60
+ }
61
+
62
+ /** Ringkas teks panjang (body issue/PR/commit). */
63
+ export function text(value, max = 2000) {
64
+ const raw = value === null || value === undefined ? '' : String(value);
65
+ return raw.length > max ? `${raw.slice(0, max)}…` : raw;
66
+ }
67
+
68
+ function loginOf(value) {
69
+ return value && typeof value === 'object' ? value.login || null : value || null;
70
+ }
71
+
72
+ export function pickUser(user) {
73
+ if (!user || typeof user !== 'object') return null;
74
+ return {
75
+ id: user.id,
76
+ login: user.login,
77
+ name: user.name || null,
78
+ type: user.type || null,
79
+ company: user.company || null,
80
+ blog: user.blog || null,
81
+ location: user.location || null,
82
+ bio: text(user.bio, 500),
83
+ public_repos: user.public_repos ?? null,
84
+ followers: user.followers ?? null,
85
+ following: user.following ?? null,
86
+ avatar_url: user.avatar_url || null,
87
+ html_url: user.html_url || null,
88
+ created_at: user.created_at || null,
89
+ updated_at: user.updated_at || null,
90
+ };
91
+ }
92
+
93
+ export function pickRepo(repo) {
94
+ if (!repo || typeof repo !== 'object') return null;
95
+ return {
96
+ id: repo.id,
97
+ name: repo.name,
98
+ full_name: repo.full_name,
99
+ owner: loginOf(repo.owner),
100
+ private: Boolean(repo.private),
101
+ fork: Boolean(repo.fork),
102
+ archived: Boolean(repo.archived),
103
+ disabled: Boolean(repo.disabled),
104
+ description: text(repo.description, 500),
105
+ default_branch: repo.default_branch || null,
106
+ language: repo.language || null,
107
+ visibility: repo.visibility || null,
108
+ html_url: repo.html_url || null,
109
+ clone_url: repo.clone_url || null,
110
+ stargazers_count: repo.stargazers_count ?? null,
111
+ forks_count: repo.forks_count ?? null,
112
+ open_issues_count: repo.open_issues_count ?? null,
113
+ permissions: repo.permissions || null,
114
+ created_at: repo.created_at || null,
115
+ updated_at: repo.updated_at || null,
116
+ pushed_at: repo.pushed_at || null,
117
+ };
118
+ }
119
+
120
+ export function pickBranch(branch) {
121
+ if (!branch || typeof branch !== 'object') return null;
122
+ return {
123
+ name: branch.name,
124
+ protected: Boolean(branch.protected),
125
+ sha: branch.commit?.sha || null,
126
+ commit_url: branch.commit?.url || null,
127
+ };
128
+ }
129
+
130
+ export function pickCommit(commit) {
131
+ if (!commit || typeof commit !== 'object') return null;
132
+ return {
133
+ sha: commit.sha,
134
+ message: text(commit.commit?.message, 1500),
135
+ author: loginOf(commit.author),
136
+ committer: loginOf(commit.committer),
137
+ date: commit.commit?.author?.date || commit.commit?.committer?.date || null,
138
+ html_url: commit.html_url || null,
139
+ parents: (commit.parents || []).map((parent) => parent.sha),
140
+ stats: commit.stats || null,
141
+ };
142
+ }
143
+
144
+ export function pickIssue(issue) {
145
+ if (!issue || typeof issue !== 'object') return null;
146
+ return {
147
+ number: issue.number,
148
+ title: issue.title,
149
+ state: issue.state,
150
+ state_reason: issue.state_reason || null,
151
+ user: loginOf(issue.user),
152
+ labels: (issue.labels || []).map((label) => (typeof label === 'string' ? label : label.name)),
153
+ assignees: (issue.assignees || []).map((assignee) => assignee.login),
154
+ comments: issue.comments ?? null,
155
+ body: text(issue.body, 4000),
156
+ html_url: issue.html_url || null,
157
+ is_pull_request: Boolean(issue.pull_request),
158
+ created_at: issue.created_at || null,
159
+ updated_at: issue.updated_at || null,
160
+ closed_at: issue.closed_at || null,
161
+ };
162
+ }
163
+
164
+ export function pickPull(pull) {
165
+ if (!pull || typeof pull !== 'object') return null;
166
+ return {
167
+ number: pull.number,
168
+ title: pull.title,
169
+ state: pull.state,
170
+ draft: Boolean(pull.draft),
171
+ merged: Boolean(pull.merged),
172
+ mergeable: pull.mergeable ?? null,
173
+ mergeable_state: pull.mergeable_state || null,
174
+ user: loginOf(pull.user),
175
+ head: pull.head ? { ref: pull.head.ref, sha: pull.head.sha } : null,
176
+ base: pull.base ? { ref: pull.base.ref, sha: pull.base.sha } : null,
177
+ body: text(pull.body, 4000),
178
+ additions: pull.additions ?? null,
179
+ deletions: pull.deletions ?? null,
180
+ changed_files: pull.changed_files ?? null,
181
+ comments: pull.comments ?? null,
182
+ html_url: pull.html_url || null,
183
+ created_at: pull.created_at || null,
184
+ updated_at: pull.updated_at || null,
185
+ merged_at: pull.merged_at || null,
186
+ };
187
+ }
188
+
189
+ export function pickRelease(release) {
190
+ if (!release || typeof release !== 'object') return null;
191
+ return {
192
+ id: release.id,
193
+ tag_name: release.tag_name,
194
+ target_commitish: release.target_commitish || null,
195
+ name: release.name || null,
196
+ draft: Boolean(release.draft),
197
+ prerelease: Boolean(release.prerelease),
198
+ author: loginOf(release.author),
199
+ body: text(release.body, 4000),
200
+ html_url: release.html_url || null,
201
+ tarball_url: release.tarball_url || null,
202
+ zipball_url: release.zipball_url || null,
203
+ created_at: release.created_at || null,
204
+ published_at: release.published_at || null,
205
+ };
206
+ }
207
+
208
+ export function pickWorkflow(workflow) {
209
+ if (!workflow || typeof workflow !== 'object') return null;
210
+ return {
211
+ id: workflow.id,
212
+ name: workflow.name,
213
+ path: workflow.path,
214
+ state: workflow.state,
215
+ html_url: workflow.html_url || null,
216
+ created_at: workflow.created_at || null,
217
+ updated_at: workflow.updated_at || null,
218
+ };
219
+ }
220
+
221
+ export function pickWorkflowRun(run) {
222
+ if (!run || typeof run !== 'object') return null;
223
+ return {
224
+ id: run.id,
225
+ name: run.name || null,
226
+ display_title: text(run.display_title, 300),
227
+ workflow_id: run.workflow_id ?? null,
228
+ run_number: run.run_number ?? null,
229
+ event: run.event || null,
230
+ status: run.status || null,
231
+ conclusion: run.conclusion || null,
232
+ head_branch: run.head_branch || null,
233
+ head_sha: run.head_sha || null,
234
+ actor: loginOf(run.actor),
235
+ html_url: run.html_url || null,
236
+ created_at: run.created_at || null,
237
+ updated_at: run.updated_at || null,
238
+ run_started_at: run.run_started_at || null,
239
+ };
240
+ }
241
+
242
+ /** Operasi destruktif bisa dimatikan lewat `/github config set confirmDestructive false`. */
243
+ export async function assertDestructiveAllowed(plugin, action) {
244
+ const settings = await plugin.loadConfig();
245
+ if (!settings.confirmDestructive) {
246
+ throw new GitHubPluginError(
247
+ `${action} diblokir karena confirmDestructive=false. Aktifkan lagi dengan /github config set confirmDestructive true.`,
248
+ { code: GITHUB_CODES.DESTRUCTIVE },
249
+ );
250
+ }
251
+ }
252
+
253
+ /** Cek `ok` pada respons yang tidak melempar error. */
254
+ export function assertOk(value, message) {
255
+ if (!value || value.ok === false) {
256
+ throw new RyuuError(message, { code: 'GITHUB_UNEXPECTED' });
257
+ }
258
+ return value;
259
+ }
@@ -0,0 +1,64 @@
1
+ import { defineTool } from '../../src/tools/registry.js';
2
+ import { pickUser } from './tool-helpers.js';
3
+
4
+ export function createAccountTools(plugin) {
5
+ return [
6
+ defineTool({
7
+ name: 'github_get_me',
8
+ description:
9
+ 'Ambil profil akun GitHub yang sedang terhubung (login, nama, tipe akun, jumlah repository publik, tautan profil). Memerlukan user access token dengan scope read:user.',
10
+ risk: 'read',
11
+ schema: { type: 'object', properties: {} },
12
+ async handler() {
13
+ const api = await plugin.requireClient();
14
+ const user = await api.get('/user');
15
+ return { ok: true, account: pickUser(user) };
16
+ },
17
+ }),
18
+
19
+ defineTool({
20
+ name: 'github_get_user',
21
+ description:
22
+ 'Ambil profil publik pengguna atau organisasi GitHub lain berdasarkan username. Tidak mengembalikan data privat pemilik akun.',
23
+ risk: 'read',
24
+ schema: {
25
+ type: 'object',
26
+ properties: {
27
+ username: { type: 'string', description: 'Username GitHub, contoh: torvalds atau github.' },
28
+ },
29
+ required: ['username'],
30
+ },
31
+ async handler(args) {
32
+ const api = await plugin.requireClient();
33
+ const username = String(args.username || '').trim();
34
+ if (!/^[A-Za-z0-9-]{1,39}$/.test(username)) {
35
+ throw new Error('username GitHub tidak valid.');
36
+ }
37
+ const user = await api.get(`/users/${encodeURIComponent(username)}`);
38
+ return { ok: true, user: pickUser(user) };
39
+ },
40
+ }),
41
+
42
+ defineTool({
43
+ name: 'github_get_rate_limit',
44
+ description:
45
+ 'Periksa sisa kuota GitHub REST API untuk token yang terhubung (core, search, graphql) beserta waktu reset. Gunakan sebelum operasi massal.',
46
+ risk: 'read',
47
+ schema: { type: 'object', properties: {} },
48
+ async handler() {
49
+ const api = await plugin.requireClient();
50
+ const rate = await api.rateLimit();
51
+ const resources = rate?.resources || {};
52
+ return {
53
+ ok: true,
54
+ rate_limit: {
55
+ core: resources.core || null,
56
+ search: resources.search || null,
57
+ graphql: resources.graphql || null,
58
+ },
59
+ documentation: 'https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api',
60
+ };
61
+ },
62
+ }),
63
+ ];
64
+ }
@@ -0,0 +1,126 @@
1
+ import { defineTool } from '../../src/tools/registry.js';
2
+ import { limitItems, pageQuery, pickWorkflow, pickWorkflowRun, repoBase } from './tool-helpers.js';
3
+
4
+ const REPO_PROPS = {
5
+ owner: { type: 'string', description: 'Pemilik repository.' },
6
+ repo: { type: 'string', description: 'Nama repository.' },
7
+ };
8
+
9
+ export function createActionTools(plugin) {
10
+ return [
11
+ defineTool({
12
+ name: 'github_list_workflows',
13
+ description:
14
+ 'Daftar GitHub Actions workflow yang terdaftar pada repository. Membutuhkan permission Actions read.',
15
+ risk: 'read',
16
+ schema: {
17
+ type: 'object',
18
+ properties: {
19
+ ...REPO_PROPS,
20
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
21
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
22
+ },
23
+ required: ['owner', 'repo'],
24
+ },
25
+ async handler(args) {
26
+ const api = await plugin.requireClient();
27
+ const result = await api.get(`${repoBase(args)}/actions/workflows`, pageQuery(args));
28
+ const workflows = Array.isArray(result?.workflows) ? result.workflows : [];
29
+ return {
30
+ ok: true,
31
+ total_count: result?.total_count ?? workflows.length,
32
+ workflows: limitItems(workflows, 100).map(pickWorkflow),
33
+ };
34
+ },
35
+ }),
36
+
37
+ defineTool({
38
+ name: 'github_list_workflow_runs',
39
+ description:
40
+ 'Daftar eksekusi workflow (workflow run), dapat difilter per workflow, branch, event, status, dan aktor. Membutuhkan permission Actions read.',
41
+ risk: 'read',
42
+ schema: {
43
+ type: 'object',
44
+ properties: {
45
+ ...REPO_PROPS,
46
+ workflow_id: { type: 'integer', minimum: 1, description: 'ID workflow; kosongkan untuk semua workflow.' },
47
+ branch: { type: 'string', description: 'Filter branch.' },
48
+ event: { type: 'string', description: 'Filter event, contoh: push atau pull_request.' },
49
+ status: {
50
+ type: 'string',
51
+ enum: ['queued', 'in_progress', 'completed', 'success', 'failure', 'cancelled', 'skipped'],
52
+ description: 'Filter status/kesimpulan.',
53
+ },
54
+ actor: { type: 'string', description: 'Filter username pemicu.' },
55
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
56
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
57
+ },
58
+ required: ['owner', 'repo'],
59
+ },
60
+ async handler(args) {
61
+ const api = await plugin.requireClient();
62
+ const base = repoBase(args);
63
+ const path = args.workflow_id
64
+ ? `${base}/actions/workflows/${Number(args.workflow_id)}/runs`
65
+ : `${base}/actions/runs`;
66
+ const result = await api.get(path, {
67
+ ...pageQuery(args),
68
+ ...(args.branch ? { branch: args.branch } : {}),
69
+ ...(args.event ? { event: args.event } : {}),
70
+ ...(args.status ? { status: args.status } : {}),
71
+ ...(args.actor ? { actor: args.actor } : {}),
72
+ });
73
+ const runs = Array.isArray(result?.workflow_runs) ? result.workflow_runs : [];
74
+ return {
75
+ ok: true,
76
+ total_count: result?.total_count ?? runs.length,
77
+ runs: limitItems(runs, 100).map(pickWorkflowRun),
78
+ };
79
+ },
80
+ }),
81
+
82
+ defineTool({
83
+ name: 'github_get_workflow_run',
84
+ description: 'Ambil detail satu workflow run: status, kesimpulan, commit, aktor, dan tautan ke halaman Actions.',
85
+ risk: 'read',
86
+ schema: {
87
+ type: 'object',
88
+ properties: {
89
+ ...REPO_PROPS,
90
+ run_id: { type: 'integer', minimum: 1, description: 'ID workflow run.' },
91
+ },
92
+ required: ['owner', 'repo', 'run_id'],
93
+ },
94
+ async handler(args) {
95
+ const api = await plugin.requireClient();
96
+ const run = await api.get(`${repoBase(args)}/actions/runs/${Number(args.run_id)}`);
97
+ return { ok: true, run: pickWorkflowRun(run) };
98
+ },
99
+ }),
100
+
101
+ defineTool({
102
+ name: 'github_rerun_workflow',
103
+ description:
104
+ 'Jalankan ulang seluruh job pada workflow run yang sudah selesai. Membutuhkan permission Actions write; operasi ini selalu meminta approval.',
105
+ risk: 'write',
106
+ schema: {
107
+ type: 'object',
108
+ properties: {
109
+ ...REPO_PROPS,
110
+ run_id: { type: 'integer', minimum: 1, description: 'ID workflow run.' },
111
+ enable_debug_logging: { type: 'boolean', description: 'Aktifkan debug logging pada rerun.' },
112
+ },
113
+ required: ['owner', 'repo', 'run_id'],
114
+ },
115
+ async handler(args) {
116
+ const api = await plugin.requireClient();
117
+ await api.post(`${repoBase(args)}/actions/runs/${Number(args.run_id)}/rerun`, {
118
+ ...(args.enable_debug_logging !== undefined
119
+ ? { enable_debug_logging: Boolean(args.enable_debug_logging) }
120
+ : {}),
121
+ });
122
+ return { ok: true, rerun: true, run_id: Number(args.run_id) };
123
+ },
124
+ }),
125
+ ];
126
+ }
@@ -0,0 +1,214 @@
1
+ import { defineTool } from '../../src/tools/registry.js';
2
+ import { limitItems, pageQuery, pickIssue, repoBase, text } from './tool-helpers.js';
3
+
4
+ const REPO_PROPS = {
5
+ owner: { type: 'string', description: 'Pemilik repository (username atau organisasi).' },
6
+ repo: { type: 'string', description: 'Nama repository.' },
7
+ };
8
+
9
+ export function createIssueTools(plugin) {
10
+ return [
11
+ defineTool({
12
+ name: 'github_list_issues',
13
+ description:
14
+ 'Daftar issue pada repository. Secara default pull request disaring keluar; set include_pull_requests=true bila hasil gabungan memang diinginkan. Membutuhkan permission Issues read.',
15
+ risk: 'read',
16
+ schema: {
17
+ type: 'object',
18
+ properties: {
19
+ ...REPO_PROPS,
20
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'Status issue. Default open.' },
21
+ labels: { type: 'string', description: 'Daftar label dipisah koma, contoh: bug,urgent.' },
22
+ sort: { type: 'string', enum: ['created', 'updated', 'comments'], description: 'Urutan hasil.' },
23
+ direction: { type: 'string', enum: ['asc', 'desc'], description: 'Arah urutan.' },
24
+ since: { type: 'string', description: 'Hanya issue yang diperbarui setelah tanggal ISO 8601 ini.' },
25
+ creator: { type: 'string', description: 'Filter berdasarkan username pembuat.' },
26
+ assignee: { type: 'string', description: 'Filter berdasarkan username assignee, atau "none".' },
27
+ milestone: { type: 'string', description: 'Nomor milestone, "none", atau "*".' },
28
+ include_pull_requests: { type: 'boolean', description: 'Sertakan pull request dalam hasil. Default false.' },
29
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
30
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
31
+ },
32
+ required: ['owner', 'repo'],
33
+ },
34
+ async handler(args) {
35
+ const api = await plugin.requireClient();
36
+ const query = {
37
+ ...pageQuery(args),
38
+ state: args.state || 'open',
39
+ ...(args.labels ? { labels: args.labels } : {}),
40
+ ...(args.sort ? { sort: args.sort } : {}),
41
+ ...(args.direction ? { direction: args.direction } : {}),
42
+ ...(args.since ? { since: args.since } : {}),
43
+ ...(args.creator ? { creator: args.creator } : {}),
44
+ ...(args.assignee ? { assignee: args.assignee } : {}),
45
+ ...(args.milestone ? { milestone: args.milestone } : {}),
46
+ };
47
+ const items = await api.paginate(`${repoBase(args)}/issues`, { query, maxPages: 2, maxItems: 100 });
48
+ const filtered = args.include_pull_requests ? items : items.filter((item) => !item.pull_request);
49
+ return {
50
+ ok: true,
51
+ count: filtered.length,
52
+ filtered_pull_requests: items.length - filtered.length,
53
+ issues: limitItems(filtered, 100).map(pickIssue),
54
+ };
55
+ },
56
+ }),
57
+
58
+ defineTool({
59
+ name: 'github_get_issue',
60
+ description: 'Ambil satu issue berdasarkan nomor, termasuk label, assignee, isi, dan jumlah komentar.',
61
+ risk: 'read',
62
+ schema: {
63
+ type: 'object',
64
+ properties: {
65
+ ...REPO_PROPS,
66
+ issue_number: { type: 'integer', minimum: 1, description: 'Nomor issue.' },
67
+ },
68
+ required: ['owner', 'repo', 'issue_number'],
69
+ },
70
+ async handler(args) {
71
+ const api = await plugin.requireClient();
72
+ const issue = await api.get(`${repoBase(args)}/issues/${Number(args.issue_number)}`);
73
+ return { ok: true, issue: pickIssue(issue) };
74
+ },
75
+ }),
76
+
77
+ defineTool({
78
+ name: 'github_create_issue',
79
+ description:
80
+ 'Buat issue baru. Membutuhkan permission Issues write. Operasi ini mengubah data dan selalu meminta approval pengguna.',
81
+ risk: 'write',
82
+ schema: {
83
+ type: 'object',
84
+ properties: {
85
+ ...REPO_PROPS,
86
+ title: { type: 'string', description: 'Judul issue (wajib).' },
87
+ body: { type: 'string', description: 'Isi issue dalam Markdown.' },
88
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label yang dipasang.' },
89
+ assignees: { type: 'array', items: { type: 'string' }, description: 'Username yang ditugaskan.' },
90
+ milestone: { type: 'integer', minimum: 1, description: 'Nomor milestone.' },
91
+ },
92
+ required: ['owner', 'repo', 'title'],
93
+ },
94
+ async handler(args) {
95
+ const api = await plugin.requireClient();
96
+ const body = { title: String(args.title) };
97
+ if (args.body !== undefined) body.body = String(args.body);
98
+ if (Array.isArray(args.labels)) body.labels = args.labels.map(String);
99
+ if (Array.isArray(args.assignees)) body.assignees = args.assignees.map(String);
100
+ if (args.milestone !== undefined) body.milestone = Number(args.milestone);
101
+ const issue = await api.post(`${repoBase(args)}/issues`, body);
102
+ return { ok: true, issue: pickIssue(issue) };
103
+ },
104
+ }),
105
+
106
+ defineTool({
107
+ name: 'github_update_issue',
108
+ description:
109
+ 'Perbarui judul, isi, status, label, assignee, atau milestone sebuah issue. Membutuhkan permission Issues write serta approval pengguna.',
110
+ risk: 'write',
111
+ schema: {
112
+ type: 'object',
113
+ properties: {
114
+ ...REPO_PROPS,
115
+ issue_number: { type: 'integer', minimum: 1, description: 'Nomor issue.' },
116
+ title: { type: 'string', description: 'Judul baru.' },
117
+ body: { type: 'string', description: 'Isi baru.' },
118
+ state: { type: 'string', enum: ['open', 'closed'], description: 'Status baru.' },
119
+ state_reason: {
120
+ type: 'string',
121
+ enum: ['completed', 'not_planned', 'reopened'],
122
+ description: 'Alasan penutupan (opsional).',
123
+ },
124
+ labels: { type: 'array', items: { type: 'string' }, description: 'Ganti seluruh label.' },
125
+ assignees: { type: 'array', items: { type: 'string' }, description: 'Ganti seluruh assignee.' },
126
+ milestone: { type: ['integer', 'null'], description: 'Nomor milestone baru atau null untuk menghapus.' },
127
+ },
128
+ required: ['owner', 'repo', 'issue_number'],
129
+ },
130
+ async handler(args) {
131
+ const api = await plugin.requireClient();
132
+ const body = {};
133
+ for (const key of ['title', 'body', 'state', 'state_reason']) {
134
+ if (args[key] !== undefined) body[key] = args[key];
135
+ }
136
+ if (Array.isArray(args.labels)) body.labels = args.labels.map(String);
137
+ if (Array.isArray(args.assignees)) body.assignees = args.assignees.map(String);
138
+ if (args.milestone !== undefined) body.milestone = args.milestone;
139
+ if (Object.keys(body).length === 0) throw new Error('Tidak ada perubahan yang diminta.');
140
+ const issue = await api.patch(`${repoBase(args)}/issues/${Number(args.issue_number)}`, body);
141
+ return { ok: true, issue: pickIssue(issue) };
142
+ },
143
+ }),
144
+
145
+ defineTool({
146
+ name: 'github_comment_issue',
147
+ description:
148
+ 'Tambahkan komentar pada issue atau pull request. Membutuhkan permission Issues write (untuk PR: Pull requests write) dan approval pengguna.',
149
+ risk: 'write',
150
+ schema: {
151
+ type: 'object',
152
+ properties: {
153
+ ...REPO_PROPS,
154
+ issue_number: { type: 'integer', minimum: 1, description: 'Nomor issue atau pull request.' },
155
+ body: { type: 'string', description: 'Isi komentar dalam Markdown.' },
156
+ },
157
+ required: ['owner', 'repo', 'issue_number', 'body'],
158
+ },
159
+ async handler(args) {
160
+ const api = await plugin.requireClient();
161
+ const comment = await api.post(`${repoBase(args)}/issues/${Number(args.issue_number)}/comments`, {
162
+ body: String(args.body),
163
+ });
164
+ return {
165
+ ok: true,
166
+ comment: {
167
+ id: comment.id,
168
+ user: comment.user?.login || null,
169
+ body: text(comment.body, 2000),
170
+ html_url: comment.html_url || null,
171
+ created_at: comment.created_at || null,
172
+ },
173
+ };
174
+ },
175
+ }),
176
+
177
+ defineTool({
178
+ name: 'github_list_issue_comments',
179
+ description: 'Daftar komentar pada sebuah issue atau pull request, terbaru sesuai urutan GitHub.',
180
+ risk: 'read',
181
+ schema: {
182
+ type: 'object',
183
+ properties: {
184
+ ...REPO_PROPS,
185
+ issue_number: { type: 'integer', minimum: 1, description: 'Nomor issue atau pull request.' },
186
+ since: { type: 'string', description: 'Hanya komentar setelah tanggal ISO 8601 ini.' },
187
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
188
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
189
+ },
190
+ required: ['owner', 'repo', 'issue_number'],
191
+ },
192
+ async handler(args) {
193
+ const api = await plugin.requireClient();
194
+ const comments = await api.paginate(`${repoBase(args)}/issues/${Number(args.issue_number)}/comments`, {
195
+ query: { ...pageQuery(args), ...(args.since ? { since: args.since } : {}) },
196
+ maxPages: 2,
197
+ maxItems: 100,
198
+ });
199
+ return {
200
+ ok: true,
201
+ count: comments.length,
202
+ comments: limitItems(comments, 100).map((comment) => ({
203
+ id: comment.id,
204
+ user: comment.user?.login || null,
205
+ body: text(comment.body, 2000),
206
+ html_url: comment.html_url || null,
207
+ created_at: comment.created_at || null,
208
+ updated_at: comment.updated_at || null,
209
+ })),
210
+ };
211
+ },
212
+ }),
213
+ ];
214
+ }