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.
Files changed (42) hide show
  1. {delphi_code-0.2.0 → delphi_code-0.2.1}/PKG-INFO +3 -3
  2. {delphi_code-0.2.0 → delphi_code-0.2.1}/README.md +2 -2
  3. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/hosts.py +101 -35
  4. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/picker.py +16 -1
  5. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/sources.py +2 -1
  6. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/PKG-INFO +3 -3
  7. {delphi_code-0.2.0 → delphi_code-0.2.1}/pyproject.toml +1 -1
  8. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_picker.py +88 -20
  9. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_sources.py +3 -1
  10. {delphi_code-0.2.0 → delphi_code-0.2.1}/LICENSE +0 -0
  11. {delphi_code-0.2.0 → delphi_code-0.2.1}/THIRD_PARTY_NOTICES.md +0 -0
  12. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/__init__.py +0 -0
  13. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/__main__.py +0 -0
  14. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/cli.py +0 -0
  15. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/doctor.py +0 -0
  16. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/download.py +0 -0
  17. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/errors.py +0 -0
  18. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/files.py +0 -0
  19. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/indexing.py +0 -0
  20. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/keys.py +0 -0
  21. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/manifest.py +0 -0
  22. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/model.py +0 -0
  23. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/model_provenance.json +0 -0
  24. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/offline.py +0 -0
  25. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/paths.py +0 -0
  26. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/projects.py +0 -0
  27. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/registry.py +0 -0
  28. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/selection.py +0 -0
  29. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/setup.py +0 -0
  30. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code/store.py +0 -0
  31. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/SOURCES.txt +0 -0
  32. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/dependency_links.txt +0 -0
  33. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/entry_points.txt +0 -0
  34. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/requires.txt +0 -0
  35. {delphi_code-0.2.0 → delphi_code-0.2.1}/delphi_code.egg-info/top_level.txt +0 -0
  36. {delphi_code-0.2.0 → delphi_code-0.2.1}/setup.cfg +0 -0
  37. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_cross_project.py +0 -0
  38. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_index_directory.py +0 -0
  39. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_model_defaults.py +0 -0
  40. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_project_names.py +0 -0
  41. {delphi_code-0.2.0 → delphi_code-0.2.1}/tests/test_registry.py +0 -0
  42. {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.0
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 bool(self._available_api_authorization)
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 _find_api_authorization(self):
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 _available_api_authorization(self):
97
- return self._given_api_authorization or self._find_api_authorization()
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 _api_authorization(self):
101
- authorization = self._available_api_authorization
102
- if not authorization:
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 authorization
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
- environment = dict(os.environ, DELPHI_CODE_API_AUTHORIZATION=self._api_authorization)
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") in (401, 403):
167
+ if error.get("status") == 401:
156
168
  raise Failure(
157
169
  "remote_auth_failed",
158
- f"{self.name} refused the credentials: {error['message']}. {self.rejected_credentials_hint or self.credentials_hint}",
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 _find_api_authorization(self):
183
- username = os.environ.get("BITBUCKET_EMAIL") or os.environ.get("BITBUCKET_USERNAME")
184
- password = os.environ.get("BITBUCKET_APP_PASSWORD")
185
- credentials = (username, password) if username and password else _git_stored_credentials(self.domain)
186
- return "Basic " + base64.b64encode(":".join(credentials).encode()).decode() if credentials else None
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
- try:
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 _find_api_authorization(self):
241
- token = _github_environment_token() or _gh_token(self.domain)
242
- if not token:
243
- stored = _git_stored_credentials(self.domain)
244
- token = stored[1] if stored else None
245
- return f"Bearer {token}" if token else None
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.close()
334
- print(json.dumps({"error": {"status": exc.code, "message": f"HTTP {exc.code} {exc.reason}"}}))
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
- owners = host.owners()
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
- commits = dict((name, value) for value, name in self._remote_refs(f"refs/heads/{ref}", f"refs/tags/{ref}"))
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.0
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "delphi-code"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Offline local code search for agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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, int):
188
- raise HTTPError(request.full_url, page, "Refused", {}, io.BytesIO())
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
- self.listed("bitbucket.org", "owners"), (1, {"error": {"status": 401, "message": "HTTP 401 Refused"}})
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("push", "-q", "origin", "main", "v1", cwd=self.work)
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