Add channel token support to importchannel management command#14056
Conversation
7f66ea8 to
f9d039f
Compare
Build Artifacts
Smoke test screenshot |
f9d039f to
a9dbaa8
Compare
a9dbaa8 to
940d830
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean implementation of channel token resolution for the importchannel command, addressing #3733.
CI passing. Two suggestions below; no blocking issues found.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Reviewed the pull request diff checking for:
- Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
- Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
- Architecture: duplicated concerns, minimal interfaces, composition over inheritance
- Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
- Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
- Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
- Checked CI status and linked issue acceptance criteria
- For UI changes: inspected screenshots for layout, visual completeness, and consistency
| baseurl = baseurl or CENTRAL_CONTENT_BASE_URL | ||
| lookup_url = get_channel_lookup_url(identifier=token, baseurl=baseurl) | ||
| client = NetworkClient.build_for_address(baseurl) | ||
| response = client.get(lookup_url) |
There was a problem hiding this comment.
suggestion: get_channel_lookup_url returns a fully-qualified URL (scheme + host + path), but NetworkClient.request() does url = join_url(self.base_url, path) — so it joins the base URL with the full URL again. This happens to work because urljoin treats an absolute second argument as-is, but it's fragile and non-idiomatic for this client.
Consider passing just the path portion instead, e.g.:
lookup_path = "api/public/v1/channels/lookup/{}?".format(token)
response = client.get(lookup_path)Or use get_channel_lookup_url without baseurl and strip the prefix — whichever fits the existing patterns better.
| channel_id, all_channels = resolve_channel_token( | ||
| identifier, baseurl=baseurl | ||
| ) | ||
| except (NetworkLocationConnectionFailure, NetworkLocationNotFound): |
There was a problem hiding this comment.
suggestion: NetworkClient.request() can also raise NetworkLocationResponseTimeout (when the server is reachable but the request times out). This isn't caught here, so it would surface as an unhandled traceback instead of a clean CommandError. Consider adding it to this except clause.
| ) | ||
|
|
||
| def copy_channel(self, channel_id, source_path, no_upgrade, content_dir): | ||
| if not is_valid_uuid(channel_id): |
There was a problem hiding this comment.
praise: Good guardrail — rejecting tokens early for disk imports with a clear error message avoids a confusing failure later in the pipeline.
AlexVelezLl
left a comment
There was a problem hiding this comment.
Code changes look good to me, and manual QA checks out. Not approving yet, since notes from rtibblesbot seem valuable.
940d830 to
798d43d
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Prior findings
Resolved:
- Use path instead of full URL with
NetworkClient(suggestion) —get_channel_lookup_urlreplaced with inline path construction atimportchannel.py:33 - Catch
NetworkLocationResponseTimeout(suggestion) — added to except clause atimportchannel.py:134
2/2 prior suggestions resolved.
Clean implementation addressing both prior suggestions. CI still running. No new issues found.
The inline path "api/public/v1/channels/lookup/{}?" duplicates knowledge from get_channel_lookup_url in paths.py, but given the URL-joining issue that motivated the change, this is a reasonable trade-off.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| :raises: ValueError if response is invalid | ||
| """ | ||
| baseurl = baseurl or CENTRAL_CONTENT_BASE_URL | ||
| lookup_path = "api/public/v1/channels/lookup/{}?".format(token) |
There was a problem hiding this comment.
suggestion: The trailing ? is carried over from get_channel_lookup_url where it precedes query parameters (keyword, language). Since this call site never appends query params, the ? is unnecessary. It's harmless (servers ignore empty query strings), but could be dropped for clarity:
lookup_path = "api/public/v1/channels/lookup/{}".format(token)| # Verify token was resolved (NetworkClient was called) | ||
| mock_network_client_class.build_for_address.assert_called() | ||
| # Verify the import proceeded | ||
| import_channel_mock.assert_called_once() |
There was a problem hiding this comment.
praise: Thorough end-to-end test — mocks the full chain from token resolution through file download, confirming the token path integrates correctly with the existing import machinery.
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean implementation of token support for importchannel. Both prior suggestions resolved.
Prior findings
2 prior findings unchanged (2 resolved).
All prior findings remain resolved — no changes since last review.
CI: prior run passed; latest run still in progress. No UI changes.
- 1 suggestion (inline)
- 1 praise (inline)
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
|
|
||
| if not channels or not isinstance(channels, list): | ||
| raise NetworkLocationResponseFailure( | ||
| "Token '{}' not found on content server".format(token) |
There was a problem hiding this comment.
suggestion: NetworkLocationResponseFailure expects a response keyword argument (see errors.py:41 — self.response = kwargs.pop("response", None)). Raising it here without one sets self.response = None, which would cause an AttributeError if any caller tried e.response.status_code. That doesn't happen today because _resolve_channel_identifier catches it generically, but it's a fragile contract. A plain ValueError (consistent with the other raises in this function) would be more appropriate here — then _resolve_channel_identifier could catch ValueError for both "not found" and "invalid response" cases.
| version="1", identifier=None, baseurl=None, keyword=None, language=None | ||
| ): | ||
| content_server_path = "api/public/v{}/channels".format(version) | ||
| def get_channel_lookup_url(version="1", identifier=None, keyword=None, language=None): |
There was a problem hiding this comment.
praise: Good cleanup — removing the baseurl parameter and the unconditional trailing ? makes this function a pure path builder. All four callers were updated consistently.
4b3bfe0 to
e4c6efc
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
No new commits since last review. The suggestion about NetworkLocationResponseFailure remains unaddressed.
Prior findings
3 prior findings unchanged (3 resolved).
Still unaddressed:
NetworkLocationResponseFailureraised withoutresponsekwarg (suggestion) — no reply, no code change. Re-raised below.
3/4 prior findings resolved. 1 re-raised below.
CI passing. No UI changes.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
|
|
||
| if not channels or not isinstance(channels, list): | ||
| raise NetworkLocationResponseFailure( | ||
| "Token '{}' not found on content server".format(token) |
There was a problem hiding this comment.
suggestion: (re-raised from prior review) NetworkLocationResponseFailure expects a response keyword argument (self.response = kwargs.pop("response", None) in errors.py:41). Raising it here without one sets self.response = None, which would break any caller that accesses e.response.status_code. A plain ValueError would be more appropriate here — _resolve_channel_identifier already catches ValueError, so this simplifies the exception handling while avoiding the fragile contract.
e4c6efc to
09c932d
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean implementation of channel token support for importchannel, addressing #3733.
Prior findings
4 prior findings unchanged (3 resolved, 1 suggestion now also resolved).
The NetworkLocationResponseFailure suggestion (previously re-raised) is now resolved — resolve_channel_token only raises ValueError, which was the recommended fix.
4/4 prior findings resolved.
CI in progress. No UI changes.
- 1 suggestion (inline)
- 1 praise (inline)
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| """ | ||
| baseurl = baseurl or CENTRAL_CONTENT_BASE_URL | ||
| client = NetworkClient.build_for_address(baseurl) | ||
| response = client.get(get_channel_lookup_url(identifier=token)) |
There was a problem hiding this comment.
suggestion: get_channel_lookup_url can raise NetworkLocationResponseFailure (from the client.get call) but _resolve_channel_identifier doesn't catch it. If the server returns a non-2xx response (e.g., 500), the exception propagates as an unhandled traceback rather than a clean CommandError.
The existing caller in resource_import.py:52 catches NetworkLocationResponseFailure and checks e.response.status_code == 404. Consider catching it here too:
except (
NetworkLocationConnectionFailure,
NetworkLocationNotFound,
NetworkLocationResponseTimeout,
NetworkLocationResponseFailure,
):Or, if you want to distinguish 404 from other server errors, handle them separately.
| version="1", identifier=None, baseurl=None, keyword=None, language=None | ||
| ): | ||
| content_server_path = "api/public/v{}/channels".format(version) | ||
| def get_channel_lookup_url(version="1", identifier=None, keyword=None, language=None): |
There was a problem hiding this comment.
praise: Clean refactor — removing baseurl from this function and the unconditional trailing ? makes it a pure path builder. All callers updated consistently.
Implements support for using channel tokens in addition to channel IDs when importing channels via the importchannel command, addressing issue #3733. Key features: - Token resolution via channel lookup API (works with Studio or any Kolibri instance) - Automatic detection of tokens vs channel IDs using UUID validation - Interactive multi-channel selection when token resolves to multiple channels - Non-interactive mode shows error with all channel options - Comprehensive error handling with user-friendly messages - Full backward compatibility with existing channel ID usage Implementation details: - Added resolve_channel_token() utility in paths.py using duck typing - Returns tuple of (channel_id, all_channels) for flexible handling - Command detects TTY to determine interactive vs non-interactive mode - Interactive mode prompts user to choose from numbered list - Non-interactive mode fails with clear error listing all options - Only network subcommand supports tokens (disk requires UUIDs) Error handling improvements: - JSON parse errors caught with helpful messages - Network failures, invalid tokens, malformed responses all handled - Validates all channels have IDs before returning - Clear error messages guide users to resolution Test coverage: - Token resolution (single and multiple channels) - Invalid JSON responses - Missing channel IDs - Default and custom baseurl - All error paths covered Fixes #3733
All callers pass the result to NetworkClient.get(), which joins it with its own base_url. Returning a full URL caused a fragile double-join via urljoin. Now returns just the path (e.g. /api/public/v1/channels/...) so the NetworkClient can join it correctly with whatever base URL it was built for.
09c932d to
70aac0b
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
No new commits since last review. All prior findings remain resolved.
Prior findings
5 prior findings unchanged (4 resolved, 1 incorrect).
Resolved:
- Use path instead of full URL with
NetworkClient(suggestion) — resolved inget_channel_lookup_urlrefactor - Catch
NetworkLocationResponseTimeout(suggestion) — added to except clause - Trailing
?without query params (suggestion) — resolved, now conditional NetworkLocationResponseFailureraised withoutresponsekwarg (suggestion) — resolved,resolve_channel_tokennow raisesValueErrorinstead
Withdrawn (incorrect):
- "
get_channel_lookup_urlcan raiseNetworkLocationResponseFailure" (suggestion from review 5) —get_channel_lookup_urlis now a pure path builder that returns a string; it cannot raise network errors. Theclient.get()call that can raiseNetworkLocationResponseFailureis already caught by_resolve_channel_identifierat line 131.
5/5 prior findings accounted for. CI passing (Raspberry Pi image build pending). No UI changes.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| "Use a channel ID instead.".format(identifier, channel_list) | ||
| ) | ||
|
|
||
| return channel_id |
There was a problem hiding this comment.
praise: Clean separation of concerns — resolve_channel_token handles network/parsing and raises ValueError, while _resolve_channel_identifier translates those into CommandError with user-friendly messages. The network exception catch at line 128-133 covers all four failure modes consistently.
AlexVelezLl
left a comment
There was a problem hiding this comment.
Code changes make sense, rtibblesbot has ✅, tests provide good coverage on new code, affected parts due to the refactor of get_channel_lookup_url have been properly refactored, and manual QA checks out. lgmt!
Summary
Adds support for using channel tokens in addition to channel IDs when importing channels via the
importchannelmanagement command.Key features:
Example usage:
References
Fixes #3733
Reviewer guidance
networksubcommand;diskrequires UUIDs since tokens need network access to resolveresolve_channel_token()and integration tests intest_import_export.py