feed-the-machine 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +170 -170
  3. package/bin/brain.py +1340 -0
  4. package/bin/convert_claude_skills_to_codex.py +490 -0
  5. package/bin/generate-manifest.mjs +463 -463
  6. package/bin/harden_codex_skills.py +141 -0
  7. package/bin/install.mjs +491 -491
  8. package/bin/migrate-eng-buddy-data.py +875 -0
  9. package/bin/playbook_engine/__init__.py +1 -0
  10. package/bin/playbook_engine/conftest.py +8 -0
  11. package/bin/playbook_engine/extractor.py +33 -0
  12. package/bin/playbook_engine/manager.py +102 -0
  13. package/bin/playbook_engine/models.py +84 -0
  14. package/bin/playbook_engine/registry.py +35 -0
  15. package/bin/playbook_engine/test_extractor.py +72 -0
  16. package/bin/playbook_engine/test_integration.py +129 -0
  17. package/bin/playbook_engine/test_manager.py +85 -0
  18. package/bin/playbook_engine/test_models.py +166 -0
  19. package/bin/playbook_engine/test_registry.py +67 -0
  20. package/bin/playbook_engine/test_tracer.py +86 -0
  21. package/bin/playbook_engine/tracer.py +93 -0
  22. package/bin/tasks_db.py +456 -0
  23. package/docs/HOOKS.md +243 -243
  24. package/docs/INBOX.md +233 -233
  25. package/ftm/SKILL.md +125 -122
  26. package/ftm-audit/SKILL.md +623 -623
  27. package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
  28. package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
  29. package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
  30. package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
  31. package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
  32. package/ftm-audit/scripts/run-knip.sh +23 -23
  33. package/ftm-audit.yml +2 -2
  34. package/ftm-brainstorm/SKILL.md +1003 -498
  35. package/ftm-brainstorm/evals/evals.json +180 -100
  36. package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
  37. package/ftm-brainstorm/references/agent-prompts.md +552 -224
  38. package/ftm-brainstorm/references/plan-template.md +209 -121
  39. package/ftm-brainstorm.yml +2 -2
  40. package/ftm-browse/SKILL.md +454 -454
  41. package/ftm-browse/daemon/browser-manager.ts +206 -206
  42. package/ftm-browse/daemon/bun.lock +30 -30
  43. package/ftm-browse/daemon/cli.ts +347 -347
  44. package/ftm-browse/daemon/commands.ts +410 -410
  45. package/ftm-browse/daemon/main.ts +357 -357
  46. package/ftm-browse/daemon/package.json +17 -17
  47. package/ftm-browse/daemon/server.ts +189 -189
  48. package/ftm-browse/daemon/snapshot.ts +519 -519
  49. package/ftm-browse/daemon/tsconfig.json +22 -22
  50. package/ftm-browse.yml +4 -4
  51. package/ftm-capture/SKILL.md +370 -370
  52. package/ftm-capture.yml +4 -4
  53. package/ftm-codex-gate/SKILL.md +361 -361
  54. package/ftm-codex-gate.yml +2 -2
  55. package/ftm-config/SKILL.md +422 -345
  56. package/ftm-config.default.yml +125 -82
  57. package/ftm-config.yml +44 -2
  58. package/ftm-council/SKILL.md +416 -416
  59. package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
  60. package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
  61. package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
  62. package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
  63. package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
  64. package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
  65. package/ftm-council.yml +2 -2
  66. package/ftm-dashboard/SKILL.md +163 -163
  67. package/ftm-dashboard.yml +4 -4
  68. package/ftm-debug/SKILL.md +1037 -1037
  69. package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
  70. package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
  71. package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
  72. package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
  73. package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
  74. package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
  75. package/ftm-debug.yml +2 -2
  76. package/ftm-diagram/SKILL.md +277 -277
  77. package/ftm-diagram.yml +2 -2
  78. package/ftm-executor/SKILL.md +777 -777
  79. package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
  80. package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
  81. package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
  82. package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
  83. package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
  84. package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
  85. package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
  86. package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
  87. package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
  88. package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
  89. package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
  90. package/ftm-executor/runtime/package.json +8 -8
  91. package/ftm-executor.yml +2 -2
  92. package/ftm-git/SKILL.md +441 -441
  93. package/ftm-git/evals/evals.json +26 -26
  94. package/ftm-git/evals/promptfoo.yaml +75 -75
  95. package/ftm-git/hooks/post-commit-experience.sh +92 -92
  96. package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
  97. package/ftm-git/references/protocols/REMEDIATION.md +139 -139
  98. package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
  99. package/ftm-git.yml +2 -2
  100. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  101. package/ftm-inbox/backend/adapters/_retry.py +64 -64
  102. package/ftm-inbox/backend/adapters/base.py +230 -230
  103. package/ftm-inbox/backend/adapters/freshservice.py +104 -104
  104. package/ftm-inbox/backend/adapters/gmail.py +125 -125
  105. package/ftm-inbox/backend/adapters/jira.py +136 -136
  106. package/ftm-inbox/backend/adapters/registry.py +192 -192
  107. package/ftm-inbox/backend/adapters/slack.py +110 -110
  108. package/ftm-inbox/backend/db/connection.py +54 -54
  109. package/ftm-inbox/backend/db/schema.py +78 -78
  110. package/ftm-inbox/backend/executor/__init__.py +7 -7
  111. package/ftm-inbox/backend/executor/engine.py +149 -149
  112. package/ftm-inbox/backend/executor/step_runner.py +98 -98
  113. package/ftm-inbox/backend/main.py +103 -103
  114. package/ftm-inbox/backend/models/__init__.py +1 -1
  115. package/ftm-inbox/backend/models/unified_task.py +36 -36
  116. package/ftm-inbox/backend/planner/__init__.py +6 -6
  117. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  119. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  120. package/ftm-inbox/backend/planner/generator.py +127 -127
  121. package/ftm-inbox/backend/planner/schema.py +34 -34
  122. package/ftm-inbox/backend/requirements.txt +5 -5
  123. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  124. package/ftm-inbox/backend/routes/execute.py +186 -186
  125. package/ftm-inbox/backend/routes/health.py +52 -52
  126. package/ftm-inbox/backend/routes/inbox.py +68 -68
  127. package/ftm-inbox/backend/routes/plan.py +271 -271
  128. package/ftm-inbox/bin/launchagent.mjs +91 -91
  129. package/ftm-inbox/bin/setup.mjs +188 -188
  130. package/ftm-inbox/bin/start.sh +10 -10
  131. package/ftm-inbox/bin/status.sh +17 -17
  132. package/ftm-inbox/bin/stop.sh +8 -8
  133. package/ftm-inbox/config.example.yml +55 -55
  134. package/ftm-inbox/package-lock.json +2898 -2898
  135. package/ftm-inbox/package.json +26 -26
  136. package/ftm-inbox/postcss.config.js +6 -6
  137. package/ftm-inbox/src/app.css +199 -199
  138. package/ftm-inbox/src/app.html +18 -18
  139. package/ftm-inbox/src/lib/api.ts +166 -166
  140. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
  141. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
  142. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
  143. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
  144. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
  145. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
  146. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
  147. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
  148. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
  149. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
  150. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
  151. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
  152. package/ftm-inbox/src/lib/theme.ts +47 -47
  153. package/ftm-inbox/src/routes/+layout.svelte +76 -76
  154. package/ftm-inbox/src/routes/+page.svelte +401 -401
  155. package/ftm-inbox/svelte.config.js +12 -12
  156. package/ftm-inbox/tailwind.config.ts +63 -63
  157. package/ftm-inbox/tsconfig.json +13 -13
  158. package/ftm-inbox/vite.config.ts +6 -6
  159. package/ftm-intent/SKILL.md +241 -241
  160. package/ftm-intent.yml +2 -2
  161. package/ftm-manifest.json +3794 -3794
  162. package/ftm-map/SKILL.md +291 -291
  163. package/ftm-map/scripts/db.py +712 -712
  164. package/ftm-map/scripts/index.py +415 -415
  165. package/ftm-map/scripts/parser.py +224 -224
  166. package/ftm-map/scripts/queries/go-tags.scm +20 -20
  167. package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
  168. package/ftm-map/scripts/queries/python-tags.scm +31 -31
  169. package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
  170. package/ftm-map/scripts/queries/rust-tags.scm +37 -37
  171. package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
  172. package/ftm-map/scripts/query.py +301 -301
  173. package/ftm-map/scripts/ranker.py +377 -377
  174. package/ftm-map/scripts/requirements.txt +5 -5
  175. package/ftm-map/scripts/setup-hooks.sh +27 -27
  176. package/ftm-map/scripts/setup.sh +56 -56
  177. package/ftm-map/scripts/test_db.py +364 -364
  178. package/ftm-map/scripts/test_parser.py +174 -174
  179. package/ftm-map/scripts/test_query.py +183 -183
  180. package/ftm-map/scripts/test_ranker.py +199 -199
  181. package/ftm-map/scripts/views.py +591 -591
  182. package/ftm-map.yml +2 -2
  183. package/ftm-mind/SKILL.md +201 -1943
  184. package/ftm-mind/evals/promptfoo.yaml +142 -142
  185. package/ftm-mind/references/blackboard-protocol.md +110 -0
  186. package/ftm-mind/references/blackboard-schema.md +328 -328
  187. package/ftm-mind/references/complexity-guide.md +110 -110
  188. package/ftm-mind/references/complexity-sizing.md +138 -0
  189. package/ftm-mind/references/decide-act-protocol.md +172 -0
  190. package/ftm-mind/references/direct-execution.md +51 -0
  191. package/ftm-mind/references/environment-discovery.md +77 -0
  192. package/ftm-mind/references/event-registry.md +319 -319
  193. package/ftm-mind/references/mcp-inventory.md +300 -296
  194. package/ftm-mind/references/ops-routing.md +47 -0
  195. package/ftm-mind/references/orient-protocol.md +234 -0
  196. package/ftm-mind/references/personality.md +40 -0
  197. package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
  198. package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
  199. package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
  200. package/ftm-mind/references/reflexion-protocol.md +249 -249
  201. package/ftm-mind/references/routing/SCENARIOS.md +22 -22
  202. package/ftm-mind/references/routing-scenarios.md +35 -35
  203. package/ftm-mind.yml +2 -2
  204. package/ftm-ops.yml +4 -0
  205. package/ftm-pause/SKILL.md +395 -395
  206. package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
  207. package/ftm-pause/references/protocols/VALIDATION.md +80 -80
  208. package/ftm-pause.yml +2 -2
  209. package/ftm-researcher/SKILL.md +275 -275
  210. package/ftm-researcher/evals/agent-diversity.yaml +17 -17
  211. package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
  212. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
  213. package/ftm-researcher/references/adaptive-search.md +116 -116
  214. package/ftm-researcher/references/agent-prompts.md +193 -193
  215. package/ftm-researcher/references/council-integration.md +193 -193
  216. package/ftm-researcher/references/output-format.md +203 -203
  217. package/ftm-researcher/references/synthesis-pipeline.md +165 -165
  218. package/ftm-researcher/scripts/score_credibility.py +234 -234
  219. package/ftm-researcher/scripts/validate_research.py +92 -92
  220. package/ftm-researcher.yml +2 -2
  221. package/ftm-resume/SKILL.md +518 -518
  222. package/ftm-resume/references/protocols/VALIDATION.md +172 -172
  223. package/ftm-resume.yml +2 -2
  224. package/ftm-retro/SKILL.md +380 -380
  225. package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
  226. package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
  227. package/ftm-retro.yml +2 -2
  228. package/ftm-routine/SKILL.md +170 -170
  229. package/ftm-routine.yml +4 -4
  230. package/ftm-state/blackboard/capabilities.json +5 -5
  231. package/ftm-state/blackboard/capabilities.schema.json +27 -27
  232. package/ftm-state/blackboard/context.json +37 -23
  233. package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
  234. package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
  235. package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
  236. package/ftm-state/blackboard/experiences/index.json +58 -9
  237. package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
  238. package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
  239. package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
  240. package/ftm-state/blackboard/patterns.json +6 -6
  241. package/ftm-state/schemas/context.schema.json +130 -130
  242. package/ftm-state/schemas/experience-index.schema.json +77 -77
  243. package/ftm-state/schemas/experience.schema.json +78 -78
  244. package/ftm-state/schemas/patterns.schema.json +44 -44
  245. package/ftm-upgrade/SKILL.md +194 -194
  246. package/ftm-upgrade/scripts/check-version.sh +76 -76
  247. package/ftm-upgrade/scripts/upgrade.sh +143 -143
  248. package/ftm-upgrade.yml +2 -2
  249. package/ftm-verify.yml +2 -2
  250. package/ftm.yml +2 -2
  251. package/hooks/ftm-auto-log.sh +137 -0
  252. package/hooks/ftm-blackboard-enforcer.sh +93 -93
  253. package/hooks/ftm-discovery-reminder.sh +90 -90
  254. package/hooks/ftm-drafts-gate.sh +61 -61
  255. package/hooks/ftm-event-logger.mjs +107 -107
  256. package/hooks/ftm-install-hooks.sh +240 -0
  257. package/hooks/ftm-learning-capture.sh +117 -0
  258. package/hooks/ftm-map-autodetect.sh +79 -79
  259. package/hooks/ftm-pending-sync-check.sh +22 -22
  260. package/hooks/ftm-plan-gate.sh +92 -92
  261. package/hooks/ftm-post-commit-trigger.sh +57 -57
  262. package/hooks/ftm-post-compaction.sh +138 -0
  263. package/hooks/ftm-pre-compaction.sh +147 -0
  264. package/hooks/ftm-session-end.sh +52 -0
  265. package/hooks/ftm-session-snapshot.sh +213 -0
  266. package/hooks/settings-template.json +81 -81
  267. package/install.sh +363 -363
  268. package/package.json +84 -84
  269. package/uninstall.sh +25 -25
