devops-bot-sdk 1.6.37__tar.gz → 1.6.38__tar.gz

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 (191) hide show
  1. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/SOURCES.txt +5 -0
  4. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/cli.py +7 -2
  6. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/client.py +42 -0
  7. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/firewall.py +163 -1
  8. devops_bot_sdk-1.6.38/sdk/github_fetch.py +198 -0
  9. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/github_ingest.py +48 -3
  10. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/intake.py +126 -1
  11. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/server.py +36 -24
  12. devops_bot_sdk-1.6.38/sdk/ui/swagger.py +167 -0
  13. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/updater.py +42 -0
  14. devops_bot_sdk-1.6.38/tests/test_firewall_open_on_update.py +213 -0
  15. devops_bot_sdk-1.6.38/tests/test_github_fetch.py +216 -0
  16. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_github_ingest.py +97 -0
  17. devops_bot_sdk-1.6.38/tests/test_intake_job_outcome.py +180 -0
  18. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_ui_gate.py +77 -7
  19. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/README.md +0 -0
  20. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  21. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  22. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/requires.txt +0 -0
  23. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  24. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/pyproject.toml +0 -0
  25. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agentd.py +0 -0
  26. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/__init__.py +0 -0
  27. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/activity.py +0 -0
  28. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/builder.py +0 -0
  29. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/chat.py +0 -0
  30. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/connectors.py +0 -0
  31. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/__init__.py +0 -0
  32. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/claude_code.py +0 -0
  33. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/http.py +0 -0
  34. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/mcp.py +0 -0
  35. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/n8n.py +0 -0
  36. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/engines/shell.py +0 -0
  37. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/integrations.py +0 -0
  38. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/inventory.py +0 -0
  39. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/library/__init__.py +0 -0
  40. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/manifest.py +0 -0
  41. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/prompt_studio.py +0 -0
  42. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/prompt_template.py +0 -0
  43. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/prompt_vars.py +0 -0
  44. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/run_log.py +0 -0
  45. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/schedules.py +0 -0
  46. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/secrets.py +0 -0
  47. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/ticket_run.py +0 -0
  48. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/usage.py +0 -0
  49. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/agents/varstore.py +0 -0
  50. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/browser_profile.py +0 -0
  51. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/collectors/__init__.py +0 -0
  52. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/collectors/files.py +0 -0
  53. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/collectors/process.py +0 -0
  54. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/collectors/screenshot.py +0 -0
  55. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/config.py +0 -0
  56. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/crucial.py +0 -0
  57. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/departments.py +0 -0
  58. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/__init__.py +0 -0
  59. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/assets.py +0 -0
  60. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/bootstrap.py +0 -0
  61. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/browser.py +0 -0
  62. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/compare.py +0 -0
  63. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/design_verify/loop.py +0 -0
  64. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/estimator.py +0 -0
  65. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/exceptions.py +0 -0
  66. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/formats.py +0 -0
  67. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/git_ops.py +0 -0
  68. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/graph_recon.py +0 -0
  69. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/graphify.py +0 -0
  70. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/hooks/__init__.py +0 -0
  71. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/hooks/crucial_guard.py +0 -0
  72. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ipc/__init__.py +0 -0
  73. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ipc/electron_bridge.py +0 -0
  74. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ipc/handlers.py +0 -0
  75. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/journal.py +0 -0
  76. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/local_exec.py +0 -0
  77. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/models/__init__.py +0 -0
  78. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/models/envelope.py +0 -0
  79. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/models/requests.py +0 -0
  80. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/models/responses.py +0 -0
  81. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/models/snapshots.py +0 -0
  82. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/phase_timer.py +0 -0
  83. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/plan_run.py +0 -0
  84. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/platform_compat.py +0 -0
  85. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/prd_progress.py +0 -0
  86. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/proof_readers.py +0 -0
  87. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/providers.py +0 -0
  88. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/py.typed +0 -0
  89. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/resume.py +0 -0
  90. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/router.py +0 -0
  91. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/run_auto.py +0 -0
  92. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/scheduler.py +0 -0
  93. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/self_report.py +0 -0
  94. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/self_report_local.py +0 -0
  95. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/sse.py +0 -0
  96. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/swagger.py +0 -0
  97. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/task_file.py +0 -0
  98. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/task_types.py +0 -0
  99. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/test.py +0 -0
  100. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/test_pipeline.py +0 -0
  101. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ticket_fields.py +0 -0
  102. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/tiers/__init__.py +0 -0
  103. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/tiers/mini_agent.py +0 -0
  104. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/__init__.py +0 -0
  105. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/api.py +0 -0
  106. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/dashboard.py +0 -0
  107. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/health.py +0 -0
  108. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/ip_allowlist.py +0 -0
  109. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/jobs.py +0 -0
  110. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/models.py +0 -0
  111. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/runner.py +0 -0
  112. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/security.py +0 -0
  113. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/service.py +0 -0
  114. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/session.py +0 -0
  115. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/setup_manager.py +0 -0
  116. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/agents.html +0 -0
  117. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/app.js +0 -0
  118. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/chat.html +0 -0
  119. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/custom-agents.html +0 -0
  120. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/default-agents.html +0 -0
  121. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/index.html +0 -0
  122. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/report.html +0 -0
  123. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/runs.html +0 -0
  124. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/schedules.html +0 -0
  125. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/setup.html +0 -0
  126. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/styles.css +0 -0
  127. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/ui/static/tickets.html +0 -0
  128. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/url_access.py +0 -0
  129. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/vps_health.py +0 -0
  130. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/sdk/write_hook.py +0 -0
  131. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/setup.cfg +0 -0
  132. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_activity.py +0 -0
  133. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_activity_tracking.py +0 -0
  134. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_chat_features.py +0 -0
  135. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_listing.py +0 -0
  136. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_prompt_async.py +0 -0
  137. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_prompt_models.py +0 -0
  138. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_run_ticket.py +0 -0
  139. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_schedules.py +0 -0
  140. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agent_workspace_settings.py +0 -0
  141. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_agents_connectors_builder.py +0 -0
  142. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_allowed_tools_config.py +0 -0
  143. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_auto_agent_dispatch.py +0 -0
  144. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_browser_profile.py +0 -0
  145. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_cheap_tier.py +0 -0
  146. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_client_jira_projects.py +0 -0
  147. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_config_token_diagnostics.py +0 -0
  148. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_configure_token_owner.py +0 -0
  149. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_console_encoding.py +0 -0
  150. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_departments.py +0 -0
  151. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_epic_decompose_run.py +0 -0
  152. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_epic_inherited_agent_id.py +0 -0
  153. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_epic_session_isolation.py +0 -0
  154. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_estimator.py +0 -0
  155. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_formats.py +0 -0
  156. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_git_action.py +0 -0
  157. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_graph_recon.py +0 -0
  158. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_graphify_build.py +0 -0
  159. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_graphify_hook_install.py +0 -0
  160. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_health.py +0 -0
  161. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_intake_plan_review.py +0 -0
  162. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_journal.py +0 -0
  163. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_local_exec_provider.py +0 -0
  164. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_phase_timer.py +0 -0
  165. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_plan_new_command.py +0 -0
  166. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_plan_run_activity.py +0 -0
  167. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_prd_artifact.py +0 -0
  168. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_proof_readers.py +0 -0
  169. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_providers.py +0 -0
  170. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_recon_session_isolation.py +0 -0
  171. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_resume.py +0 -0
  172. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_router.py +0 -0
  173. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_run_auto_agent_only.py +0 -0
  174. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_run_outputs.py +0 -0
  175. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_schedule_api.py +0 -0
  176. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_scheduler.py +0 -0
  177. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_self_report.py +0 -0
  178. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_service_token_warning.py +0 -0
  179. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_swagger_command.py +0 -0
  180. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_task_file_and_plan_run.py +0 -0
  181. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_task_file_format_doc.py +0 -0
  182. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_ticket_fields.py +0 -0
  183. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_ticket_run_resolve_by_id.py +0 -0
  184. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_ticket_speed_fixes.py +0 -0
  185. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_tier_flag_parity.py +0 -0
  186. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_ui_lifespan.py +0 -0
  187. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_update_timer.py +0 -0
  188. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_updater_extras.py +0 -0
  189. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_url_access.py +0 -0
  190. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_vps_health.py +0 -0
  191. {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.38}/tests/test_write_hook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.37
