devops-bot-sdk 1.4.159__tar.gz → 1.5.0__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 (107) hide show
  1. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/cli.py +61 -1
  6. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/client.py +78 -10
  7. devops_bot_sdk-1.5.0/sdk/github_ingest.py +345 -0
  8. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/api.py +34 -0
  9. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/server.py +24 -13
  10. devops_bot_sdk-1.5.0/tests/test_github_ingest.py +327 -0
  11. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/README.md +0 -0
  12. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  13. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  14. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/requires.txt +0 -0
  15. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  16. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/pyproject.toml +0 -0
  17. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agentd.py +0 -0
  18. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/__init__.py +0 -0
  19. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/activity.py +0 -0
  20. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/builder.py +0 -0
  21. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/chat.py +0 -0
  22. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/connectors.py +0 -0
  23. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/claude_code.py +0 -0
  25. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/http.py +0 -0
  26. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/mcp.py +0 -0
  27. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/n8n.py +0 -0
  28. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/engines/shell.py +0 -0
  29. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/integrations.py +0 -0
  30. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/inventory.py +0 -0
  31. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/library/__init__.py +0 -0
  32. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/manifest.py +0 -0
  33. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/prompt_studio.py +0 -0
  34. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/prompt_template.py +0 -0
  35. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/prompt_vars.py +0 -0
  36. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/run_log.py +0 -0
  37. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/secrets.py +0 -0
  38. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/ticket_run.py +0 -0
  39. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/usage.py +0 -0
  40. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/agents/varstore.py +0 -0
  41. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/collectors/__init__.py +0 -0
  42. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/collectors/files.py +0 -0
  43. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/collectors/process.py +0 -0
  44. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/collectors/screenshot.py +0 -0
  45. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/config.py +0 -0
  46. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/crucial.py +0 -0
  47. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/__init__.py +0 -0
  48. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/assets.py +0 -0
  49. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/bootstrap.py +0 -0
  50. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/browser.py +0 -0
  51. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/compare.py +0 -0
  52. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/design_verify/loop.py +0 -0
  53. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/exceptions.py +0 -0
  54. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/git_ops.py +0 -0
  55. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/graphify.py +0 -0
  56. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/hooks/__init__.py +0 -0
  57. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/hooks/crucial_guard.py +0 -0
  58. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ipc/__init__.py +0 -0
  59. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ipc/electron_bridge.py +0 -0
  60. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ipc/handlers.py +0 -0
  61. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/local_exec.py +0 -0
  62. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/models/__init__.py +0 -0
  63. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/models/envelope.py +0 -0
  64. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/models/requests.py +0 -0
  65. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/models/responses.py +0 -0
  66. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/models/snapshots.py +0 -0
  67. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/platform_compat.py +0 -0
  68. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/py.typed +0 -0
  69. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/run_auto.py +0 -0
  70. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/sse.py +0 -0
  71. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/test.py +0 -0
  72. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/test_pipeline.py +0 -0
  73. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/__init__.py +0 -0
  74. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/dashboard.py +0 -0
  75. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/health.py +0 -0
  76. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/ip_allowlist.py +0 -0
  77. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/jobs.py +0 -0
  78. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/models.py +0 -0
  79. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/runner.py +0 -0
  80. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/security.py +0 -0
  81. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/service.py +0 -0
  82. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/session.py +0 -0
  83. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/setup_manager.py +0 -0
  84. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/agents.html +0 -0
  85. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/app.js +0 -0
  86. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/custom-agents.html +0 -0
  87. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/default-agents.html +0 -0
  88. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/index.html +0 -0
  89. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/login.html +0 -0
  90. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/runs.html +0 -0
  91. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/setup.html +0 -0
  92. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/styles.css +0 -0
  93. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/ui/static/tickets.html +0 -0
  94. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/updater.py +0 -0
  95. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/sdk/vps_health.py +0 -0
  96. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/setup.cfg +0 -0
  97. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agent_activity.py +0 -0
  98. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agent_chat_features.py +0 -0
  99. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agent_prompt_async.py +0 -0
  100. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agent_prompt_models.py +0 -0
  101. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agent_run_ticket.py +0 -0
  102. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_agents_connectors_builder.py +0 -0
  103. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_health.py +0 -0
  105. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_otp_login.py +0 -0
  106. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_updater_extras.py +0 -0
  107. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.5.0}/tests/test_vps_health.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.159
