pygpt-net 2.6.1__py3-none-any.whl → 2.6.2__py3-none-any.whl

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 (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +15 -1
  4. pygpt_net/controller/chat/response.py +5 -3
  5. pygpt_net/controller/chat/stream.py +40 -2
  6. pygpt_net/controller/plugins/plugins.py +25 -0
  7. pygpt_net/controller/presets/editor.py +33 -88
  8. pygpt_net/controller/presets/experts.py +20 -1
  9. pygpt_net/controller/presets/presets.py +2 -2
  10. pygpt_net/controller/ui/mode.py +17 -66
  11. pygpt_net/core/agents/runner.py +15 -7
  12. pygpt_net/core/experts/experts.py +3 -3
  13. pygpt_net/data/config/config.json +3 -3
  14. pygpt_net/data/config/models.json +3 -3
  15. pygpt_net/data/locale/locale.de.ini +2 -0
  16. pygpt_net/data/locale/locale.en.ini +2 -0
  17. pygpt_net/data/locale/locale.es.ini +2 -0
  18. pygpt_net/data/locale/locale.fr.ini +2 -0
  19. pygpt_net/data/locale/locale.it.ini +2 -0
  20. pygpt_net/data/locale/locale.pl.ini +3 -1
  21. pygpt_net/data/locale/locale.uk.ini +2 -0
  22. pygpt_net/data/locale/locale.zh.ini +2 -0
  23. pygpt_net/plugin/base/plugin.py +35 -3
  24. pygpt_net/plugin/bitbucket/__init__.py +12 -0
  25. pygpt_net/plugin/bitbucket/config.py +267 -0
  26. pygpt_net/plugin/bitbucket/plugin.py +125 -0
  27. pygpt_net/plugin/bitbucket/worker.py +569 -0
  28. pygpt_net/plugin/facebook/__init__.py +12 -0
  29. pygpt_net/plugin/facebook/config.py +359 -0
  30. pygpt_net/plugin/facebook/plugin.py +114 -0
  31. pygpt_net/plugin/facebook/worker.py +698 -0
  32. pygpt_net/plugin/github/__init__.py +12 -0
  33. pygpt_net/plugin/github/config.py +441 -0
  34. pygpt_net/plugin/github/plugin.py +124 -0
  35. pygpt_net/plugin/github/worker.py +674 -0
  36. pygpt_net/plugin/google/__init__.py +12 -0
  37. pygpt_net/plugin/google/config.py +367 -0
  38. pygpt_net/plugin/google/plugin.py +126 -0
  39. pygpt_net/plugin/google/worker.py +826 -0
  40. pygpt_net/plugin/slack/__init__.py +12 -0
  41. pygpt_net/plugin/slack/config.py +349 -0
  42. pygpt_net/plugin/slack/plugin.py +116 -0
  43. pygpt_net/plugin/slack/worker.py +639 -0
  44. pygpt_net/plugin/telegram/__init__.py +12 -0
  45. pygpt_net/plugin/telegram/config.py +308 -0
  46. pygpt_net/plugin/telegram/plugin.py +118 -0
  47. pygpt_net/plugin/telegram/worker.py +563 -0
  48. pygpt_net/plugin/twitter/__init__.py +12 -0
  49. pygpt_net/plugin/twitter/config.py +491 -0
  50. pygpt_net/plugin/twitter/plugin.py +126 -0
  51. pygpt_net/plugin/twitter/worker.py +837 -0
  52. pygpt_net/provider/agents/llama_index/legacy/openai_assistant.py +35 -3
  53. pygpt_net/ui/base/config_dialog.py +4 -0
  54. pygpt_net/ui/dialog/preset.py +34 -77
  55. pygpt_net/ui/layout/toolbox/presets.py +2 -2
  56. pygpt_net/ui/main.py +3 -1
  57. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/METADATA +145 -2
  58. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/RECORD +61 -33
  59. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.15 00:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ import time
18
+ from typing import Any, Dict, List, Optional
19
+ from urllib.parse import quote
20
+
21
+ import requests
22
+ from PySide6.QtCore import Slot
23
+
24
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
25
+
26
+
27
+ class WorkerSignals(BaseSignals):
28
+ pass
29
+
30
+
31
+ class Worker(BaseWorker):
32
+ """
33
+ GitHub plugin worker: Auth (Device Flow or PAT), Users, Repos, Contents, Issues, Pull Requests, Search.
34
+ Auto-authorization when required (Device Flow).
35
+ """
36
+
37
+ def __init__(self, *args, **kwargs):
38
+ super(Worker, self).__init__()
39
+ self.signals = WorkerSignals()
40
+ self.args = args
41
+ self.kwargs = kwargs
42
+ self.plugin = None
43
+ self.cmds = None
44
+ self.ctx = None
45
+ self.msg = None
46
+
47
+ # ---------------------- Core runner ----------------------
48
+
49
+ @Slot()
50
+ def run(self):
51
+ try:
52
+ responses = []
53
+ for item in self.cmds:
54
+ if self.is_stopped():
55
+ break
56
+ try:
57
+ response = None
58
+ if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
59
+
60
+ # -------- Auth --------
61
+ if item["cmd"] == "gh_device_begin":
62
+ response = self.cmd_gh_device_begin(item)
63
+ elif item["cmd"] == "gh_device_poll":
64
+ response = self.cmd_gh_device_poll(item)
65
+ elif item["cmd"] == "gh_set_pat":
66
+ response = self.cmd_gh_set_pat(item)
67
+
68
+ # -------- Users --------
69
+ elif item["cmd"] == "gh_me":
70
+ response = self.cmd_gh_me(item)
71
+ elif item["cmd"] == "gh_user_get":
72
+ response = self.cmd_gh_user_get(item)
73
+
74
+ # -------- Repos --------
75
+ elif item["cmd"] == "gh_repos_list":
76
+ response = self.cmd_gh_repos_list(item)
77
+ elif item["cmd"] == "gh_repo_get":
78
+ response = self.cmd_gh_repo_get(item)
79
+ elif item["cmd"] == "gh_repo_create":
80
+ response = self.cmd_gh_repo_create(item)
81
+ elif item["cmd"] == "gh_repo_delete":
82
+ response = self.cmd_gh_repo_delete(item)
83
+
84
+ # -------- Contents (files) --------
85
+ elif item["cmd"] == "gh_contents_get":
86
+ response = self.cmd_gh_contents_get(item)
87
+ elif item["cmd"] == "gh_file_put":
88
+ response = self.cmd_gh_file_put(item)
89
+ elif item["cmd"] == "gh_file_delete":
90
+ response = self.cmd_gh_file_delete(item)
91
+
92
+ # -------- Issues --------
93
+ elif item["cmd"] == "gh_issues_list":
94
+ response = self.cmd_gh_issues_list(item)
95
+ elif item["cmd"] == "gh_issue_create":
96
+ response = self.cmd_gh_issue_create(item)
97
+ elif item["cmd"] == "gh_issue_comment":
98
+ response = self.cmd_gh_issue_comment(item)
99
+ elif item["cmd"] == "gh_issue_close":
100
+ response = self.cmd_gh_issue_close(item)
101
+
102
+ # -------- Pull Requests --------
103
+ elif item["cmd"] == "gh_pulls_list":
104
+ response = self.cmd_gh_pulls_list(item)
105
+ elif item["cmd"] == "gh_pull_create":
106
+ response = self.cmd_gh_pull_create(item)
107
+ elif item["cmd"] == "gh_pull_merge":
108
+ response = self.cmd_gh_pull_merge(item)
109
+
110
+ # -------- Search --------
111
+ elif item["cmd"] == "gh_search_repos":
112
+ response = self.cmd_gh_search_repos(item)
113
+ elif item["cmd"] == "gh_search_issues":
114
+ response = self.cmd_gh_search_issues(item)
115
+ elif item["cmd"] == "gh_search_code":
116
+ response = self.cmd_gh_search_code(item)
117
+
118
+ if response:
119
+ responses.append(response)
120
+
121
+ except Exception as e:
122
+ responses.append(self.make_response(item, self.throw_error(e)))
123
+
124
+ if responses:
125
+ self.reply_more(responses)
126
+ if self.msg is not None:
127
+ self.status(self.msg)
128
+ except Exception as e:
129
+ self.error(e)
130
+ finally:
131
+ self.cleanup()
132
+
133
+ # ---------------------- HTTP / Helpers ----------------------
134
+
135
+ def _api_base(self) -> str:
136
+ return (self.plugin.get_option_value("api_base") or "https://api.github.com").rstrip("/")
137
+
138
+ def _web_base(self) -> str:
139
+ return (self.plugin.get_option_value("web_base") or "https://github.com").rstrip("/")
140
+
141
+ def _timeout(self) -> int:
142
+ try:
143
+ return int(self.plugin.get_option_value("http_timeout") or 30)
144
+ except Exception:
145
+ return 30
146
+
147
+ def _headers(self) -> Dict[str, str]:
148
+ token, scheme = self._resolve_token()
149
+ if not token:
150
+ # try auto device flow if enabled
151
+ if bool(self.plugin.get_option_value("oauth_auto_begin") or True):
152
+ self._auto_authorize_device()
153
+ token, scheme = self._resolve_token()
154
+ if not token:
155
+ raise RuntimeError("Missing token. Provide PAT or complete OAuth Device Flow.")
156
+ hdrs = {
157
+ "Authorization": f"{scheme} {token}",
158
+ "Accept": "application/vnd.github+json",
159
+ "User-Agent": "pygpt-net-github-plugin/1.0",
160
+ "X-GitHub-Api-Version": self.plugin.get_option_value("api_version") or "2022-11-28",
161
+ }
162
+ return hdrs
163
+
164
+ def _resolve_token(self) -> (Optional[str], str):
165
+ # Prefer PAT if set; else OAuth device/web access token
166
+ pat = (self.plugin.get_option_value("pat_token") or "").strip()
167
+ if pat:
168
+ scheme = (self.plugin.get_option_value("auth_scheme") or "token").strip() or "token"
169
+ return pat, scheme # PAT typically uses "token"
170
+ access = (self.plugin.get_option_value("gh_access_token") or "").strip()
171
+ if access:
172
+ scheme = (self.plugin.get_option_value("auth_scheme") or "Bearer").strip() or "Bearer"
173
+ return access, scheme
174
+ return None, "Bearer"
175
+
176
+ def _handle_response(self, r: requests.Response) -> dict:
177
+ # Try read JSON; if not JSON, keep raw text
178
+ try:
179
+ payload = r.json() if r.content else None
180
+ except Exception:
181
+ payload = r.text
182
+
183
+ if not (200 <= r.status_code < 300):
184
+ message = None
185
+ errors = None
186
+ if isinstance(payload, dict):
187
+ message = payload.get("message")
188
+ errors = payload.get("errors")
189
+ raise RuntimeError(json.dumps({
190
+ "status": r.status_code,
191
+ "error": message or (payload if isinstance(payload, str) else str(payload)),
192
+ "errors": errors
193
+ }, ensure_ascii=False))
194
+
195
+ # Normalize to a dict envelope with 'data' + '_meta'
196
+ if isinstance(payload, list):
197
+ ret = {"data": payload}
198
+ elif isinstance(payload, dict):
199
+ ret = payload
200
+ elif payload is None:
201
+ ret = {}
202
+ else:
203
+ ret = {"data": payload}
204
+
205
+ ret["_meta"] = {
206
+ "status": r.status_code,
207
+ "ratelimit-remaining": r.headers.get("X-RateLimit-Remaining"),
208
+ "ratelimit-reset": r.headers.get("X-RateLimit-Reset"),
209
+ "ratelimit-limit": r.headers.get("X-RateLimit-Limit"),
210
+ }
211
+ return ret
212
+
213
+ def _get(self, path: str, params: dict | None = None):
214
+ url = f"{self._api_base()}{path}"
215
+ r = requests.get(url, headers=self._headers(), params=params or {}, timeout=self._timeout())
216
+ return self._handle_response(r)
217
+
218
+ def _delete(self, path: str, params: dict | None = None):
219
+ url = f"{self._api_base()}{path}"
220
+ r = requests.delete(url, headers=self._headers(), params=params or {}, timeout=self._timeout())
221
+ return self._handle_response(r)
222
+
223
+ def _delete_json(self, path: str, payload: dict | None = None, params: dict | None = None):
224
+ url = f"{self._api_base()}{path}"
225
+ r = requests.delete(
226
+ url,
227
+ headers=self._headers(),
228
+ params=params or {},
229
+ json=payload or {},
230
+ timeout=self._timeout(),
231
+ )
232
+ return self._handle_response(r)
233
+
234
+ def _post_json(self, path: str, payload: dict):
235
+ url = f"{self._api_base()}{path}"
236
+ r = requests.post(url, headers=self._headers(), json=payload or {}, timeout=self._timeout())
237
+ return self._handle_response(r)
238
+
239
+ def _patch_json(self, path: str, payload: dict):
240
+ url = f"{self._api_base()}{path}"
241
+ r = requests.patch(url, headers=self._headers(), json=payload or {}, timeout=self._timeout())
242
+ return self._handle_response(r)
243
+
244
+ def _put_json(self, path: str, payload: dict):
245
+ url = f"{self._api_base()}{path}"
246
+ r = requests.put(url, headers=self._headers(), json=payload or {}, timeout=self._timeout())
247
+ return self._handle_response(r)
248
+
249
+ def _now(self) -> int:
250
+ return int(time.time())
251
+
252
+ def _b64(self, b: bytes) -> str:
253
+ return base64.b64encode(b).decode("utf-8")
254
+
255
+ # ---------------------- Auth: Device Flow (auto) ----------------------
256
+
257
+ def _device_begin(self, scopes: str) -> dict:
258
+ url = f"{self._web_base()}/login/device/code"
259
+ data = {"client_id": self.plugin.get_option_value("oauth_client_id") or "", "scope": scopes}
260
+ hdrs = {"Accept": "application/json"}
261
+ r = requests.post(url, data=data, headers=hdrs, timeout=self._timeout())
262
+ if not (200 <= r.status_code < 300):
263
+ raise RuntimeError(f"Device begin failed: HTTP {r.status_code} {r.text}")
264
+ return r.json()
265
+
266
+ def _device_poll(self, device_code: str) -> dict:
267
+ url = f"{self._web_base()}/login/oauth/access_token"
268
+ data = {
269
+ "client_id": self.plugin.get_option_value("oauth_client_id") or "",
270
+ "device_code": device_code,
271
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
272
+ }
273
+ hdrs = {"Accept": "application/json"}
274
+ r = requests.post(url, data=data, headers=hdrs, timeout=self._timeout())
275
+ if not (200 <= r.status_code < 300):
276
+ raise RuntimeError(f"Device poll failed: HTTP {r.status_code} {r.text}")
277
+ return r.json()
278
+
279
+ def _auto_authorize_device(self):
280
+ client_id = (self.plugin.get_option_value("oauth_client_id") or "").strip()
281
+ if not client_id:
282
+ raise RuntimeError("Set oauth_client_id or configure PAT token.")
283
+ scopes = (self.plugin.get_option_value("oauth_scopes") or "repo read:org read:user user:email").strip()
284
+ begin = self._device_begin(scopes)
285
+ verification_uri = begin.get("verification_uri") or "https://github.com/login/device"
286
+ verification_uri_complete = begin.get("verification_uri_complete") or (verification_uri + "?user_code=" + begin.get("user_code", ""))
287
+ interval = int(begin.get("interval") or 5)
288
+ expires_in = int(begin.get("expires_in") or 900)
289
+ # Open browser to complete the flow
290
+ try:
291
+ self.plugin.open_url(verification_uri_complete)
292
+ except Exception:
293
+ pass
294
+
295
+ # Polling loop until token received or expired
296
+ start = self._now()
297
+ while self._now() - start < expires_in:
298
+ time.sleep(interval)
299
+ res = self._device_poll(begin["device_code"])
300
+ if res.get("error"):
301
+ if res["error"] in ("authorization_pending", "slow_down"):
302
+ if res["error"] == "slow_down":
303
+ interval += 5
304
+ continue
305
+ if res["error"] in ("expired_token", "access_denied"):
306
+ raise RuntimeError(f"Device flow aborted: {res['error']}")
307
+ access = res.get("access_token")
308
+ if access:
309
+ self.plugin.set_option_value("gh_access_token", access)
310
+ self.plugin.set_option_value("auth_scheme", (res.get("token_type") or "Bearer").title())
311
+ self.plugin.set_option_value("oauth_scope_granted", res.get("scope") or "")
312
+ # Cache identity
313
+ try:
314
+ me = self._get("/user")
315
+ data = me if isinstance(me, dict) else {}
316
+ d = data.get("data") or data
317
+ if d.get("id"):
318
+ self.plugin.set_option_value("user_id", str(d["id"]))
319
+ if d.get("login"):
320
+ self.plugin.set_option_value("username", d["login"])
321
+ except Exception:
322
+ pass
323
+ self.msg = "GitHub: Authorization complete."
324
+ return
325
+ raise RuntimeError("Device flow timeout.")
326
+
327
+ # ---------------------- Auth commands ----------------------
328
+
329
+ def cmd_gh_device_begin(self, item: dict) -> dict:
330
+ p = item.get("params", {})
331
+ client_id = (self.plugin.get_option_value("oauth_client_id") or "").strip()
332
+ if not client_id:
333
+ return self.make_response(item, "Set oauth_client_id in options first.")
334
+ scopes = p.get("scopes") or (self.plugin.get_option_value("oauth_scopes") or "repo read:org read:user user:email")
335
+ begin = self._device_begin(scopes)
336
+ try:
337
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
338
+ self.plugin.open_url(begin.get("verification_uri_complete") or begin.get("verification_uri"))
339
+ except Exception:
340
+ pass
341
+ return self.make_response(item, begin)
342
+
343
+ def cmd_gh_device_poll(self, item: dict) -> dict:
344
+ p = item.get("params", {})
345
+ code = p.get("device_code")
346
+ if not code:
347
+ return self.make_response(item, "Param 'device_code' required")
348
+ res = self._device_poll(code)
349
+ if res.get("access_token"):
350
+ self.plugin.set_option_value("gh_access_token", res["access_token"])
351
+ self.plugin.set_option_value("auth_scheme", (res.get("token_type") or "Bearer").title())
352
+ self.plugin.set_option_value("oauth_scope_granted", res.get("scope") or "")
353
+ return self.make_response(item, res)
354
+
355
+ def cmd_gh_set_pat(self, item: dict) -> dict:
356
+ p = item.get("params", {})
357
+ tok = (p.get("token") or "").strip()
358
+ if not tok:
359
+ return self.make_response(item, "Param 'token' required")
360
+ self.plugin.set_option_value("pat_token", tok)
361
+ if p.get("scheme"):
362
+ self.plugin.set_option_value("auth_scheme", p["scheme"])
363
+ return self.make_response(item, {"ok": True})
364
+
365
+ # ---------------------- Users ----------------------
366
+
367
+ def cmd_gh_me(self, item: dict) -> dict:
368
+ res = self._get("/user")
369
+ data = res.get("data") or res
370
+ if data.get("id"):
371
+ self.plugin.set_option_value("user_id", str(data["id"]))
372
+ if data.get("login"):
373
+ self.plugin.set_option_value("username", data["login"])
374
+ return self.make_response(item, res)
375
+
376
+ def cmd_gh_user_get(self, item: dict) -> dict:
377
+ p = item.get("params", {})
378
+ username = p.get("username")
379
+ if not username:
380
+ return self.make_response(item, "Param 'username' required")
381
+ res = self._get(f"/users/{quote(username)}")
382
+ return self.make_response(item, res)
383
+
384
+ # ---------------------- Repos ----------------------
385
+
386
+ def cmd_gh_repos_list(self, item: dict) -> dict:
387
+ p = item.get("params", {})
388
+ username = p.get("username") # list for a user (public)
389
+ org = p.get("org")
390
+ params = {}
391
+ if p.get("type"):
392
+ params["type"] = p["type"] # all, owner, member (for /user/repos)
393
+ if p.get("visibility"):
394
+ params["visibility"] = p["visibility"] # all/public/private
395
+ if p.get("sort"):
396
+ params["sort"] = p["sort"]
397
+ if p.get("direction"):
398
+ params["direction"] = p["direction"]
399
+ if p.get("per_page"):
400
+ params["per_page"] = int(p["per_page"])
401
+ if p.get("page"):
402
+ params["page"] = int(p["page"])
403
+
404
+ if org:
405
+ res = self._get(f"/orgs/{quote(org)}/repos", params=params)
406
+ elif username:
407
+ res = self._get(f"/users/{quote(username)}/repos", params=params)
408
+ else:
409
+ res = self._get("/user/repos", params=params)
410
+ return self.make_response(item, res)
411
+
412
+ def cmd_gh_repo_get(self, item: dict) -> dict:
413
+ p = item.get("params", {})
414
+ owner = p.get("owner")
415
+ repo = p.get("repo")
416
+ if not (owner and repo):
417
+ return self.make_response(item, "Params 'owner' and 'repo' required")
418
+ res = self._get(f"/repos/{quote(owner)}/{quote(repo)}")
419
+ return self.make_response(item, res)
420
+
421
+ def cmd_gh_repo_create(self, item: dict) -> dict:
422
+ p = item.get("params", {})
423
+ name = p.get("name")
424
+ if not name:
425
+ return self.make_response(item, "Param 'name' required")
426
+ payload = {
427
+ "name": name,
428
+ "description": p.get("description"),
429
+ "private": bool(p.get("private", False)),
430
+ "auto_init": bool(p.get("auto_init", True)),
431
+ }
432
+ org = p.get("org")
433
+ if org:
434
+ res = self._post_json(f"/orgs/{quote(org)}/repos", payload)
435
+ else:
436
+ res = self._post_json("/user/repos", payload)
437
+ return self.make_response(item, res)
438
+
439
+ def cmd_gh_repo_delete(self, item: dict) -> dict:
440
+ p = item.get("params", {})
441
+ owner = p.get("owner")
442
+ repo = p.get("repo")
443
+ confirm = bool(p.get("confirm", False))
444
+ if not (owner and repo):
445
+ return self.make_response(item, "Params 'owner' and 'repo' required")
446
+ if not confirm:
447
+ return self.make_response(item, "Confirm deletion by setting 'confirm': true")
448
+ res = self._delete(f"/repos/{quote(owner)}/{quote(repo)}")
449
+ return self.make_response(item, res)
450
+
451
+ # ---------------------- Contents (files) ----------------------
452
+
453
+ def cmd_gh_contents_get(self, item: dict) -> dict:
454
+ p = item.get("params", {})
455
+ owner, repo, path = p.get("owner"), p.get("repo"), (p.get("path") or "")
456
+ if not (owner and repo):
457
+ return self.make_response(item, "Params 'owner' and 'repo' required")
458
+ params = {}
459
+ if p.get("ref"):
460
+ params["ref"] = p["ref"]
461
+ path_enc = "/".join([quote(x) for x in path.strip("/").split("/")]) if path else ""
462
+ res = self._get(f"/repos/{quote(owner)}/{quote(repo)}/contents/{path_enc}", params=params)
463
+ return self.make_response(item, res)
464
+
465
+ def _read_local_bytes(self, local_path: str) -> bytes:
466
+ local = self.prepare_path(local_path)
467
+ if not os.path.exists(local):
468
+ raise RuntimeError(f"Local file not found: {local}")
469
+ with open(local, "rb") as fh:
470
+ return fh.read()
471
+
472
+ def cmd_gh_file_put(self, item: dict) -> dict:
473
+ p = item.get("params", {})
474
+ owner, repo, path = p.get("owner"), p.get("repo"), p.get("path")
475
+ if not (owner and repo and path):
476
+ return self.make_response(item, "Params 'owner', 'repo', 'path' required")
477
+ message = p.get("message") or f"Update {path}"
478
+ branch = p.get("branch")
479
+ sha = p.get("sha") # if updating existing file; if not provided, we'll try to resolve
480
+ content_str = p.get("content")
481
+ local_path = p.get("local_path")
482
+
483
+ if local_path and not content_str:
484
+ data = self._read_local_bytes(local_path)
485
+ elif content_str is not None:
486
+ data = content_str.encode("utf-8")
487
+ else:
488
+ return self.make_response(item, "Provide 'content' or 'local_path'")
489
+
490
+ # Resolve sha if not provided (update vs create)
491
+ if not sha and bool(p.get("resolve_sha", True)):
492
+ try:
493
+ meta = self._get(f"/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path)}", params={"ref": branch} if branch else None)
494
+ md = meta.get("data") or meta
495
+ if isinstance(md, dict) and md.get("sha"):
496
+ sha = md["sha"]
497
+ except Exception:
498
+ sha = None # creating new
499
+
500
+ payload: Dict[str, Any] = {
501
+ "message": message,
502
+ "content": self._b64(data),
503
+ }
504
+ if sha:
505
+ payload["sha"] = sha
506
+ if branch:
507
+ payload["branch"] = branch
508
+ if p.get("committer"):
509
+ payload["committer"] = p["committer"] # {"name":"", "email":""}
510
+ if p.get("author"):
511
+ payload["author"] = p["author"]
512
+
513
+ res = self._put_json(f"/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path)}", payload)
514
+ return self.make_response(item, res)
515
+
516
+ def cmd_gh_file_delete(self, item: dict) -> dict:
517
+ p = item.get("params", {})
518
+ owner, repo, path, message, sha = p.get("owner"), p.get("repo"), p.get("path"), p.get("message"), p.get("sha")
519
+ if not (owner and repo and path and message and sha):
520
+ return self.make_response(item, "Params 'owner','repo','path','message','sha' required")
521
+ payload = {"message": message, "sha": sha}
522
+ if p.get("branch"):
523
+ payload["branch"] = p["branch"]
524
+ # DELETE must carry JSON body
525
+ res = self._delete_json(f"/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path)}", payload=payload)
526
+ return self.make_response(item, res)
527
+
528
+ # ---------------------- Issues ----------------------
529
+
530
+ def cmd_gh_issues_list(self, item: dict) -> dict:
531
+ p = item.get("params", {})
532
+ owner, repo = p.get("owner"), p.get("repo")
533
+ if not (owner and repo):
534
+ return self.make_response(item, "Params 'owner' and 'repo' required")
535
+ params = {}
536
+ for k in ("state", "labels", "creator", "mentioned", "assignee", "since", "per_page", "page"):
537
+ if p.get(k) is not None:
538
+ params[k] = p[k]
539
+ res = self._get(f"/repos/{quote(owner)}/{quote(repo)}/issues", params=params)
540
+ return self.make_response(item, res)
541
+
542
+ def cmd_gh_issue_create(self, item: dict) -> dict:
543
+ p = item.get("params", {})
544
+ owner, repo, title = p.get("owner"), p.get("repo"), p.get("title")
545
+ if not (owner and repo and title):
546
+ return self.make_response(item, "Params 'owner','repo','title' required")
547
+ payload = {"title": title}
548
+ if p.get("body"):
549
+ payload["body"] = p["body"]
550
+ if p.get("assignees"):
551
+ payload["assignees"] = p["assignees"]
552
+ if p.get("labels"):
553
+ payload["labels"] = p["labels"]
554
+ res = self._post_json(f"/repos/{quote(owner)}/{quote(repo)}/issues", payload)
555
+ return self.make_response(item, res)
556
+
557
+ def cmd_gh_issue_comment(self, item: dict) -> dict:
558
+ p = item.get("params", {})
559
+ owner, repo, number, body = p.get("owner"), p.get("repo"), p.get("number"), p.get("body")
560
+ if not (owner and repo and number and body):
561
+ return self.make_response(item, "Params 'owner','repo','number','body' required")
562
+ res = self._post_json(f"/repos/{quote(owner)}/{quote(repo)}/issues/{int(number)}/comments", {"body": body})
563
+ return self.make_response(item, res)
564
+
565
+ def cmd_gh_issue_close(self, item: dict) -> dict:
566
+ p = item.get("params", {})
567
+ owner, repo, number = p.get("owner"), p.get("repo"), p.get("number")
568
+ if not (owner and repo and number):
569
+ return self.make_response(item, "Params 'owner','repo','number' required")
570
+ res = self._patch_json(f"/repos/{quote(owner)}/{quote(repo)}/issues/{int(number)}", {"state": "closed"})
571
+ return self.make_response(item, res)
572
+
573
+ # ---------------------- Pull Requests ----------------------
574
+
575
+ def cmd_gh_pulls_list(self, item: dict) -> dict:
576
+ p = item.get("params", {})
577
+ owner, repo = p.get("owner"), p.get("repo")
578
+ if not (owner and repo):
579
+ return self.make_response(item, "Params 'owner' and 'repo' required")
580
+ params = {}
581
+ for k in ("state", "head", "base", "sort", "direction", "per_page", "page"):
582
+ if p.get(k) is not None:
583
+ params[k] = p[k]
584
+ res = self._get(f"/repos/{quote(owner)}/{quote(repo)}/pulls", params=params)
585
+ return self.make_response(item, res)
586
+
587
+ def cmd_gh_pull_create(self, item: dict) -> dict:
588
+ p = item.get("params", {})
589
+ owner, repo, title, head, base = p.get("owner"), p.get("repo"), p.get("title"), p.get("head"), p.get("base")
590
+ if not (owner and repo and title and head and base):
591
+ return self.make_response(item, "Params 'owner','repo','title','head','base' required")
592
+ payload = {"title": title, "head": head, "base": base}
593
+ if p.get("body"):
594
+ payload["body"] = p["body"]
595
+ if p.get("draft") is not None:
596
+ payload["draft"] = bool(p["draft"])
597
+ res = self._post_json(f"/repos/{quote(owner)}/{quote(repo)}/pulls", payload)
598
+ return self.make_response(item, res)
599
+
600
+ def cmd_gh_pull_merge(self, item: dict) -> dict:
601
+ p = item.get("params", {})
602
+ owner, repo, number = p.get("owner"), p.get("repo"), p.get("number")
603
+ if not (owner and repo and number):
604
+ return self.make_response(item, "Params 'owner','repo','number' required")
605
+ payload = {}
606
+ if p.get("commit_title"):
607
+ payload["commit_title"] = p["commit_title"]
608
+ if p.get("commit_message"):
609
+ payload["commit_message"] = p["commit_message"]
610
+ if p.get("merge_method"):
611
+ payload["merge_method"] = p["merge_method"] # merge|squash|rebase
612
+ res = self._put_json(f"/repos/{quote(owner)}/{quote(repo)}/pulls/{int(number)}/merge", payload)
613
+ return self.make_response(item, res)
614
+
615
+ # ---------------------- Search ----------------------
616
+
617
+ def cmd_gh_search_repos(self, item: dict) -> dict:
618
+ p = item.get("params", {})
619
+ q = p.get("q")
620
+ if not q:
621
+ return self.make_response(item, "Param 'q' required")
622
+ params = {"q": q}
623
+ if p.get("sort"):
624
+ params["sort"] = p["sort"] # stars|forks|help-wanted-issues|updated
625
+ if p.get("order"):
626
+ params["order"] = p["order"] # desc|asc
627
+ if p.get("per_page"):
628
+ params["per_page"] = int(p["per_page"])
629
+ if p.get("page"):
630
+ params["page"] = int(p["page"])
631
+ res = self._get("/search/repositories", params=params)
632
+ return self.make_response(item, res)
633
+
634
+ def cmd_gh_search_issues(self, item: dict) -> dict:
635
+ p = item.get("params", {})
636
+ q = p.get("q")
637
+ if not q:
638
+ return self.make_response(item, "Param 'q' required")
639
+ params = {"q": q}
640
+ if p.get("sort"):
641
+ params["sort"] = p["sort"] # comments|reactions|reactions-+1|reactions--1|reactions-smile|created|updated
642
+ if p.get("order"):
643
+ params["order"] = p["order"]
644
+ if p.get("per_page"):
645
+ params["per_page"] = int(p["per_page"])
646
+ if p.get("page"):
647
+ params["page"] = int(p["page"])
648
+ res = self._get("/search/issues", params=params)
649
+ return self.make_response(item, res)
650
+
651
+ def cmd_gh_search_code(self, item: dict) -> dict:
652
+ p = item.get("params", {})
653
+ q = p.get("q")
654
+ if not q:
655
+ return self.make_response(item, "Param 'q' required")
656
+ params = {"q": q}
657
+ if p.get("per_page"):
658
+ params["per_page"] = int(p["per_page"])
659
+ if p.get("page"):
660
+ params["page"] = int(p["page"])
661
+ res = self._get("/search/code", params=params)
662
+ return self.make_response(item, res)
663
+
664
+ # ---------------------- FS helpers ----------------------
665
+
666
+ def prepare_path(self, path: str) -> str:
667
+ if path in [".", "./"]:
668
+ return self.plugin.window.core.config.get_user_dir("data")
669
+ if self.is_absolute_path(path):
670
+ return path
671
+ return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
672
+
673
+ def is_absolute_path(self, path: str) -> bool:
674
+ return os.path.isabs(path)