devops-bot-sdk 1.6.37__tar.gz → 1.6.39__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.
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/PKG-INFO +1 -1
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/SOURCES.txt +7 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/cli.py +7 -2
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/client.py +42 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/firewall.py +163 -1
- devops_bot_sdk-1.6.39/sdk/github_fetch.py +198 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/github_ingest.py +48 -3
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/intake.py +126 -1
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ipc/handlers.py +24 -3
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/platform_compat.py +31 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/run_auto.py +33 -13
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/server.py +36 -24
- devops_bot_sdk-1.6.39/sdk/ui/swagger.py +167 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/updater.py +269 -67
- devops_bot_sdk-1.6.39/tests/test_firewall_open_on_update.py +247 -0
- devops_bot_sdk-1.6.39/tests/test_github_fetch.py +216 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_github_ingest.py +97 -0
- devops_bot_sdk-1.6.39/tests/test_intake_job_outcome.py +180 -0
- devops_bot_sdk-1.6.39/tests/test_run_verdict_is_honest.py +147 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_task_file_and_plan_run.py +6 -2
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_ui_gate.py +77 -7
- devops_bot_sdk-1.6.39/tests/test_updater_reports_the_truth.py +328 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/README.md +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/pyproject.toml +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agentd.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/activity.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/builder.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/chat.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/connectors.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/claude_code.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/http.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/mcp.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/n8n.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/engines/shell.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/integrations.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/inventory.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/library/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/manifest.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_studio.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_template.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/prompt_vars.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/run_log.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/schedules.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/secrets.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/ticket_run.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/usage.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/agents/varstore.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/browser_profile.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/config.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/departments.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/assets.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/bootstrap.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/browser.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/compare.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/design_verify/loop.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/estimator.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/formats.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/graph_recon.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/journal.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/phase_timer.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/plan_run.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/prd_progress.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/proof_readers.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/providers.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/resume.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/router.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/scheduler.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/self_report.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/self_report_local.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/swagger.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/task_file.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/task_types.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/test.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ticket_fields.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/tiers/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/tiers/mini_agent.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/__init__.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/api.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/dashboard.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/health.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/ip_allowlist.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/jobs.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/models.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/runner.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/security.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/service.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/session.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/setup_manager.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/agents.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/app.js +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/chat.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/custom-agents.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/default-agents.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/index.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/report.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/runs.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/schedules.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/setup.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/styles.css +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/ui/static/tickets.html +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/url_access.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/vps_health.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/sdk/write_hook.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/setup.cfg +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_activity.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_activity_tracking.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_chat_features.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_listing.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_prompt_async.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_prompt_models.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_run_ticket.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_schedules.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agent_workspace_settings.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_agents_connectors_builder.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_allowed_tools_config.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_auto_agent_dispatch.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_browser_profile.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_cheap_tier.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_client_jira_projects.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_config_token_diagnostics.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_configure_token_owner.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_console_encoding.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_departments.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_epic_decompose_run.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_epic_inherited_agent_id.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_epic_session_isolation.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_estimator.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_formats.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_git_action.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_graph_recon.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_graphify_build.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_graphify_hook_install.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_health.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_intake_plan_review.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_journal.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_local_exec_provider.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_phase_timer.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_plan_new_command.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_plan_run_activity.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_prd_artifact.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_proof_readers.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_providers.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_recon_session_isolation.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_resume.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_router.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_run_auto_agent_only.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_run_outputs.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_schedule_api.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_scheduler.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_self_report.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_service_token_warning.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_swagger_command.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_task_file_format_doc.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_ticket_fields.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_ticket_run_resolve_by_id.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_ticket_speed_fixes.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_tier_flag_parity.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_ui_lifespan.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_update_timer.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_updater_extras.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_url_access.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_vps_health.py +0 -0
- {devops_bot_sdk-1.6.37 → devops_bot_sdk-1.6.39}/tests/test_write_hook.py +0 -0
|
@@ -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
|
|
@@ -164,6 +169,7 @@ tests/test_resume.py
|
|
|
164
169
|
tests/test_router.py
|
|
165
170
|
tests/test_run_auto_agent_only.py
|
|
166
171
|
tests/test_run_outputs.py
|
|
172
|
+
tests/test_run_verdict_is_honest.py
|
|
167
173
|
tests/test_schedule_api.py
|
|
168
174
|
tests/test_scheduler.py
|
|
169
175
|
tests/test_self_report.py
|
|
@@ -179,6 +185,7 @@ tests/test_ui_gate.py
|
|
|
179
185
|
tests/test_ui_lifespan.py
|
|
180
186
|
tests/test_update_timer.py
|
|
181
187
|
tests/test_updater_extras.py
|
|
188
|
+
tests/test_updater_reports_the_truth.py
|
|
182
189
|
tests/test_url_access.py
|
|
183
190
|
tests/test_vps_health.py
|
|
184
191
|
tests/test_write_hook.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.6.
|
|
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
|
+
__version__ = "1.6.39" # 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
|
-
|
|
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 (
|
|
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
|
-
#
|
|
410
|
-
#
|
|
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
|
-
|
|
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"))
|