world-model-optimizer 0.2.0__py3-none-any.whl
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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/connect/connector.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""ContextConnector protocol + a small registry (mirrors `wmo.ingest.adapter`).
|
|
2
|
+
|
|
3
|
+
A connector owns one service end to end: the interactive auth flow, a cheap credential check,
|
|
4
|
+
and pulling content normalized into `ContextItem`s. Connectors register themselves on import and
|
|
5
|
+
are looked up by name (`get_connector`) or listed (`list_connectors`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from wmo.connect.types import ConnectorAuth, ContextItem, PullQuery
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ConnectUI:
|
|
19
|
+
"""Presentation callbacks a connector uses during interactive auth.
|
|
20
|
+
|
|
21
|
+
Connectors never print: the CLI layer builds one of these over its rich Console; tests pass
|
|
22
|
+
recording lambdas.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
open_url: Show and/or open the browser authorization URL.
|
|
26
|
+
present_code: Show a device-flow verification URI and user code.
|
|
27
|
+
prompt_secret: Ask the user for a secret (e.g. a pasted API token); returns the value.
|
|
28
|
+
info: Show a short status message.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
open_url: Callable[[str], None]
|
|
32
|
+
present_code: Callable[[str, str], None]
|
|
33
|
+
prompt_secret: Callable[[str], str]
|
|
34
|
+
info: Callable[[str], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class ContextConnector(Protocol):
|
|
39
|
+
"""One service's auth + pull logic, producing normalized `ContextItem`s.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
name: Registry key and `ContextItem.source` value (e.g. "github").
|
|
43
|
+
label: Human name shown in pickers (e.g. "GitHub").
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
label: str
|
|
48
|
+
|
|
49
|
+
def connect(self, ui: ConnectUI) -> ConnectorAuth:
|
|
50
|
+
"""Run the interactive auth flow (OAuth or token prompt) and return the credential."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
54
|
+
"""Cheap API call returning a human identity string; raises ConnectError on bad auth."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
58
|
+
"""Fetch content matching `query`, normalized and capped at `query.limit` items."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_CONNECTORS: dict[str, ContextConnector] = {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def register_connector(connector: ContextConnector) -> None:
|
|
66
|
+
"""Register a connector under its `name` (typically at module import time)."""
|
|
67
|
+
_CONNECTORS[connector.name] = connector
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_connector(name: str) -> ContextConnector:
|
|
71
|
+
"""Look up a registered connector by name."""
|
|
72
|
+
if name not in _CONNECTORS:
|
|
73
|
+
raise ValueError(f"no context connector registered for {name!r}; have {list(_CONNECTORS)}")
|
|
74
|
+
return _CONNECTORS[name]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_connectors() -> list[str]:
|
|
78
|
+
"""Names of all registered connectors, sorted (what the CLI picker shows)."""
|
|
79
|
+
return sorted(_CONNECTORS)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Connector credentials, stored once per user.
|
|
2
|
+
|
|
3
|
+
Like the platform credential (`wmo/platform/credentials.py`), connector tokens are user-global:
|
|
4
|
+
`~/.wmo/connectors.toml` (directory overridable via `$WMO_HOME`, exact file overridable via
|
|
5
|
+
`$WMO_CONNECTORS_PATH`), one TOML table per connector name. A `WMO_<NAME>_TOKEN` environment
|
|
6
|
+
variable injects a token-kind credential without a file, so CI and headless runs never write one
|
|
7
|
+
to disk; a few connectors additionally honor a service-native variable (`ENV_TOKEN_ALIASES`)
|
|
8
|
+
that deployments already carry.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import tomli_w
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from wmo.connect.types import ConnectError, ConnectorAuth
|
|
22
|
+
from wmo.core.types import JsonObject
|
|
23
|
+
from wmo.platform.credentials import wmo_home
|
|
24
|
+
|
|
25
|
+
ENV_CONNECTORS_PATH = "WMO_CONNECTORS_PATH"
|
|
26
|
+
|
|
27
|
+
CONNECTORS_FILENAME = "connectors.toml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def connectors_path() -> Path:
|
|
31
|
+
"""Where the connector credential file lives (`$WMO_CONNECTORS_PATH` wins over `$WMO_HOME`)."""
|
|
32
|
+
override = os.environ.get(ENV_CONNECTORS_PATH)
|
|
33
|
+
return Path(override) if override else wmo_home() / CONNECTORS_FILENAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def token_env_var(name: str) -> str:
|
|
37
|
+
"""The env var that injects a plain token for connector `name`: WMO_<NAME>_TOKEN."""
|
|
38
|
+
return f"WMO_{name.upper().replace('-', '_')}_TOKEN"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Service-native env vars accepted as token sources per connector, consulted after the generic
|
|
42
|
+
# WMO_<NAME>_TOKEN override. BRAVE_SEARCH_API_KEY is the key the grounding engine already uses
|
|
43
|
+
# (wmo/engine/grounding.py) and deployments already carry, so the brave connector honors it too.
|
|
44
|
+
ENV_TOKEN_ALIASES: dict[str, tuple[str, ...]] = {"brave": ("BRAVE_SEARCH_API_KEY",)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def token_env_vars(name: str) -> list[str]:
|
|
48
|
+
"""Every env var that can inject a token for connector `name`, in precedence order."""
|
|
49
|
+
return [token_env_var(name), *ENV_TOKEN_ALIASES.get(name, ())]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_env_token(name: str) -> tuple[str, str] | None:
|
|
53
|
+
"""The first set env token for connector `name` as (var, token), or None when none is set.
|
|
54
|
+
|
|
55
|
+
Set-but-empty vars are treated as unset.
|
|
56
|
+
"""
|
|
57
|
+
for var in token_env_vars(name):
|
|
58
|
+
token = os.environ.get(var)
|
|
59
|
+
if token:
|
|
60
|
+
return var, token
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_connector_auth(name: str) -> ConnectorAuth | None:
|
|
65
|
+
"""Load the stored credential for one connector, or None when not connected.
|
|
66
|
+
|
|
67
|
+
A non-empty env token (`WMO_<NAME>_TOKEN`, then any `ENV_TOKEN_ALIASES` entry) takes
|
|
68
|
+
precedence over the file and yields a token-kind auth (a set-but-empty var is treated as
|
|
69
|
+
unset).
|
|
70
|
+
"""
|
|
71
|
+
resolved = resolve_env_token(name)
|
|
72
|
+
if resolved is not None:
|
|
73
|
+
return ConnectorAuth(kind="token", access_token=resolved[1])
|
|
74
|
+
section = _read_sections().get(name)
|
|
75
|
+
if section is None:
|
|
76
|
+
return None
|
|
77
|
+
return _validate_section(name, section)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_connector_auth(name: str, auth: ConnectorAuth) -> Path:
|
|
81
|
+
"""Persist one connector's credential, keeping every other connector's table intact.
|
|
82
|
+
|
|
83
|
+
Mirrors the platform credential writer: refuses symlinks, writes through a 0600 mkstemp,
|
|
84
|
+
and swaps into place atomically.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the target path is a symlink.
|
|
88
|
+
"""
|
|
89
|
+
sections = _read_sections()
|
|
90
|
+
sections[name] = {
|
|
91
|
+
key: value for key, value in auth.model_dump(mode="json").items() if value is not None
|
|
92
|
+
}
|
|
93
|
+
return _write_sections(sections)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def delete_connector_auth(name: str) -> bool:
|
|
97
|
+
"""Remove one connector's credential; returns whether one existed.
|
|
98
|
+
|
|
99
|
+
Deleting the last entry removes the file entirely.
|
|
100
|
+
"""
|
|
101
|
+
sections = _read_sections()
|
|
102
|
+
if name not in sections:
|
|
103
|
+
return False
|
|
104
|
+
del sections[name]
|
|
105
|
+
if sections:
|
|
106
|
+
_write_sections(sections)
|
|
107
|
+
else:
|
|
108
|
+
connectors_path().unlink()
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_connected() -> dict[str, ConnectorAuth]:
|
|
113
|
+
"""Every connector with a stored credential, by name (sorted).
|
|
114
|
+
|
|
115
|
+
Only reads the file: connections injected purely via `WMO_<NAME>_TOKEN` env vars are not
|
|
116
|
+
enumerable and do not appear here (per-name lookups still see them via
|
|
117
|
+
`load_connector_auth`).
|
|
118
|
+
"""
|
|
119
|
+
return {
|
|
120
|
+
name: _validate_section(name, section) for name, section in sorted(_read_sections().items())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_sections() -> dict[str, JsonObject]:
|
|
125
|
+
"""All connector tables in the credential file (empty when the file is absent)."""
|
|
126
|
+
path = connectors_path()
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return {}
|
|
129
|
+
document = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
130
|
+
return {name: section for name, section in document.items() if isinstance(section, dict)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate_section(name: str, section: JsonObject) -> ConnectorAuth:
|
|
134
|
+
"""Parse one connector table, turning pydantic errors into an actionable ConnectError."""
|
|
135
|
+
try:
|
|
136
|
+
return ConnectorAuth.model_validate(section)
|
|
137
|
+
except ValidationError as exc:
|
|
138
|
+
raise ConnectError(
|
|
139
|
+
f"invalid [{name}] entry in {connectors_path()}: {exc}; "
|
|
140
|
+
f"the stored credential for {name} is malformed; provide a valid token or "
|
|
141
|
+
"reauthorize the connection"
|
|
142
|
+
) from exc
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_sections(sections: dict[str, JsonObject]) -> Path:
|
|
146
|
+
"""Atomically rewrite the credential file with owner-only permissions."""
|
|
147
|
+
path = connectors_path()
|
|
148
|
+
if path.is_symlink():
|
|
149
|
+
msg = (
|
|
150
|
+
f"refusing to write connector credentials through the symlink {path}; "
|
|
151
|
+
f"remove the link or point ${ENV_CONNECTORS_PATH} elsewhere"
|
|
152
|
+
)
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
# mkstemp creates the file 0600: that IS the owner-only mechanism.
|
|
156
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.")
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
159
|
+
fh.write(tomli_w.dumps(sections))
|
|
160
|
+
os.replace(tmp_name, path)
|
|
161
|
+
except BaseException:
|
|
162
|
+
os.unlink(tmp_name)
|
|
163
|
+
raise
|
|
164
|
+
return path
|
wmo/connect/github.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""GitHub context connector: device-flow OAuth, then repo issues, pull requests, and the README.
|
|
2
|
+
|
|
3
|
+
`connect` runs the RFC 8628 device flow against the shared "github" OAuth app (client id only,
|
|
4
|
+
no secret needed), `verify` resolves the token to its login via `GET /user`, and `pull` fetches
|
|
5
|
+
one repository's issues and pull requests (the combined listing, newest first) plus its README,
|
|
6
|
+
normalized into `ContextItem`s. A `PullQuery.query` switches the listing to the GitHub search
|
|
7
|
+
API; `PullQuery.since` maps to the listing's `since` parameter (or an `updated:>=` qualifier in
|
|
8
|
+
search).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from wmo.connect.apps import get_app
|
|
22
|
+
from wmo.connect.connector import ConnectUI, register_connector
|
|
23
|
+
from wmo.connect.oauth import run_device_flow
|
|
24
|
+
from wmo.connect.types import (
|
|
25
|
+
ConnectError,
|
|
26
|
+
ConnectorAuth,
|
|
27
|
+
ContextItem,
|
|
28
|
+
ItemKind,
|
|
29
|
+
PullQuery,
|
|
30
|
+
opt_str,
|
|
31
|
+
transport_errors,
|
|
32
|
+
)
|
|
33
|
+
from wmo.core.types import JsonObject, JsonValue
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_API_BASE = "https://api.github.com"
|
|
38
|
+
_API_HOST = "api.github.com"
|
|
39
|
+
_API_VERSION = "2022-11-28"
|
|
40
|
+
_PER_PAGE = 100
|
|
41
|
+
_TIMEOUT_SECONDS = 30.0
|
|
42
|
+
_SOURCE = "github"
|
|
43
|
+
|
|
44
|
+
_TARGET_RE = re.compile(r"^([^/\s]+)/([^/\s]+)$")
|
|
45
|
+
|
|
46
|
+
# GitHub App tokens only reach repositories where the app is installed; users pick those
|
|
47
|
+
# repos on their app's installation page after connecting (a PAT needs no installation).
|
|
48
|
+
_INSTALL_HINT = (
|
|
49
|
+
"GitHub App tokens only reach repositories the app is installed on: install yours on the "
|
|
50
|
+
"target repos from https://github.com/settings/apps (your app > Install App)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_target(target: str | None) -> tuple[str, str]:
|
|
55
|
+
"""Split an "owner/repo" pull target, raising an actionable error on anything else."""
|
|
56
|
+
match = _TARGET_RE.match(target.strip()) if target else None
|
|
57
|
+
if match is None:
|
|
58
|
+
raise ConnectError(
|
|
59
|
+
f"github pull needs a repository target like 'owner/repo' (got {target!r}); "
|
|
60
|
+
"pass --target <owner>/<repo>"
|
|
61
|
+
)
|
|
62
|
+
return match.group(1), match.group(2)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_reset(reset: str) -> str:
|
|
66
|
+
"""A rate-limit reset epoch as ISO-8601 UTC (falls back to the raw header value)."""
|
|
67
|
+
try:
|
|
68
|
+
moment = datetime.fromtimestamp(int(reset), tz=UTC)
|
|
69
|
+
except (OSError, OverflowError, ValueError):
|
|
70
|
+
return reset
|
|
71
|
+
return moment.isoformat(timespec="seconds")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _raise_for_response(response: httpx.Response, *, doing: str, repo: str | None = None) -> None:
|
|
75
|
+
"""Turn GitHub error statuses into ConnectErrors that say what to do next."""
|
|
76
|
+
status = response.status_code
|
|
77
|
+
if status < 400:
|
|
78
|
+
return
|
|
79
|
+
if status == 401:
|
|
80
|
+
raise ConnectError(
|
|
81
|
+
f"github rejected the stored credential during {doing} (HTTP 401); "
|
|
82
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
83
|
+
)
|
|
84
|
+
if status == 403 and response.headers.get("X-RateLimit-Remaining") == "0":
|
|
85
|
+
reset = response.headers.get("X-RateLimit-Reset", "unknown")
|
|
86
|
+
raise ConnectError(
|
|
87
|
+
f"github rate limit exceeded during {doing}; it resets at {_format_reset(reset)} "
|
|
88
|
+
f"(X-RateLimit-Reset {reset}); wait for the reset or connect a higher-limit account"
|
|
89
|
+
)
|
|
90
|
+
if status == 404 and repo is not None:
|
|
91
|
+
raise ConnectError(
|
|
92
|
+
f"github repository {repo!r} was not found during {doing} (HTTP 404): it does not "
|
|
93
|
+
"exist, the connected account cannot see it, or (GitHub App auth) the app is not "
|
|
94
|
+
"installed on it; check the owner/repo spelling, install your app on the repo "
|
|
95
|
+
"(https://github.com/settings/apps > your app > Install App), or reauthorize the "
|
|
96
|
+
"connection with an account that has access"
|
|
97
|
+
)
|
|
98
|
+
raise ConnectError(
|
|
99
|
+
f"github {doing} failed (HTTP {status}): {response.text[:200]}; "
|
|
100
|
+
"check the request (token scopes, target, query) and retry"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _json_object(response: httpx.Response) -> JsonObject:
|
|
105
|
+
"""The response body as a JSON object ({} when it is anything else)."""
|
|
106
|
+
try:
|
|
107
|
+
raw = response.json()
|
|
108
|
+
except ValueError:
|
|
109
|
+
return {}
|
|
110
|
+
return cast(JsonObject, raw) if isinstance(raw, dict) else {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _page_rows(response: httpx.Response, *, nested: bool) -> list[JsonObject]:
|
|
114
|
+
"""One page of issue rows: the raw JSON list, or the search wrapper's `items` list."""
|
|
115
|
+
try:
|
|
116
|
+
raw = response.json()
|
|
117
|
+
except ValueError:
|
|
118
|
+
return []
|
|
119
|
+
rows = raw.get("items") if nested and isinstance(raw, dict) else raw
|
|
120
|
+
if not isinstance(rows, list):
|
|
121
|
+
return []
|
|
122
|
+
return [cast(JsonObject, row) for row in rows if isinstance(row, dict)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _paginate(
|
|
126
|
+
client: httpx.Client,
|
|
127
|
+
url: str,
|
|
128
|
+
params: dict[str, str],
|
|
129
|
+
*,
|
|
130
|
+
limit: int,
|
|
131
|
+
repo: str,
|
|
132
|
+
doing: str,
|
|
133
|
+
nested: bool,
|
|
134
|
+
) -> list[JsonObject]:
|
|
135
|
+
"""Follow `Link: rel="next"` pagination, stopping as soon as `limit` rows are in hand."""
|
|
136
|
+
rows: list[JsonObject] = []
|
|
137
|
+
next_url: str | None = url
|
|
138
|
+
next_params: dict[str, str] | None = params
|
|
139
|
+
while next_url is not None and len(rows) < limit:
|
|
140
|
+
response = client.get(next_url, params=next_params)
|
|
141
|
+
_raise_for_response(response, doing=doing, repo=repo)
|
|
142
|
+
page = _page_rows(response, nested=nested)
|
|
143
|
+
rows.extend(page)
|
|
144
|
+
next_url = response.links.get("next", {}).get("url")
|
|
145
|
+
next_params = None # the next URL already carries its query string
|
|
146
|
+
if not page and next_url:
|
|
147
|
+
break # an empty page that still advertises a next link would loop forever
|
|
148
|
+
return rows[:limit]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _issue_item(row: JsonObject, *, owner: str, repo: str) -> ContextItem:
|
|
152
|
+
"""Normalize one combined-listing (or search) row into an issue or pull-request item."""
|
|
153
|
+
number = row.get("number")
|
|
154
|
+
suffix = str(number) if isinstance(number, int) else str(row.get("id") or "unknown")
|
|
155
|
+
title = opt_str(row.get("title")) or "(untitled)"
|
|
156
|
+
body = row.get("body")
|
|
157
|
+
user = row.get("user")
|
|
158
|
+
author = opt_str(user.get("login")) if isinstance(user, dict) else None
|
|
159
|
+
labels_value = row.get("labels")
|
|
160
|
+
labels: list[JsonValue] = []
|
|
161
|
+
if isinstance(labels_value, list):
|
|
162
|
+
labels = [
|
|
163
|
+
label["name"]
|
|
164
|
+
for label in labels_value
|
|
165
|
+
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
|
166
|
+
]
|
|
167
|
+
comments = row.get("comments")
|
|
168
|
+
return ContextItem(
|
|
169
|
+
id=f"{owner}/{repo}#{suffix}",
|
|
170
|
+
source=_SOURCE,
|
|
171
|
+
kind=ItemKind.PULL_REQUEST if "pull_request" in row else ItemKind.ISSUE,
|
|
172
|
+
title=f"#{suffix} {title}",
|
|
173
|
+
body=body if isinstance(body, str) else "",
|
|
174
|
+
url=opt_str(row.get("html_url")),
|
|
175
|
+
created_at=opt_str(row.get("created_at")),
|
|
176
|
+
updated_at=opt_str(row.get("updated_at")),
|
|
177
|
+
metadata={
|
|
178
|
+
"state": opt_str(row.get("state")),
|
|
179
|
+
"labels": labels,
|
|
180
|
+
"author": author,
|
|
181
|
+
"comments": comments if isinstance(comments, int) else 0,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class GitHubConnector:
|
|
187
|
+
"""GitHub connector: device-flow OAuth plus repository issue/PR/README pulls.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
transport: Injected httpx transport threaded into every HTTP call (tests pass
|
|
191
|
+
`httpx.MockTransport`); None means the real network.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
name = _SOURCE
|
|
195
|
+
label = "GitHub"
|
|
196
|
+
|
|
197
|
+
def __init__(self, *, transport: httpx.BaseTransport | None = None) -> None:
|
|
198
|
+
self._transport = transport
|
|
199
|
+
|
|
200
|
+
def connect(self, ui: ConnectUI) -> ConnectorAuth:
|
|
201
|
+
"""Authorize via the RFC 8628 device flow and stamp the credential with its identity."""
|
|
202
|
+
app = get_app(self.name)
|
|
203
|
+
auth = run_device_flow(
|
|
204
|
+
app, scopes=app.scopes, present=ui.present_code, transport=self._transport
|
|
205
|
+
)
|
|
206
|
+
account = self.verify(auth)
|
|
207
|
+
ui.info(f"connected to GitHub as {account}")
|
|
208
|
+
ui.info(_INSTALL_HINT)
|
|
209
|
+
return auth.model_copy(update={"account": account})
|
|
210
|
+
|
|
211
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
212
|
+
"""Resolve the credential to its GitHub identity via the cheapest call, `GET /user`."""
|
|
213
|
+
with self._client(auth) as client, transport_errors(_API_HOST):
|
|
214
|
+
response = client.get("/user")
|
|
215
|
+
_raise_for_response(response, doing="the GitHub identity check")
|
|
216
|
+
payload = _json_object(response)
|
|
217
|
+
login = opt_str(payload.get("login"))
|
|
218
|
+
if login is None:
|
|
219
|
+
raise ConnectError(
|
|
220
|
+
"github's /user endpoint returned no login for the stored credential; "
|
|
221
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
222
|
+
)
|
|
223
|
+
name = opt_str(payload.get("name"))
|
|
224
|
+
return f"{login} ({name})" if name else login
|
|
225
|
+
|
|
226
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
227
|
+
"""Fetch a repository's issues, pull requests, and README, newest first, up to the limit.
|
|
228
|
+
|
|
229
|
+
`query.target` must be "owner/repo". Issues and pull requests come from the combined
|
|
230
|
+
`GET /repos/{owner}/{repo}/issues` listing (sorted by update time, descending, honoring
|
|
231
|
+
`query.since`); when `query.query` is set, the GitHub search API replaces that listing.
|
|
232
|
+
The README is appended as one DOCUMENT item when the limit leaves room for it.
|
|
233
|
+
"""
|
|
234
|
+
owner, repo = _parse_target(query.target)
|
|
235
|
+
with self._client(auth) as client, transport_errors(_API_HOST):
|
|
236
|
+
rows = self._issue_rows(client, owner, repo, query)
|
|
237
|
+
items = [_issue_item(row, owner=owner, repo=repo) for row in rows]
|
|
238
|
+
if len(items) < query.limit:
|
|
239
|
+
readme = self._readme_item(client, owner, repo)
|
|
240
|
+
if readme is not None:
|
|
241
|
+
items.append(readme)
|
|
242
|
+
logger.debug("pulled %d github items from %s/%s", len(items), owner, repo)
|
|
243
|
+
return items
|
|
244
|
+
|
|
245
|
+
def _client(self, auth: ConnectorAuth) -> httpx.Client:
|
|
246
|
+
"""An API client carrying the credential and GitHub's versioned-media headers."""
|
|
247
|
+
return httpx.Client(
|
|
248
|
+
base_url=_API_BASE,
|
|
249
|
+
headers={
|
|
250
|
+
"Authorization": f"Bearer {auth.access_token}",
|
|
251
|
+
"Accept": "application/vnd.github+json",
|
|
252
|
+
"X-GitHub-Api-Version": _API_VERSION,
|
|
253
|
+
},
|
|
254
|
+
timeout=_TIMEOUT_SECONDS,
|
|
255
|
+
transport=self._transport,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _issue_rows(
|
|
259
|
+
self, client: httpx.Client, owner: str, repo: str, query: PullQuery
|
|
260
|
+
) -> list[JsonObject]:
|
|
261
|
+
"""Issue/PR rows, newest first: the combined listing, or search when a query is set."""
|
|
262
|
+
target = f"{owner}/{repo}"
|
|
263
|
+
per_page = str(min(query.limit, _PER_PAGE))
|
|
264
|
+
if query.query:
|
|
265
|
+
q = f"repo:{target} {query.query}"
|
|
266
|
+
if query.since:
|
|
267
|
+
q += f" updated:>={query.since}"
|
|
268
|
+
if query.until:
|
|
269
|
+
q += f" updated:<={query.until}"
|
|
270
|
+
params = {"q": q, "sort": "updated", "order": "desc", "per_page": per_page}
|
|
271
|
+
return _paginate(
|
|
272
|
+
client,
|
|
273
|
+
"/search/issues",
|
|
274
|
+
params,
|
|
275
|
+
limit=query.limit,
|
|
276
|
+
repo=target,
|
|
277
|
+
doing="the GitHub issue search",
|
|
278
|
+
nested=True,
|
|
279
|
+
)
|
|
280
|
+
params = {"state": "all", "sort": "updated", "direction": "desc", "per_page": per_page}
|
|
281
|
+
if query.since:
|
|
282
|
+
params["since"] = query.since
|
|
283
|
+
return _paginate(
|
|
284
|
+
client,
|
|
285
|
+
f"/repos/{owner}/{repo}/issues",
|
|
286
|
+
params,
|
|
287
|
+
limit=query.limit,
|
|
288
|
+
repo=target,
|
|
289
|
+
doing="the GitHub issue listing",
|
|
290
|
+
nested=False,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _readme_item(self, client: httpx.Client, owner: str, repo: str) -> ContextItem | None:
|
|
294
|
+
"""The repo README as one DOCUMENT item, or None when the repo has no README (404)."""
|
|
295
|
+
response = client.get(f"/repos/{owner}/{repo}/readme")
|
|
296
|
+
if response.status_code == 404:
|
|
297
|
+
logger.debug("no README in %s/%s; skipping", owner, repo)
|
|
298
|
+
return None
|
|
299
|
+
_raise_for_response(response, doing="the GitHub README fetch", repo=f"{owner}/{repo}")
|
|
300
|
+
payload = _json_object(response)
|
|
301
|
+
content = payload.get("content")
|
|
302
|
+
body = ""
|
|
303
|
+
if isinstance(content, str) and content:
|
|
304
|
+
try:
|
|
305
|
+
body = base64.b64decode(content).decode("utf-8", errors="replace")
|
|
306
|
+
except ValueError:
|
|
307
|
+
logger.debug("undecodable README content in %s/%s; empty body", owner, repo)
|
|
308
|
+
name = opt_str(payload.get("name")) or "README"
|
|
309
|
+
path = opt_str(payload.get("path")) or name
|
|
310
|
+
return ContextItem(
|
|
311
|
+
id=f"{owner}/{repo}:{path}",
|
|
312
|
+
source=_SOURCE,
|
|
313
|
+
kind=ItemKind.DOCUMENT,
|
|
314
|
+
title=name,
|
|
315
|
+
body=body,
|
|
316
|
+
url=opt_str(payload.get("html_url")),
|
|
317
|
+
metadata={"path": path},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
register_connector(GitHubConnector())
|