3
+ Version: 1.5.0
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.4.159
3
+ Version: 1.5.0
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
@@ -14,6 +14,7 @@ sdk/config.py
14
14
  sdk/crucial.py
15
15
  sdk/exceptions.py
16
16
  sdk/git_ops.py
17
+ sdk/github_ingest.py
17
18
  sdk/graphify.py
18
19
  sdk/local_exec.py
19
20
  sdk/platform_compat.py
@@ -97,6 +98,7 @@ tests/test_agent_prompt_models.py
97
98
  tests/test_agent_run_ticket.py
98
99
  tests/test_agents_connectors_builder.py
99
100
  tests/test_git_action.py
101
+ tests/test_github_ingest.py
100
102
  tests/test_health.py
101
103
  tests/test_otp_login.py
102
104
  tests/test_updater_extras.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.154
3
+ Version: 1.5.0
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.159" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.5.0" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -96,12 +96,16 @@ def main() -> None:
96
96
  _default_agents_command(rest)
97
97
  return
98
98
 
99
+ if noun == "github":
100
+ _github_command(rest)
101
+ return
102
+
99
103
  _print_usage()
100
104
  sys.exit(1)
101
105
 
102
106
 
103
107
  def _print_usage() -> None:
104
- print("usage: agentos <configure|agent|integration|default-agents|agentd> ...")
108
+ print("usage: agentos <configure|agent|integration|default-agents|agentd|github> ...")
105
109
  print()
106
110
  print(" agentos configure [--rotate]")
107
111
  print(" agentos agent <list|run NAME|chat NAME MSG|logs NAME|enable NAME|disable NAME|rm NAME>")
@@ -109,6 +113,7 @@ def _print_usage() -> None:
109
113
  print(" agentos integration <add PATH|test NAME|list|rm NAME>")
110
114
  print(" agentos default-agents list [--department-id UUID] # read-only; agents are created in the web app")
111
115
  print(" agentos agentd <start [--foreground]|status>")
116
+ print(" agentos github <ingest [--days N]|status> # PR ingestion; --days N backfills N days")
112
117
 
113
118
 
114
119
  # ── agent ────────────────────────────────────────────────────────────────
@@ -590,5 +595,60 @@ def _agentd_command(args: list[str]) -> None:
590
595
  sys.exit(1)
591
596
 
592
597
 
598
+ # ── github ───────────────────────────────────────────────────────────────
599
+
600
+ def _github_command(args: list[str]) -> None:
601
+ """`agentos github ingest [--days N]` / `agentos github status`.
602
+
603
+ The daily ingestion normally runs by itself inside `agentos-ui` (see
604
+ sdk.github_ingest). This is the manual handle: `ingest` runs one now,
605
+ `--days N` forces an N-day window — that is the backfill.
606
+ """
607
+ from sdk import github_ingest as gi
608
+
609
+ verb = args[0] if args else None
610
+
611
+ if verb == "status":
612
+ st = gi.status()
613
+ state = st.get("state") or {}
614
+ print(f"enabled : {st['enabled']}")
615
+ print(f"daily slot : {st['slot_utc']} UTC")
616
+ print(f"catch-up cap : {st['lookback_days']} days")
617
+ print(f"first-run seed : {st['backfill_days']} days"
618
+ f"{'' if st['backfilled'] else ' ← not run yet; the next run backfills'}")
619
+ print(f"last success : {state.get('last_success_at') or '(never)'}")
620
+ print(f"last status : {state.get('last_status') or '(none)'}")
621
+ if state.get("last_job_id"):
622
+ print(f"last job : {state['last_job_id']} {state.get('last_status_url', '')}")
623
+ if state.get("last_error"):
624
+ print(f"last error : {state['last_error']}")
625
+ return
626
+
627
+ if verb == "ingest":
628
+ days = None
629
+ if "--days" in args:
630
+ try:
631
+ days = int(args[args.index("--days") + 1])
632
+ except (IndexError, ValueError):
633
+ print("error: --days needs a whole number of days, e.g. --days 50")
634
+ sys.exit(1)
635
+ result = asyncio.run(gi.run_once(days=days))
636
+ status = result.get("status")
637
+ if status == "queued":
638
+ print(f"queued: PRs since {result['since']} job_id={result.get('job_id') or '(none)'}")
639
+ if result.get("status_url"):
640
+ print(f"poll : {result['status_url']} (on the AI backend)")
641
+ return
642
+ if status == "no_github_link":
643
+ print("nothing to do: this account has no GitHub connected.")
644
+ return
645
+ print(f"failed: {result.get('reason') or 'unknown error'}")
646
+ sys.exit(1)
647
+
648
+ print("usage: agentos github ingest [--days N] # N-day backfill; omit for the normal window")
649
+ print(" agentos github status")
650
+ sys.exit(1)
651
+
652
+
593
653
  if __name__ == "__main__":