@@ -1,125 +1,125 @@
1
- """
2
- GmailAdapter — polls Gmail for recent emails matching a label filter.
3
-
4
- Required credentials:
5
- credentials_json_path Path to Google OAuth credentials JSON
6
- token_path Path to stored OAuth token
7
-
8
- Config keys:
9
- label_filter Gmail label to filter by, default "INBOX"
10
- max_results Max emails per poll cycle, default 50
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- import logging
17
- from pathlib import Path
18
- from typing import Any
19
-
20
- import requests
21
-
22
- from backend.adapters._retry import retry
23
- from backend.adapters.base import BaseAdapter, NormalizedItem
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
-
28
- class GmailAdapter(BaseAdapter):
29
- """Polls Gmail for recent emails using the Gmail REST API."""
30
-
31
- source_name = "gmail"
32
- required_credentials = ["credentials_json_path"]
33
-
34
- def __init__(self, credentials: dict, config: dict) -> None:
35
- super().__init__(credentials, config)
36
- self._credentials_path = Path(
37
- credentials.get("credentials_json_path", "")
38
- ).expanduser()
39
- self._token_path = Path(
40
- credentials.get("token_path", "~/.config/ftm-inbox/gmail-token.json")
41
- ).expanduser()
42
- self._label_filter = config.get("label_filter", "INBOX")
43
- self._max_results = int(config.get("max_results", 50))
44
- self._access_token: str | None = None
45
-
46
- def _get_access_token(self) -> str:
47
- """Load OAuth access token from token file."""
48
- if self._access_token:
49
- return self._access_token
50
-
51
- if not self._token_path.exists():
52
- raise RuntimeError(
53
- f"Gmail token file not found at {self._token_path}. "
54
- "Run the setup wizard first."
55
- )
56
-
57
- token_data = json.loads(self._token_path.read_text())
58
- self._access_token = token_data.get("access_token", "")
59
- if not self._access_token:
60
- raise RuntimeError("Gmail token file has no access_token field.")
61
- return self._access_token
62
-
63
- @retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
64
- def poll(self) -> list[dict]:
65
- """Fetch recent emails from Gmail matching the label filter."""
66
- token = self._get_access_token()
67
- headers = {"Authorization": f"Bearer {token}"}
68
-
69
- url = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
70
- params: dict[str, Any] = {
71
- "maxResults": self._max_results,
72
- "labelIds": self._label_filter,
73
- }
74
- response = requests.get(url, headers=headers, params=params, timeout=30)
75
- response.raise_for_status()
76
- data = response.json()
77
- message_ids = [m["id"] for m in data.get("messages", [])]
78
-
79
- messages: list[dict] = []
80
- for msg_id in message_ids:
81
- msg_url = f"{url}/{msg_id}"
82
- msg_params = {"format": "metadata", "metadataHeaders": "Subject,From,Date"}
83
- msg_response = requests.get(
84
- msg_url, headers=headers, params=msg_params, timeout=30
85
- )
86
- if msg_response.ok:
87
- messages.append(msg_response.json())
88
-
89
- return messages
90
-
91
- def normalize(self, raw_item: dict) -> NormalizedItem:
92
- """Map a Gmail message dict to NormalizedItem."""
93
- msg_id = raw_item.get("id", "")
94
- headers = raw_item.get("payload", {}).get("headers", [])
95
-
96
- header_map: dict[str, str] = {}
97
- for h in headers:
98
- header_map[h.get("name", "").lower()] = h.get("value", "")
99
-
100
- title = header_map.get("subject") or "(no subject)"
101
- requester = header_map.get("from")
102
- created_at = header_map.get("date")
103
-
104
- snippet = raw_item.get("snippet", "")
105
-
106
- source_url = f"https://mail.google.com/mail/u/0/#inbox/{msg_id}"
107
-
108
- label_ids: list[str] = raw_item.get("labelIds", [])
109
-
110
- return NormalizedItem(
111
- source=self.source_name,
112
- source_id=msg_id,
113
- title=title,
114
- body=snippet,
115
- status="open",
116
- priority="medium",
117
- assignee=None,
118
- requester=requester,
119
- created_at=created_at,
120
- updated_at=None,
121
- tags=label_ids,
122
- custom_fields={},
123
- raw_payload=raw_item,
124
- source_url=source_url,
125
- )
1
+ """
2
+ GmailAdapter — polls Gmail for recent emails matching a label filter.
3
+
4
+ Required credentials:
5
+ credentials_json_path Path to Google OAuth credentials JSON
6
+ token_path Path to stored OAuth token
7
+
8
+ Config keys:
9
+ label_filter Gmail label to filter by, default "INBOX"
10
+ max_results Max emails per poll cycle, default 50
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import requests
21
+
22
+ from backend.adapters._retry import retry
23
+ from backend.adapters.base import BaseAdapter, NormalizedItem
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class GmailAdapter(BaseAdapter):
29
+ """Polls Gmail for recent emails using the Gmail REST API."""
30
+
31
+ source_name = "gmail"
32
+ required_credentials = ["credentials_json_path"]
33
+
34
+ def __init__(self, credentials: dict, config: dict) -> None:
35
+ super().__init__(credentials, config)
36
+ self._credentials_path = Path(
37
+ credentials.get("credentials_json_path", "")
38
+ ).expanduser()
39
+ self._token_path = Path(
40
+ credentials.get("token_path", "~/.config/ftm-inbox/gmail-token.json")
41
+ ).expanduser()
42
+ self._label_filter = config.get("label_filter", "INBOX")
43
+ self._max_results = int(config.get("max_results", 50))
44
+ self._access_token: str | None = None
45
+
46
+ def _get_access_token(self) -> str:
47
+ """Load OAuth access token from token file."""
48
+ if self._access_token:
49
+ return self._access_token
50
+
51
+ if not self._token_path.exists():
52
+ raise RuntimeError(
53
+ f"Gmail token file not found at {self._token_path}. "
54
+ "Run the setup wizard first."
55
+ )
56
+
57
+ token_data = json.loads(self._token_path.read_text())
58
+ self._access_token = token_data.get("access_token", "")
59
+ if not self._access_token:
60
+ raise RuntimeError("Gmail token file has no access_token field.")
61
+ return self._access_token
62
+
63
+ @retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
64
+ def poll(self) -> list[dict]:
65
+ """Fetch recent emails from Gmail matching the label filter."""
66
+ token = self._get_access_token()
67
+ headers = {"Authorization": f"Bearer {token}"}
68
+
69
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
70
+ params: dict[str, Any] = {
71
+ "maxResults": self._max_results,
72
+ "labelIds": self._label_filter,
73
+ }
74
+ response = requests.get(url, headers=headers, params=params, timeout=30)
75
+ response.raise_for_status()
76
+ data = response.json()
77
+ message_ids = [m["id"] for m in data.get("messages", [])]
78
+
79
+ messages: list[dict] = []
80
+ for msg_id in message_ids:
81
+ msg_url = f"{url}/{msg_id}"
82
+ msg_params = {"format": "metadata", "metadataHeaders": "Subject,From,Date"}
83
+ msg_response = requests.get(
84
+ msg_url, headers=headers, params=msg_params, timeout=30
85
+ )
86
+ if msg_response.ok:
87
+ messages.append(msg_response.json())
88
+
89
+ return messages
90
+
91
+ def normalize(self, raw_item: dict) -> NormalizedItem:
92
+ """Map a Gmail message dict to NormalizedItem."""
93
+ msg_id = raw_item.get("id", "")
94
+ headers = raw_item.get("payload", {}).get("headers", [])
95
+
96
+ header_map: dict[str, str] = {}
97
+ for h in headers:
98
+ header_map[h.get("name", "").lower()] = h.get("value", "")
99
+
100
+ title = header_map.get("subject") or "(no subject)"
101
+ requester = header_map.get("from")
102
+ created_at = header_map.get("date")
103
+
104
+ snippet = raw_item.get("snippet", "")
105
+
106
+ source_url = f"https://mail.google.com/mail/u/0/#inbox/{msg_id}"
107
+
108
+ label_ids: list[str] = raw_item.get("labelIds", [])
109
+
110
+ return NormalizedItem(
111
+ source=self.source_name,
112
+ source_id=msg_id,
113
+ title=title,
114
+ body=snippet,
115
+ status="open",
116
+ priority="medium",
117
+ assignee=None,
118
+ requester=requester,
119
+ created_at=created_at,
120
+ updated_at=None,
121
+ tags=label_ids,
122
+ custom_fields={},
123
+ raw_payload=raw_item,
124
+ source_url=source_url,
125
+ )
@@ -1,136 +1,136 @@
1
- """
2
- JiraAdapter — polls a Jira project via the REST API v3 and normalizes issues.
3
-
4
- Required credentials:
5
- api_token Jira API token (generated at id.atlassian.net)
6
- email Atlassian account email associated with the token
7
-
8
- Config keys:
9
- base_url e.g. "https://myorg.atlassian.net"
10
- jql JQL filter string, default "assignee = currentUser() ORDER BY updated DESC"
11
- max_results Number of issues to fetch per poll cycle, default 50
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import logging
17
- from typing import Any
18
-
19
- import requests
20
- from requests.auth import HTTPBasicAuth
21
-
22
- from backend.adapters._retry import retry
23
- from backend.adapters.base import BaseAdapter, NormalizedItem
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
- _DEFAULT_JQL = "assignee = currentUser() ORDER BY updated DESC"
28
- _DEFAULT_MAX_RESULTS = 50
29
-
30
-
31
- class JiraAdapter(BaseAdapter):
32
- """Polls Jira issues using the REST API v3."""
33
-
34
- source_name = "jira"
35
- required_credentials = ["api_token", "email"]
36
-
37
- def __init__(self, credentials: dict, config: dict) -> None:
38
- super().__init__(credentials, config)
39
- self._base_url = config.get("base_url", "").rstrip("/")
40
- if not self._base_url:
41
- raise ValueError("JiraAdapter requires config['base_url']")
42
- self._auth = HTTPBasicAuth(
43
- credentials["email"], credentials["api_token"]
44
- )
45
- self._jql = config.get("jql", _DEFAULT_JQL)
46
- self._max_results = int(config.get("max_results", _DEFAULT_MAX_RESULTS))
47
-
48
- @retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
49
- def poll(self) -> list[dict]:
50
- """Fetch Jira issues matching the configured JQL query."""
51
- url = f"{self._base_url}/rest/api/3/search"
52
- params: dict[str, Any] = {
53
- "jql": self._jql,
54
- "maxResults": self._max_results,
55
- "fields": (
56
- "summary,description,status,priority,"
57
- "assignee,reporter,created,updated,labels,issuetype"
58
- ),
59
- }
60
- response = requests.get(url, auth=self._auth, params=params, timeout=30)
61
- response.raise_for_status()
62
- data = response.json()
63
- return data.get("issues", [])
64
-
65
- def normalize(self, raw_item: dict) -> NormalizedItem:
66
- """Map a Jira issue dict to NormalizedItem."""
67
- fields = raw_item.get("fields") or {}
68
- key = raw_item.get("key", "")
69
- issue_id = raw_item.get("id", key)
70
-
71
- title = fields.get("summary") or key or "(no summary)"
72
- body = _extract_jira_text(fields.get("description"))
73
-
74
- status_obj = fields.get("status") or {}
75
- status = (status_obj.get("name") or "open").lower()
76
-
77
- priority_obj = fields.get("priority") or {}
78
- priority = (priority_obj.get("name") or "medium").lower()
79
-
80
- assignee_obj = fields.get("assignee") or {}
81
- assignee = assignee_obj.get("displayName") or assignee_obj.get("emailAddress")
82
-
83
- reporter_obj = fields.get("reporter") or {}
84
- requester = reporter_obj.get("displayName") or reporter_obj.get("emailAddress")
85
-
86
- created_at = fields.get("created")
87
- updated_at = fields.get("updated")
88
-
89
- tags: list[str] = fields.get("labels") or []
90
-
91
- issuetype_obj = fields.get("issuetype") or {}
92
- custom_fields: dict[str, Any] = {}
93
- if issuetype_obj.get("name"):
94
- custom_fields["issue_type"] = issuetype_obj["name"]
95
-
96
- source_url = f"{self._base_url}/browse/{key}" if key else None
97
-
98
- return NormalizedItem(
99
- source=self.source_name,
100
- source_id=str(issue_id),
101
- title=title,
102
- body=body,
103
- status=status,
104
- priority=priority,
105
- assignee=assignee,
106
- requester=requester,
107
- created_at=created_at,
108
- updated_at=updated_at,
109
- tags=tags,
110
- custom_fields=custom_fields,
111
- raw_payload=raw_item,
112
- source_url=source_url,
113
- )
114
-
115
-
116
- def _extract_jira_text(adf: Any) -> str:
117
- """Recursively pull plain text from an Atlassian Document Format node."""
118
- if adf is None:
119
- return ""
120
- if isinstance(adf, str):
121
- return adf
122
- if not isinstance(adf, dict):
123
- return ""
124
-
125
- node_type = adf.get("type", "")
126
- if node_type == "text":
127
- return adf.get("text", "")
128
-
129
- parts: list[str] = []
130
- for child in adf.get("content") or []:
131
- part = _extract_jira_text(child)
132
- if part:
133
- parts.append(part)
134
-
135
- separator = "\n" if node_type in ("paragraph", "heading", "bulletList", "orderedList") else " "
136
- return separator.join(parts).strip()
1
+ """
2
+ JiraAdapter — polls a Jira project via the REST API v3 and normalizes issues.
3
+
4
+ Required credentials:
5
+ api_token Jira API token (generated at id.atlassian.net)
6
+ email Atlassian account email associated with the token
7
+
8
+ Config keys:
9
+ base_url e.g. "https://myorg.atlassian.net"
10
+ jql JQL filter string, default "assignee = currentUser() ORDER BY updated DESC"
11
+ max_results Number of issues to fetch per poll cycle, default 50
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ import requests
20
+ from requests.auth import HTTPBasicAuth
21
+
22
+ from backend.adapters._retry import retry
23
+ from backend.adapters.base import BaseAdapter, NormalizedItem
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _DEFAULT_JQL = "assignee = currentUser() ORDER BY updated DESC"
28
+ _DEFAULT_MAX_RESULTS = 50
29
+
30
+
31
+ class JiraAdapter(BaseAdapter):
32
+ """Polls Jira issues using the REST API v3."""
33
+
34
+ source_name = "jira"
35
+ required_credentials = ["api_token", "email"]
36
+
37
+ def __init__(self, credentials: dict, config: dict) -> None:
38
+ super().__init__(credentials, config)
39
+ self._base_url = config.get("base_url", "").rstrip("/")
40
+ if not self._base_url:
41
+ raise ValueError("JiraAdapter requires config['base_url']")
42
+ self._auth = HTTPBasicAuth(
43
+ credentials["email"], credentials["api_token"]
44
+ )
45
+ self._jql = config.get("jql", _DEFAULT_JQL)
46
+ self._max_results = int(config.get("max_results", _DEFAULT_MAX_RESULTS))
47
+
48
+ @retry(max_attempts=3, base_delay=1.0, exceptions=(requests.RequestException,))
49
+ def poll(self) -> list[dict]:
50
+ """Fetch Jira issues matching the configured JQL query."""
51
+ url = f"{self._base_url}/rest/api/3/search"
52
+ params: dict[str, Any] = {
53
+ "jql": self._jql,
54
+ "maxResults": self._max_results,
55
+ "fields": (
56
+ "summary,description,status,priority,"
57
+ "assignee,reporter,created,updated,labels,issuetype"
58
+ ),
59
+ }
60
+ response = requests.get(url, auth=self._auth, params=params, timeout=30)
61
+ response.raise_for_status()
62
+ data = response.json()
63
+ return data.get("issues", [])
64
+
65
+ def normalize(self, raw_item: dict) -> NormalizedItem:
66
+ """Map a Jira issue dict to NormalizedItem."""
67
+ fields = raw_item.get("fields") or {}
68
+ key = raw_item.get("key", "")
69
+ issue_id = raw_item.get("id", key)
70
+
71
+ title = fields.get("summary") or key or "(no summary)"
72
+ body = _extract_jira_text(fields.get("description"))
73
+
74
+ status_obj = fields.get("status") or {}
75
+ status = (status_obj.get("name") or "open").lower()
76
+
77
+ priority_obj = fields.get("priority") or {}
78
+ priority = (priority_obj.get("name") or "medium").lower()
79
+
80
+ assignee_obj = fields.get("assignee") or {}
81
+ assignee = assignee_obj.get("displayName") or assignee_obj.get("emailAddress")
82
+
83
+ reporter_obj = fields.get("reporter") or {}
84
+ requester = reporter_obj.get("displayName") or reporter_obj.get("emailAddress")
85
+
86
+ created_at = fields.get("created")
87
+ updated_at = fields.get("updated")
88
+
89
+ tags: list[str] = fields.get("labels") or []
90
+
91
+ issuetype_obj = fields.get("issuetype") or {}
92
+ custom_fields: dict[str, Any] = {}
93
+ if issuetype_obj.get("name"):
94
+ custom_fields["issue_type"] = issuetype_obj["name"]
95
+
96
+ source_url = f"{self._base_url}/browse/{key}" if key else None
97
+
98
+ return NormalizedItem(
99
+ source=self.source_name,
100
+ source_id=str(issue_id),
101
+ title=title,
102
+ body=body,
103
+ status=status,
104
+ priority=priority,
105
+ assignee=assignee,
106
+ requester=requester,
107
+ created_at=created_at,
108
+ updated_at=updated_at,
109
+ tags=tags,
110
+ custom_fields=custom_fields,
111
+ raw_payload=raw_item,
112
+ source_url=source_url,
113
+ )
114
+
115
+
116
+ def _extract_jira_text(adf: Any) -> str:
117
+ """Recursively pull plain text from an Atlassian Document Format node."""
118
+ if adf is None:
119
+ return ""
120
+ if isinstance(adf, str):
121
+ return adf
122
+ if not isinstance(adf, dict):
123
+ return ""
124
+
125
+ node_type = adf.get("type", "")
126
+ if node_type == "text":
127
+ return adf.get("text", "")
128
+
129
+ parts: list[str] = []
130
+ for child in adf.get("content") or []:
131
+ part = _extract_jira_text(child)
132
+ if part:
133
+ parts.append(part)
134
+
135
+ separator = "\n" if node_type in ("paragraph", "heading", "bulletList", "orderedList") else " "
136
+ return separator.join(parts).strip()