@namewta/speculo 2.0.5 → 2.1.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.
Files changed (283) hide show
  1. package/README.md +3 -0
  2. package/THIRD-PARTY-NOTICES.md +4 -0
  3. package/dist/src/agent-skills.d.ts +5 -0
  4. package/dist/src/agent-skills.js +10 -7
  5. package/dist/src/agent-skills.js.map +1 -1
  6. package/dist/src/cli.js +17 -4
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/coordination.d.ts +3 -3
  9. package/dist/src/coordination.js +21 -4
  10. package/dist/src/coordination.js.map +1 -1
  11. package/dist/src/doctor.js +8 -1
  12. package/dist/src/doctor.js.map +1 -1
  13. package/dist/src/external-files.d.ts +4 -2
  14. package/dist/src/external-files.js +4 -4
  15. package/dist/src/external-files.js.map +1 -1
  16. package/dist/src/hub-catalog.d.ts +11 -0
  17. package/dist/src/hub-catalog.js +91 -0
  18. package/dist/src/hub-catalog.js.map +1 -0
  19. package/dist/src/hub-resources.json +1 -0
  20. package/dist/src/hub-skills.d.ts +53 -0
  21. package/dist/src/hub-skills.js +157 -0
  22. package/dist/src/hub-skills.js.map +1 -0
  23. package/dist/src/index.d.ts +6 -0
  24. package/dist/src/index.js +48 -47
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/install-ui.d.ts +16 -0
  27. package/dist/src/install-ui.js +67 -11
  28. package/dist/src/install-ui.js.map +1 -1
  29. package/dist/src/manifest.d.ts +2 -0
  30. package/dist/src/manifest.js +2 -0
  31. package/dist/src/manifest.js.map +1 -1
  32. package/dist/src/refresh.d.ts +1 -0
  33. package/dist/src/refresh.js +13 -10
  34. package/dist/src/refresh.js.map +1 -1
  35. package/dist/src/retained-workflows.d.ts +10 -0
  36. package/dist/src/retained-workflows.js +25 -0
  37. package/dist/src/retained-workflows.js.map +1 -0
  38. package/dist/src/runtime-entry.d.ts +1 -0
  39. package/dist/src/runtime-entry.js +1 -0
  40. package/dist/src/runtime-entry.js.map +1 -1
  41. package/dist/src/skill-trees.d.ts +27 -0
  42. package/dist/src/skill-trees.js +118 -0
  43. package/dist/src/skill-trees.js.map +1 -0
  44. package/dist/src/transaction.d.ts +3 -14
  45. package/dist/src/transaction.js +20 -36
  46. package/dist/src/transaction.js.map +1 -1
  47. package/dist/src/workflow-install.d.ts +15 -0
  48. package/dist/src/workflow-install.js +104 -0
  49. package/dist/src/workflow-install.js.map +1 -0
  50. package/package.json +4 -2
  51. package/skills-hub/automation/grok-bot-team-steward/CHEATSHEET.md +17 -0
  52. package/skills-hub/automation/grok-bot-team-steward/INSTALL.md +23 -0
  53. package/skills-hub/automation/grok-bot-team-steward/LICENSE +21 -0
  54. package/skills-hub/automation/grok-bot-team-steward/README.md +9 -0
  55. package/skills-hub/automation/grok-bot-team-steward/SKILL.md +52 -0
  56. package/skills-hub/automation/grok-bot-team-steward/agents/openai.yaml +4 -0
  57. package/skills-hub/automation/grok-bot-team-steward/references/00-hard-rules.md +50 -0
  58. package/skills-hub/automation/grok-bot-team-steward/references/01-snapshot-schema.md +84 -0
  59. package/skills-hub/automation/grok-bot-team-steward/references/02-export-protocol.md +57 -0
  60. package/skills-hub/automation/grok-bot-team-steward/references/03-restore-protocol.md +64 -0
  61. package/skills-hub/automation/grok-bot-team-steward/references/04-inventory.md +37 -0
  62. package/skills-hub/automation/grok-bot-team-steward/references/05-conversations.md +71 -0
  63. package/skills-hub/automation/grok-bot-team-steward/references/06-sanitizer.md +39 -0
  64. package/skills-hub/automation/grok-bot-team-steward/references/07-prompts.md +87 -0
  65. package/skills-hub/automation/grok-bot-team-steward/references/08-verification.md +30 -0
  66. package/skills-hub/automation/grok-bot-team-steward/references/09-sources.md +31 -0
  67. package/skills-hub/automation/grok-bot-team-steward/scripts/scrub.sh +17 -0
  68. package/skills-hub/automation/grok-bot-team-steward/scripts/snapshot-name.sh +18 -0
  69. package/skills-hub/automation/grok-bot-team-steward/scripts/validate-snapshot.sh +62 -0
  70. package/skills-hub/automation/grok-bot-team-steward/steward/ACTIVATE.md +58 -0
  71. package/skills-hub/automation/grok-bot-team-steward/steward/SETUP-INSTRUCTIONS.md +33 -0
  72. package/skills-hub/automation/grok-bot-team-steward/steward/STEWARD-PROFILE.md +28 -0
  73. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHANGELOG.md +3 -0
  74. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHARTER.md +27 -0
  75. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/DROP-LIST.md +10 -0
  76. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/GAPS.md +6 -0
  77. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/INIT.md +14 -0
  78. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.md +30 -0
  79. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.yaml +20 -0
  80. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ORG.md +15 -0
  81. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/README.md +8 -0
  82. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/RESTORE.md +13 -0
  83. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ROSTER.md +11 -0
  84. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/GAPS.md +7 -0
  85. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/MEMORY.md +9 -0
  86. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/PROFILE.md +27 -0
  87. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/agent-spec.md +9 -0
  88. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/INDEX.md +5 -0
  89. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/main.md +9 -0
  90. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/routines.md +5 -0
  91. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/skills.md +5 -0
  92. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/connectors.md +7 -0
  93. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/COMPLETED.md +6 -0
  94. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/CREATED.md +6 -0
  95. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/RESTORE-NOTES.md +13 -0
  96. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SANITIZER.md +7 -0
  97. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SOURCE.md +8 -0
  98. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/rooms/_ROOM.md +8 -0
  99. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/routines/_ROUTINE.md +12 -0
  100. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/shared-memory.md +5 -0
  101. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/workspace-map.md +5 -0
  102. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/skills/_SKILL/SKILL.md +18 -0
  103. package/skills-hub/automation/herdr/SKILL.md +246 -0
  104. package/skills-hub/automation/orca/SKILL.md +328 -0
  105. package/skills-hub/coding/gitea-repo/LICENSE +21 -0
  106. package/skills-hub/coding/gitea-repo/SKILL.md +50 -0
  107. package/skills-hub/coding/gitea-repo/agents/openai.yaml +4 -0
  108. package/skills-hub/coding/gitea-repo/references/auth-and-scopes.md +58 -0
  109. package/skills-hub/coding/gitea-repo/references/endpoint-map.md +80 -0
  110. package/skills-hub/coding/gitea-repo/references/pagination-and-errors.md +34 -0
  111. package/skills-hub/coding/gitea-repo/scripts/gitea_api.py +814 -0
  112. package/skills-hub/coding/github-repo-steward/LICENSE +21 -0
  113. package/skills-hub/coding/github-repo-steward/SKILL.md +48 -0
  114. package/skills-hub/coding/github-repo-steward/agents/openai.yaml +4 -0
  115. package/skills-hub/coding/github-repo-steward/references/00-auth-and-tokens.md +73 -0
  116. package/skills-hub/coding/github-repo-steward/references/01-inventory.md +55 -0
  117. package/skills-hub/coding/github-repo-steward/references/02-lifecycle.md +57 -0
  118. package/skills-hub/coding/github-repo-steward/references/03-stars.md +42 -0
  119. package/skills-hub/coding/github-repo-steward/references/04-hygiene.md +40 -0
  120. package/skills-hub/coding/github-repo-steward/references/05-sources.md +42 -0
  121. package/skills-hub/coding/github-repo-steward/scripts/inventory.sh +64 -0
  122. package/skills-hub/coding/vscode-fullstack/LICENSE +21 -0
  123. package/skills-hub/coding/vscode-fullstack/SKILL.md +47 -0
  124. package/skills-hub/coding/vscode-fullstack/agents/openai.yaml +4 -0
  125. package/skills-hub/coding/vscode-fullstack/references/extensions-and-settings.md +181 -0
  126. package/skills-hub/coding/vscode-fullstack/references/profiles-and-validation.md +62 -0
  127. package/skills-hub/coding/vscode-fullstack/references/runtime-reuse.md +52 -0
  128. package/skills-hub/design/photo-retouch/LICENSE +21 -0
  129. package/skills-hub/design/photo-retouch/SKILL.md +50 -0
  130. package/skills-hub/design/photo-retouch/agents/openai.yaml +4 -0
  131. package/skills-hub/design/photo-retouch/references/basic-retouch.md +58 -0
  132. package/skills-hub/design/photo-retouch/references/formal-portrait.md +57 -0
  133. package/skills-hub/design/photo-retouch/references/object-and-canvas.md +82 -0
  134. package/skills-hub/design/photo-retouch/references/product-photo.md +41 -0
  135. package/skills-hub/design/photo-retouch/references/repair-and-look.md +61 -0
  136. package/skills-hub/design/photo-retouch/references/social-effects.md +55 -0
  137. package/skills-hub/design/photo-retouch/references/travel-creative.md +79 -0
  138. package/skills-hub/system/clash-client-profile/LICENSE +21 -0
  139. package/skills-hub/system/clash-client-profile/SKILL.md +79 -0
  140. package/skills-hub/system/clash-client-profile/agents/openai.yaml +4 -0
  141. package/skills-hub/system/clash-client-profile/references/acceptance.md +81 -0
  142. package/skills-hub/system/clash-client-profile/references/browser-privacy.md +35 -0
  143. package/skills-hub/system/clash-client-profile/references/clash-verge.md +40 -0
  144. package/skills-hub/system/clash-client-profile/references/dns-workflow.md +147 -0
  145. package/skills-hub/system/clash-client-profile/references/flclash.md +24 -0
  146. package/skills-hub/system/clash-client-profile/references/network-performance.md +25 -0
  147. package/skills-hub/system/clash-client-profile/references/platform-dns.md +109 -0
  148. package/skills-hub/system/clash-client-profile/references/sources.md +46 -0
  149. package/skills-hub/system/clash-client-profile/references/troubleshooting.md +82 -0
  150. package/skills-hub/system/clash-client-profile/scripts/dns_guard.py +650 -0
  151. package/skills-hub/system/clash-client-profile/scripts/proxy_doctor.py +213 -0
  152. package/skills-hub/system/clash-client-profile/scripts/render_rules.py +34 -0
  153. package/skills-hub/system/clash-client-profile/templates/ai-rules.yaml +18 -0
  154. package/skills-hub/system/clash-client-profile/templates/dns-request.example.json +9 -0
  155. package/skills-hub/system/clash-client-profile/templates/dns.yaml +8 -0
  156. package/skills-hub/system/linux-dev-disk-cleanup/LICENSE +21 -0
  157. package/skills-hub/system/linux-dev-disk-cleanup/SKILL.md +42 -0
  158. package/skills-hub/system/linux-dev-disk-cleanup/agents/openai.yaml +4 -0
  159. package/skills-hub/system/linux-dev-disk-cleanup/references/linux-cleanup-workflow.md +145 -0
  160. package/skills-hub/system/optimize-codex-config/LICENSE +21 -0
  161. package/skills-hub/system/optimize-codex-config/SKILL.md +101 -0
  162. package/skills-hub/system/optimize-codex-config/agents/openai.yaml +4 -0
  163. package/skills-hub/system/optimize-codex-config/assets/capabilities.example.json +22 -0
  164. package/skills-hub/system/optimize-codex-config/references/cli.md +35 -0
  165. package/skills-hub/system/optimize-codex-config/references/configuration-contract.md +33 -0
  166. package/skills-hub/system/optimize-codex-config/references/optimization-method.md +38 -0
  167. package/skills-hub/system/optimize-codex-config/references/privacy.md +41 -0
  168. package/skills-hub/system/optimize-codex-config/references/sources.md +24 -0
  169. package/skills-hub/system/optimize-codex-config/references/statusline.md +31 -0
  170. package/skills-hub/system/optimize-codex-config/references/transactions.md +32 -0
  171. package/skills-hub/system/optimize-codex-config/references/troubleshooting.md +95 -0
  172. package/skills-hub/system/optimize-codex-config/scripts/adapters/claude.mjs +24 -0
  173. package/skills-hub/system/optimize-codex-config/scripts/adapters/codex.mjs +10 -0
  174. package/skills-hub/system/optimize-codex-config/scripts/ai-cli-config.mjs +67 -0
  175. package/skills-hub/system/optimize-codex-config/scripts/audit-codex-config.mjs +762 -0
  176. package/skills-hub/system/optimize-codex-config/scripts/lib/document.mjs +166 -0
  177. package/skills-hub/system/optimize-codex-config/scripts/lib/inspect.mjs +211 -0
  178. package/skills-hub/system/optimize-codex-config/scripts/lib/io.mjs +104 -0
  179. package/skills-hub/system/optimize-codex-config/scripts/lib/plan.mjs +79 -0
  180. package/skills-hub/system/optimize-codex-config/scripts/lib/transaction.mjs +205 -0
  181. package/skills-hub/system/optimize-codex-config/scripts/lib/writers.mjs +46 -0
  182. package/skills-hub/system/optimize-codex-config/scripts/statusline/claude-statusline.mjs +94 -0
  183. package/skills-hub/system/optimize-codex-config/scripts/vendor/THIRD-PARTY-NOTICES.txt +250 -0
  184. package/skills-hub/system/optimize-codex-config/scripts/vendor/build-package-lock.json +488 -0
  185. package/skills-hub/system/optimize-codex-config/scripts/vendor/manifest.json +14 -0
  186. package/skills-hub/system/optimize-codex-config/scripts/vendor/parsers.mjs +16 -0
  187. package/skills-hub/system/proxy-region-locale/LICENSE +21 -0
  188. package/skills-hub/system/proxy-region-locale/SKILL.md +42 -0
  189. package/skills-hub/system/proxy-region-locale/agents/openai.yaml +4 -0
  190. package/skills-hub/system/proxy-region-locale/references/linux.md +35 -0
  191. package/skills-hub/system/proxy-region-locale/references/macos.md +31 -0
  192. package/skills-hub/system/proxy-region-locale/references/windows.md +45 -0
  193. package/skills-hub/system/system-health-audit/LICENSE +21 -0
  194. package/skills-hub/system/system-health-audit/SKILL.md +41 -0
  195. package/skills-hub/system/system-health-audit/agents/openai.yaml +4 -0
  196. package/skills-hub/system/system-health-audit/references/platforms.md +37 -0
  197. package/skills-hub/system/system-health-audit/scripts/system_audit.py +41 -0
  198. package/skills-hub/system/windows-dev-disk-cleanup/LICENSE +21 -0
  199. package/skills-hub/system/windows-dev-disk-cleanup/SKILL.md +40 -0
  200. package/skills-hub/system/windows-dev-disk-cleanup/agents/openai.yaml +4 -0
  201. package/skills-hub/system/windows-dev-disk-cleanup/references/windows-cleanup-workflow.md +121 -0
  202. package/skills-hub/writing/.gitkeep +0 -0
  203. package/skills-hub/writing/job-application/LICENSE +21 -0
  204. package/skills-hub/writing/job-application/SKILL.md +160 -0
  205. package/skills-hub/writing/job-application/agents/openai.yaml +4 -0
  206. package/skills-hub/writing/job-application/assets/resume.css +10 -0
  207. package/skills-hub/writing/job-application/config.example.json +9 -0
  208. package/skills-hub/writing/job-application/references/company-research.md +7 -0
  209. package/skills-hub/writing/job-application/references/corpus-schema.md +33 -0
  210. package/skills-hub/writing/job-application/references/honesty-gate.md +29 -0
  211. package/skills-hub/writing/job-application/references/improvement-plan.md +15 -0
  212. package/skills-hub/writing/job-application/references/ingest-protocol.md +18 -0
  213. package/skills-hub/writing/job-application/references/interview-speech.md +16 -0
  214. package/skills-hub/writing/job-application/references/jd-parse.md +18 -0
  215. package/skills-hub/writing/job-application/references/persist-paths.md +34 -0
  216. package/skills-hub/writing/job-application/references/question-bank.md +19 -0
  217. package/skills-hub/writing/job-application/references/rendering.md +9 -0
  218. package/skills-hub/writing/job-application/references/resume-playbook.md +30 -0
  219. package/skills-hub/writing/job-application/scripts/README.md +12 -0
  220. package/skills-hub/writing/job-application/scripts/ingest_kb.py +188 -0
  221. package/skills-hub/writing/job-application/scripts/render_resume.py +144 -0
  222. package/skills-hub/writing/job-application/scripts/validate_claims.py +52 -0
  223. package/skills-hub/writing/job-application/scripts/write_config.py +72 -0
  224. package/skills-hub/writing/job-application/templates/application-index.md +10 -0
  225. package/skills-hub/writing/job-application/templates/brief.md +16 -0
  226. package/skills-hub/writing/job-application/templates/changelog.md +6 -0
  227. package/skills-hub/writing/job-application/templates/company-research.md +15 -0
  228. package/skills-hub/writing/job-application/templates/corpus.md +11 -0
  229. package/skills-hub/writing/job-application/templates/evidence-map.md +5 -0
  230. package/skills-hub/writing/job-application/templates/framings.md +6 -0
  231. package/skills-hub/writing/job-application/templates/improvement-plan.md +16 -0
  232. package/skills-hub/writing/job-application/templates/ingest-log.md +4 -0
  233. package/skills-hub/writing/job-application/templates/known-gaps.md +4 -0
  234. package/skills-hub/writing/job-application/templates/match-report.md +21 -0
  235. package/skills-hub/writing/job-application/templates/methodology-cache.md +10 -0
  236. package/skills-hub/writing/job-application/templates/persist-index.md +11 -0
  237. package/skills-hub/writing/job-application/templates/profile.md +21 -0
  238. package/skills-hub/writing/job-application/templates/questions.md +22 -0
  239. package/skills-hub/writing/job-application/templates/resume-original.md +10 -0
  240. package/skills-hub/writing/job-application/templates/resume-tailored.md +21 -0
  241. package/skills-hub/writing/job-application/templates/sources-index.md +6 -0
  242. package/skills-hub/writing/job-application/templates/speech.md +19 -0
  243. package/skills-hub/writing/job-application/templates/stories.md +9 -0
  244. package/skills-hub/writing/job-application/templates/story-bank.md +11 -0
  245. package/skills-hub/writing/job-application/templates/strengths.md +4 -0
  246. package/skills-hub/writing/job-application/templates/style-memory.md +7 -0
  247. package/skills-hub/writing/ste-zh/LICENSE +21 -0
  248. package/skills-hub/writing/ste-zh/NOTICE.md +9 -0
  249. package/skills-hub/writing/ste-zh/SKILL.md +130 -0
  250. package/skills-hub/writing/ste-zh/agents/openai.yaml +6 -0
  251. package/skills-hub/writing/ste-zh/examples/zh-report.md +23 -0
  252. package/skills-hub/writing/ste-zh/references/english-modes.md +13 -0
  253. package/skills-hub/writing/ste-zh/references/terminology.md +13 -0
  254. package/skills-hub/writing/ste-zh/references/zh-rules.md +29 -0
  255. package/template/.speculo/refresh-contract.json +2 -0
  256. package/template/canonical/canonical-specdev-goal-plan.md +170 -101
  257. package/template/canonical/canonical-specdev-grill-with-docs.md +346 -277
  258. package/template/canonical/canonical-specdev-spec.md +346 -277
  259. package/template/canonical/canonical-specdev-tickets.md +346 -277
  260. package/template/canonical/canonical-specdev-wayfinder.md +346 -277
  261. package/template/lib/speculo-runtime.mjs +183 -8
  262. package/template/references/coordination/coordination-contract.md +12 -4
  263. package/template/references/coordination/modes/cli.md +3 -3
  264. package/template/references/coordination/modes/herdr.md +11 -11
  265. package/template/references/coordination/modes/orca.md +14 -14
  266. package/template/references/coordination/project-facts.schema.json +1 -0
  267. package/template/references/coordination/report-template.md +18 -0
  268. package/template/references/coordination/result-and-recovery.md +3 -3
  269. package/template/references/coordination/session-files.md +49 -56
  270. package/template/references/coordination/task-brief.md +3 -3
  271. package/template/references/coordination/task-template.md +25 -0
  272. package/template/workflows/specdev/common/rules/path-reference-contract.md +1 -1
  273. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
  274. package/template/workflows/specdev/common/schemas/session-locator.schema.json +15 -0
  275. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +1 -1
  276. package/template/workflows/specdev/common/tools/README.md +6 -0
  277. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +1 -1
  278. package/template/workflows/specdev/common/tools/validate-handoff.mjs +108 -0
  279. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +2 -2
  280. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +1 -1
  281. package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +7 -6
  282. package/template/workflows/specdev/specdev-i-init-setup/references/first-setup.md +30 -1
  283. package/template/workflows/specdev/specdev-p-goal-plan/references/lead-orchestration.md +1 -1
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env python3
2
+ """Generic Gitea/Forgejo HTTP API wrapper, gh-style.
3
+
4
+ Token is read from --token / GITEA_TOKEN / git credential fill only; never printed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import ipaddress
11
+ import json
12
+ import os
13
+ import ssl
14
+ import subprocess
15
+ import sys
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ DEFAULT_LIMIT = 50
23
+ API_PREFIX = "/api/v1"
24
+ GITHUB_HOSTS = {"github.com", "www.github.com"}
25
+ SSL_CONTEXT: ssl.SSLContext | None = None
26
+
27
+
28
+ class GiteaError(Exception):
29
+ def __init__(self, status: int, message: str, body: Any = None) -> None:
30
+ super().__init__(f"HTTP {status}: {message}")
31
+ self.status = status
32
+ self.message = message
33
+ self.body = body
34
+
35
+
36
+ def die(msg: str, code: int = 1) -> None:
37
+ print(msg, file=sys.stderr)
38
+ raise SystemExit(code)
39
+
40
+
41
+ def read_body_text(inline: str | None, path: str | None, required: bool = False) -> str:
42
+ """优先读 --body-file,避免 Windows 命令行长度限制。"""
43
+ if path:
44
+ file_path = Path(path)
45
+ if not file_path.is_file():
46
+ die(f"找不到 --body-file: {path}")
47
+ return file_path.read_text(encoding="utf-8")
48
+ if inline:
49
+ return inline
50
+ if required:
51
+ die("需要 --body 或 --body-file")
52
+ return ""
53
+
54
+
55
+ def git_output(args: list[str], cwd: str | None = None) -> str:
56
+ try:
57
+ proc = subprocess.run(
58
+ ["git", *args],
59
+ cwd=cwd,
60
+ capture_output=True,
61
+ text=True,
62
+ encoding="utf-8",
63
+ errors="replace",
64
+ check=False,
65
+ )
66
+ except FileNotFoundError:
67
+ return ""
68
+ if proc.returncode != 0:
69
+ return ""
70
+ return (proc.stdout or "").strip()
71
+
72
+
73
+ def _hostname_is_private(hostname: str) -> bool:
74
+ host = hostname.strip().strip("[]").split("%", 1)[0]
75
+ if host in {"localhost", "127.0.0.1", "::1"}:
76
+ return True
77
+ try:
78
+ ip = ipaddress.ip_address(host)
79
+ except ValueError:
80
+ return False
81
+ return bool(ip.is_private or ip.is_loopback or ip.is_link_local)
82
+
83
+
84
+ def _ssh_host_url(hostname: str, port: int | None = None) -> str:
85
+ """SSH transport ports do not identify an HTTP API port; never infer plaintext."""
86
+ del port
87
+ host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
88
+ return f"https://{host}"
89
+
90
+
91
+ def is_github_host(host_url: str) -> bool:
92
+ host = (urllib.parse.urlparse(host_url).hostname or "").lower()
93
+ return host in GITHUB_HOSTS or host.endswith(".github.com")
94
+
95
+
96
+ def parse_remote_url(url: str) -> tuple[str, str, str] | None:
97
+ """Parse git remote into (instance_root, owner, repo).
98
+
99
+ Extra path segments before owner/repo become the instance subpath
100
+ (Gitea installed at https://example.com/git/owner/repo).
101
+ """
102
+ if not url:
103
+ return None
104
+ url = url.strip()
105
+ if url.endswith(".git"):
106
+ url = url[:-4]
107
+
108
+ if url.startswith("git@"):
109
+ _, rest = url.split("@", 1)
110
+ if ":" not in rest:
111
+ return None
112
+ host, path = rest.split(":", 1)
113
+ return _split_instance_and_repo(_ssh_host_url(host), path)
114
+
115
+ parsed = urllib.parse.urlparse(url)
116
+ if parsed.scheme in {"ssh", "git"}:
117
+ host = parsed.hostname
118
+ if not host:
119
+ return None
120
+ return _split_instance_and_repo(_ssh_host_url(host, parsed.port), parsed.path)
121
+
122
+ if not parsed.scheme or not parsed.netloc:
123
+ return None
124
+ hostname = parsed.hostname or parsed.netloc
125
+ host_url = f"{parsed.scheme}://{hostname}"
126
+ if parsed.port:
127
+ host_url += f":{parsed.port}"
128
+ return _split_instance_and_repo(host_url, parsed.path)
129
+
130
+
131
+ def _split_instance_and_repo(host_url: str, path: str) -> tuple[str, str, str] | None:
132
+ parts = [p for p in path.split("/") if p]
133
+ if len(parts) < 2:
134
+ return None
135
+ owner, repo = parts[-2], parts[-1]
136
+ prefix = "/".join(parts[:-2])
137
+ root = host_url.rstrip("/")
138
+ if prefix:
139
+ root = f"{root}/{prefix}"
140
+ return root, owner, repo
141
+
142
+
143
+ def discover_remote(remote_name: str | None = None) -> tuple[str, str, str] | None:
144
+ if remote_name:
145
+ return parse_remote_url(git_output(["remote", "get-url", remote_name]))
146
+
147
+ remotes = [r for r in git_output(["remote"]).splitlines() if r]
148
+ ordered: list[str] = []
149
+ if "origin" in remotes:
150
+ ordered.append("origin")
151
+ ordered.extend(r for r in remotes if r != "origin")
152
+
153
+ for name in ordered:
154
+ parsed = parse_remote_url(git_output(["remote", "get-url", name]))
155
+ if not parsed:
156
+ continue
157
+ if is_github_host(parsed[0]):
158
+ continue
159
+ return parsed
160
+ return None
161
+
162
+
163
+ def credential_token(host_url: str) -> str:
164
+ """用 git credential fill 取 PAT,避免在命令行回显密码。"""
165
+ parsed = urllib.parse.urlparse(host_url)
166
+ body = f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n"
167
+ env = os.environ.copy()
168
+ env["GIT_TERMINAL_PROMPT"] = "0"
169
+ env["GCM_INTERACTIVE"] = "never"
170
+ try:
171
+ proc = subprocess.run(
172
+ ["git", "credential", "fill"],
173
+ input=body,
174
+ capture_output=True,
175
+ text=True,
176
+ encoding="utf-8",
177
+ errors="replace",
178
+ check=False,
179
+ env=env,
180
+ )
181
+ except FileNotFoundError:
182
+ return ""
183
+ if proc.returncode != 0:
184
+ return ""
185
+ for line in proc.stdout.splitlines():
186
+ if line.startswith("password="):
187
+ return line.split("=", 1)[1].strip()
188
+ return ""
189
+
190
+
191
+ def resolve_context(args: argparse.Namespace) -> tuple[str, str, str, str]:
192
+ discovered = discover_remote(getattr(args, "remote", None))
193
+ host = (args.host or os.environ.get("GITEA_HOST") or (discovered[0] if discovered else "")).rstrip(
194
+ "/"
195
+ )
196
+ if not host:
197
+ die(
198
+ "未确定 Gitea 实例。请传 --host(例如 https://gitea.example.com),"
199
+ "或设置 GITEA_HOST,或在已配置 Gitea/Forgejo remote 的仓库内运行。"
200
+ "GitHub.com 请用 gh / github-repo-steward。"
201
+ )
202
+ if is_github_host(host):
203
+ die("GITEA_HOST/remote 指向 GitHub。请用 gh / github-repo-steward。")
204
+
205
+ repo_spec = args.repo or os.environ.get("GITEA_REPO") or ""
206
+ if repo_spec:
207
+ if "/" not in repo_spec:
208
+ die("--repo 需要 owner/repo 形式")
209
+ owner, repo = repo_spec.split("/", 1)
210
+ elif discovered:
211
+ owner, repo = discovered[1], discovered[2]
212
+ else:
213
+ die("未确定仓库。请传 --repo owner/repo,或设置 GITEA_REPO,或在目标仓内运行。")
214
+
215
+ token = args.token or os.environ.get("GITEA_TOKEN") or credential_token(host)
216
+ if not token:
217
+ cred_host = urllib.parse.urlparse(host).netloc or host
218
+ die(
219
+ "未找到 Gitea 令牌。请设置 GITEA_TOKEN,或把 PAT 写入 "
220
+ f"git credential(host={cred_host}),或传 --token。"
221
+ )
222
+ return host, owner, repo, token
223
+
224
+
225
+ def api_request(
226
+ host: str,
227
+ token: str,
228
+ method: str,
229
+ path: str,
230
+ query: dict[str, Any] | None = None,
231
+ body: Any = None,
232
+ ) -> tuple[Any, dict[str, str]]:
233
+ path = path if path.startswith("/") else "/" + path
234
+ if not path.startswith(API_PREFIX):
235
+ path = API_PREFIX + path
236
+ query = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
237
+ url = host + path
238
+ if query:
239
+ url += "?" + urllib.parse.urlencode(query, doseq=True)
240
+ data = None
241
+ headers = {
242
+ "Authorization": f"token {token}",
243
+ "Accept": "application/json",
244
+ "User-Agent": "gitea-repo",
245
+ }
246
+ if body is not None:
247
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
248
+ headers["Content-Type"] = "application/json"
249
+ req = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
250
+ try:
251
+ with urllib.request.urlopen(req, timeout=60, context=SSL_CONTEXT) as resp:
252
+ raw = resp.read()
253
+ hdrs = {k.lower(): v for k, v in resp.headers.items()}
254
+ if not raw:
255
+ return None, hdrs
256
+ try:
257
+ return json.loads(raw.decode("utf-8")), hdrs
258
+ except json.JSONDecodeError:
259
+ return raw.decode("utf-8", errors="replace"), hdrs
260
+ except urllib.error.HTTPError as err:
261
+ raw = err.read()
262
+ parsed: Any
263
+ try:
264
+ parsed = json.loads(raw.decode("utf-8"))
265
+ except Exception:
266
+ parsed = raw.decode("utf-8", errors="replace")
267
+ message = parsed.get("message") if isinstance(parsed, dict) else str(parsed)
268
+ raise GiteaError(err.code, message or err.reason, parsed) from None
269
+ except urllib.error.URLError as err:
270
+ raise GiteaError(0, str(err.reason or err)) from None
271
+
272
+
273
+ def paginate(
274
+ host: str,
275
+ token: str,
276
+ path: str,
277
+ query: dict[str, Any],
278
+ fetch_all: bool,
279
+ limit: int,
280
+ ) -> list[Any]:
281
+ items: list[Any] = []
282
+ page = int(query.get("page") or 1)
283
+ if page < 1 or limit < 1:
284
+ raise GiteaError(0, "page and limit must be positive")
285
+ previous_batch: list[Any] | None = None
286
+ while True:
287
+ q = dict(query)
288
+ q["page"] = page
289
+ q["limit"] = limit
290
+ payload, _hdrs = api_request(host, token, "GET", path, q)
291
+ batch = payload if isinstance(payload, list) else None
292
+ # /repos/search 返回 {ok, data}
293
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
294
+ batch = payload["data"]
295
+ if batch is None:
296
+ raise GiteaError(0, "Expected an array or a data array from paginated endpoint")
297
+ if fetch_all and batch and batch == previous_batch:
298
+ raise GiteaError(0, "Pagination did not advance; refusing to report a complete list")
299
+ items.extend(batch)
300
+ # Servers can cap the requested limit. A short page is not proof of EOF.
301
+ if not fetch_all or not batch:
302
+ break
303
+ previous_batch = batch
304
+ page += 1
305
+ return items
306
+
307
+
308
+ def emit(data: Any, as_json: bool) -> None:
309
+ if as_json or not isinstance(data, (dict, list, str)):
310
+ print(json.dumps(data, ensure_ascii=False, indent=2))
311
+ return
312
+ print(data)
313
+
314
+
315
+ def fmt_issue_row(item: dict[str, Any]) -> str:
316
+ number = item.get("number")
317
+ state = item.get("state")
318
+ title = (item.get("title") or "").replace("\t", " ")
319
+ user = (item.get("user") or {}).get("login") or ""
320
+ labels = ",".join(lbl.get("name") or "" for lbl in (item.get("labels") or []) if lbl)
321
+ kind = "pr" if item.get("pull_request") else "issue"
322
+ return f"#{number}\t{kind}\t{state}\t{user}\t{labels}\t{title}"
323
+
324
+
325
+ def resolve_label_ids(host: str, token: str, owner: str, repo: str, labels: list[str]) -> list[int]:
326
+ if not labels:
327
+ return []
328
+ # A label name may be on a later page of the catalog.
329
+ existing = paginate(host, token, f"/repos/{owner}/{repo}/labels", {}, True, DEFAULT_LIMIT)
330
+ by_name = {str(x.get("name")): int(x["id"]) for x in existing or []}
331
+ ids: list[int] = []
332
+ for raw in labels:
333
+ if raw.isdigit():
334
+ ids.append(int(raw))
335
+ continue
336
+ if raw not in by_name:
337
+ die(f"找不到标签: {raw}。现有: {', '.join(by_name) or '(无)'}")
338
+ ids.append(by_name[raw])
339
+ return ids
340
+
341
+
342
+ def cmd_version(ctx: tuple[str, str, str, str], _args: argparse.Namespace) -> None:
343
+ host, _o, _r, token = ctx
344
+ try:
345
+ payload, _ = api_request(host, token, "GET", "/version")
346
+ emit(payload, True)
347
+ except GiteaError as err:
348
+ die(f"GET /version 失败({err})。实例仍可能可用,请改用 issue list 验证。")
349
+
350
+
351
+ def cmd_whoami(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
352
+ host, owner, repo, token = ctx
353
+ try:
354
+ payload, _ = api_request(host, token, "GET", "/user")
355
+ emit(payload, args.json)
356
+ return
357
+ except GiteaError as err:
358
+ if err.status != 403:
359
+ die(str(err))
360
+ # 缺 read:user 时退化为探测仓库可达性,避免误判令牌失效
361
+ probe_path = f"/repos/{owner}/{repo}"
362
+ repo_obj, _ = api_request(host, token, "GET", probe_path)
363
+ out = {
364
+ "login": None,
365
+ "note": "GET /user 需要 read:user,当前令牌无此 scope",
366
+ "error": err.message,
367
+ "repo": repo_obj.get("full_name") if isinstance(repo_obj, dict) else probe_path,
368
+ "permissions": repo_obj.get("permissions") if isinstance(repo_obj, dict) else None,
369
+ }
370
+ emit(out, True)
371
+
372
+
373
+ def cmd_issue_list(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
374
+ host, owner, repo, token = ctx
375
+ query = {
376
+ "state": args.state,
377
+ "type": args.type,
378
+ "q": args.q,
379
+ "labels": args.labels,
380
+ "page": args.page,
381
+ "limit": args.limit,
382
+ }
383
+ items = paginate(
384
+ host, token, f"/repos/{owner}/{repo}/issues", query, args.all, args.limit
385
+ )
386
+ if args.json:
387
+ emit(items, True)
388
+ return
389
+ if not items:
390
+ print("(empty)")
391
+ return
392
+ print("number\tkind\tstate\tuser\tlabels\ttitle")
393
+ for item in items:
394
+ print(fmt_issue_row(item))
395
+
396
+
397
+ def cmd_issue_view(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
398
+ host, owner, repo, token = ctx
399
+ payload, _ = api_request(host, token, "GET", f"/repos/{owner}/{repo}/issues/{args.index}")
400
+ if args.json:
401
+ emit(payload, True)
402
+ return
403
+ labels = ", ".join(x.get("name") or "" for x in (payload.get("labels") or []) if x)
404
+ assignees = ", ".join(
405
+ (x or {}).get("login") or "" for x in (payload.get("assignees") or []) if x
406
+ )
407
+ print(f"#{payload.get('number')} {payload.get('state')} {payload.get('title')}")
408
+ print(f"author: {(payload.get('user') or {}).get('login')}")
409
+ print(f"url: {payload.get('html_url')}")
410
+ print(f"labels: {labels or '-'}")
411
+ print(f"assignees: {assignees or '-'}")
412
+ print(f"comments: {payload.get('comments')}")
413
+ print()
414
+ print(payload.get("body") or "")
415
+
416
+
417
+ def cmd_issue_create(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
418
+ host, owner, repo, token = ctx
419
+ body: dict[str, Any] = {
420
+ "title": args.title,
421
+ "body": read_body_text(args.body, args.body_file),
422
+ }
423
+ if args.assignee:
424
+ body["assignees"] = args.assignee
425
+ if args.label:
426
+ body["labels"] = resolve_label_ids(host, token, owner, repo, args.label)
427
+ payload, _ = api_request(host, token, "POST", f"/repos/{owner}/{repo}/issues", body=body)
428
+ emit(payload if args.json else f"#{payload.get('number')} {payload.get('html_url')}", args.json)
429
+
430
+
431
+ def cmd_issue_state(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
432
+ host, owner, repo, token = ctx
433
+ state = "closed" if args.action == "close" else "open"
434
+ payload, _ = api_request(
435
+ host,
436
+ token,
437
+ "PATCH",
438
+ f"/repos/{owner}/{repo}/issues/{args.index}",
439
+ body={"state": state},
440
+ )
441
+ emit(payload if args.json else f"#{payload.get('number')} {payload.get('state')}", args.json)
442
+
443
+
444
+ def cmd_issue_comment(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
445
+ host, owner, repo, token = ctx
446
+ payload, _ = api_request(
447
+ host,
448
+ token,
449
+ "POST",
450
+ f"/repos/{owner}/{repo}/issues/{args.index}/comments",
451
+ body={"body": read_body_text(args.body, args.body_file, required=True)},
452
+ )
453
+ emit(payload if args.json else f"comment {payload.get('id')} on #{args.index}", args.json)
454
+
455
+
456
+ def cmd_issue_comments(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
457
+ host, owner, repo, token = ctx
458
+ items = paginate(
459
+ host,
460
+ token,
461
+ f"/repos/{owner}/{repo}/issues/{args.index}/comments",
462
+ {"page": args.page, "limit": args.limit},
463
+ args.all,
464
+ args.limit,
465
+ )
466
+ if args.json:
467
+ emit(items, True)
468
+ return
469
+ if not items:
470
+ print("(empty)")
471
+ return
472
+ for item in items:
473
+ login = (item.get("user") or {}).get("login")
474
+ print(f"[{item.get('id')}] {login} {item.get('created_at')}")
475
+ print(item.get("body") or "")
476
+ print("---")
477
+
478
+
479
+ def cmd_pr_list(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
480
+ host, owner, repo, token = ctx
481
+ items = paginate(
482
+ host,
483
+ token,
484
+ f"/repos/{owner}/{repo}/pulls",
485
+ {"state": args.state, "page": args.page, "limit": args.limit},
486
+ args.all,
487
+ args.limit,
488
+ )
489
+ if args.json:
490
+ emit(items, True)
491
+ return
492
+ if not items:
493
+ print("(empty)")
494
+ return
495
+ print("number\tstate\tuser\thead -> base\ttitle")
496
+ for item in items:
497
+ head = ((item.get("head") or {}).get("ref")) or ""
498
+ base = ((item.get("base") or {}).get("ref")) or ""
499
+ user = (item.get("user") or {}).get("login") or ""
500
+ title = (item.get("title") or "").replace("\t", " ")
501
+ print(f"#{item.get('number')}\t{item.get('state')}\t{user}\t{head} -> {base}\t{title}")
502
+
503
+
504
+ def cmd_pr_view(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
505
+ host, owner, repo, token = ctx
506
+ payload, _ = api_request(host, token, "GET", f"/repos/{owner}/{repo}/pulls/{args.index}")
507
+ if args.json:
508
+ emit(payload, True)
509
+ return
510
+ head = ((payload.get("head") or {}).get("ref")) or ""
511
+ base = ((payload.get("base") or {}).get("ref")) or ""
512
+ print(f"#{payload.get('number')} {payload.get('state')} {payload.get('title')}")
513
+ print(f"{head} -> {base}")
514
+ print(f"url: {payload.get('html_url')}")
515
+ print(f"merged: {payload.get('merged')}")
516
+ print()
517
+ print(payload.get("body") or "")
518
+
519
+
520
+ def cmd_pr_create(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
521
+ host, owner, repo, token = ctx
522
+ payload, _ = api_request(
523
+ host,
524
+ token,
525
+ "POST",
526
+ f"/repos/{owner}/{repo}/pulls",
527
+ body={
528
+ "title": args.title,
529
+ "body": read_body_text(args.body, args.body_file),
530
+ "head": args.head,
531
+ "base": args.base,
532
+ },
533
+ )
534
+ emit(payload if args.json else f"#{payload.get('number')} {payload.get('html_url')}", args.json)
535
+
536
+
537
+ def cmd_pr_merge(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
538
+ host, owner, repo, token = ctx
539
+ # Gitea 合并体字段名必须是 Do,小写 do 会被忽略
540
+ body: dict[str, Any] = {"Do": args.style}
541
+ if args.delete_branch:
542
+ body["delete_branch_after_merge"] = True
543
+ payload, _ = api_request(
544
+ host, token, "POST", f"/repos/{owner}/{repo}/pulls/{args.index}/merge", body=body
545
+ )
546
+ emit(payload if payload is not None else {"ok": True, "index": args.index}, True)
547
+
548
+
549
+ def cmd_simple_list(path_tpl: str):
550
+ def _run(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
551
+ host, owner, repo, token = ctx
552
+ query = {"page": args.page, "limit": args.limit}
553
+ if hasattr(args, "state") and args.state:
554
+ query["state"] = args.state
555
+ items = paginate(host, token, path_tpl.format(owner=owner, repo=repo), query, args.all, args.limit)
556
+ if args.json:
557
+ emit(items, True)
558
+ return
559
+ if not items:
560
+ print("(empty)")
561
+ return
562
+ for item in items:
563
+ if "name" in item and "id" in item:
564
+ print(f"{item.get('id')}\t{item.get('name')}\t{item.get('color') or ''}")
565
+ elif "title" in item:
566
+ print(f"{item.get('id')}\t{item.get('state')}\t{item.get('title')}")
567
+ elif "tag_name" in item:
568
+ print(f"{item.get('id')}\t{item.get('tag_name')}\t{item.get('title')}")
569
+ else:
570
+ print(json.dumps(item, ensure_ascii=False))
571
+
572
+ return _run
573
+
574
+
575
+ def cmd_repo_view(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
576
+ host, owner, repo, token = ctx
577
+ payload, _ = api_request(host, token, "GET", f"/repos/{owner}/{repo}")
578
+ if args.json:
579
+ emit(payload, True)
580
+ return
581
+ print(payload.get("full_name"))
582
+ print(payload.get("html_url"))
583
+ print(f"default_branch: {payload.get('default_branch')}")
584
+ print(f"private: {payload.get('private')} empty: {payload.get('empty')}")
585
+ print(payload.get("description") or "")
586
+
587
+
588
+ def cmd_search_issues(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
589
+ host, _owner, _repo, token = ctx
590
+ items = paginate(
591
+ host,
592
+ token,
593
+ "/repos/issues/search",
594
+ {
595
+ "state": args.state,
596
+ "type": args.type,
597
+ "q": args.q,
598
+ "owner": args.owner,
599
+ "page": args.page,
600
+ "limit": args.limit,
601
+ },
602
+ args.all,
603
+ args.limit,
604
+ )
605
+ if args.json:
606
+ emit(items, True)
607
+ return
608
+ if not items:
609
+ print("(empty)")
610
+ return
611
+ print("repo\tnumber\tkind\tstate\ttitle")
612
+ for item in items:
613
+ repo = (item.get("repository") or {}).get("full_name") or ""
614
+ kind = "pr" if item.get("pull_request") else "issue"
615
+ print(f"{repo}\t#{item.get('number')}\t{kind}\t{item.get('state')}\t{item.get('title')}")
616
+
617
+
618
+ def cmd_api(ctx: tuple[str, str, str, str], args: argparse.Namespace) -> None:
619
+ host, owner, repo, token = ctx
620
+ path = args.path.replace("{owner}", owner).replace("{repo}", repo)
621
+ body = json.loads(args.body) if args.body else None
622
+ query = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(path).query))
623
+ path_only = urllib.parse.urlparse(path).path
624
+ method = args.method.upper()
625
+ if args.paginate and method == "GET":
626
+ items = paginate(host, token, path_only, query, True, int(query.get("limit") or args.limit))
627
+ emit(items, True)
628
+ return
629
+ payload, hdrs = api_request(host, token, method, path_only, query, body)
630
+ if args.json or not isinstance(payload, str):
631
+ if args.include_headers:
632
+ emit({"headers": hdrs, "body": payload}, True)
633
+ else:
634
+ emit(payload, True)
635
+ return
636
+ print(payload)
637
+
638
+
639
+ def add_page_flags(p: argparse.ArgumentParser) -> None:
640
+ p.add_argument("--page", type=int, default=1)
641
+ p.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
642
+ p.add_argument("--all", action="store_true", help="自动翻页直到取完")
643
+
644
+
645
+ def build_parser() -> argparse.ArgumentParser:
646
+ parser = argparse.ArgumentParser(description="Gitea/Forgejo HTTP API(gh 风格)")
647
+ parser.add_argument("--host", help="实例根 URL,例如 https://gitea.example.com")
648
+ parser.add_argument("--repo", help="owner/repo;默认从 git remote 解析")
649
+ parser.add_argument("--remote", help="从该 git remote 解析 host/repo,默认 origin 再其它非 GitHub remote")
650
+ parser.add_argument("--token", help="PAT;默认读 GITEA_TOKEN 或 git credential")
651
+ parser.add_argument(
652
+ "--insecure",
653
+ action="store_true",
654
+ help="跳过 HTTPS 证书校验(自签证书);也可设 GITEA_INSECURE=1",
655
+ )
656
+ parser.add_argument("--json", action="store_true", help="输出原始 JSON")
657
+ sub = parser.add_subparsers(dest="cmd", required=True)
658
+
659
+ sub.add_parser("version", help="GET /version")
660
+ sub.add_parser("whoami", help="GET /user,缺 scope 时探测仓库权限")
661
+
662
+ issue = sub.add_parser("issue", help="issue 操作")
663
+ issue_sub = issue.add_subparsers(dest="issue_cmd", required=True)
664
+
665
+ p = issue_sub.add_parser("list")
666
+ p.add_argument("--state", default="open", choices=["open", "closed", "all"])
667
+ p.add_argument("--type", default="issues", choices=["issues", "pulls"])
668
+ p.add_argument("--q")
669
+ p.add_argument("--labels", help="标签名,逗号分隔")
670
+ add_page_flags(p)
671
+
672
+ p = issue_sub.add_parser("view")
673
+ p.add_argument("index", type=int)
674
+
675
+ p = issue_sub.add_parser("create")
676
+ p.add_argument("--title", required=True)
677
+ p.add_argument("--body", default="")
678
+ p.add_argument("--body-file", help="从 UTF-8 文件读取正文,优先于 --body")
679
+ p.add_argument("--assignee", action="append", default=[])
680
+ p.add_argument("--label", action="append", default=[], help="标签名或 ID,可重复")
681
+
682
+ p = issue_sub.add_parser("close")
683
+ p.add_argument("index", type=int)
684
+ p = issue_sub.add_parser("reopen")
685
+ p.add_argument("index", type=int)
686
+
687
+ p = issue_sub.add_parser("comment")
688
+ p.add_argument("index", type=int)
689
+ p.add_argument("--body", default="")
690
+ p.add_argument("--body-file", help="从 UTF-8 文件读取评论,优先于 --body")
691
+
692
+ p = issue_sub.add_parser("comments")
693
+ p.add_argument("index", type=int)
694
+ add_page_flags(p)
695
+
696
+ pr = sub.add_parser("pr", help="pull request 操作")
697
+ pr_sub = pr.add_subparsers(dest="pr_cmd", required=True)
698
+ p = pr_sub.add_parser("list")
699
+ p.add_argument("--state", default="open", choices=["open", "closed", "all"])
700
+ add_page_flags(p)
701
+ p = pr_sub.add_parser("view")
702
+ p.add_argument("index", type=int)
703
+ p = pr_sub.add_parser("create")
704
+ p.add_argument("--title", required=True)
705
+ p.add_argument("--head", required=True)
706
+ p.add_argument("--base", required=True)
707
+ p.add_argument("--body", default="")
708
+ p.add_argument("--body-file", help="从 UTF-8 文件读取正文,优先于 --body")
709
+ p = pr_sub.add_parser("merge")
710
+ p.add_argument("index", type=int)
711
+ p.add_argument("--style", default="merge", choices=["merge", "squash", "rebase", "rebase-merge"])
712
+ p.add_argument("--delete-branch", action="store_true")
713
+
714
+ p = sub.add_parser("label")
715
+ label_sub = p.add_subparsers(dest="label_cmd", required=True)
716
+ lp = label_sub.add_parser("list")
717
+ add_page_flags(lp)
718
+
719
+ p = sub.add_parser("milestone")
720
+ ms = p.add_subparsers(dest="milestone_cmd", required=True)
721
+ mp = ms.add_parser("list")
722
+ mp.add_argument("--state", default="open", choices=["open", "closed", "all"])
723
+ add_page_flags(mp)
724
+
725
+ p = sub.add_parser("release")
726
+ rs = p.add_subparsers(dest="release_cmd", required=True)
727
+ rp = rs.add_parser("list")
728
+ add_page_flags(rp)
729
+
730
+ repo_grp = sub.add_parser("repo", help="仓库信息")
731
+ repo_sub = repo_grp.add_subparsers(dest="repo_cmd", required=True)
732
+ repo_sub.add_parser("view")
733
+
734
+ p = sub.add_parser("search")
735
+ ss = p.add_subparsers(dest="search_cmd", required=True)
736
+ sp = ss.add_parser("issues")
737
+ sp.add_argument("--q")
738
+ sp.add_argument("--state", default="open", choices=["open", "closed", "all"])
739
+ sp.add_argument("--type", default="issues", choices=["issues", "pulls"])
740
+ sp.add_argument("--owner")
741
+ add_page_flags(sp)
742
+
743
+ p = sub.add_parser("api", help="透传任意 /api/v1 路径")
744
+ p.add_argument("method", help="GET/POST/PATCH/PUT/DELETE")
745
+ p.add_argument("path", help="如 /repos/{owner}/{repo}/issues")
746
+ p.add_argument("--body", help="JSON 字符串")
747
+ p.add_argument("--paginate", action="store_true")
748
+ p.add_argument("--include-headers", action="store_true")
749
+ p.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
750
+
751
+ return parser
752
+
753
+
754
+ def main(argv: list[str] | None = None) -> int:
755
+ parser = build_parser()
756
+ args = parser.parse_args(argv)
757
+ global SSL_CONTEXT
758
+ insecure = bool(args.insecure) or os.environ.get("GITEA_INSECURE", "").strip() in {
759
+ "1",
760
+ "true",
761
+ "TRUE",
762
+ "yes",
763
+ "YES",
764
+ }
765
+ SSL_CONTEXT = ssl._create_unverified_context() if insecure else None
766
+ try:
767
+ ctx = resolve_context(args)
768
+ cmd = args.cmd
769
+ if cmd == "version":
770
+ cmd_version(ctx, args)
771
+ elif cmd == "whoami":
772
+ cmd_whoami(ctx, args)
773
+ elif cmd == "issue":
774
+ if args.issue_cmd in ("close", "reopen"):
775
+ args.action = args.issue_cmd
776
+ cmd_issue_state(ctx, args)
777
+ else:
778
+ {
779
+ "list": cmd_issue_list,
780
+ "view": cmd_issue_view,
781
+ "create": cmd_issue_create,
782
+ "comment": cmd_issue_comment,
783
+ "comments": cmd_issue_comments,
784
+ }[args.issue_cmd](ctx, args)
785
+ elif cmd == "pr":
786
+ mapping = {
787
+ "list": cmd_pr_list,
788
+ "view": cmd_pr_view,
789
+ "create": cmd_pr_create,
790
+ "merge": cmd_pr_merge,
791
+ }
792
+ mapping[args.pr_cmd](ctx, args)
793
+ elif cmd == "label":
794
+ cmd_simple_list("/repos/{owner}/{repo}/labels")(ctx, args)
795
+ elif cmd == "milestone":
796
+ cmd_simple_list("/repos/{owner}/{repo}/milestones")(ctx, args)
797
+ elif cmd == "release":
798
+ cmd_simple_list("/repos/{owner}/{repo}/releases")(ctx, args)
799
+ elif cmd == "repo":
800
+ cmd_repo_view(ctx, args)
801
+ elif cmd == "search":
802
+ cmd_search_issues(ctx, args)
803
+ elif cmd == "api":
804
+ cmd_api(ctx, args)
805
+ else:
806
+ die(f"未知命令: {cmd}")
807
+ return 0
808
+ except GiteaError as err:
809
+ die(str(err))
810
+ return 1
811
+
812
+
813
+ if __name__ == "__main__":
814
+ raise SystemExit(main())