594
654
  main()
@@ -763,13 +763,18 @@ class BackendClient:
763
763
  except Exception:
764
764
  return "pending"
765
765
 
766
- async def github_token(self) -> str | None:
767
- """GET /ml-api/github/token → the user's GitHub access token (gho_…).
768
-
769
- Used by the LOCAL agent to authenticate git/gh for clone/push/PR. The
770
- endpoint lives on the ML backend (devopsbot-be), authorised by the same
771
- co_ token. Best-effort: returns None on any failure so a run without a
772
- connected GitHub account still proceeds (clone/PR steps just won't auth).
766
+ async def github_token_record(self) -> dict | None:
767
+ """GET /ml-api/github/token → the caller's WHOLE GitHub link record.
768
+
769
+ Returns the ``data`` object — ``{id, userId, githubId, username,
770
+ accessToken, scope, …}`` — or None when this user has no GitHub link, the
771
+ token is rejected, or the backend is unreachable. Inherently per-user: the
772
+ ML backend resolves WHICH link to return from the ``co_`` token, so there
773
+ is no list-all form.
774
+
775
+ ``github_token()`` below is the accessToken-only view of this. The daily
776
+ PR-ingest cron (``sdk.github_ingest``) needs ``username`` too, which is why
777
+ the full record is exposed. Best-effort — never raises.
773
778
  """
774
779
  from sdk.config import BE_DEFAULT_URL
775
780
 
@@ -785,9 +790,72 @@ class BackendClient:
785
790
  resp = await client.get(url, headers=headers)
786
791
  if resp.status_code >= 400:
787
792
  return None
788
- data = (resp.json() or {}).get("data") or {}
789
- return data.get("accessToken") or None
790
- except Exception:
793
+ return (resp.json() or {}).get("data") or None
794
+ except Exception: # noqa: BLE001
795
+ return None
796
+
797
+ async def github_token(self) -> str | None:
798
+ """GET /ml-api/github/token → the user's GitHub access token (gho_…).
799
+
800
+ Used by the LOCAL agent to authenticate git/gh for clone/push/PR. The
801
+ endpoint lives on the ML backend (devopsbot-be), authorised by the same
802
+ co_ token. Best-effort: returns None on any failure so a run without a
803
+ connected GitHub account still proceeds (clone/PR steps just won't auth).
804
+ """
805
+ record = await self.github_token_record()
806
+ return (record or {}).get("accessToken") or None
807
+
808
+ async def ingest_github_prs(
809
+ self,
810
+ *,
811
+ github_username: str,
812
+ github_access_token: str,
813
+ since: str | None = None,
814
+ user_id: str = "",
815
+ ) -> dict | None:
816
+ """POST /api/v1/github/prs — queue PR ingestion for THIS user on the backend.
817
+
818
+ This is the daily GitHub ingestion trigger. It runs on the AI backend
819
+ (``devopsbot-ai.apiswagger.co.uk`` — ``self._url``, the same base every other
820
+ ``/api/v1`` call uses), NOT the ML gateway: the backend owns the LLM
821
+ summarise, the downstream daily-summary forward, and the Qdrant upsert.
822
+ The SDK only decides WHEN, because only this machine knows this user's
823
+ schedule and its own last-success watermark.
824
+
825
+ ``since`` is an ISO date (``YYYY-MM-DD``) → the backend's
826
+ ``GetUserPRsRequest.date_from``. Omitted means "today", which is what a
827
+ never-missed daily run wants; ``sdk.github_ingest`` sends the catch-up
828
+ watermark instead so a machine that was off does not silently lose days.
829
+
830
+ The credentials are sent in the body because that is what the endpoint
831
+ accepts today. The ``Authorization: co_`` header goes with every request
832
+ regardless, so once the backend resolves the GitHub link server-side from
833
+ that token (see docs/GITHUB_SCHEDULING_SDK_MIGRATION.md §3 step 1) it can
834
+ ignore the body fields without breaking this client.
835
+
836
+ Returns the response JSON (``{job_id, …}``) or None on any failure —
837
+ never raises into the cron.
838
+ """
839
+ payload = [{
840
+ "userId": user_id or self._user_id_from_token(),
841
+ "githubUsername": github_username,
842
+ "githubAccessToken": github_access_token,
843
+ "apiTokenHash": self._token, # downstream daily-summary Authorization
844
+ }]
845
+ if since:
846
+ payload[0]["date_from"] = since
847
+ try:
848
+ async with httpx.AsyncClient(timeout=60.0) as client:
849
+ resp = await client.post(
850
+ self._url("/api/v1/github/prs"), json=payload, headers=self._headers,
851
+ )
852
+ if resp.status_code >= 400:
853
+ logger.warning("client.ingest_github_prs failed status=%s body=%s",
854
+ resp.status_code, resp.text[:200])
855
+ return None
856
+ return resp.json()
857
+ except Exception as exc: # noqa: BLE001
858
+ logger.warning("client.ingest_github_prs error err=%s", exc)
791
859
  return None
792
860
 
793
861
  async def move_to_backlog(self, task_id: str) -> bool:
@@ -0,0 +1,345 @@
1
+ """Daily GitHub PR ingestion — the SDK-side scheduler.
2
+
3
+ Replaces the AI backend's Celery-beat entry ``github-prs-daily``, which fired
4
+ once for the whole org and iterated a Postgres roster of every user holding a
5
+ ``co_`` token. Here there is no roster: one machine is one user, and this machine
6
+ already holds that user's token — so it schedules only itself. See
7
+ ``docs/GITHUB_SCHEDULING_SDK_MIGRATION.md`` in the devops-bot-ai repo (Option A).
8
+
9
+ What moves and what does NOT:
10
+ * MOVED here — the trigger, and only the trigger.
11
+ * STAYS on the backend — fetching the PRs, the LLM summarise, the per-repo
12
+ daily-summary forward, and the Qdrant upsert. This machine never talks to
13
+ Postgres, Qdrant, or Azure, and holds no credential beyond the ``co_`` token
14
+ it was configured with. One VPS cannot write another user's vectors.
15
+
16
+ Schedule semantics — a real daily slot, not a naive interval:
17
+ * the loop ticks every ``_TICK_S`` (cheap: no network unless a run is due),
18
+ * a run is DUE when the UTC clock has passed ``AGENTOS_GITHUB_INGEST_AT``
19
+ (default ``13:30`` UTC = 18:30 Asia/Karachi, the slot the beat entry used) and
20
+ no successful run has been recorded for today's UTC date,
21
+ * so a machine that was asleep at 13:30 catches up the moment it comes back —
22
+ the failure mode that made this the riskiest part of the migration.
23
+
24
+ Catch-up window: the request carries ``since`` = the date of the last SUCCESSFUL
25
+ run (capped at ``_MAX_LOOKBACK_DAYS``), not "today". A box that was off for three
26
+ days asks for three days of PRs on its next run instead of silently losing them.
27
+ The backend's ingestion is expected to be idempotent per (user, repo, date) —
28
+ overlapping windows WILL re-send PRs.
29
+
30
+ State lives in ``~/.agentos/github_ingest.json``: ``last_success_at`` (the
31
+ watermark), plus the last attempt's outcome for ``GET /api/ui/github-ingest``.
32
+
33
+ Config — process env OR ``~/.agentos/.env`` (process env wins), all optional:
34
+ ``AGENTOS_GITHUB_INGEST`` ``0``/``false``/``no``/``off`` disables the cron.
35
+ ``AGENTOS_GITHUB_INGEST_AT`` daily UTC slot, ``HH:MM`` (default ``13:30``).
36
+ ``AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS`` catch-up cap (default 7, max 90).
37
+
38
+ Best-effort throughout: a failed run is recorded and retried on the next day's
39
+ slot; nothing here raises into the host process.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import json
45
+ import logging
46
+ import os
47
+ from datetime import date, datetime, timedelta, timezone
48
+ from pathlib import Path
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ if TYPE_CHECKING: # pragma: no cover — typing only
52
+ from sdk.client import BackendClient
53
+
54
+ logger = logging.getLogger("sdk.github_ingest")
55
+
56
+ STATE_FILE = Path.home() / ".agentos" / "github_ingest.json"
57
+
58
+ DEFAULT_AT = "13:30" # UTC — 18:30 Asia/Karachi (PKT = UTC+5, no DST)
59
+ DEFAULT_LOOKBACK_DAYS = 7
60
+ MAX_LOOKBACK_DAYS = 90
61
+ # First run on a machine (no watermark yet) pulls HISTORY, not just today — so a
62
+ # box that upgrades into this version seeds the platform with real PR history
63
+ # instead of starting empty. This is the auto-trigger: the state file does not
64
+ # exist on any machine yet, so the first tick after the upgrade backfills.
65
+ DEFAULT_BACKFILL_DAYS = 50
66
+ MAX_BACKFILL_DAYS = 365
67
+ # Identifies THIS rollout's one-time history seed. The backfill runs once per
68
+ # machine per id: a box that already has a watermark (it was ingesting before this
69
+ # release) still gets the 50-day seed exactly once, and no future upgrade repeats
70
+ # it. Bump this string ONLY to deliberately re-seed the whole fleet.
71
+ BACKFILL_ID = "2026-08-pr50"
72
+ _TICK_S = 900.0 # 15 min — resolution of the daily slot check
73
+ _FALSEY = {"0", "false", "no", "off"}
74
+
75
+
76
+ # ── Config ──────────────────────────────────────────────────────────────────
77
+
78
+ def _setting(name: str) -> str:
79
+ """Read a toggle from the process env OR ``~/.agentos/.env`` (process env wins)."""
80
+ val = (os.environ.get(name) or "").strip()
81
+ if val:
82
+ return val
83
+ try:
84
+ from sdk.config import _dotenv_value
85
+ return (_dotenv_value(name) or "").strip()
86
+ except Exception: # noqa: BLE001 — a config hiccup must never break the cron
87
+ return ""
88
+
89
+
90
+ def enabled() -> bool:
91
+ """True unless ``AGENTOS_GITHUB_INGEST`` is explicitly falsey (opt-OUT)."""
92
+ return _setting("AGENTOS_GITHUB_INGEST").lower() not in _FALSEY
93
+
94
+
95
+ def slot() -> tuple[int, int]:
96
+ """The daily UTC slot as ``(hour, minute)``. Bad values fall back to the default."""
97
+ raw = _setting("AGENTOS_GITHUB_INGEST_AT") or DEFAULT_AT
98
+ try:
99
+ hh, _, mm = raw.partition(":")
100
+ hour, minute = int(hh), int(mm or 0)
101
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
102
+ return hour, minute
103
+ except ValueError:
104
+ pass
105
+ logger.warning("github_ingest.bad_slot value=%r — using %s", raw, DEFAULT_AT)
106
+ hh, _, mm = DEFAULT_AT.partition(":")
107
+ return int(hh), int(mm)
108
+
109
+
110
+ def lookback_days() -> int:
111
+ """Catch-up cap in days — how far back a long-offline machine may ask for."""
112
+ raw = _setting("AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS")
113
+ if not raw:
114
+ return DEFAULT_LOOKBACK_DAYS
115
+ try:
116
+ return max(1, min(MAX_LOOKBACK_DAYS, int(raw)))
117
+ except ValueError:
118
+ logger.warning("github_ingest.bad_lookback value=%r — using %s",
119
+ raw, DEFAULT_LOOKBACK_DAYS)
120
+ return DEFAULT_LOOKBACK_DAYS
121
+
122
+
123
+ def backfill_days() -> int:
124
+ """How much history the FIRST run on this machine pulls (default 50 days).
125
+
126
+ Distinct from ``lookback_days()``: that bounds a routine catch-up after
127
+ downtime, this is the one-time seed. Set ``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS=0``
128
+ to skip the backfill and start from today.
129
+ """
130
+ raw = _setting("AGENTOS_GITHUB_INGEST_BACKFILL_DAYS")
131
+ if not raw:
132
+ return DEFAULT_BACKFILL_DAYS
133
+ try:
134
+ return max(0, min(MAX_BACKFILL_DAYS, int(raw)))
135
+ except ValueError:
136
+ logger.warning("github_ingest.bad_backfill value=%r — using %s",
137
+ raw, DEFAULT_BACKFILL_DAYS)
138
+ return DEFAULT_BACKFILL_DAYS
139
+
140
+
141
+ # ── State ───────────────────────────────────────────────────────────────────
142
+
143
+ def load_state() -> dict:
144
+ """Read the watermark file. Missing / corrupt → empty state (a first run)."""
145
+ try:
146
+ if STATE_FILE.exists():
147
+ data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
148
+ if isinstance(data, dict):
149
+ return data
150
+ except Exception as exc: # noqa: BLE001 — a bad state file must not wedge the cron
151
+ logger.warning("github_ingest.state_unreadable err=%s", exc)
152
+ return {}
153
+
154
+
155
+ def save_state(state: dict) -> None:
156
+ """Persist the watermark. Never raises — a lost write just re-runs tomorrow."""
157
+ try:
158
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
159
+ STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.warning("github_ingest.state_write_failed err=%s", exc)
162
+
163
+
164
+ # ── Scheduling decisions (pure — the interesting logic, kept testable) ──────
165
+
166
+ def backfill_pending(state: dict) -> bool:
167
+ """True when THIS rollout's one-time history seed has not run on this machine.
168
+
169
+ Keyed on ``BACKFILL_ID``, not on "is the state file empty" — so a machine that
170
+ was already ingesting daily before this release still seeds its history once,
171
+ and a machine that has seeded never does it again.
172
+ """
173
+ return backfill_days() > 0 and str(state.get("backfill_id") or "") != BACKFILL_ID
174
+
175
+
176
+ def is_due(now: datetime, state: dict) -> bool:
177
+ """Should a run happen right now?
178
+
179
+ Two reasons: the one-time backfill for this rollout is still pending (fires
180
+ immediately, without waiting for the daily slot — the history should land as
181
+ part of the update, not up to 24h later), or today's slot has passed with no
182
+ successful run recorded for today (UTC).
183
+ """
184
+ if backfill_pending(state):
185
+ return True
186
+ hour, minute = slot()
187
+ if (now.hour, now.minute) < (hour, minute):
188
+ return False
189
+ last = str(state.get("last_success_at") or "")
190
+ return last[:10] != now.date().isoformat()
191
+
192
+
193
+ def since_for(now: datetime, state: dict, days: int | None = None) -> str:
194
+ """The ``date_from`` to request.
195
+
196
+ Three cases, in order:
197
+ * ``days`` given — an explicit backfill (CLI / API): ``now - days``;
198
+ * this rollout's one-time seed still pending (``backfill_pending``) —
199
+ ``now - backfill_days()``, 50 days by default, so upgrading into this
200
+ version seeds history instead of starting blank;
201
+ * otherwise — the last success's date, floored at ``lookback_days()``.
202
+ Re-sending the last successful day is deliberate: a run that succeeded at
203
+ 13:30 did not see that day's later PRs. With no watermark and the seed
204
+ disabled (``…BACKFILL_DAYS=0``) it falls back to today.
205
+ """
206
+ if days is not None:
207
+ return (now - timedelta(days=max(0, days))).date().isoformat()
208
+
209
+ if backfill_pending(state):
210
+ return (now - timedelta(days=backfill_days())).date().isoformat()
211
+
212
+ last = str(state.get("last_success_at") or "")[:10]
213
+ if not last:
214
+ return now.date().isoformat()
215
+ try:
216
+ parsed = date.fromisoformat(last)
217
+ except ValueError:
218
+ return now.date().isoformat()
219
+ floor = (now - timedelta(days=lookback_days())).date()
220
+ return max(parsed, floor).isoformat()
221
+
222
+
223
+ # ── One run ─────────────────────────────────────────────────────────────────
224
+
225
+ async def run_once(client: "BackendClient | None" = None, *, now: datetime | None = None,
226
+ days: int | None = None) -> dict:
227
+ """Resolve this user's GitHub link, POST the ingest request, record the outcome.
228
+
229
+ ``days`` forces an explicit window (``now - days`` → today) instead of the
230
+ watermark — that is what ``agentos github backfill --days 50`` and
231
+ ``POST /api/ui/github-ingest/run?days=50`` use. It still advances the watermark
232
+ on success, so a manual backfill also satisfies that day's scheduled run.
233
+
234
+ Returns ``{ok, status, since, job_id, reason}``. ``status`` is ``queued``,
235
+ ``no_github_link`` (this user never connected GitHub — not a failure), or
236
+ ``failed``. Never raises.
237
+ """
238
+ stamp = (now or datetime.now(timezone.utc))
239
+ state = load_state()
240
+ since = since_for(stamp, state, days)
241
+ out: dict[str, Any] = {"ok": False, "status": "failed", "since": since,
242
+ "job_id": "", "reason": ""}
243
+ try:
244
+ if client is None:
245
+ from sdk.client import BackendClient
246
+ client = BackendClient.from_config()
247
+
248
+ record = await client.github_token_record()
249
+ username = str((record or {}).get("username") or "").strip()
250
+ access_token = str((record or {}).get("accessToken") or "").strip()
251
+ if not username or not access_token:
252
+ # The common case for a user who never connected GitHub. Record it and
253
+ # move on — retrying every tick would spam the gateway for no reason.
254
+ out.update({"ok": True, "status": "no_github_link"})
255
+ logger.info("github_ingest.no_github_link")
256
+ state.update({"last_run_at": stamp.isoformat(), "last_status": "no_github_link",
257
+ "backfill_id": BACKFILL_ID})
258
+ save_state(state)
259
+ return out
260
+
261
+ resp = await client.ingest_github_prs(
262
+ github_username=username, github_access_token=access_token, since=since,
263
+ )
264
+ if resp is None:
265
+ out["reason"] = "backend_rejected_or_unreachable"
266
+ state.update({"last_run_at": stamp.isoformat(), "last_status": "failed",
267
+ "last_error": out["reason"]})
268
+ save_state(state)
269
+ logger.warning("github_ingest.failed since=%s", since)
270
+ return out
271
+
272
+ job_id = str(resp.get("job_id") or resp.get("jobId") or "")
273
+ # The backend returns its own poll path (`/api/v1/github/prs/status/{id}`) —
274
+ # keep it verbatim so an operator can follow a queued job without
275
+ # reconstructing the URL.
276
+ status_url = str(resp.get("status_url") or "")
277
+ out.update({"ok": True, "status": "queued", "job_id": job_id,
278
+ "status_url": status_url})
279
+ # Watermark advances on ACCEPTANCE (202 + job_id), not on completion — the
280
+ # endpoint is fire-and-forget. A job that then fails backend-side is visible
281
+ # in its own job status; re-running the whole window daily would be worse.
282
+ state.update({
283
+ "last_success_at": stamp.isoformat(),
284
+ "last_run_at": stamp.isoformat(),
285
+ "last_status": "queued",
286
+ "last_job_id": job_id,
287
+ "last_status_url": status_url,
288
+ "last_since": since,
289
+ "last_error": "",
290
+ # Marks this rollout's one-time seed as done. Stamped on ANY accepted
291
+ # run — the first accepted run IS the seed (its window was the 50 days),
292
+ # so there is nothing left to catch up.
293
+ "backfill_id": BACKFILL_ID,
294
+ })
295
+ save_state(state)
296
+ logger.info("github_ingest.queued job_id=%s since=%s", job_id, since)
297
+ return out
298
+ except Exception as exc: # noqa: BLE001 — the cron must never take the panel down
299
+ out["reason"] = str(exc)[:200]
300
+ logger.warning("github_ingest.error err=%s", exc)
301
+ return out
302
+
303
+
304
+ def status() -> dict:
305
+ """Config + watermark + last outcome — surfaced by ``GET /api/ui/github-ingest``."""
306
+ hour, minute = slot()
307
+ state = load_state()
308
+ return {
309
+ "enabled": enabled(),
310
+ "slot_utc": f"{hour:02d}:{minute:02d}",
311
+ "lookback_days": lookback_days(),
312
+ "backfill_days": backfill_days(),
313
+ "backfill_id": BACKFILL_ID,
314
+ "backfill_pending": backfill_pending(state), # True → the next run seeds history
315
+ "backfilled": not backfill_pending(state),
316
+ "state": state,
317
+ }
318
+
319
+
320
+ # ── The cron loop ───────────────────────────────────────────────────────────
321
+
322
+ async def run_forever(tick_s: float = _TICK_S) -> None:
323
+ """Tick until cancelled; run when today's slot has passed and today has no run."""
324
+ hour, minute = slot()
325
+ logger.info("github_ingest.cron_start slot_utc=%02d:%02d tick_s=%s", hour, minute, tick_s)
326
+ try:
327
+ while True:
328
+ try:
329
+ now = datetime.now(timezone.utc)
330
+ if is_due(now, load_state()):
331
+ await run_once(now=now)
332
+ except Exception as exc: # noqa: BLE001 — one bad tick must not end the loop
333
+ logger.warning("github_ingest.tick_failed err=%s", exc)
334
+ await asyncio.sleep(tick_s)
335
+ except asyncio.CancelledError: # pragma: no cover — shutdown path
336
+ logger.info("github_ingest.cron_stop")
337
+ raise
338
+
339
+
340
+ def start() -> "asyncio.Task | None":
341
+ """Start the cron as a background task when enabled. Returns the task (or None)."""
342
+ if not enabled():
343
+ logger.info("github_ingest.disabled (AGENTOS_GITHUB_INGEST is off)")
344
+ return None
345
+ return asyncio.ensure_future(run_forever())
@@ -207,6 +207,40 @@ async def vps_allocation_report() -> dict:
207
207
  return {"ok": ok, **vps_health.last_report()}
208
208
 
209
209
 
210
+ @router.get("/github-ingest", dependencies=[_auth])
211
+ async def github_ingest_status() -> dict:
212
+ """State of the daily GitHub PR ingestion cron: enabled?, slot, watermark.
213
+
214
+ This machine schedules PR ingestion for ITS user (replacing the AI backend's
215
+ org-wide `github-prs-daily` beat entry). ``state.last_success_at`` is the
216
+ catch-up watermark — the next run asks the backend for PRs since that date.
217
+ """
218
+ from sdk import github_ingest
219
+
220
+ return github_ingest.status()
221
+
222
+
223
+ @router.post("/github-ingest/run", dependencies=[_auth])
224
+ async def github_ingest_run(
225
+ days: int | None = Query(
226
+ None, ge=0, le=365,
227
+ description="Force an N-day window (a backfill) instead of the watermark.",
228
+ ),
229
+ ) -> dict:
230
+ """Trigger ONE ingestion now, out of band from the daily slot.
231
+
232
+ For QA / first-boot verification, catching up manually, and backfilling:
233
+ ``?days=50`` asks the backend for the last 50 days of PRs rather than the
234
+ incremental window. Returns ``{ok, status, since, job_id}``; ``status`` is
235
+ ``queued``, ``no_github_link``, or ``failed``. Poll the backend's
236
+ ``GET /api/v1/github/prs/status/{job_id}`` for what happened to a queued job.
237
+ """
238
+ from sdk import github_ingest
239
+
240
+ result = await github_ingest.run_once(days=days)
241
+ return {**result, "cron": github_ingest.status()}
242
+
243
+
210
244
  # ── IP allowlist management ─────────────────────────────────────────────────
211
245
  # Whitelist a client IP for THIS SDK. A whitelisted IP passes the IP gate for
212
246
  # EVERY path (all /api/ui/* endpoints + the UI + /docs); data endpoints still