3
+ Version: 1.6.38
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.6.37
3
+ Version: 1.6.38
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -19,6 +19,7 @@ sdk/exceptions.py
19
19
  sdk/firewall.py
20
20
  sdk/formats.py
21
21
  sdk/git_ops.py
22
+ sdk/github_fetch.py
22
23
  sdk/github_ingest.py
23
24
  sdk/graph_recon.py
24
25
  sdk/graphify.py
@@ -108,6 +109,7 @@ sdk/ui/server.py
108
109
  sdk/ui/service.py
109
110
  sdk/ui/session.py
110
111
  sdk/ui/setup_manager.py
112
+ sdk/ui/swagger.py
111
113
  sdk/ui/static/agents.html
112
114
  sdk/ui/static/app.js
113
115
  sdk/ui/static/chat.html
@@ -143,13 +145,16 @@ tests/test_epic_decompose_run.py
143
145
  tests/test_epic_inherited_agent_id.py
144
146
  tests/test_epic_session_isolation.py
145
147
  tests/test_estimator.py
148
+ tests/test_firewall_open_on_update.py
146
149
  tests/test_formats.py
147
150
  tests/test_git_action.py
151
+ tests/test_github_fetch.py
148
152
  tests/test_github_ingest.py
149
153
  tests/test_graph_recon.py
150
154
  tests/test_graphify_build.py
