delphi-code 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {delphi_code-0.2.0 → delphi_code-0.2.1}/PKG-INFO +3 -3
- {delphi_code-0.2.0 → delphi_code-0.2.1}/README.md +2 -2
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/hosts.py +101 -35
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/picker.py +16 -1
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/sources.py +2 -1
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/PKG-INFO +3 -3
- {delphi_code-0.2.0 → delphi_code-0.2.1}/pyproject.toml +1 -1
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_picker.py +88 -20
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_sources.py +3 -1
- {delphi_code-0.2.0 → delphi_code-0.2.1}/LICENSE +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/THIRD_PARTY_NOTICES.md +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/__init__.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/__main__.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/cli.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/doctor.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/download.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/errors.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/files.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/indexing.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/keys.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/manifest.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/model.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/model_provenance.json +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/offline.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/paths.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/projects.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/registry.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/selection.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/setup.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/store.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/SOURCES.txt +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/dependency_links.txt +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/entry_points.txt +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/requires.txt +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/top_level.txt +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/setup.cfg +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_cross_project.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_index_directory.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_model_defaults.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_project_names.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_registry.py +0 -0
- {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delphi-code
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Offline local code search for agents
|
|
5
5
|
Author: Mark Dorofeev
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -168,7 +168,7 @@ delphi-code add github.com/octo/tools
|
|
|
168
168
|
delphi-code add
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
171
|
+
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). If your Bitbucket credentials may not list workspaces, you type the workspace name instead; `BITBUCKET_WORKSPACE` pre-fills it. Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
172
172
|
|
|
173
173
|
**Credentials.** Git never prompts. Credentials from the environment reach git through `GIT_ASKPASS`, never in URLs or arguments, and take precedence over configured git credentials. Listing repositories for the picker uses the services' REST APIs. The API requests run in a separate process, like the model download in `setup`, so the main process keeps its network guard.
|
|
174
174
|
|
|
@@ -177,7 +177,7 @@ Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbu
|
|
|
177
177
|
| Cloning | `BITBUCKET_USERNAME` and `BITBUCKET_APP_PASSWORD`, otherwise git's own credentials | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise git's own credentials (`gh auth setup-git` configures them) |
|
|
178
178
|
| Listing for the picker | The same variables (with `BITBUCKET_EMAIL` in place of the username when set), otherwise the credentials git has stored for bitbucket.org | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise `gh auth token`, otherwise the token git has stored for github.com |
|
|
179
179
|
|
|
180
|
-
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`.
|
|
180
|
+
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`. Cloning and listing a workspace's repositories need the `read:repository:bitbucket` scope. Listing your workspaces also needs `read:workspace:bitbucket`; without it, the picker asks for the workspace name. An error names the credentials that were used and the scopes Bitbucket requires.
|
|
181
181
|
|
|
182
182
|
`DELPHI_CODE_BITBUCKET_GIT_BASE` and `DELPHI_CODE_GITHUB_GIT_BASE` send clones to another base URL, such as a mirror, and `DELPHI_CODE_BITBUCKET_API_BASE` and `DELPHI_CODE_GITHUB_API_BASE` do the same for the APIs. Result links still point to bitbucket.org and github.com.
|
|
183
183
|
|
|
@@ -138,7 +138,7 @@ delphi-code add github.com/octo/tools
|
|
|
138
138
|
delphi-code add
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
141
|
+
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). If your Bitbucket credentials may not list workspaces, you type the workspace name instead; `BITBUCKET_WORKSPACE` pre-fills it. Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
142
142
|
|
|
143
143
|
**Credentials.** Git never prompts. Credentials from the environment reach git through `GIT_ASKPASS`, never in URLs or arguments, and take precedence over configured git credentials. Listing repositories for the picker uses the services' REST APIs. The API requests run in a separate process, like the model download in `setup`, so the main process keeps its network guard.
|
|
144
144
|
|
|
@@ -147,7 +147,7 @@ Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbu
|
|
|
147
147
|
| Cloning | `BITBUCKET_USERNAME` and `BITBUCKET_APP_PASSWORD`, otherwise git's own credentials | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise git's own credentials (`gh auth setup-git` configures them) |
|
|
148
148
|
| Listing for the picker | The same variables (with `BITBUCKET_EMAIL` in place of the username when set), otherwise the credentials git has stored for bitbucket.org | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise `gh auth token`, otherwise the token git has stored for github.com |
|
|
149
149
|
|
|
150
|
-
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`.
|
|
150
|
+
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`. Cloning and listing a workspace's repositories need the `read:repository:bitbucket` scope. Listing your workspaces also needs `read:workspace:bitbucket`; without it, the picker asks for the workspace name. An error names the credentials that were used and the scopes Bitbucket requires.
|
|
151
151
|
|
|
152
152
|
`DELPHI_CODE_BITBUCKET_GIT_BASE` and `DELPHI_CODE_GITHUB_GIT_BASE` send clones to another base URL, such as a mirror, and `DELPHI_CODE_BITBUCKET_API_BASE` and `DELPHI_CODE_GITHUB_API_BASE` do the same for the APIs. Result links still point to bitbucket.org and github.com.
|
|
153
153
|
|
|
@@ -8,7 +8,7 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
from typing import Any, NamedTuple
|
|
10
10
|
from urllib.error import HTTPError, URLError
|
|
11
|
-
from urllib.parse import quote, urlencode
|
|
11
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
12
12
|
from urllib.request import Request, urlopen
|
|
13
13
|
|
|
14
14
|
if __package__:
|
|
@@ -24,6 +24,11 @@ class Owner(NamedTuple):
|
|
|
24
24
|
name: str
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
class ApiCredentials(NamedTuple):
|
|
28
|
+
authorization: str
|
|
29
|
+
source: str
|
|
30
|
+
|
|
31
|
+
|
|
27
32
|
class Repository(NamedTuple):
|
|
28
33
|
host: str
|
|
29
34
|
owner: str
|
|
@@ -43,6 +48,7 @@ class RepositoryHost:
|
|
|
43
48
|
default_api_base: str
|
|
44
49
|
credentials_hint: str
|
|
45
50
|
rejected_credentials_hint = ""
|
|
51
|
+
missing_permission_hint = ""
|
|
46
52
|
|
|
47
53
|
def __init__(self, api_authorization=None):
|
|
48
54
|
self._given_api_authorization = api_authorization
|
|
@@ -57,11 +63,14 @@ class RepositoryHost:
|
|
|
57
63
|
def permalink_template(self, owner, repository, commit):
|
|
58
64
|
raise NotImplementedError
|
|
59
65
|
|
|
66
|
+
def suggested_owner(self):
|
|
67
|
+
return None
|
|
68
|
+
|
|
60
69
|
def clone_credentials(self):
|
|
61
70
|
raise NotImplementedError
|
|
62
71
|
|
|
63
72
|
def has_api_credentials(self):
|
|
64
|
-
return
|
|
73
|
+
return self._available_api_credentials is not None
|
|
65
74
|
|
|
66
75
|
def owners(self):
|
|
67
76
|
return [Owner(**item) for item in self._list_in_child_process("owners")]
|
|
@@ -69,7 +78,7 @@ class RepositoryHost:
|
|
|
69
78
|
def repositories(self, owner):
|
|
70
79
|
return [Repository(**item) for item in self._list_in_child_process("repositories", owner)]
|
|
71
80
|
|
|
72
|
-
def
|
|
81
|
+
def _find_api_credentials(self):
|
|
73
82
|
raise NotImplementedError
|
|
74
83
|
|
|
75
84
|
def _list_owners(self):
|
|
@@ -93,19 +102,21 @@ class RepositoryHost:
|
|
|
93
102
|
return os.environ.get(f"{self._variable_prefix}_API_BASE", self.default_api_base).rstrip("/")
|
|
94
103
|
|
|
95
104
|
@cached_property
|
|
96
|
-
def
|
|
97
|
-
|
|
105
|
+
def _available_api_credentials(self):
|
|
106
|
+
if self._given_api_authorization:
|
|
107
|
+
return ApiCredentials(self._given_api_authorization, "given credentials")
|
|
108
|
+
return self._find_api_credentials()
|
|
98
109
|
|
|
99
110
|
@property
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
if
|
|
111
|
+
def _api_credentials(self):
|
|
112
|
+
credentials = self._available_api_credentials
|
|
113
|
+
if credentials is None:
|
|
103
114
|
raise Failure(
|
|
104
115
|
"remote_auth_missing",
|
|
105
116
|
f"Listing {self.name} repositories needs credentials: {self.credentials_hint}",
|
|
106
117
|
ExitCode.RUNTIME_ASSETS,
|
|
107
118
|
)
|
|
108
|
-
return
|
|
119
|
+
return credentials
|
|
109
120
|
|
|
110
121
|
def _repository(self, owner, slug, description, private):
|
|
111
122
|
return Repository(self.domain, owner, slug, description or "", bool(private))
|
|
@@ -129,7 +140,8 @@ class RepositoryHost:
|
|
|
129
140
|
return json.load(response), (response.headers.get("Link") if response.headers else None) or ""
|
|
130
141
|
|
|
131
142
|
def _list_in_child_process(self, listing, *arguments) -> list[dict]:
|
|
132
|
-
|
|
143
|
+
credentials = self._api_credentials
|
|
144
|
+
environment = dict(os.environ, DELPHI_CODE_API_AUTHORIZATION=credentials.authorization)
|
|
133
145
|
try:
|
|
134
146
|
result = subprocess.run(
|
|
135
147
|
[sys.executable, __file__, self.domain, listing, *arguments],
|
|
@@ -152,10 +164,18 @@ class RepositoryHost:
|
|
|
152
164
|
if result.returncode == 0:
|
|
153
165
|
return payload
|
|
154
166
|
error = payload.get("error", {})
|
|
155
|
-
if error.get("status")
|
|
167
|
+
if error.get("status") == 401:
|
|
156
168
|
raise Failure(
|
|
157
169
|
"remote_auth_failed",
|
|
158
|
-
f"{self.name}
|
|
170
|
+
f"{self.name} rejected the {credentials.source}: {error['message']}. "
|
|
171
|
+
f"{self.rejected_credentials_hint or self.credentials_hint}",
|
|
172
|
+
ExitCode.OPERATION,
|
|
173
|
+
)
|
|
174
|
+
if error.get("status") == 403:
|
|
175
|
+
raise Failure(
|
|
176
|
+
"remote_permission_denied",
|
|
177
|
+
f"{self.name} accepted the {credentials.source} but refused {error.get('path')}: {error['message']}. "
|
|
178
|
+
f"{self.missing_permission_hint}",
|
|
159
179
|
ExitCode.OPERATION,
|
|
160
180
|
)
|
|
161
181
|
raise Failure(
|
|
@@ -170,39 +190,43 @@ class Bitbucket(RepositoryHost):
|
|
|
170
190
|
default_api_base = "https://api.bitbucket.org/2.0"
|
|
171
191
|
credentials_hint = "set BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD, or store git credentials for bitbucket.org"
|
|
172
192
|
rejected_credentials_hint = "With an API token, set BITBUCKET_EMAIL to your Atlassian account email"
|
|
193
|
+
missing_permission_hint = (
|
|
194
|
+
"Listing needs an API token with the scopes read:user:bitbucket, read:workspace:bitbucket and "
|
|
195
|
+
"read:repository:bitbucket, or an app password with Account, Workspace membership and Repositories read access"
|
|
196
|
+
)
|
|
173
197
|
_repository_fields = "next,values.slug,values.description,values.is_private"
|
|
174
198
|
|
|
175
199
|
def permalink_template(self, owner, repository, commit):
|
|
176
200
|
return f"{self.web_url(owner, repository)}/src/{commit}/{{path}}#lines-{{start}}:{{end}}"
|
|
177
201
|
|
|
202
|
+
def suggested_owner(self):
|
|
203
|
+
return os.environ.get("BITBUCKET_WORKSPACE")
|
|
204
|
+
|
|
178
205
|
def clone_credentials(self):
|
|
179
206
|
username, password = os.environ.get("BITBUCKET_USERNAME"), os.environ.get("BITBUCKET_APP_PASSWORD")
|
|
180
207
|
return (username, password) if username and password else None
|
|
181
208
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
password = os.environ.get("BITBUCKET_APP_PASSWORD")
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
def _find_api_credentials(self):
|
|
210
|
+
username_variable = "BITBUCKET_EMAIL" if os.environ.get("BITBUCKET_EMAIL") else "BITBUCKET_USERNAME"
|
|
211
|
+
username, password = os.environ.get(username_variable), os.environ.get("BITBUCKET_APP_PASSWORD")
|
|
212
|
+
if username and password:
|
|
213
|
+
source = f"{username_variable} and BITBUCKET_APP_PASSWORD from the environment"
|
|
214
|
+
else:
|
|
215
|
+
stored = _git_stored_credentials(self.domain)
|
|
216
|
+
if stored is None:
|
|
217
|
+
return None
|
|
218
|
+
(username, password), source = stored, f"git credentials stored for {self.domain}"
|
|
219
|
+
return ApiCredentials("Basic " + base64.b64encode(f"{username}:{password}".encode()).decode(), source)
|
|
187
220
|
|
|
188
221
|
def _workspaces_url(self):
|
|
189
222
|
return f"{self._api_base}/user/workspaces?pagelen={PAGE_LENGTH}"
|
|
190
223
|
|
|
191
|
-
def _legacy_workspaces_url(self):
|
|
192
|
-
return f"{self._api_base}/user/permissions/workspaces?pagelen={PAGE_LENGTH}"
|
|
193
|
-
|
|
194
224
|
def _repositories_url(self, workspace):
|
|
195
225
|
query = urlencode({"role": "member", "pagelen": PAGE_LENGTH, "sort": "slug", "fields": self._repository_fields})
|
|
196
226
|
return f"{self._api_base}/repositories/{quote(workspace)}?{query}"
|
|
197
227
|
|
|
198
228
|
def _list_owners(self):
|
|
199
|
-
|
|
200
|
-
memberships = self._fetch_pages(self._workspaces_url())
|
|
201
|
-
except HTTPError as exc:
|
|
202
|
-
exc.close()
|
|
203
|
-
if exc.code not in (404, 410):
|
|
204
|
-
raise
|
|
205
|
-
memberships = self._fetch_pages(self._legacy_workspaces_url())
|
|
229
|
+
memberships = self._fetch_pages(self._workspaces_url())
|
|
206
230
|
names_by_slug = {}
|
|
207
231
|
for membership in memberships:
|
|
208
232
|
workspace = membership.get("workspace", membership)
|
|
@@ -228,6 +252,9 @@ class GitHub(RepositoryHost):
|
|
|
228
252
|
owner_noun = "account"
|
|
229
253
|
default_api_base = "https://api.github.com"
|
|
230
254
|
credentials_hint = "set GITHUB_TOKEN, sign in with gh auth login, or store git credentials for github.com"
|
|
255
|
+
missing_permission_hint = (
|
|
256
|
+
"Listing needs a classic token with the repo scope, or a fine-grained token with Metadata read access"
|
|
257
|
+
)
|
|
231
258
|
_next_link = re.compile(r'<([^>]+)>;\s*rel="next"')
|
|
232
259
|
|
|
233
260
|
def permalink_template(self, owner, repository, commit):
|
|
@@ -237,12 +264,16 @@ class GitHub(RepositoryHost):
|
|
|
237
264
|
token = _github_environment_token()
|
|
238
265
|
return ("x-access-token", token) if token else None
|
|
239
266
|
|
|
240
|
-
def
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
token
|
|
245
|
-
|
|
267
|
+
def _find_api_credentials(self):
|
|
268
|
+
for source, find_token in (
|
|
269
|
+
("GITHUB_TOKEN or GH_TOKEN from the environment", _github_environment_token),
|
|
270
|
+
("token from gh auth token", lambda: _gh_token(self.domain)),
|
|
271
|
+
(f"token stored in git credentials for {self.domain}", lambda: _git_stored_token(self.domain)),
|
|
272
|
+
):
|
|
273
|
+
token = find_token()
|
|
274
|
+
if token:
|
|
275
|
+
return ApiCredentials(f"Bearer {token}", source)
|
|
276
|
+
return None
|
|
246
277
|
|
|
247
278
|
def owners(self):
|
|
248
279
|
owners = sorted({repository.owner for repository in self._accessible_repositories}, key=str.lower)
|
|
@@ -304,6 +335,11 @@ def _gh_token(domain):
|
|
|
304
335
|
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
|
|
305
336
|
|
|
306
337
|
|
|
338
|
+
def _git_stored_token(domain):
|
|
339
|
+
stored = _git_stored_credentials(domain)
|
|
340
|
+
return stored[1] if stored else None
|
|
341
|
+
|
|
342
|
+
|
|
307
343
|
def _git_stored_credentials(domain):
|
|
308
344
|
environment = {name: value for name, value in os.environ.items() if name not in {"GIT_ASKPASS", "SSH_ASKPASS"}}
|
|
309
345
|
environment["GIT_TERMINAL_PROMPT"] = "0"
|
|
@@ -324,14 +360,44 @@ def _git_stored_credentials(domain):
|
|
|
324
360
|
return fields["username"], fields["password"]
|
|
325
361
|
|
|
326
362
|
|
|
363
|
+
def _error_body(exc):
|
|
364
|
+
try:
|
|
365
|
+
return exc.read()
|
|
366
|
+
except (OSError, ValueError):
|
|
367
|
+
return b""
|
|
368
|
+
finally:
|
|
369
|
+
exc.close()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _service_error_message(body):
|
|
373
|
+
try:
|
|
374
|
+
payload = json.loads(body)
|
|
375
|
+
except ValueError:
|
|
376
|
+
return None
|
|
377
|
+
if not isinstance(payload, dict):
|
|
378
|
+
return None
|
|
379
|
+
bitbucket_error = payload.get("error")
|
|
380
|
+
if not isinstance(bitbucket_error, dict):
|
|
381
|
+
return payload.get("message")
|
|
382
|
+
message, detail = bitbucket_error.get("message"), bitbucket_error.get("detail")
|
|
383
|
+
if isinstance(detail, dict):
|
|
384
|
+
detail = "; ".join(f"{name}: {_listed(value)}" for name, value in detail.items())
|
|
385
|
+
return f"{message} ({detail})" if message and detail else message or detail
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _listed(value):
|
|
389
|
+
return ", ".join(map(str, value)) if isinstance(value, list) else str(value)
|
|
390
|
+
|
|
391
|
+
|
|
327
392
|
def main(arguments):
|
|
328
393
|
domain, listing, *listing_arguments = arguments
|
|
329
394
|
host = HOSTS_BY_DOMAIN[domain](os.environ["DELPHI_CODE_API_AUTHORIZATION"])
|
|
330
395
|
try:
|
|
331
396
|
items = host._list_owners() if listing == "owners" else host._list_repositories(*listing_arguments)
|
|
332
397
|
except HTTPError as exc:
|
|
333
|
-
exc.
|
|
334
|
-
|
|
398
|
+
reason = _service_error_message(_error_body(exc)) or exc.reason
|
|
399
|
+
path = urlsplit(exc.url).path if exc.url else None
|
|
400
|
+
print(json.dumps({"error": {"status": exc.code, "path": path, "message": f"HTTP {exc.code} {reason}"}}))
|
|
335
401
|
return 1
|
|
336
402
|
except (URLError, OSError, ValueError, KeyError, TypeError) as exc:
|
|
337
403
|
print(json.dumps({"error": {"status": None, "message": str(exc)}}))
|
|
@@ -78,7 +78,12 @@ class RepositoryPicker:
|
|
|
78
78
|
def _choose_owner(self, host):
|
|
79
79
|
import questionary
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
try:
|
|
82
|
+
owners = host.owners()
|
|
83
|
+
except Failure as failure:
|
|
84
|
+
if failure.code != "remote_permission_denied":
|
|
85
|
+
raise
|
|
86
|
+
return self._type_owner(host)
|
|
82
87
|
if not owners:
|
|
83
88
|
raise Failure("remote_empty", f"Your {host.name} account has no repositories", ExitCode.USAGE)
|
|
84
89
|
if len(owners) == 1:
|
|
@@ -91,6 +96,16 @@ class RepositoryPicker:
|
|
|
91
96
|
**self._terminal,
|
|
92
97
|
).ask()
|
|
93
98
|
|
|
99
|
+
def _type_owner(self, host):
|
|
100
|
+
import questionary
|
|
101
|
+
|
|
102
|
+
answer = questionary.text(
|
|
103
|
+
f"These credentials cannot list {host.name} {host.owner_noun}s. {host.name} {host.owner_noun} to index:",
|
|
104
|
+
default=host.suggested_owner() or "",
|
|
105
|
+
**self._terminal,
|
|
106
|
+
).ask()
|
|
107
|
+
return (answer or "").strip() or None
|
|
108
|
+
|
|
94
109
|
def _choose_repositories(self, owner, repositories, tracked_keys):
|
|
95
110
|
import questionary
|
|
96
111
|
|
|
@@ -110,7 +110,8 @@ class GitRemoteSource:
|
|
|
110
110
|
return Revision(branch, commit)
|
|
111
111
|
|
|
112
112
|
def _named_revision(self, ref):
|
|
113
|
-
|
|
113
|
+
advertised = self._remote_refs(f"refs/heads/{ref}", f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}")
|
|
114
|
+
commits = dict((name, value) for value, name in advertised)
|
|
114
115
|
commit = (
|
|
115
116
|
commits.get(f"refs/heads/{ref}") or commits.get(f"refs/tags/{ref}^{{}}") or commits.get(f"refs/tags/{ref}")
|
|
116
117
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delphi-code
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Offline local code search for agents
|
|
5
5
|
Author: Mark Dorofeev
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -168,7 +168,7 @@ delphi-code add github.com/octo/tools
|
|
|
168
168
|
delphi-code add
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
171
|
+
Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbucket workspace or GitHub account (skipped when there is only one). If your Bitbucket credentials may not list workspaces, you type the workspace name instead; `BITBUCKET_WORKSPACE` pre-fills it. Then check repositories in a list you can filter by typing. Repositories you already track start checked, so unchecking one stops tracking it and deletes its index. After you confirm, the new repositories are indexed. Filter options and `--ref` apply to the repositories you add. The prompts are drawn on stderr, and stdout still receives one JSON object with the added repositories under `repos` and the untracked ones under `removed`. Cancelling changes nothing and reports `"cancelled": true`. Without a terminal, `add` requires sources.
|
|
172
172
|
|
|
173
173
|
**Credentials.** Git never prompts. Credentials from the environment reach git through `GIT_ASKPASS`, never in URLs or arguments, and take precedence over configured git credentials. Listing repositories for the picker uses the services' REST APIs. The API requests run in a separate process, like the model download in `setup`, so the main process keeps its network guard.
|
|
174
174
|
|
|
@@ -177,7 +177,7 @@ Choose Bitbucket or GitHub (asked only when both have credentials), then a Bitbu
|
|
|
177
177
|
| Cloning | `BITBUCKET_USERNAME` and `BITBUCKET_APP_PASSWORD`, otherwise git's own credentials | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise git's own credentials (`gh auth setup-git` configures them) |
|
|
178
178
|
| Listing for the picker | The same variables (with `BITBUCKET_EMAIL` in place of the username when set), otherwise the credentials git has stored for bitbucket.org | `GITHUB_TOKEN` or `GH_TOKEN`, otherwise `gh auth token`, otherwise the token git has stored for github.com |
|
|
179
179
|
|
|
180
|
-
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`.
|
|
180
|
+
Bitbucket has replaced app passwords with API tokens. An API token also works in `BITBUCKET_APP_PASSWORD`. Git over HTTPS takes the username that Bitbucket shows for it, and the REST API takes your Atlassian account email, which you set in `BITBUCKET_EMAIL`. Cloning and listing a workspace's repositories need the `read:repository:bitbucket` scope. Listing your workspaces also needs `read:workspace:bitbucket`; without it, the picker asks for the workspace name. An error names the credentials that were used and the scopes Bitbucket requires.
|
|
181
181
|
|
|
182
182
|
`DELPHI_CODE_BITBUCKET_GIT_BASE` and `DELPHI_CODE_GITHUB_GIT_BASE` send clones to another base URL, such as a mirror, and `DELPHI_CODE_BITBUCKET_API_BASE` and `DELPHI_CODE_GITHUB_API_BASE` do the same for the APIs. Result links still point to bitbucket.org and github.com.
|
|
183
183
|
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
8
|
import tempfile
|
|
9
|
+
from typing import NamedTuple
|
|
9
10
|
import unittest
|
|
10
11
|
from unittest.mock import MagicMock, patch
|
|
11
12
|
from urllib.error import HTTPError
|
|
@@ -15,7 +16,7 @@ from prompt_toolkit.input import create_pipe_input
|
|
|
15
16
|
from prompt_toolkit.output import DummyOutput
|
|
16
17
|
|
|
17
18
|
from delphi_code import hosts
|
|
18
|
-
from delphi_code.errors import Failure
|
|
19
|
+
from delphi_code.errors import ExitCode, Failure
|
|
19
20
|
from delphi_code.hosts import Bitbucket, GitHub, Owner, Repository, configured_hosts
|
|
20
21
|
from delphi_code.picker import RepositoryPicker, TrackingChanges
|
|
21
22
|
|
|
@@ -29,12 +30,18 @@ def answering(value):
|
|
|
29
30
|
class FakeHost:
|
|
30
31
|
owner_noun = "workspace"
|
|
31
32
|
|
|
32
|
-
def __init__(self, owners, repositories, name="Bitbucket"):
|
|
33
|
+
def __init__(self, owners, repositories, name="Bitbucket", suggested_owner=None):
|
|
33
34
|
self._owners, self._repositories, self.name = owners, repositories, name
|
|
35
|
+
self._suggested_owner = suggested_owner
|
|
34
36
|
|
|
35
37
|
def owners(self):
|
|
38
|
+
if isinstance(self._owners, Failure):
|
|
39
|
+
raise self._owners
|
|
36
40
|
return self._owners
|
|
37
41
|
|
|
42
|
+
def suggested_owner(self):
|
|
43
|
+
return self._suggested_owner
|
|
44
|
+
|
|
38
45
|
def repositories(self, owner):
|
|
39
46
|
return [repository for repository in self._repositories if repository.owner == owner]
|
|
40
47
|
|
|
@@ -108,6 +115,28 @@ class Picking(unittest.TestCase):
|
|
|
108
115
|
with patch("questionary.select", answering(None)):
|
|
109
116
|
self.assertIsNone(two_hosts.choose(set()))
|
|
110
117
|
|
|
118
|
+
def test_owner_is_typed_when_listing_owners_is_forbidden(self):
|
|
119
|
+
forbidden = Failure("remote_permission_denied", "lacks read:workspace:bitbucket", ExitCode.OPERATION)
|
|
120
|
+
host = FakeHost(forbidden, ACME, suggested_owner="acme")
|
|
121
|
+
with (
|
|
122
|
+
patch("questionary.text", answering(" acme ")) as text,
|
|
123
|
+
patch("questionary.checkbox", answering(["bitbucket.org/acme/api"])),
|
|
124
|
+
patch("questionary.confirm", answering(True)),
|
|
125
|
+
):
|
|
126
|
+
changes = RepositoryPicker([host], terminal={}).choose(set())
|
|
127
|
+
self.assertEqual(text.call_args.kwargs["default"], "acme")
|
|
128
|
+
self.assertEqual(changes, TrackingChanges(["bitbucket.org/acme/api"], []))
|
|
129
|
+
for cancelled in (None, " "):
|
|
130
|
+
with patch("questionary.text", answering(cancelled)):
|
|
131
|
+
self.assertIsNone(RepositoryPicker([FakeHost(forbidden, ACME)], terminal={}).choose(set()))
|
|
132
|
+
|
|
133
|
+
def test_other_listing_failures_are_not_turned_into_questions(self):
|
|
134
|
+
unavailable = Failure("remote_unavailable", "down", ExitCode.OPERATION)
|
|
135
|
+
with patch("questionary.text") as text, self.assertRaises(Failure) as raised:
|
|
136
|
+
RepositoryPicker([FakeHost(unavailable, ACME)], terminal={}).choose(set())
|
|
137
|
+
self.assertEqual(raised.exception.code, "remote_unavailable")
|
|
138
|
+
text.assert_not_called()
|
|
139
|
+
|
|
111
140
|
def test_unchanged_selection_needs_no_confirmation(self):
|
|
112
141
|
with (
|
|
113
142
|
patch("questionary.checkbox", answering(["bitbucket.org/acme/api"])),
|
|
@@ -165,6 +194,20 @@ class FakeResponse(io.BytesIO):
|
|
|
165
194
|
self.headers = {"Link": link}
|
|
166
195
|
|
|
167
196
|
|
|
197
|
+
class HttpFailure(NamedTuple):
|
|
198
|
+
status: int
|
|
199
|
+
body: object = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
BITBUCKET_SCOPE_ERROR = {
|
|
203
|
+
"type": "error",
|
|
204
|
+
"error": {
|
|
205
|
+
"message": "Your credentials lack one or more required privilege scopes.",
|
|
206
|
+
"detail": {"granted": ["repository"], "required": ["account"]},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
168
211
|
class HostListingScript(unittest.TestCase):
|
|
169
212
|
def setUp(self):
|
|
170
213
|
self.enterContext(
|
|
@@ -184,8 +227,10 @@ class HostListingScript(unittest.TestCase):
|
|
|
184
227
|
def urlopen(request, timeout, context):
|
|
185
228
|
requests.append(request)
|
|
186
229
|
page = pages_by_path[urlsplit(request.full_url).path]
|
|
187
|
-
if isinstance(page,
|
|
188
|
-
raise HTTPError(
|
|
230
|
+
if isinstance(page, HttpFailure):
|
|
231
|
+
raise HTTPError(
|
|
232
|
+
request.full_url, page.status, "Refused", {}, io.BytesIO(json.dumps(page.body).encode())
|
|
233
|
+
)
|
|
189
234
|
return page if isinstance(page, FakeResponse) else FakeResponse(page)
|
|
190
235
|
|
|
191
236
|
self.enterContext(patch.object(hosts, "urlopen", side_effect=urlopen))
|
|
@@ -218,20 +263,6 @@ class HostListingScript(unittest.TestCase):
|
|
|
218
263
|
)
|
|
219
264
|
self.assertEqual(requests[0].get_header("Authorization"), "Basic bWU6cHc=")
|
|
220
265
|
|
|
221
|
-
def test_bitbucket_workspaces_fall_back_to_the_permissions_endpoint(self):
|
|
222
|
-
self.serve(
|
|
223
|
-
{
|
|
224
|
-
"/2.0/user/workspaces": 410,
|
|
225
|
-
"/2.0/user/permissions/workspaces": {
|
|
226
|
-
"values": [{"workspace": {"slug": "zeta", "name": "Zeta"}}, {"workspace": {"slug": "acme"}}]
|
|
227
|
-
},
|
|
228
|
-
}
|
|
229
|
-
)
|
|
230
|
-
self.assertEqual(
|
|
231
|
-
self.listed("bitbucket.org", "owners"),
|
|
232
|
-
(0, [{"slug": "acme", "name": "acme"}, {"slug": "zeta", "name": "Zeta"}]),
|
|
233
|
-
)
|
|
234
|
-
|
|
235
266
|
def test_github_pages_follow_link_headers(self):
|
|
236
267
|
self.serve(
|
|
237
268
|
{
|
|
@@ -252,11 +283,30 @@ class HostListingScript(unittest.TestCase):
|
|
|
252
283
|
)
|
|
253
284
|
|
|
254
285
|
def test_http_errors_are_reported_as_json(self):
|
|
255
|
-
self.serve({"/2.0/user/workspaces": 401})
|
|
286
|
+
self.serve({"/2.0/user/workspaces": HttpFailure(401)})
|
|
287
|
+
self.assertEqual(
|
|
288
|
+
self.listed("bitbucket.org", "owners"),
|
|
289
|
+
(1, {"error": {"status": 401, "path": "/2.0/user/workspaces", "message": "HTTP 401 Refused"}}),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def test_bitbucket_scope_errors_name_the_missing_scopes(self):
|
|
293
|
+
self.serve(
|
|
294
|
+
{
|
|
295
|
+
"/2.0/user/workspaces": HttpFailure(403, BITBUCKET_SCOPE_ERROR),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
code, output = self.listed("bitbucket.org", "owners")
|
|
299
|
+
self.assertEqual((code, output["error"]["status"], output["error"]["path"]), (1, 403, "/2.0/user/workspaces"))
|
|
256
300
|
self.assertEqual(
|
|
257
|
-
|
|
301
|
+
output["error"]["message"],
|
|
302
|
+
"HTTP 403 Your credentials lack one or more required privilege scopes. (granted: repository; required: account)",
|
|
258
303
|
)
|
|
259
304
|
|
|
305
|
+
def test_github_error_messages_are_reported(self):
|
|
306
|
+
self.serve({"/user/repos": HttpFailure(403, {"message": "Resource not accessible by personal access token"})})
|
|
307
|
+
_, output = self.listed("github.com", "repositories")
|
|
308
|
+
self.assertEqual(output["error"]["message"], "HTTP 403 Resource not accessible by personal access token")
|
|
309
|
+
|
|
260
310
|
|
|
261
311
|
class Hosts(unittest.TestCase):
|
|
262
312
|
def setUp(self):
|
|
@@ -306,6 +356,24 @@ class Hosts(unittest.TestCase):
|
|
|
306
356
|
self.assertEqual(raised.exception.code, "remote_auth_failed")
|
|
307
357
|
self.assertIn("BITBUCKET_EMAIL", str(raised.exception))
|
|
308
358
|
|
|
359
|
+
def test_forbidden_listing_names_the_credentials_used_and_the_scopes_needed(self):
|
|
360
|
+
self.isolate_git_and_gh().write_text("https://stored-user:stored-pass@bitbucket.org\n")
|
|
361
|
+
bitbucket = Bitbucket()
|
|
362
|
+
self.assertTrue(bitbucket.has_api_credentials())
|
|
363
|
+
forbidden = subprocess.CompletedProcess(
|
|
364
|
+
[],
|
|
365
|
+
1,
|
|
366
|
+
json.dumps({"error": {"status": 403, "path": "/2.0/user/workspaces", "message": "HTTP 403 lacks scopes"}}),
|
|
367
|
+
"",
|
|
368
|
+
)
|
|
369
|
+
with patch("delphi_code.hosts.subprocess.run", return_value=forbidden), self.assertRaises(Failure) as raised:
|
|
370
|
+
bitbucket.owners()
|
|
371
|
+
self.assertEqual(raised.exception.code, "remote_permission_denied")
|
|
372
|
+
message = str(raised.exception)
|
|
373
|
+
self.assertIn("accepted the git credentials stored for bitbucket.org but refused /2.0/user/workspaces", message)
|
|
374
|
+
self.assertIn("read:workspace:bitbucket", message)
|
|
375
|
+
self.assertNotIn("BITBUCKET_EMAIL", message)
|
|
376
|
+
|
|
309
377
|
def authorization_sent(self, host):
|
|
310
378
|
self.assertTrue(host.has_api_credentials())
|
|
311
379
|
answered = subprocess.CompletedProcess([], 0, "[]", "")
|
|
@@ -49,7 +49,8 @@ class LocalHostWithApiRepository(unittest.TestCase):
|
|
|
49
49
|
git("config", "user.name", "Test", cwd=self.work)
|
|
50
50
|
self.commit("parse.py", "def parse(text):\n return text\n")
|
|
51
51
|
git("tag", "v1", cwd=self.work)
|
|
52
|
-
git("
|
|
52
|
+
git("tag", "--annotate", "v1-annotated", "--message", "release", cwd=self.work)
|
|
53
|
+
git("push", "-q", "origin", "main", "v1", "v1-annotated", cwd=self.work)
|
|
53
54
|
|
|
54
55
|
def commit(self, name, text):
|
|
55
56
|
(self.work / name).write_text(text)
|
|
@@ -98,6 +99,7 @@ class RemoteGitCheckouts(LocalHostWithApiRepository):
|
|
|
98
99
|
head = git("rev-parse", "HEAD", cwd=self.work)
|
|
99
100
|
self.assertEqual(source.latest_revision(None), ("main", head))
|
|
100
101
|
self.assertEqual(source.latest_revision("v1"), ("v1", head))
|
|
102
|
+
self.assertEqual(source.latest_revision("v1-annotated"), ("v1-annotated", head))
|
|
101
103
|
with self.assertRaises(Failure) as raised:
|
|
102
104
|
source.latest_revision("missing")
|
|
103
105
|
self.assertEqual(raised.exception.code, "remote_ref_missing")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|