devops-bot-sdk 1.4.159__tar.gz → 1.6.2__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.6.2}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/cli.py +61 -1
  6. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/client.py +78 -10
  7. devops_bot_sdk-1.6.2/sdk/github_ingest.py +408 -0
  8. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/run_auto.py +41 -0
  9. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/api.py +34 -0
  10. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/server.py +24 -13
  11. devops_bot_sdk-1.6.2/tests/test_github_ingest.py +396 -0
  12. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/README.md +0 -0
  13. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  14. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  15. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/requires.txt +0 -0
  16. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  17. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/pyproject.toml +0 -0
  18. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agentd.py +0 -0
  19. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/__init__.py +0 -0
  20. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/activity.py +0 -0
  21. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/builder.py +0 -0
  22. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/chat.py +0 -0
  23. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/connectors.py +0 -0
  24. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/claude_code.py +0 -0
  26. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/http.py +0 -0
  27. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/mcp.py +0 -0
  28. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/n8n.py +0 -0
  29. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/shell.py +0 -0
  30. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/integrations.py +0 -0
  31. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/inventory.py +0 -0
  32. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/library/__init__.py +0 -0
  33. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/manifest.py +0 -0
  34. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_studio.py +0 -0
  35. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_template.py +0 -0
  36. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_vars.py +0 -0
  37. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/run_log.py +0 -0
  38. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/secrets.py +0 -0
  39. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/ticket_run.py +0 -0
  40. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/usage.py +0 -0
  41. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/varstore.py +0 -0
  42. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/__init__.py +0 -0
  43. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/files.py +0 -0
  44. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/process.py +0 -0
  45. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/screenshot.py +0 -0
  46. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/config.py +0 -0
  47. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/crucial.py +0 -0
  48. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/__init__.py +0 -0
  49. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/assets.py +0 -0
  50. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/bootstrap.py +0 -0
  51. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/browser.py +0 -0
  52. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/compare.py +0 -0
  53. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/loop.py +0 -0
  54. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/exceptions.py +0 -0
  55. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/git_ops.py +0 -0
  56. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/graphify.py +0 -0
  57. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/hooks/__init__.py +0 -0
  58. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/hooks/crucial_guard.py +0 -0
  59. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/__init__.py +0 -0
  60. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/electron_bridge.py +0 -0
  61. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/handlers.py +0 -0
  62. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/local_exec.py +0 -0
  63. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/__init__.py +0 -0
  64. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/envelope.py +0 -0
  65. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/requests.py +0 -0
  66. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/responses.py +0 -0
  67. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/snapshots.py +0 -0
  68. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/platform_compat.py +0 -0
  69. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/py.typed +0 -0
  70. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/sse.py +0 -0
  71. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/test.py +0 -0
  72. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/test_pipeline.py +0 -0
  73. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/__init__.py +0 -0
  74. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/dashboard.py +0 -0
  75. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/health.py +0 -0
  76. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/ip_allowlist.py +0 -0
  77. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/jobs.py +0 -0
  78. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/models.py +0 -0
  79. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/runner.py +0 -0
  80. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/security.py +0 -0
  81. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/service.py +0 -0
  82. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/session.py +0 -0
  83. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/setup_manager.py +0 -0
  84. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/agents.html +0 -0
  85. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/app.js +0 -0
  86. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/custom-agents.html +0 -0
  87. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/default-agents.html +0 -0
  88. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/index.html +0 -0
  89. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/login.html +0 -0
  90. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/runs.html +0 -0
  91. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/setup.html +0 -0
  92. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/styles.css +0 -0
  93. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/tickets.html +0 -0
  94. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/updater.py +0 -0
  95. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/vps_health.py +0 -0
  96. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/setup.cfg +0 -0
  97. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_activity.py +0 -0
  98. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_chat_features.py +0 -0
  99. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_prompt_async.py +0 -0
  100. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_prompt_models.py +0 -0
  101. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_run_ticket.py +0 -0
  102. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agents_connectors_builder.py +0 -0
  103. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_health.py +0 -0
  105. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_otp_login.py +0 -0
  106. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_updater_extras.py +0 -0
  107. {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/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.6.2
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.6.2
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.6.2
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.6.2" # 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,408 @@
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
+ **Two things run, both on by default, no configuration needed:**
17
+ 1. a ONE-TIME 50-day history backfill, fired from ``agentos-auto update`` via
18
+ :func:`run_backfill_once` and guarded by ``BACKFILL_ID`` so it happens at
19
+ most once per machine;
20
+ 2. the DAILY loop below, started by the ``agentos-ui`` lifespan alongside the
21
+ 2-hourly ``sdk.vps_health`` ping.
22
+
23
+ The daily loop also covers the backfill: if a box never runs ``agentos-auto
24
+ update``, the first tick sees ``backfill_pending`` and seeds the history itself.
25
+
26
+ Schedule semantics — a real daily slot, not a naive interval:
27
+ * the loop ticks every ``_TICK_S`` (cheap: no network unless a run is due),
28
+ * a run is DUE when the UTC clock has passed ``AGENTOS_GITHUB_INGEST_AT``
29
+ (default ``13:30`` UTC = 18:30 Asia/Karachi, the slot the beat entry used) and
30
+ no successful run has been recorded for today's UTC date,
31
+ * so a machine that was asleep at 13:30 catches up the moment it comes back —
32
+ the failure mode that made this the riskiest part of the migration.
33
+
34
+ Catch-up window: the request carries ``since`` = the date of the last SUCCESSFUL
35
+ run (capped at ``_MAX_LOOKBACK_DAYS``), not "today". A box that was off for three
36
+ days asks for three days of PRs on its next run instead of silently losing them.
37
+ The backend's ingestion is expected to be idempotent per (user, repo, date) —
38
+ overlapping windows WILL re-send PRs.
39
+
40
+ State lives in ``~/.agentos/github_ingest.json``: ``last_success_at`` (the
41
+ watermark), plus the last attempt's outcome for ``GET /api/ui/github-ingest``.
42
+
43
+ Config — process env OR ``~/.agentos/.env`` (process env wins), all optional:
44
+ ``AGENTOS_GITHUB_INGEST`` ``0``/``false``/``no``/``off`` disables everything.
45
+ ``AGENTOS_GITHUB_INGEST_DAILY`` ``0`` turns the recurring daily loop OFF (default ON).
46
+ ``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS`` one-time seed size (default 50, max 365).
47
+ ``AGENTOS_GITHUB_INGEST_AT`` daily UTC slot, ``HH:MM`` (default ``13:30``).
48
+ ``AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS`` catch-up cap (default 7, max 90).
49
+
50
+ Best-effort throughout: a failed run is recorded and retried on the next day's
51
+ slot; nothing here raises into the host process.
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import asyncio
56
+ import json
57
+ import logging
58
+ import os
59
+ from datetime import date, datetime, timedelta, timezone
60
+ from pathlib import Path
61
+ from typing import TYPE_CHECKING, Any
62
+
63
+ if TYPE_CHECKING: # pragma: no cover — typing only
64
+ from sdk.client import BackendClient
65
+
66
+ logger = logging.getLogger("sdk.github_ingest")
67
+
68
+ STATE_FILE = Path.home() / ".agentos" / "github_ingest.json"
69
+
70
+ DEFAULT_AT = "13:30" # UTC — 18:30 Asia/Karachi (PKT = UTC+5, no DST)
71
+ DEFAULT_LOOKBACK_DAYS = 7
72
+ MAX_LOOKBACK_DAYS = 90
73
+ # First run on a machine (no watermark yet) pulls HISTORY, not just today — so a
74
+ # box that upgrades into this version seeds the platform with real PR history
75
+ # instead of starting empty. This is the auto-trigger: the state file does not
76
+ # exist on any machine yet, so the first tick after the upgrade backfills.
77
+ DEFAULT_BACKFILL_DAYS = 50
78
+ MAX_BACKFILL_DAYS = 365
79
+ # Identifies THIS rollout's one-time history seed. The backfill runs once per
80
+ # machine per id: a box that already has a watermark (it was ingesting before this
81
+ # release) still gets the 50-day seed exactly once, and no future upgrade repeats
82
+ # it. Bump this string ONLY to deliberately re-seed the whole fleet.
83
+ BACKFILL_ID = "2026-08-pr50"
84
+ _TICK_S = 900.0 # 15 min — resolution of the daily slot check
85
+ _FALSEY = {"0", "false", "no", "off"}
86
+
87
+
88
+ # ── Config ──────────────────────────────────────────────────────────────────
89
+
90
+ def _setting(name: str) -> str:
91
+ """Read a toggle from the process env OR ``~/.agentos/.env`` (process env wins)."""
92
+ val = (os.environ.get(name) or "").strip()
93
+ if val:
94
+ return val
95
+ try:
96
+ from sdk.config import _dotenv_value
97
+ return (_dotenv_value(name) or "").strip()
98
+ except Exception: # noqa: BLE001 — a config hiccup must never break the cron
99
+ return ""
100
+
101
+
102
+ def enabled() -> bool:
103
+ """Master switch — False only when ``AGENTOS_GITHUB_INGEST`` is explicitly falsey.
104
+
105
+ Gates BOTH the one-time backfill and the daily loop.
106
+ """
107
+ return _setting("AGENTOS_GITHUB_INGEST").lower() not in _FALSEY
108
+
109
+
110
+ def daily_enabled() -> bool:
111
+ """Is the RECURRING daily loop switched on? **Default: yes.**
112
+
113
+ The steady state is two schedules running side by side on every box, with no
114
+ configuration required: ``sdk.vps_health`` every 2 hours, and this one daily
115
+ at :func:`slot`. Opt OUT with ``AGENTOS_GITHUB_INGEST_DAILY=0`` (or turn off
116
+ GitHub ingestion entirely with ``AGENTOS_GITHUB_INGEST=0``).
117
+ """
118
+ return _setting("AGENTOS_GITHUB_INGEST_DAILY").lower() not in _FALSEY
119
+
120
+
121
+ def slot() -> tuple[int, int]:
122
+ """The daily UTC slot as ``(hour, minute)``. Bad values fall back to the default."""
123
+ raw = _setting("AGENTOS_GITHUB_INGEST_AT") or DEFAULT_AT
124
+ try:
125
+ hh, _, mm = raw.partition(":")
126
+ hour, minute = int(hh), int(mm or 0)
127
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
128
+ return hour, minute
129
+ except ValueError:
130
+ pass
131
+ logger.warning("github_ingest.bad_slot value=%r — using %s", raw, DEFAULT_AT)
132
+ hh, _, mm = DEFAULT_AT.partition(":")
133
+ return int(hh), int(mm)
134
+
135
+
136
+ def lookback_days() -> int:
137
+ """Catch-up cap in days — how far back a long-offline machine may ask for."""
138
+ raw = _setting("AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS")
139
+ if not raw:
140
+ return DEFAULT_LOOKBACK_DAYS
141
+ try:
142
+ return max(1, min(MAX_LOOKBACK_DAYS, int(raw)))
143
+ except ValueError:
144
+ logger.warning("github_ingest.bad_lookback value=%r — using %s",
145
+ raw, DEFAULT_LOOKBACK_DAYS)
146
+ return DEFAULT_LOOKBACK_DAYS
147
+
148
+
149
+ def backfill_days() -> int:
150
+ """How much history the FIRST run on this machine pulls (default 50 days).
151
+
152
+ Distinct from ``lookback_days()``: that bounds a routine catch-up after
153
+ downtime, this is the one-time seed. Set ``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS=0``
154
+ to skip the backfill and start from today.
155
+ """
156
+ raw = _setting("AGENTOS_GITHUB_INGEST_BACKFILL_DAYS")
157
+ if not raw:
158
+ return DEFAULT_BACKFILL_DAYS
159
+ try:
160
+ return max(0, min(MAX_BACKFILL_DAYS, int(raw)))
161
+ except ValueError:
162
+ logger.warning("github_ingest.bad_backfill value=%r — using %s",
163
+ raw, DEFAULT_BACKFILL_DAYS)
164
+ return DEFAULT_BACKFILL_DAYS
165
+
166
+
167
+ # ── State ───────────────────────────────────────────────────────────────────
168
+
169
+ def load_state() -> dict:
170
+ """Read the watermark file. Missing / corrupt → empty state (a first run)."""
171
+ try:
172
+ if STATE_FILE.exists():
173
+ data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
174
+ if isinstance(data, dict):
175
+ return data
176
+ except Exception as exc: # noqa: BLE001 — a bad state file must not wedge the cron
177
+ logger.warning("github_ingest.state_unreadable err=%s", exc)
178
+ return {}
179
+
180
+
181
+ def save_state(state: dict) -> None:
182
+ """Persist the watermark. Never raises — a lost write just re-runs tomorrow."""
183
+ try:
184
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
185
+ STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
186
+ except Exception as exc: # noqa: BLE001
187
+ logger.warning("github_ingest.state_write_failed err=%s", exc)
188
+
189
+
190
+ # ── Scheduling decisions (pure — the interesting logic, kept testable) ──────
191
+
192
+ def backfill_pending(state: dict) -> bool:
193
+ """True when THIS rollout's one-time history seed has not run on this machine.
194
+
195
+ Keyed on ``BACKFILL_ID``, not on "is the state file empty" — so a machine that
196
+ was already ingesting daily before this release still seeds its history once,
197
+ and a machine that has seeded never does it again.
198
+ """
199
+ return backfill_days() > 0 and str(state.get("backfill_id") or "") != BACKFILL_ID
200
+
201
+
202
+ def is_due(now: datetime, state: dict) -> bool:
203
+ """Should a run happen right now?
204
+
205
+ Two reasons: the one-time backfill for this rollout is still pending (fires
206
+ immediately, without waiting for the daily slot — the history should land as
207
+ part of the update, not up to 24h later), or today's slot has passed with no
208
+ successful run recorded for today (UTC).
209
+ """
210
+ if backfill_pending(state):
211
+ return True
212
+ hour, minute = slot()
213
+ if (now.hour, now.minute) < (hour, minute):
214
+ return False
215
+ last = str(state.get("last_success_at") or "")
216
+ return last[:10] != now.date().isoformat()
217
+
218
+
219
+ def since_for(now: datetime, state: dict, days: int | None = None) -> str:
220
+ """The ``date_from`` to request.
221
+
222
+ Three cases, in order:
223
+ * ``days`` given — an explicit backfill (CLI / API): ``now - days``;
224
+ * this rollout's one-time seed still pending (``backfill_pending``) —
225
+ ``now - backfill_days()``, 50 days by default, so upgrading into this
226
+ version seeds history instead of starting blank;
227
+ * otherwise — the last success's date, floored at ``lookback_days()``.
228
+ Re-sending the last successful day is deliberate: a run that succeeded at
229
+ 13:30 did not see that day's later PRs. With no watermark and the seed
230
+ disabled (``…BACKFILL_DAYS=0``) it falls back to today.
231
+ """
232
+ if days is not None:
233
+ return (now - timedelta(days=max(0, days))).date().isoformat()
234
+
235
+ if backfill_pending(state):
236
+ return (now - timedelta(days=backfill_days())).date().isoformat()
237
+
238
+ last = str(state.get("last_success_at") or "")[:10]
239
+ if not last:
240
+ return now.date().isoformat()
241
+ try:
242
+ parsed = date.fromisoformat(last)
243
+ except ValueError:
244
+ return now.date().isoformat()
245
+ floor = (now - timedelta(days=lookback_days())).date()
246
+ return max(parsed, floor).isoformat()
247
+
248
+
249
+ # ── One run ─────────────────────────────────────────────────────────────────
250
+
251
+ async def run_once(client: "BackendClient | None" = None, *, now: datetime | None = None,
252
+ days: int | None = None) -> dict:
253
+ """Resolve this user's GitHub link, POST the ingest request, record the outcome.
254
+
255
+ ``days`` forces an explicit window (``now - days`` → today) instead of the
256
+ watermark — that is what ``agentos github backfill --days 50`` and
257
+ ``POST /api/ui/github-ingest/run?days=50`` use. It still advances the watermark
258
+ on success, so a manual backfill also satisfies that day's scheduled run.
259
+
260
+ Returns ``{ok, status, since, job_id, reason}``. ``status`` is ``queued``,
261
+ ``no_github_link`` (this user never connected GitHub — not a failure), or
262
+ ``failed``. Never raises.
263
+ """
264
+ stamp = (now or datetime.now(timezone.utc))
265
+ state = load_state()
266
+ since = since_for(stamp, state, days)
267
+ out: dict[str, Any] = {"ok": False, "status": "failed", "since": since,
268
+ "job_id": "", "reason": ""}
269
+ try:
270
+ if client is None:
271
+ from sdk.client import BackendClient
272
+ client = BackendClient.from_config()
273
+
274
+ record = await client.github_token_record()
275
+ username = str((record or {}).get("username") or "").strip()
276
+ access_token = str((record or {}).get("accessToken") or "").strip()
277
+ if not username or not access_token:
278
+ # The common case for a user who never connected GitHub. Record it and
279
+ # move on — retrying every tick would spam the gateway for no reason.
280
+ out.update({"ok": True, "status": "no_github_link"})
281
+ logger.info("github_ingest.no_github_link")
282
+ state.update({"last_run_at": stamp.isoformat(), "last_status": "no_github_link",
283
+ "backfill_id": BACKFILL_ID})
284
+ save_state(state)
285
+ return out
286
+
287
+ resp = await client.ingest_github_prs(
288
+ github_username=username, github_access_token=access_token, since=since,
289
+ )
290
+ if resp is None:
291
+ out["reason"] = "backend_rejected_or_unreachable"
292
+ state.update({"last_run_at": stamp.isoformat(), "last_status": "failed",
293
+ "last_error": out["reason"]})
294
+ save_state(state)
295
+ logger.warning("github_ingest.failed since=%s", since)
296
+ return out
297
+
298
+ job_id = str(resp.get("job_id") or resp.get("jobId") or "")
299
+ # The backend returns its own poll path (`/api/v1/github/prs/status/{id}`) —
300
+ # keep it verbatim so an operator can follow a queued job without
301
+ # reconstructing the URL.
302
+ status_url = str(resp.get("status_url") or "")
303
+ out.update({"ok": True, "status": "queued", "job_id": job_id,
304
+ "status_url": status_url})
305
+ # Watermark advances on ACCEPTANCE (202 + job_id), not on completion — the
306
+ # endpoint is fire-and-forget. A job that then fails backend-side is visible
307
+ # in its own job status; re-running the whole window daily would be worse.
308
+ state.update({
309
+ "last_success_at": stamp.isoformat(),
310
+ "last_run_at": stamp.isoformat(),
311
+ "last_status": "queued",
312
+ "last_job_id": job_id,
313
+ "last_status_url": status_url,
314
+ "last_since": since,
315
+ "last_error": "",
316
+ # Marks this rollout's one-time seed as done. Stamped on ANY accepted
317
+ # run — the first accepted run IS the seed (its window was the 50 days),
318
+ # so there is nothing left to catch up.
319
+ "backfill_id": BACKFILL_ID,
320
+ })
321
+ save_state(state)
322
+ logger.info("github_ingest.queued job_id=%s since=%s", job_id, since)
323
+ return out
324
+ except Exception as exc: # noqa: BLE001 — the cron must never take the panel down
325
+ out["reason"] = str(exc)[:200]
326
+ logger.warning("github_ingest.error err=%s", exc)
327
+ return out
328
+
329
+
330
+ def status() -> dict:
331
+ """Config + watermark + last outcome — surfaced by ``GET /api/ui/github-ingest``."""
332
+ hour, minute = slot()
333
+ state = load_state()
334
+ return {
335
+ "enabled": enabled(),
336
+ "daily_loop": daily_enabled(),
337
+ "slot_utc": f"{hour:02d}:{minute:02d}",
338
+ "lookback_days": lookback_days(),
339
+ "backfill_days": backfill_days(),
340
+ "backfill_id": BACKFILL_ID,
341
+ "backfill_pending": backfill_pending(state), # True → the next run seeds history
342
+ "backfilled": not backfill_pending(state),
343
+ "state": state,
344
+ }
345
+
346
+
347
+ # ── The cron loop ───────────────────────────────────────────────────────────
348
+
349
+ async def run_forever(tick_s: float = _TICK_S) -> None:
350
+ """Tick until cancelled; run when today's slot has passed and today has no run."""
351
+ hour, minute = slot()
352
+ logger.info("github_ingest.cron_start slot_utc=%02d:%02d tick_s=%s", hour, minute, tick_s)
353
+ try:
354
+ while True:
355
+ try:
356
+ now = datetime.now(timezone.utc)
357
+ if is_due(now, load_state()):
358
+ await run_once(now=now)
359
+ except Exception as exc: # noqa: BLE001 — one bad tick must not end the loop
360
+ logger.warning("github_ingest.tick_failed err=%s", exc)
361
+ await asyncio.sleep(tick_s)
362
+ except asyncio.CancelledError: # pragma: no cover — shutdown path
363
+ logger.info("github_ingest.cron_stop")
364
+ raise
365
+
366
+
367
+ def start() -> "asyncio.Task | None":
368
+ """Start the RECURRING daily loop. On by default; returns the task, or None
369
+ when GitHub ingestion (or just the daily loop) has been switched off."""
370
+ if not enabled():
371
+ logger.info("github_ingest.disabled (AGENTOS_GITHUB_INGEST is off)")
372
+ return None
373
+ if not daily_enabled():
374
+ logger.info("github_ingest.daily_loop_off (AGENTOS_GITHUB_INGEST_DAILY is off)")
375
+ return None
376
+ return asyncio.ensure_future(run_forever())
377
+
378
+
379
+ # ── The one-time backfill (what `agentos-auto update` fires) ────────────────
380
+
381
+ def run_backfill_once(client: "BackendClient | None" = None) -> dict:
382
+ """Run THIS rollout's 50-day history backfill — at most once per machine.
383
+
384
+ Called from ``agentos-auto update``. Safe to call on every update: the
385
+ ``BACKFILL_ID`` marker in ``~/.agentos/github_ingest.json`` means the second
386
+ and later calls are a no-op, so a user who updates weekly still ingests the
387
+ history exactly once.
388
+
389
+ Synchronous by design — the update command is a plain CLI flow, not an event
390
+ loop. Returns ``{ran, status, since, job_id, status_url, reason}``;
391
+ ``ran=False`` with ``status="already_done"`` when the marker is already set,
392
+ or ``"disabled"`` when the feature is switched off. Never raises: a failed
393
+ backfill must not fail the update.
394
+ """
395
+ if not enabled():
396
+ return {"ran": False, "status": "disabled", "reason": "AGENTOS_GITHUB_INGEST is off"}
397
+
398
+ state = load_state()
399
+ if not backfill_pending(state):
400
+ return {"ran": False, "status": "already_done",
401
+ "since": state.get("last_since", ""), "job_id": state.get("last_job_id", "")}
402
+
403
+ try:
404
+ result = asyncio.run(run_once(client, days=backfill_days()))
405
+ except Exception as exc: # noqa: BLE001 — never fail `agentos-auto update`
406
+ logger.warning("github_ingest.backfill_failed err=%s", exc)
407
+ return {"ran": True, "status": "failed", "reason": str(exc)[:200]}
408
+ return {"ran": True, **result}