151
155
  tests/test_graphify_hook_install.py
152
156
  tests/test_health.py
157
+ tests/test_intake_job_outcome.py
153
158
  tests/test_intake_plan_review.py
154
159
  tests/test_journal.py
155
160
  tests/test_local_exec_provider.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.6.37
3
+ Version: 1.6.38
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -34,7 +34,7 @@ Rules:
34
34
  - All data egress through submit_webhook only
35
35
  """
36
36
 
37
- __version__ = "1.6.37" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
37
+ __version__ = "1.6.38" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
38
38
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
39
39
 
40
40
  from sdk.client import BackendClient
@@ -58,14 +58,18 @@ Command surface:
58
58
  agentos integration rm <name>
59
59
  Remove a registered integration.
60
60
 
61
- agentos firewall [--apply] [--revoke IP] [--port N]
61
+ agentos firewall [--apply] [--any] [--revoke IP] [--port N]
62
62
  Print the host-firewall rules that open the agentos-ui port to every IP in
63
63
  the application allowlist (`sdk.config.get_allowed_ips`), for THIS OS — ufw
64
64
  on Linux, `netsh advfirewall` on Windows, a pf note on macOS. Prints by
65
65
  default; `--apply` executes them and needs root / Administrator. The host
66
66
  firewall is a separate layer the allowlist cannot reach, and the two drifting
67
67
  apart is what silently blinded every VPS when the backend's egress IP moved.
68
- See sdk.firewall.
68
+ `--any` opens the port to every source instead, leaving
69
+ IPAllowlistMiddleware as the gate — the more robust choice when the
70
+ backend's egress IP can move, since a stale per-IP kernel rule drops the
71
+ SYN before the allowlist is ever consulted. This is what runs
72
+ automatically after a self-update. See sdk.firewall.
69
73
 
70
74
  agentos swagger [--open] [--json [PATH]] [--port N]
71
75
  Where the panel's Swagger UI is and whether it can actually be reached:
@@ -193,6 +197,7 @@ def _print_usage() -> None:
193
197
  print(" agentos report <submit|list|show ID> # report work Jira/GitHub never saw")
194
198
  print(" agentos browser <login URL|status|logout> # sign in once; private links become readable")
195
199
  print(" agentos firewall [--apply] [--revoke IP] [--port N] # open the UI port to the allowlisted IPs")
200
+ print(" agentos firewall --any [--apply] # open the UI port to any source (allowlist is the gate)")
196
201
  print(" agentos swagger [--open] [--json [PATH]] # where /docs is, and can you reach it")
197
202
 
198
203
 
@@ -1025,6 +1025,48 @@ class BackendClient:
1025
1025
  self.last_ingest_error = {"status": 0, "body": str(exc)[:500], "retryable": True}
1026
1026
  return None
1027
1027
 
1028
+ async def ingest_github_prs_raw(self, prs: list[dict],
1029
+ since: str | None = None) -> dict | None:
1030
+ """POST {AI}/api/v1/github/prs/raw — PRs THIS machine already fetched.
1031
+
1032
+ The counterpart to :meth:`ingest_github_prs`. That one asks the backend to
1033
+ fetch with a GitHub token the platform has to hold; this one sends records
1034
+ `sdk.github_fetch` produced with the developer's own `gh`, so no GitHub
1035
+ credential exists on the platform at all, the request runs under that
1036
+ developer's own rate limit, and their private repos resolve.
1037
+
1038
+ Only PR DATA travels. The backend resolves the GitHub username and user id
1039
+ from the ``co_`` token server-side and files the records under those, so this
1040
+ cannot be used to attribute PRs to anyone else.
1041
+
1042
+ Same contract as `ingest_github_prs`: the response JSON on success,
1043
+ **None** on any failure, with the reason in ``self.last_ingest_error``.
1044
+ """
1045
+ self.last_ingest_error = {}
1046
+ body: dict = {"prs": prs}
1047
+ if since:
1048
+ body["date_from"] = since
1049
+ try:
1050
+ async with httpx.AsyncClient(timeout=120.0) as client:
1051
+ resp = await client.post(
1052
+ self._url("/api/v1/github/prs/raw"), json=body, headers=self._headers,
1053
+ )
1054
+ if resp.status_code >= 400:
1055
+ detail = resp.text[:500]
1056
+ logger.warning("client.ingest_github_prs_raw failed status=%s body=%s",
1057
+ resp.status_code, detail[:200])
1058
+ self.last_ingest_error = {
1059
+ "status": resp.status_code,
1060
+ "body": detail,
1061
+ "retryable": is_retryable_backend_error(resp.status_code, detail),
1062
+ }
1063
+ return None
1064
+ return resp.json()
1065
+ except Exception as exc: # noqa: BLE001 — a cron must not die on a blip
1066
+ logger.warning("client.ingest_github_prs_raw error err=%s", exc)
1067
+ self.last_ingest_error = {"status": 0, "body": str(exc)[:500], "retryable": True}
1068
+ return None
1069
+
1028
1070
  async def move_to_backlog(self, task_id: str) -> bool:
1029
1071
  """PUT {BE}/tasks-ml/{id} status=backlog (best-effort).
