feed-the-machine 1.5.0 → 1.6.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.
- package/LICENSE +21 -21
- package/README.md +170 -170
- package/bin/generate-manifest.mjs +463 -463
- package/bin/install.mjs +491 -491
- package/docs/HOOKS.md +243 -243
- package/docs/INBOX.md +233 -233
- package/ftm/SKILL.md +122 -122
- package/ftm-audit/SKILL.md +623 -541
- package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
- package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
- package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
- package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
- package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
- package/ftm-audit/scripts/run-knip.sh +23 -23
- package/ftm-audit.yml +2 -2
- package/ftm-brainstorm/SKILL.md +498 -498
- package/ftm-brainstorm/evals/evals.json +100 -100
- package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
- package/ftm-brainstorm/references/agent-prompts.md +224 -224
- package/ftm-brainstorm/references/plan-template.md +121 -121
- package/ftm-brainstorm.yml +2 -2
- package/ftm-browse/SKILL.md +454 -454
- package/ftm-browse/daemon/browser-manager.ts +206 -206
- package/ftm-browse/daemon/bun.lock +30 -30
- package/ftm-browse/daemon/cli.ts +347 -347
- package/ftm-browse/daemon/commands.ts +410 -410
- package/ftm-browse/daemon/main.ts +357 -357
- package/ftm-browse/daemon/package.json +17 -17
- package/ftm-browse/daemon/server.ts +189 -189
- package/ftm-browse/daemon/snapshot.ts +519 -519
- package/ftm-browse/daemon/tsconfig.json +22 -22
- package/ftm-browse.yml +4 -4
- package/ftm-capture/SKILL.md +370 -370
- package/ftm-capture.yml +4 -4
- package/ftm-codex-gate/SKILL.md +361 -361
- package/ftm-codex-gate.yml +2 -2
- package/ftm-config/SKILL.md +345 -345
- package/ftm-config.default.yml +82 -80
- package/ftm-config.yml +2 -2
- package/ftm-council/SKILL.md +416 -416
- package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
- package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
- package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
- package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
- package/ftm-council.yml +2 -2
- package/ftm-dashboard/SKILL.md +163 -163
- package/ftm-dashboard.yml +4 -4
- package/ftm-debug/SKILL.md +1037 -1037
- package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
- package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
- package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
- package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
- package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
- package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
- package/ftm-debug.yml +2 -2
- package/ftm-diagram/SKILL.md +277 -277
- package/ftm-diagram.yml +2 -2
- package/ftm-executor/SKILL.md +777 -767
- package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
- package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
- package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
- package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
- package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
- package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
- package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
- package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
- package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -44
- package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
- package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
- package/ftm-executor/runtime/package.json +8 -8
- package/ftm-executor.yml +2 -2
- package/ftm-git/SKILL.md +441 -441
- package/ftm-git/evals/evals.json +26 -26
- package/ftm-git/evals/promptfoo.yaml +75 -75
- package/ftm-git/hooks/post-commit-experience.sh +92 -92
- package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
- package/ftm-git/references/protocols/REMEDIATION.md +139 -139
- package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
- package/ftm-git.yml +2 -2
- package/ftm-inbox/backend/adapters/_retry.py +64 -64
- package/ftm-inbox/backend/adapters/base.py +230 -230
- package/ftm-inbox/backend/adapters/freshservice.py +104 -104
- package/ftm-inbox/backend/adapters/gmail.py +125 -125
- package/ftm-inbox/backend/adapters/jira.py +136 -136
- package/ftm-inbox/backend/adapters/registry.py +192 -192
- package/ftm-inbox/backend/adapters/slack.py +110 -110
- package/ftm-inbox/backend/db/connection.py +54 -54
- package/ftm-inbox/backend/db/schema.py +78 -78
- package/ftm-inbox/backend/executor/__init__.py +7 -7
- package/ftm-inbox/backend/executor/engine.py +149 -149
- package/ftm-inbox/backend/executor/step_runner.py +98 -98
- package/ftm-inbox/backend/main.py +103 -103
- package/ftm-inbox/backend/models/__init__.py +1 -1
- package/ftm-inbox/backend/models/unified_task.py +36 -36
- package/ftm-inbox/backend/planner/__init__.py +6 -6
- package/ftm-inbox/backend/planner/generator.py +127 -127
- package/ftm-inbox/backend/planner/schema.py +34 -34
- package/ftm-inbox/backend/requirements.txt +5 -5
- package/ftm-inbox/backend/routes/execute.py +186 -186
- package/ftm-inbox/backend/routes/health.py +52 -52
- package/ftm-inbox/backend/routes/inbox.py +68 -68
- package/ftm-inbox/backend/routes/plan.py +271 -271
- package/ftm-inbox/bin/launchagent.mjs +91 -91
- package/ftm-inbox/bin/setup.mjs +188 -188
- package/ftm-inbox/bin/start.sh +10 -10
- package/ftm-inbox/bin/status.sh +17 -17
- package/ftm-inbox/bin/stop.sh +8 -8
- package/ftm-inbox/config.example.yml +55 -55
- package/ftm-inbox/package-lock.json +2898 -2898
- package/ftm-inbox/package.json +26 -26
- package/ftm-inbox/postcss.config.js +6 -6
- package/ftm-inbox/src/app.css +199 -199
- package/ftm-inbox/src/app.html +18 -18
- package/ftm-inbox/src/lib/api.ts +166 -166
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
- package/ftm-inbox/src/lib/theme.ts +47 -47
- package/ftm-inbox/src/routes/+layout.svelte +76 -76
- package/ftm-inbox/src/routes/+page.svelte +401 -401
- package/ftm-inbox/svelte.config.js +12 -12
- package/ftm-inbox/tailwind.config.ts +63 -63
- package/ftm-inbox/tsconfig.json +13 -13
- package/ftm-inbox/vite.config.ts +6 -6
- package/ftm-intent/SKILL.md +241 -241
- package/ftm-intent.yml +2 -2
- package/ftm-manifest.json +3794 -3794
- package/ftm-map/SKILL.md +291 -291
- package/ftm-map/scripts/db.py +712 -712
- package/ftm-map/scripts/index.py +415 -415
- package/ftm-map/scripts/parser.py +224 -224
- package/ftm-map/scripts/queries/go-tags.scm +20 -20
- package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
- package/ftm-map/scripts/queries/python-tags.scm +31 -31
- package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
- package/ftm-map/scripts/queries/rust-tags.scm +37 -37
- package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
- package/ftm-map/scripts/query.py +301 -301
- package/ftm-map/scripts/ranker.py +377 -377
- package/ftm-map/scripts/requirements.txt +5 -5
- package/ftm-map/scripts/setup-hooks.sh +27 -27
- package/ftm-map/scripts/setup.sh +56 -56
- package/ftm-map/scripts/test_db.py +364 -364
- package/ftm-map/scripts/test_parser.py +174 -174
- package/ftm-map/scripts/test_query.py +183 -183
- package/ftm-map/scripts/test_ranker.py +199 -199
- package/ftm-map/scripts/views.py +591 -591
- package/ftm-map.yml +2 -2
- package/ftm-mind/SKILL.md +1943 -1943
- package/ftm-mind/evals/promptfoo.yaml +142 -142
- package/ftm-mind/references/blackboard-schema.md +328 -328
- package/ftm-mind/references/complexity-guide.md +110 -110
- package/ftm-mind/references/event-registry.md +319 -319
- package/ftm-mind/references/mcp-inventory.md +296 -296
- package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
- package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
- package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
- package/ftm-mind/references/reflexion-protocol.md +249 -249
- package/ftm-mind/references/routing/SCENARIOS.md +22 -22
- package/ftm-mind/references/routing-scenarios.md +35 -35
- package/ftm-mind.yml +2 -2
- package/ftm-pause/SKILL.md +395 -395
- package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
- package/ftm-pause/references/protocols/VALIDATION.md +80 -80
- package/ftm-pause.yml +2 -2
- package/ftm-researcher/SKILL.md +275 -275
- package/ftm-researcher/evals/agent-diversity.yaml +17 -17
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
- package/ftm-researcher/references/adaptive-search.md +116 -116
- package/ftm-researcher/references/agent-prompts.md +193 -193
- package/ftm-researcher/references/council-integration.md +193 -193
- package/ftm-researcher/references/output-format.md +203 -203
- package/ftm-researcher/references/synthesis-pipeline.md +165 -165
- package/ftm-researcher/scripts/score_credibility.py +234 -234
- package/ftm-researcher/scripts/validate_research.py +92 -92
- package/ftm-researcher.yml +2 -2
- package/ftm-resume/SKILL.md +518 -518
- package/ftm-resume/references/protocols/VALIDATION.md +172 -172
- package/ftm-resume.yml +2 -2
- package/ftm-retro/SKILL.md +380 -380
- package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
- package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
- package/ftm-retro.yml +2 -2
- package/ftm-routine/SKILL.md +170 -170
- package/ftm-routine.yml +4 -4
- package/ftm-state/blackboard/capabilities.json +5 -5
- package/ftm-state/blackboard/capabilities.schema.json +27 -27
- package/ftm-state/blackboard/context.json +23 -23
- package/ftm-state/blackboard/experiences/index.json +9 -9
- package/ftm-state/blackboard/patterns.json +6 -6
- package/ftm-state/schemas/context.schema.json +130 -130
- package/ftm-state/schemas/experience-index.schema.json +77 -77
- package/ftm-state/schemas/experience.schema.json +78 -78
- package/ftm-state/schemas/patterns.schema.json +44 -44
- package/ftm-upgrade/SKILL.md +194 -194
- package/ftm-upgrade/scripts/check-version.sh +76 -76
- package/ftm-upgrade/scripts/upgrade.sh +143 -143
- package/ftm-upgrade.yml +2 -2
- package/ftm-verify.yml +2 -2
- package/ftm.yml +2 -2
- package/hooks/ftm-blackboard-enforcer.sh +93 -93
- package/hooks/ftm-discovery-reminder.sh +90 -90
- package/hooks/ftm-drafts-gate.sh +61 -61
- package/hooks/ftm-event-logger.mjs +107 -107
- package/hooks/ftm-map-autodetect.sh +79 -79
- package/hooks/ftm-pending-sync-check.sh +22 -22
- package/hooks/ftm-plan-gate.sh +92 -92
- package/hooks/ftm-post-commit-trigger.sh +57 -57
- package/hooks/settings-template.json +81 -81
- package/install.sh +363 -363
- package/package.json +84 -84
- 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()
|