1030
1072
 
@@ -13,10 +13,17 @@ dropped the SYN before the server ever saw it (`docs/VPS_HEALTH_AND_GITHUB_INGES
13
13
  _DIAGNOSIS.md` #5). This module closes that gap by deriving the firewall rules
14
14
  from the *same* list the middleware enforces, so the two layers cannot drift.
15
15
 
16
- agentos firewall # show the rules for this OS (safe, prints only)
16
+ agentos firewall # show the per-IP rules for this OS (prints only)
17
17
  agentos firewall --apply # run them (needs root / Administrator)
18
+ agentos firewall --any # show the open-to-any rule instead
19
+ agentos firewall --any --apply# open the port, leaving the allowlist as the gate
18
20
  agentos firewall --revoke IP # print the matching delete rule
19
21
 
22
+ ``--any`` is also what runs automatically after a self-update
23
+ (``updater._ensure_firewall``), because a pip upgrade replaces code and
24
+ nothing else — a host whose kernel drops the SYNs stays broken across every
25
+ update while reporting a healthy version.
26
+
20
27
  Applying is deliberately opt-in: editing a live firewall is exactly the operation
21
28
  that can lock an operator out of their own box, so the default is to print and let
22
29
  a human read it first.
@@ -87,6 +94,131 @@ def allow_rules(ips: list[str], port: int, system: str | None = None) -> list[st
87
94
  return out
88
95
 
89
96
 
97
+ def allow_any_rules(port: int, system: str | None = None) -> list[str]:
98
+ """The commands that open ``port`` to ANY source — one per OS.
99
+
100
+ Deliberately different from :func:`allow_rules`, which scopes each rule to an
101
+ allowlisted IP. Both exist because they fail in opposite directions:
102
+
103
+ * **Per-IP rules go stale silently.** The kernel rule names an address, so the
104
+ day the backend's egress moves the SYNs are dropped before the server sees
105
+ them — the allowlist still says yes, and the symptom is a 30s hang then a
106
+ 502. That is not hypothetical: it is the incident in this module's own
107
+ docstring (``20.127.87.169``), and it blinded every VPS at once.
108
+ * **An open port is only safe because it is not the real gate.**
109
+ ``IPAllowlistMiddleware`` still refuses every request from an
110
+ unlisted source, and it is reachable over the API — so access can be
111
+ corrected in seconds without root on the box, which is exactly what a
112
+ kernel rule cannot offer.
113
+
114
+ So: open the port, keep the decision in the allowlist. Use
115
+ :func:`allow_rules` instead when the host must not accept a connection from
116
+ an unlisted address at all (a shared or hostile network), and accept that a
117
+ backend IP change then needs a root session on every host.
118
+ """
119
+ sysname = (system or _system()).lower()
120
+
121
+ if sysname == "linux":
122
+ # `/tcp` and not a bare `allow 8765`: the bare form opens UDP too, and the
123
+ # server has never listened on UDP. Re-running is harmless — ufw reports
124
+ # "Skipping adding existing rule".
125
+ return [f"sudo ufw allow {port}/tcp comment '{_RULE_TAG}'"]
126
+ if sysname == "windows":
127
+ # No `remoteip=` — that omission IS the "any source" part. The stable
128
+ # `name=` is what makes the rule findable and removable later.
129
+ return [
130
+ f'netsh advfirewall firewall add rule name="{_RULE_TAG} {port} any" '
131
+ f"dir=in action=allow protocol=TCP localport={port}"
132
+ ]
133
+ if sysname == "darwin":
134
+ return [
135
+ f"# macOS (pf) — append to /etc/pf.anchors/agentos then reload:\n"
136
+ f"# pass in proto tcp from any to any port {port}\n"
137
+ f"sudo pfctl -f /etc/pf.conf && sudo pfctl -E"
138
+ ]
139
+ return [f"# unsupported platform {sysname!r} — open TCP {port} inbound"]
140
+
141
+
142
+ def revoke_any_rule(port: int, system: str | None = None) -> str:
143
+ """The command that removes the open-to-any rule for ``port``."""
144
+ sysname = (system or _system()).lower()
145
+ if sysname == "linux":
146
+ return f"sudo ufw delete allow {port}/tcp"
147
+ if sysname == "windows":
148
+ return f'netsh advfirewall firewall delete rule name="{_RULE_TAG} {port} any"'
149
+ if sysname == "darwin":
150
+ return ("# macOS: remove the matching line from /etc/pf.anchors/agentos, "
151
+ "then: sudo pfctl -f /etc/pf.conf")
152
+ return f"# unsupported platform {sysname!r}"
153
+
154
+
155
+ def ensure_open(port: int | None = None, *, apply: bool = True) -> tuple[bool, str]:
156
+ """Make sure ``port`` is reachable. Returns ``(changed, message)``. Never raises.
157
+
158
+ Built for the post-update hook, so every branch is a no-op-or-explain rather
159
+ than a failure:
160
+
161
+ * already open (or open to a superset) → ``(False, "…already open…")`` and
162
+ **no command is run**. This is the common case on the second and every
163
+ later update, and it is why the hook is cheap enough to run every time.
164
+ * state unreadable (no tool, or unprivileged) → ``(False, <the command>)``.
165
+ Unreadable is NEVER treated as closed: `port_sources` returns ``None`` for
166
+ "cannot tell", and acting on that as though the port were shut would run a
167
+ firewall edit on a host whose state we do not know.
168
+ * closed and privileged → apply, and report what happened.
169
+ * closed and unprivileged → print nothing, return the command so the caller
170
+ can show it. An update runs unattended; it must not stall waiting for sudo.
171
+
172
+ macOS never applies: pf rules live in a file, so there is no command to run.
173
+ """
174
+ port = ui_port() if port is None else port
175
+ sysname = _system()
176
+
177
+ try:
178
+ sources = port_sources(port, sysname)
179
+ except Exception: # noqa: BLE001 — a status probe must never break an update
180
+ sources = None
181
+
182
+ if sources:
183
+ if "Anywhere" in sources:
184
+ return (False, f"TCP {port} is already open to any source")
185
+ return (False, f"TCP {port} is already open to: {', '.join(sorted(sources))}")
186
+
187
+ rules = allow_any_rules(port, sysname)
188
+ printable = " " + "\n ".join(rules)
189
+
190
+ if sources is None and sysname == "darwin":
191
+ return (False, f"macOS: pf state is not readable and pf rules are file-based —\n{printable}")
192
+ if sources is None:
193
+ ok, tool = _tool_present()
194
+ why = tool if not ok else "needs root to read the firewall state"
195
+ return (False, f"could not read the firewall state ({why}) — to open it yourself:\n{printable}")
196
+
197
+ # Readable and genuinely empty for this port.
198
+ if not apply or sysname == "darwin":
199
+ return (False, f"TCP {port} is not open. To open it:\n{printable}")
200
+ if not _is_privileged():
201
+ who = "Administrator" if sysname == "windows" else "root (sudo)"
202
+ return (False, f"TCP {port} is not open and opening it needs {who}:\n{printable}")
203
+
204
+ failed: list[str] = []
205
+ for rule in rules:
206
+ cmd = rule[len("sudo "):] if rule.startswith("sudo ") else rule
207
+ try:
208
+ proc = subprocess.run(
209
+ cmd, shell=True, capture_output=True, text=True, timeout=30,
210
+ )
211
+ except Exception as exc: # noqa: BLE001
212
+ failed.append(f"{cmd}: {exc}")
213
+ continue
214
+ if proc.returncode != 0:
215
+ tail = (proc.stderr or proc.stdout or "").strip().splitlines()
216
+ failed.append(f"{cmd}: {tail[-1][:120] if tail else f'exit {proc.returncode}'}")
217
+ if failed:
218
+ return (False, "could not open TCP %d: %s" % (port, "; ".join(failed)))
219
+ return (True, f"opened TCP {port} inbound")
220
+
221
+
90
222
  def revoke_rule(ip: str, port: int, system: str | None = None) -> str:
91
223
  """The command that removes the rule for one ``ip``."""
92
224
  sysname = (system or _system()).lower()
@@ -249,6 +381,36 @@ def cli(argv: list[str]) -> int:
249
381
  print(f"UI port : {port}")
250
382
  print(f"Allowed : {', '.join(ips) if ips else '(none — loopback only)'}")
251
383
 
384
+ # `--any` opens the port to every source and leaves the decision to
385
+ # IPAllowlistMiddleware. This is what the post-update hook uses; see
386
+ # `allow_any_rules` for why a per-IP kernel rule is the more fragile of the
387
+ # two. It does NOT need an allowlist to be populated first, so it is checked
388
+ # before the "nothing to open" bail below.
389
+ if "--any" in argv or "--open" in argv:
390
+ current = port_sources(port, sysname)
391
+ if current:
392
+ where = "any source" if "Anywhere" in current else ", ".join(sorted(current))
393
+ print(f"\nAlready open to: {where}")
394
+ if "Anywhere" not in current and "--apply" not in argv:
395
+ print("Re-run with --any --apply to widen it to any source.")
396
+ if "Anywhere" in current:
397
+ return 0
398
+ elif current is None:
399
+ print("\n(firewall state unreadable — showing the rule anyway)")
400
+
401
+ rules = allow_any_rules(port, sysname)
402
+ print("\nOpen the port to any source:\n")
403
+ for r in rules:
404
+ print(f" {r}")
405
+ print("\nAccess is still gated by the app allowlist above — an unlisted IP is\n"
406
+ "refused by IPAllowlistMiddleware even with the port open.")
407
+ if "--apply" not in argv:
408
+ print("\n(dry run — nothing changed. Re-run with --apply to execute.)")
409
+ return 0
410
+ changed, msg = ensure_open(port, apply=True)
411
+ print(f"\n{msg}")
412
+ return 0 if changed or "already open" in msg else 1
413
+
252
414
  if not ips:
253
415
  print("\nNothing to open. Loopback always reaches the server; add the backend's\n"
254
416
  "egress IP to AGENTOS_UI_ALLOWED_IPS first.")
@@ -0,0 +1,198 @@
1
+ """Fetch this developer's PRs with their own `gh`, in the shape the backend expects.
2
+
3
+ Every developer installs and authenticates `gh` on their own machine, and one VPS is
4
+ one developer — so `gh` here IS the per-user credential. That makes fetching on this
5
+ side strictly better than the backend doing it:
6
+
7
+ * **the platform never holds a GitHub token.** The alternative (Option C in the
8
+ backend's ``docs/GITHUB_SCHEDULING_SDK_MIGRATION.md``) has the SDK pull a
9
+ ``gho_`` token from the ML gateway — a long-lived credential copied onto every
10
+ VPS. With ``gh`` the credential is already here, owned and rotated by the
11
+ developer, and this module neither reads nor transmits it: ``gh`` does.
12
+ * **per-user rate limits** — 5000 requests/hour each, rather than every developer
13
+ contending for one shared pool. The backend fetcher carries an explicit
14
+ ``search_limit_exhausted`` backoff for exactly that contention.
15
+ * **private and IP-allowlisted repos** resolve, because the call originates from
16
+ the machine that has access.
17
+
18
+ Deliberately ``gh api``, not ``gh search prs``. The latter reshapes results through
19
+ GraphQL: it returns an opaque node id (``PR_kwDO…``) instead of the numeric REST id,
20
+ and has no ``mergedAt`` field at all — so records would not match the backend's and
21
+ the two ingestion paths would drift. ``gh api`` is a thin authenticated proxy to the
22
+ REST endpoint ``modules/ingestion/github_pr_fetcher`` already calls, so the records
23
+ this produces are byte-identical and ``pr_facts()`` needs no per-source branch.
24
+
25
+ What stays on the backend: the LLM narrative, the ``content.prs`` facts, the
26
+ daily-summary forward and the Qdrant upsert. ``daily_summaries`` is uniquely keyed on
27
+ ``(user_id, date, channel_type, project)``, so one writer must assemble the whole
28
+ record — this module only supplies the raw material, through
29
+ ``POST /api/v1/github/prs/raw``.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import shutil
36
+ import subprocess
37
+
38
+ logger = logging.getLogger("sdk.github_fetch")
39
+
40
+ # Per-PR file/commit detail costs two extra API calls each, so it is bounded. A
41
+ # developer's weekly window is tens of PRs; a 50-day backfill for a busy one ran 31.
42
+ MAX_DETAIL_PRS = 300
43
+ _GH_TIMEOUT = 60.0
44
+
45
+
46
+ class GhUnavailable(RuntimeError):
47
+ """`gh` is missing, not authenticated, or unusable — with an operator fix."""
48
+
49
+
50
+ def gh_available() -> tuple[bool, str]:
51
+ """``(ok, detail)`` — is `gh` installed AND authenticated for api calls?
52
+
53
+ Checked as two separate failures because the fixes differ: an absent binary is
54
+ an install, a rejected token is ``gh auth login``. Never raises.
55
+ """
56
+ if not shutil.which("gh"):
57
+ return False, ("`gh` is not installed — install the GitHub CLI "
58
+ "(https://cli.github.com) and run `gh auth login`")
59
+ try:
60
+ proc = subprocess.run(["gh", "auth", "status"], capture_output=True,
61
+ text=True, timeout=20, check=False)
62
+ except Exception as exc: # noqa: BLE001 — a hung probe is an unavailable gh
63
+ return False, f"`gh auth status` failed: {exc}"
64
+ if proc.returncode != 0:
65
+ return False, ("`gh` is installed but not authenticated — run "
66
+ "`gh auth login` as the user this service runs as")
67
+ account = ""
68
+ for line in (proc.stdout or proc.stderr or "").splitlines():
69
+ if "Logged in to" in line:
70
+ account = line.strip().lstrip("✓- ").strip()
71
+ break
72
+ return True, account or "authenticated"
73
+
74
+
75
+ def _gh_api(path: str, params: dict | None = None) -> object:
76
+ """One `gh api` GET, parsed. Raises :class:`GhUnavailable` on failure.
77
+
78
+ `gh` owns the credential, the base URL, and retry/paging headers — this only
79
+ shapes the request and reads stdout.
80
+ """
81
+ cmd = ["gh", "api", "-X", "GET", path]
82
+ for key, value in (params or {}).items():
83
+ cmd += ["-f", f"{key}={value}"]
84
+ try:
85
+ proc = subprocess.run(cmd, capture_output=True, text=True,
86
+ timeout=_GH_TIMEOUT, check=False)
87
+ except subprocess.TimeoutExpired as exc:
88
+ raise GhUnavailable(f"`gh api {path}` timed out after {_GH_TIMEOUT}s") from exc
89
+ except Exception as exc:
90
+ raise GhUnavailable(f"`gh api {path}` could not run: {exc}") from exc
91
+ if proc.returncode != 0:
92
+ detail = (proc.stderr or proc.stdout or "").strip().splitlines()
93
+ raise GhUnavailable(f"`gh api {path}` failed: {detail[-1] if detail else '?'}")
94
+ try:
95
+ return json.loads(proc.stdout or "null")
96
+ except json.JSONDecodeError as exc:
97
+ raise GhUnavailable(f"`gh api {path}` returned non-JSON: {exc}") from exc
98
+
99
+
100
+ def _repo_full_name(item: dict) -> str:
101
+ """``owner/name`` from a search item's ``repository_url``."""
102
+ parts = (item.get("repository_url") or "").rstrip("/").split("/")
103
+ return "/".join(parts[-2:]) if len(parts) >= 2 else ""
104
+
105
+
106
+ def _detail(repo: str, number: int) -> tuple[list[dict], list[dict]]:
107
+ """``(files, commits)`` for one PR, mapped to the backend fetcher's sub-shapes.
108
+
109
+ Both are best-effort: a PR whose detail cannot be read still ingests with empty
110
+ lists, exactly as the backend fetcher does, because the narrative degrades but
111
+ the countable facts do not depend on it.
112
+ """
113
+ files: list[dict] = []
114
+ commits: list[dict] = []
115
+ try:
116
+ raw = _gh_api(f"repos/{repo}/pulls/{number}/files", {"per_page": 100})
117
+ files = [
118
+ {"filename": f.get("filename"), "status": f.get("status"),
119
+ "additions": f.get("additions"), "deletions": f.get("deletions"),
120
+ "changes": f.get("changes")}
121
+ for f in (raw or []) if isinstance(f, dict)
122
+ ]
123
+ except GhUnavailable as exc:
124
+ logger.warning("github_fetch.files_failed repo=%s pr=%s err=%s", repo, number, exc)
125
+ try:
126
+ raw = _gh_api(f"repos/{repo}/pulls/{number}/commits", {"per_page": 100})
127
+ for c in (raw or []):
128
+ if not isinstance(c, dict):
129
+ continue
130
+ commit = c.get("commit") or {}
131
+ author = commit.get("author") or {}
132
+ commits.append({
133
+ "sha": c.get("sha"),
134
+ "message": commit.get("message"),
135
+ "author": author.get("name") or (c.get("author") or {}).get("login"),
136
+ "email": author.get("email"),
137
+ "date": author.get("date"),
138
+ "url": c.get("html_url"),
139
+ })
140
+ except GhUnavailable as exc:
141
+ logger.warning("github_fetch.commits_failed repo=%s pr=%s err=%s", repo, number, exc)
142
+ return files, commits
143
+
144
+
145
+ def fetch_prs(username: str, since: str, *, with_detail: bool = True,
146
+ max_detail: int = MAX_DETAIL_PRS) -> list[dict]:
147
+ """PRs authored by ``username`` updated on/after ``since`` (``YYYY-MM-DD``).
148
+
149
+ ``updated:>=``, not ``created:>=``: a PR opened five weeks ago and merged
150
+ yesterday has to come back inside a 7-day window, or the daily-summary record
151
+ for its creation day is never rewritten and its ``mergedAt`` stays null forever.
152
+ The backend files each PR under its own creation date regardless, so widening
153
+ this changes which PRs are returned, not which day each lands on.
154
+
155
+ Returns records in ``modules.ingestion.github_pr_fetcher``'s shape, so the
156
+ backend can use them without a per-source branch. Raises
157
+ :class:`GhUnavailable` if `gh` cannot serve the request — the caller decides
158
+ whether that is fatal or a reason to fall back to server-side fetching.
159
+ """
160
+ query = f"is:pr author:{username} updated:>={since}"
161
+ items: list[dict] = []
162
+ page = 1
163
+ while page <= 10: # 10 x 100 = the search API's own ceiling
164
+ body = _gh_api("search/issues", {"q": query, "per_page": 100, "page": page})
165
+ batch = (body or {}).get("items") or [] if isinstance(body, dict) else []
166
+ items.extend(i for i in batch if isinstance(i, dict))
167
+ if len(batch) < 100:
168
+ break
169
+ page += 1
170
+
171
+ out: list[dict] = []
172
+ for idx, item in enumerate(items):
173
+ repo = _repo_full_name(item)
174
+ number = item.get("number")
175
+ files: list[dict] = []
176
+ commits: list[dict] = []
177
+ if with_detail and repo and number and idx < max_detail:
178
+ files, commits = _detail(repo, number)
179
+ out.append({
180
+ "id": item.get("id"),
181
+ "number": number,
182
+ "title": item.get("title"),
183
+ "state": item.get("state"),
184
+ "author": (item.get("user") or {}).get("login"),
185
+ "createdAt": item.get("created_at"),
186
+ "updatedAt": item.get("updated_at"),
187
+ "closedAt": item.get("closed_at"),
188
+ "mergedAt": (item.get("pull_request") or {}).get("merged_at"),
189
+ "draft": item.get("draft", False),
190
+ "url": item.get("html_url"),
191
+ "repository": repo,
192
+ "commentsCount": item.get("comments", 0),
193
+ "files": files,
194
+ "commits": commits,
195
+ })
196
+ logger.info("github_fetch.done user=%s since=%s prs=%d detail=%s",
197
+ username, since, len(out), with_detail)
198
+ return out
@@ -370,6 +370,27 @@ def since_for(now: datetime, state: dict, days: int | None = None) -> str:
370
370
 
371
371
  # ── One run ─────────────────────────────────────────────────────────────────
372
372
 
373
+ def _fetch_locally(username: str, since: str) -> tuple[list[dict] | None, str]:
374
+ """``(prs, note)`` from this machine's `gh`, or ``(None, why-not)``.
375
+
376
+ ``None`` means "could not fetch here" and the caller should let the backend do
377
+ it — an absent or unauthenticated `gh`, or a `gh` that errored. An empty LIST
378
+ means "fetched fine, this developer has no PRs in the window", which is a
379
+ different answer and must not trigger a fallback.
380
+ """
381
+ from sdk.github_fetch import GhUnavailable, fetch_prs, gh_available
382
+
383
+ ok, detail = gh_available()
384
+ if not ok:
385
+ logger.info("github_ingest.gh_unavailable %s", detail)
386
+ return None, detail
387
+ try:
388
+ return fetch_prs(username, since), detail
389
+ except GhUnavailable as exc:
390
+ logger.warning("github_ingest.gh_fetch_failed err=%s", exc)
391
+ return None, str(exc)
392
+
393
+
373
394
  async def run_once(client: "BackendClient | None" = None, *, now: datetime | None = None,
374
395
  days: int | None = None) -> dict:
375
396
  """Resolve this user's GitHub link, POST the ingest request, record the outcome.
@@ -406,12 +427,36 @@ async def run_once(client: "BackendClient | None" = None, *, now: datetime | Non
406
427
  save_state(state)
407
428
  return out
408
429
 
409
- # No credentials in the call: the backend resolves the GitHub link
410
- # server-side from the co_ token and overwrites anything the body sends
430
+ # Fetch HERE when `gh` can, and only ask the backend to fetch when it
431
+ # cannot. `gh` is authenticated per developer and one VPS is one developer,
432
+ # so fetching locally means the platform holds no GitHub token, the request
433
+ # runs under this developer's own rate limit, and their private and
434
+ # IP-allowlisted repos resolve. Falling back keeps a box without `gh`
435
+ # working exactly as before rather than silently ingesting nothing.
436
+ #
437
+ # Either way NO credentials go in the body: the backend resolves the GitHub
438
+ # link server-side from the co_ token and overwrites anything sent
411
439
  # (docs/GITHUB_SCHEDULING_SDK_MIGRATION.md §7/§9). The record fetched above
412
440
  # is still worth having — it is how `no_github_link` is answered without a
413
441
  # wasted round-trip.
414
- resp = await client.ingest_github_prs(since=since)
442
+ local_prs, fetch_note = _fetch_locally(username, since)
443
+ if local_prs is None:
444
+ out["fetch"] = f"backend ({fetch_note})"
445
+ resp = await client.ingest_github_prs(since=since)
446
+ elif not local_prs:
447
+ # `gh` worked and this developer genuinely has no PRs in the window.
448
+ # Posting an empty list would be a 422, and asking the backend to look
449
+ # again would just spend its rate limit to reach the same answer.
450
+ out.update({"ok": True, "status": "no_prs", "fetch": "local"})
451
+ logger.info("github_ingest.no_prs since=%s", since)
452
+ state.update({"last_run_at": stamp.isoformat(), "last_status": "no_prs",
453
+ "backfill_id": BACKFILL_ID})
454
+ save_state(state)
455
+ return out
456
+ else:
457
+ out["fetch"] = "local"
458
+ out["prs_fetched"] = len(local_prs)
459
+ resp = await client.ingest_github_prs_raw(local_prs, since=since)
415
460
  if resp is None:
416
461
  err = getattr(client, "last_ingest_error", {}) or {}
417
462
  retryable = bool(err.get("retryable"))