mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1cdb976e | |||
| 1f09296136 | |||
| 49d37119a9 | |||
| cfd416c29f | |||
| c052dd7da5 | |||
| 3f77b8229a | |||
| 8d13c9f328 | |||
| f46b112f17 | |||
| 44dc7f9e9b | |||
| 00eaa7a6e1 | |||
| 9f1d6963b8 | |||
| f61fa93596 | |||
| 3e87c08631 | |||
| 21f3ef540f | |||
| 61a93d010c | |||
| 70e5d12ba9 | |||
| bcb3160d95 | |||
| 174c691744 | |||
| af34d446e9 | |||
| 6604924f76 | |||
| b2def1e438 | |||
| 2b8e47aca9 | |||
| dba8b28824 |
@@ -45,6 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
The MIT License (MIT)
|
||||
Portions of this software are licensed as follows:
|
||||
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
|
||||
|
||||
=====================
|
||||
|
||||
Copyright © 2023
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -46,7 +46,14 @@ When running on Linux, you might run into the error `ERROR:root:<class 'httpx.Co
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Add the `--network host` to the docker run command.
|
||||
If you installed Docker from your distribution’s package repository (e.g., docker.io on Debian/Ubuntu), be aware that
|
||||
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
|
||||
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
|
||||
|
||||
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
|
||||
* `--network host`
|
||||
* `-e SANDBOX_USE_HOST_NETWORK=true`
|
||||
* `-e DOCKER_HOST_ADDR=127.0.0.1`
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose. However, you may only make changes or
|
||||
new works based on the software according to [Changes and New
|
||||
Works License](#changes-and-new-works-license), and you may
|
||||
not distribute copies of the software.
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## Free Trial
|
||||
|
||||
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
If you violate any of these terms, or do anything with the
|
||||
software not covered by your licenses, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
+45
-79
@@ -18,6 +18,7 @@ const mockUseGitRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -40,6 +41,10 @@ vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
mockUseMicroagentManagementConversations(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -219,6 +224,12 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
@@ -743,17 +754,24 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// No repositories should be visible
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
// Wait for debounced search to complete (300ms debounce + buffer)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
// Wait for the search to complete and check that no repositories are visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/TestRepository"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
@@ -1272,11 +1290,14 @@ describe("MicroagentManagement", () => {
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
@@ -1962,11 +1983,14 @@ describe("MicroagentManagement", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
|
||||
@@ -2521,64 +2545,6 @@ describe("MicroagentManagement", () => {
|
||||
screen.queryByTestId("learn-this-repo-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle API call for branches when learn this repo modal opens", async () => {
|
||||
// Mock branch API
|
||||
const branchesSpy = vi
|
||||
.spyOn(OpenHands, "getRepositoryBranches")
|
||||
.mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
|
||||
// Mock other APIs
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([]);
|
||||
searchConversationsSpy.mockResolvedValue([]);
|
||||
|
||||
// Test with direct Redux state that has modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: false,
|
||||
updateMicroagentModalVisible: false,
|
||||
learnThisRepoModalVisible: true, // Modal should be visible
|
||||
selectedRepository: {
|
||||
id: "1",
|
||||
full_name: "test-org/test-repo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The branches API should be called when the modal is visible
|
||||
await waitFor(() => {
|
||||
expect(branchesSpy).toHaveBeenCalledWith("test-org/test-repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Learn something new button functionality tests
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
import { SubscriptionAccess } from "#/types/billing";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -433,6 +434,13 @@ class OpenHands {
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
|
||||
@@ -49,13 +49,11 @@ export interface GetConfigResponse {
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
|
||||
-1
@@ -288,7 +288,6 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions: formData.query,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
|
||||
+3
-114
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -10,13 +10,6 @@ import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementLearnThisRepoModalProps {
|
||||
onConfirm: (formData: LearnThisRepoFormData) => void;
|
||||
@@ -32,47 +25,11 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = useRef<boolean>(false);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Auto-select main or master branch if it exists.
|
||||
useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -83,7 +40,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,66 +51,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<ModalBody
|
||||
@@ -200,9 +99,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<div data-testid="branch-selector-container">
|
||||
{renderBranchSelector()}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -245,16 +141,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{isLoading || isLoadingBranches
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
+15
-1
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
@@ -11,16 +11,30 @@ import { MicroagentManagementAccordionTitle } from "./microagent-management-acco
|
||||
type MicroagentManagementRepositoriesProps = {
|
||||
repositories: GitRepository[];
|
||||
tabType: TabType;
|
||||
isSearchLoading?: boolean;
|
||||
};
|
||||
|
||||
export function MicroagentManagementRepositories({
|
||||
repositories,
|
||||
tabType,
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementRepositoriesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const numberOfRepoMicroagents = repositories.length;
|
||||
|
||||
// Show spinner when search is in progress, regardless of repository count
|
||||
if (isSearchLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white">
|
||||
{t("HOME$SEARCHING_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
if (tabType === "personal") {
|
||||
return (
|
||||
|
||||
+10
-1
@@ -5,7 +5,13 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementSidebarTabs() {
|
||||
interface MicroagentManagementSidebarTabsProps {
|
||||
isSearchLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebarTabs({
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementSidebarTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { repositories, personalRepositories, organizationRepositories } =
|
||||
@@ -29,18 +35,21 @@ export function MicroagentManagementSidebarTabs() {
|
||||
<MicroagentManagementRepositories
|
||||
repositories={personalRepositories}
|
||||
tabType="personal"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={repositories}
|
||||
tabType="repositories"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={organizationRepositories}
|
||||
tabType="organizations"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
+77
-19
@@ -5,6 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
@@ -16,6 +17,7 @@ import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
@@ -31,17 +33,29 @@ export function MicroagentManagementSidebar({
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: repositories, isLoading } = useGitRepositories({
|
||||
// Use Git repositories hook with pagination for infinite scrolling
|
||||
const {
|
||||
data: repositories,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
} = useGitRepositories({
|
||||
provider: selectedProvider,
|
||||
pageSize: 200,
|
||||
pageSize: 30, // Load 30 repositories per page
|
||||
enabled: !!selectedProvider,
|
||||
});
|
||||
|
||||
// Server-side search functionality
|
||||
const { data: searchResults, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(debouncedSearchQuery, selectedProvider, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
@@ -54,23 +68,31 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Filter repositories based on search query
|
||||
// Filter repositories based on search query and available data
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!repositories?.pages) return null;
|
||||
// If we have search results, use them directly (no filtering needed)
|
||||
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
// If no search query or no search results, use paginated repositories
|
||||
if (!repositories?.pages) return [];
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, searchQuery, selectedProvider]);
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
@@ -104,12 +126,28 @@ export function MicroagentManagementSidebar({
|
||||
dispatch(setRepositories(otherRepos));
|
||||
}, [filteredRepositories, selectedProvider, dispatch]);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
// Only enable pagination when not searching
|
||||
if (debouncedSearchQuery && searchResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
|
||||
isSmallerScreen && "w-full border-none",
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<MicroagentManagementSidebarHeader />
|
||||
|
||||
@@ -131,18 +169,26 @@ export function MicroagentManagementSidebar({
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
<div className="relative">
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
"pr-10", // Space for spinner
|
||||
)}
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -153,7 +199,19 @@ export function MicroagentManagementSidebar({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<MicroagentManagementSidebarTabs />
|
||||
<>
|
||||
<MicroagentManagementSidebarTabs isSearchLoading={isSearchLoading} />
|
||||
|
||||
{/* Show loading indicator for pagination (only when not searching) */}
|
||||
{isFetchingNextPage && !debouncedSearchQuery && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white ml-2">
|
||||
{t("HOME$LOADING_MORE_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,9 +37,6 @@ export function Sidebar() {
|
||||
const shouldHideLlmSettings =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
|
||||
|
||||
const shouldHideMicroagentManagement =
|
||||
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldHideLlmSettings) return;
|
||||
|
||||
@@ -83,11 +80,9 @@ export function Sidebar() {
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
{!shouldHideMicroagentManagement && (
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
)}
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import UnionIcon from "#/icons/union.svg?react";
|
||||
import RobotIcon from "#/icons/robot.svg?react";
|
||||
|
||||
interface MicroagentManagementButtonProps {
|
||||
disabled?: boolean;
|
||||
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
|
||||
testId="microagent-management-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<UnionIcon />
|
||||
<RobotIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,16 @@ import { Provider } from "#/types/settings";
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
pageSize: number = 3,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
OpenHands.searchGitRepositories(
|
||||
query,
|
||||
pageSize,
|
||||
selectedProvider || undefined,
|
||||
),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useSubscriptionAccess = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
queryFn: OpenHands.getSubscriptionAccess,
|
||||
enabled:
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
@@ -85,6 +85,8 @@ export enum I18nKey {
|
||||
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
|
||||
HOME$LOADING = "HOME$LOADING",
|
||||
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
|
||||
HOME$SEARCHING_REPOSITORIES = "HOME$SEARCHING_REPOSITORIES",
|
||||
HOME$LOADING_MORE_REPOSITORIES = "HOME$LOADING_MORE_REPOSITORIES",
|
||||
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
|
||||
HOME$LOADING_BRANCHES = "HOME$LOADING_BRANCHES",
|
||||
HOME$FAILED_TO_LOAD_BRANCHES = "HOME$FAILED_TO_LOAD_BRANCHES",
|
||||
|
||||
@@ -1359,6 +1359,38 @@
|
||||
"de": "Repositories werden geladen...",
|
||||
"uk": "Завантаження репозиторіїв..."
|
||||
},
|
||||
"HOME$SEARCHING_REPOSITORIES": {
|
||||
"en": "Searching repositories...",
|
||||
"ja": "リポジトリを検索中...",
|
||||
"zh-CN": "搜索仓库中...",
|
||||
"zh-TW": "搜尋儲存庫中...",
|
||||
"ko-KR": "저장소 검색 중...",
|
||||
"no": "Søker i repositories...",
|
||||
"it": "Ricerca repository in corso...",
|
||||
"pt": "Pesquisando repositórios...",
|
||||
"es": "Buscando repositorios...",
|
||||
"ar": "جار البحث في المستودعات...",
|
||||
"fr": "Recherche de dépôts...",
|
||||
"tr": "Depolar aranıyor...",
|
||||
"de": "Repositories werden durchsucht...",
|
||||
"uk": "Пошук репозиторіїв..."
|
||||
},
|
||||
"HOME$LOADING_MORE_REPOSITORIES": {
|
||||
"en": "Loading more repositories...",
|
||||
"ja": "さらに多くのリポジトリを読み込み中...",
|
||||
"zh-CN": "加载更多仓库中...",
|
||||
"zh-TW": "載入更多儲存庫中...",
|
||||
"ko-KR": "더 많은 저장소 로딩 중...",
|
||||
"no": "Laster flere repositories...",
|
||||
"it": "Caricamento di altri repository...",
|
||||
"pt": "Carregando mais repositórios...",
|
||||
"es": "Cargando más repositorios...",
|
||||
"ar": "جار تحميل المزيد من المستودعات...",
|
||||
"fr": "Chargement de plus de dépôts...",
|
||||
"tr": "Daha fazla depolar yükleniyor...",
|
||||
"de": "Weitere Repositories werden geladen...",
|
||||
"uk": "Завантаження більше репозиторіїв..."
|
||||
},
|
||||
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
|
||||
"en": "Failed to load repositories",
|
||||
"ja": "リポジトリの読み込みに失敗しました",
|
||||
@@ -11952,20 +11984,20 @@
|
||||
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "What would you like to know about this repository?",
|
||||
"ja": "このリポジトリについて何を知りたいですか?",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요?",
|
||||
"no": "Hva vil du vite om dette depotet?",
|
||||
"it": "Cosa vorresti sapere su questo repository?",
|
||||
"pt": "O que você gostaria de saber sobre este repositório?",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio?",
|
||||
"ar": "ماذا تريد أن تعرف عن هذا المستودع؟",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ?",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz?",
|
||||
"de": "Was möchten Sie über dieses Repository wissen?",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій?"
|
||||
"en": "What would you like to know about this repository? (optional)",
|
||||
"ja": "このリポジトリについて知りたいことは何ですか?(任意)",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?(可选)",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?(選填)",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요? (선택 사항)",
|
||||
"no": "Hva vil du vite om dette depotet? (valgfritt)",
|
||||
"it": "Cosa vorresti sapere su questo repository? (opzionale)",
|
||||
"pt": "O que você gostaria de saber sobre este repositório? (opcional)",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio? (opcional)",
|
||||
"ar": "ماذا ترغب في معرفته عن هذا المستودع؟ (اختياري)",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)",
|
||||
"de": "Was möchten Sie über dieses Repository wissen? (optional)",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "Describe what you would like to know about this repository.",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="47" height="42" viewBox="0 0 47 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.58409 21.782C2.01747 21.782 1.48418 22.0153 1.11755 22.3819C0.750916 22.7486 0.517578 23.3152 0.517578 23.8485V28.0148C0.517578 28.5814 0.750916 29.1147 1.11755 29.4813C1.51751 29.8813 2.05081 30.0813 2.58409 30.0813C3.11737 30.0813 3.684 29.8479 4.05063 29.4813C4.45059 29.0814 4.65055 28.5481 4.65055 28.0148V23.8485C4.65055 23.2819 4.41726 22.7486 4.05063 22.3819C3.684 22.0153 3.11737 21.782 2.58409 21.782ZM44.2802 21.782C43.7136 21.782 43.1803 22.0153 42.8137 22.3819C42.4471 22.7486 42.2138 23.3152 42.2138 23.8485V28.0148C42.2138 28.5814 42.4471 29.1147 42.8137 29.4813C43.2137 29.8813 43.747 30.0813 44.2802 30.0813C44.8135 30.0813 45.3801 29.8479 45.7468 29.4813C46.1467 29.0814 46.3467 28.5481 46.3467 28.0148V23.8485C46.3467 23.2819 46.1134 22.7486 45.7468 22.3819C45.3801 22.0153 44.8135 21.782 44.2802 21.782ZM21.349 10.3164H13.0164C11.3499 10.3164 9.75011 10.983 8.58355 12.1496C7.41699 13.3161 6.75037 14.916 6.75037 16.5825V35.3474C6.75037 37.0139 7.41699 38.6138 8.58355 39.7804C9.75011 40.9469 11.3499 41.6135 13.0164 41.6135H33.8812C35.5477 41.6135 37.1476 40.9469 38.3141 39.7804C39.4807 38.6138 40.1473 37.0139 40.1473 35.3474V16.5825C40.1473 14.916 39.4807 13.3161 38.3141 12.1496C37.1476 10.983 35.5477 10.3164 33.8812 10.3164H25.5486M19.8491 14.4827H27.0152ZM31.3148 14.4827H33.8478C34.4145 14.4827 34.9478 14.716 35.3144 15.0826C35.7144 15.4826 35.9144 16.0159 35.9144 16.5492V35.3141C35.9144 35.8807 35.681 36.3807 35.3144 36.7806C34.9144 37.1806 34.3811 37.3806 33.8478 37.3806H12.9831C12.4165 37.3806 11.8832 37.1473 11.5166 36.7806C11.1166 36.3807 10.9167 35.8474 10.9167 35.3141V16.5492C10.9167 15.9825 11.1499 15.4493 11.5166 15.0826C11.9165 14.6827 12.4498 14.4827 12.9831 14.4827H15.5162M17.7494 21.782C17.1828 21.782 16.6495 22.0153 16.2828 22.3819C15.9162 22.7486 15.6829 23.3152 15.6829 23.8485V28.0148C15.6829 28.5814 15.9162 29.1147 16.2828 29.4813C16.6828 29.8813 17.2161 30.0813 17.7494 30.0813C18.2827 30.0813 18.8492 29.8479 19.2159 29.4813C19.6158 29.0814 19.8158 28.5481 19.8158 28.0148V23.8485C19.8158 23.2819 19.5825 22.7486 19.2159 22.3819C18.8492 22.0153 18.2827 21.782 17.7494 21.782ZM29.0816 21.782C28.515 21.782 27.9817 22.0153 27.6151 22.3819C27.2485 22.7486 27.0152 23.3152 27.0152 23.8485V28.0148C27.0152 28.5814 27.2485 29.1147 27.6151 29.4813C28.0151 29.8813 28.5484 30.0813 29.0816 30.0813C29.6149 30.0813 30.1816 29.8479 30.5482 29.4813C30.9481 29.0814 31.1481 28.5481 31.1481 28.0148V23.8485C31.1481 23.2819 30.9148 22.7486 30.5482 22.3819C30.1816 22.0153 29.6149 21.782 29.0816 21.782Z" fill="currentColor"/>
|
||||
<path d="M23.4122 0.851806C22.7122 0.851806 22.0123 1.05179 21.4123 1.45175C20.8124 1.85171 20.3791 2.41834 20.0791 3.05162C19.8125 3.71822 19.7458 4.41814 19.8792 5.11808C20.0125 5.81801 20.3458 6.4513 20.8457 6.95125C21.3457 7.45121 21.979 7.78451 22.6789 7.91783C23.3788 8.05115 24.1121 7.98448 24.7454 7.71783C25.412 7.45119 25.9452 6.98459 26.3452 6.38464C26.7452 5.7847 26.9452 5.11807 26.9452 4.38481C26.9452 3.41823 26.5785 2.51833 25.8786 1.85172C25.1786 1.18512 24.2787 0.785156 23.3455 0.785156L23.4122 0.851806Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -169,7 +169,6 @@ export const handlers = [
|
||||
APP_MODE: mockSaas ? "saas" : "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
STRIPE_PUBLISHABLE_KEY: "",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: mockSaas,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
@@ -8,6 +9,7 @@ import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
@@ -62,10 +64,22 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
const navItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (isSaas) {
|
||||
if (subscriptionAccess) {
|
||||
items.push({ to: "/settings", text: "SETTINGS$NAV_LLM" });
|
||||
}
|
||||
items.push(...SAAS_NAV_ITEMS);
|
||||
} else {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SubscriptionAccess = {
|
||||
status: "ACTIVE" | "DISABLED";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -22,5 +22,4 @@ export interface MicroagentFormData {
|
||||
|
||||
export interface LearnThisRepoFormData {
|
||||
query: string;
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.service import (
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketReposMixin,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
class BitBucketService(
|
||||
BitBucketReposMixin,
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@@ -38,10 +35,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
The class is instantiated via get_impl() in openhands.server.shared.py.
|
||||
"""
|
||||
|
||||
BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
@@ -50,7 +43,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.external_token_manager = external_token_manager
|
||||
self.external_auth_id = external_auth_id
|
||||
@@ -66,695 +59,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
def provider(self) -> str:
|
||||
return ProviderType.BITBUCKET.value
|
||||
|
||||
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
|
||||
"""Extract owner and repo from repository string.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo)
|
||||
|
||||
Raises:
|
||||
ValueError: If repository format is invalid
|
||||
"""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
return parts[-2], parts[-1]
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get latest working token of the user."""
|
||||
return self.token
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'commit_file'
|
||||
and item['path'].endswith('.md')
|
||||
and not item['path'].endswith('README.md')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['path'].split('/')[-1]
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
async def _get_bitbucket_headers(self) -> dict[str, str]:
|
||||
"""Get headers for Bitbucket API requests."""
|
||||
token_value = self.token.get_secret_value()
|
||||
|
||||
# Check if the token contains a colon, which indicates it's in username:password format
|
||||
if ':' in token_value:
|
||||
auth_str = base64.b64encode(token_value.encode()).decode()
|
||||
return {
|
||||
'Authorization': f'Basic {auth_str}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'Authorization': f'Bearer {token_value}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
"""Make a request to the Bitbucket API.
|
||||
|
||||
Args:
|
||||
url: The URL to request
|
||||
params: Optional parameters for the request
|
||||
method: The HTTP method to use
|
||||
|
||||
Returns:
|
||||
A tuple of (response_data, response_headers)
|
||||
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
bitbucket_headers = await self._get_bitbucket_headers()
|
||||
response = await self.execute_request(
|
||||
client, url, bitbucket_headers, params, method
|
||||
)
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
bitbucket_headers = await self._get_bitbucket_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=bitbucket_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json(), dict(response.headers)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user's information."""
|
||||
url = f'{self.BASE_URL}/user'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
account_id = data.get('account_id', '')
|
||||
|
||||
return User(
|
||||
id=account_id,
|
||||
login=data.get('username', ''),
|
||||
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
|
||||
name=data.get('display_name'),
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a Bitbucket API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from Bitbucket API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
repo_id = repo.get('uuid', '')
|
||||
|
||||
workspace_slug = repo.get('workspace', {}).get('slug', '')
|
||||
repo_slug = repo.get('slug', '')
|
||||
full_name = (
|
||||
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
|
||||
)
|
||||
|
||||
is_public = not repo.get('is_private', True)
|
||||
owner_type = OwnerType.ORGANIZATION
|
||||
main_branch = repo.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=repo_id,
|
||||
full_name=full_name, # type: ignore[arg-type]
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=is_public,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=owner_type,
|
||||
link_header=link_header,
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories."""
|
||||
repositories = []
|
||||
|
||||
if public:
|
||||
# Extract workspace and repo from URL
|
||||
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
|
||||
# Split by '/' and find workspace and repo parts
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) >= 5: # https:, '', domain, workspace, repo
|
||||
workspace_slug = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
|
||||
repo = await self.get_repository_details_from_repo_name(
|
||||
f'{workspace_slug}/{repo_name}'
|
||||
)
|
||||
repositories.append(repo)
|
||||
|
||||
return repositories
|
||||
|
||||
# Search for repos once workspace prefix exists
|
||||
if '/' in query:
|
||||
workspace_slug, repo_query = query.split('/', 1)
|
||||
return await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, repo_query
|
||||
)
|
||||
|
||||
all_installations = await self.get_installations()
|
||||
|
||||
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
|
||||
matching_workspace_slugs = [
|
||||
installation for installation in all_installations if query in installation
|
||||
]
|
||||
for workspace_slug in matching_workspace_slugs:
|
||||
# Get repositories where query matches workspace name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for workspace_slug in all_installations:
|
||||
# Get repositories in all workspaces where query matches repo name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, query
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return repositories
|
||||
|
||||
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
|
||||
"""Get all workspaces the user has access to"""
|
||||
url = f'{self.BASE_URL}/workspaces'
|
||||
data, _ = await self._make_request(url)
|
||||
return data.get('values', [])
|
||||
|
||||
async def _fetch_paginated_data(
|
||||
self, url: str, params: dict, max_items: int
|
||||
) -> list[dict]:
|
||||
"""Fetch data with pagination support for Bitbucket API.
|
||||
|
||||
Args:
|
||||
url: The API endpoint URL
|
||||
params: Query parameters for the request
|
||||
max_items: Maximum number of items to fetch
|
||||
|
||||
Returns:
|
||||
List of data items from all pages
|
||||
"""
|
||||
all_items: list[dict] = []
|
||||
current_url = url
|
||||
|
||||
while current_url and len(all_items) < max_items:
|
||||
response, _ = await self._make_request(current_url, params)
|
||||
|
||||
# Extract items from response
|
||||
page_items = response.get('values', [])
|
||||
if not page_items: # No more items
|
||||
break
|
||||
|
||||
all_items.extend(page_items)
|
||||
|
||||
# Get the next page URL from the response
|
||||
current_url = response.get('next')
|
||||
|
||||
# Clear params for subsequent requests since the next URL already contains all parameters
|
||||
params = {}
|
||||
|
||||
return all_items[:max_items] # Trim to max_items if needed
|
||||
|
||||
async def get_installations(
|
||||
self, query: str | None = None, limit: int = 100
|
||||
) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
params = {}
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract next URL from response
|
||||
next_link = response.get('next', '')
|
||||
|
||||
# Format the link header in a way that the frontend can understand
|
||||
# The frontend expects a format like: <url>; rel="next"
|
||||
# where the URL contains a page parameter
|
||||
formatted_link_header = ''
|
||||
if next_link:
|
||||
# Extract the page number from the next URL if possible
|
||||
page_match = re.search(r'[?&]page=(\d+)', next_link)
|
||||
if page_match:
|
||||
next_page = page_match.group(1)
|
||||
# Format it in a way that extractNextPageFromLink in frontend can parse
|
||||
formatted_link_header = (
|
||||
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
|
||||
)
|
||||
else:
|
||||
# If we can't extract the page, just use the next URL as is
|
||||
formatted_link_header = f'<{next_link}>; rel="next"'
|
||||
|
||||
repositories = [
|
||||
self._parse_repository(repo, link_header=formatted_link_header)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
by iterating through their workspaces and fetching repositories from each workspace.
|
||||
This approach is more comprehensive and efficient than the previous implementation
|
||||
that made separate calls for public and private repositories.
|
||||
"""
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by Bitbucket API
|
||||
repositories: list[Repository] = []
|
||||
|
||||
# Get user's workspaces with pagination
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, {}, MAX_REPOS)
|
||||
|
||||
for workspace in workspaces:
|
||||
workspace_slug = workspace.get('slug')
|
||||
if not workspace_slug:
|
||||
continue
|
||||
|
||||
# Get repositories for this workspace with pagination
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values and ensure descending order
|
||||
# to show most recently changed repos at the top
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = (
|
||||
'-updated_on' # Use negative prefix for descending order
|
||||
)
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': PER_PAGE,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
# Fetch all repositories for this workspace with pagination
|
||||
workspace_repos = await self._fetch_paginated_data(
|
||||
workspace_repos_url, params, MAX_REPOS - len(repositories)
|
||||
)
|
||||
|
||||
for repo in workspace_repos:
|
||||
repositories.append(self._parse_repository(repo))
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories."""
|
||||
# TODO: implemented suggested tasks
|
||||
return []
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Gets all repository details from repository name."""
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(data)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
# Set maximum branches to fetch (similar to GitHub/GitLab implementations)
|
||||
MAX_BRANCHES = 1000
|
||||
PER_PAGE = 100
|
||||
|
||||
params = {
|
||||
'pagelen': PER_PAGE,
|
||||
'sort': '-target.date', # Sort by most recent commit date, descending
|
||||
}
|
||||
|
||||
# Fetch all branches with pagination
|
||||
branch_data = await self._fetch_paginated_data(url, params, MAX_BRANCHES)
|
||||
|
||||
branches = []
|
||||
for branch in branch_data:
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination."""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': '-target.date', # Sort by most recent commit date, descending
|
||||
}
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
# Bitbucket provides pagination info in the response
|
||||
has_next_page = response.get('next') is not None
|
||||
total_count = response.get('size') # Total number of items
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches by name using Bitbucket API with `q` param."""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
# Bitbucket filtering: name ~ "query"
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'q': f'name~"{query}"',
|
||||
'sort': '-target.date',
|
||||
}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False,
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> str:
|
||||
"""Creates a pull request in Bitbucket.
|
||||
|
||||
Args:
|
||||
repo_name: The repository name in the format "workspace/repo"
|
||||
source_branch: The source branch name
|
||||
target_branch: The target branch name
|
||||
title: The title of the pull request
|
||||
body: The description of the pull request
|
||||
draft: Whether to create a draft pull request
|
||||
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
"""
|
||||
owner, repo = self._extract_owner_and_repo(repo_name)
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'description': body or '',
|
||||
'source': {'branch': {'name': source_branch}},
|
||||
'destination': {'branch': {'name': target_branch}},
|
||||
'close_source_branch': False,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
data, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw Bitbucket API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a Bitbucket pull request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (OPEN), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# Bitbucket API response structure
|
||||
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
|
||||
if 'state' in pr_details:
|
||||
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
|
||||
return pr_details['state'] == 'OPEN'
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
bitbucket_service_cls = os.environ.get(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from .base import BitBucketMixinBase
|
||||
from .branches import BitBucketBranchesMixin
|
||||
from .features import BitBucketFeaturesMixin
|
||||
from .prs import BitBucketPRsMixin
|
||||
from .repos import BitBucketReposMixin
|
||||
|
||||
__all__ = [
|
||||
'BitBucketMixinBase',
|
||||
'BitBucketBranchesMixin',
|
||||
'BitBucketFeaturesMixin',
|
||||
'BitBucketPRsMixin',
|
||||
'BitBucketReposMixin',
|
||||
]
|
||||
@@ -0,0 +1,247 @@
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class BitBucketMixinBase(BaseGitService, HTTPClient):
|
||||
"""
|
||||
Base mixin for BitBucket service containing common functionality
|
||||
"""
|
||||
|
||||
BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
|
||||
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
|
||||
"""Extract owner and repo from repository string.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo)
|
||||
|
||||
Raises:
|
||||
ValueError: If repository format is invalid
|
||||
"""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
return parts[-2], parts[-1]
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get latest working token of the user."""
|
||||
return self.token
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
async def _get_headers(self) -> dict[str, str]:
|
||||
"""Get headers for Bitbucket API requests."""
|
||||
token_value = self.token.get_secret_value()
|
||||
|
||||
# Check if the token contains a colon, which indicates it's in username:password format
|
||||
if ':' in token_value:
|
||||
auth_str = base64.b64encode(token_value.encode()).decode()
|
||||
return {
|
||||
'Authorization': f'Basic {auth_str}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'Authorization': f'Bearer {token_value}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
"""Make a request to the Bitbucket API.
|
||||
|
||||
Args:
|
||||
url: The URL to request
|
||||
params: Optional parameters for the request
|
||||
method: The HTTP method to use
|
||||
|
||||
Returns:
|
||||
A tuple of (response_data, response_headers)
|
||||
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
bitbucket_headers = await self._get_headers()
|
||||
response = await self.execute_request(
|
||||
client, url, bitbucket_headers, params, method
|
||||
)
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
bitbucket_headers = await self._get_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=bitbucket_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json(), dict(response.headers)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def _fetch_paginated_data(
|
||||
self, url: str, params: dict, max_items: int
|
||||
) -> list[dict]:
|
||||
"""Fetch data with pagination support for Bitbucket API.
|
||||
|
||||
Args:
|
||||
url: The API endpoint URL
|
||||
params: Query parameters for the request
|
||||
max_items: Maximum number of items to fetch
|
||||
|
||||
Returns:
|
||||
List of data items from all pages
|
||||
"""
|
||||
all_items: list[dict] = []
|
||||
current_url = url
|
||||
|
||||
while current_url and len(all_items) < max_items:
|
||||
response, _ = await self._make_request(current_url, params)
|
||||
|
||||
# Extract items from response
|
||||
page_items = response.get('values', [])
|
||||
all_items.extend(page_items)
|
||||
|
||||
# Get next page URL from response
|
||||
current_url = response.get('next')
|
||||
|
||||
# Clear params for subsequent requests as they're included in the next URL
|
||||
params = {}
|
||||
|
||||
return all_items[:max_items]
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user's information."""
|
||||
url = f'{self.BASE_URL}/user'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
account_id = data.get('account_id', '')
|
||||
|
||||
return User(
|
||||
id=account_id,
|
||||
login=data.get('username', ''),
|
||||
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
|
||||
name=data.get('display_name'),
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a Bitbucket API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from Bitbucket API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
repo_id = repo.get('uuid', '')
|
||||
|
||||
workspace_slug = repo.get('workspace', {}).get('slug', '')
|
||||
repo_slug = repo.get('slug', '')
|
||||
full_name = (
|
||||
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
|
||||
)
|
||||
|
||||
is_public = not repo.get('is_private', True)
|
||||
owner_type = OwnerType.ORGANIZATION
|
||||
main_branch = repo.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=repo_id,
|
||||
full_name=full_name, # type: ignore[arg-type]
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=is_public,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=owner_type,
|
||||
link_header=link_header,
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Get repository details from repository name.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
|
||||
Returns:
|
||||
Repository object with details
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repositories/{repository}'
|
||||
data, _ = await self._make_request(url)
|
||||
return self._parse_repository(data)
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'commit_file'
|
||||
and item['path'].endswith('.md')
|
||||
and not item['path'].endswith('README.md')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['path'].split('/')[-1]
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
@@ -0,0 +1,116 @@
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
class BitBucketBranchesMixin(BitBucketMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket branch-related operations
|
||||
"""
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
# Set maximum branches to fetch (similar to GitHub/GitLab implementations)
|
||||
MAX_BRANCHES = 1000
|
||||
PER_PAGE = 100
|
||||
|
||||
params = {
|
||||
'pagelen': PER_PAGE,
|
||||
'sort': '-target.date', # Sort by most recent commit date, descending
|
||||
}
|
||||
|
||||
# Fetch all branches with pagination
|
||||
branch_data = await self._fetch_paginated_data(url, params, MAX_BRANCHES)
|
||||
|
||||
branches = []
|
||||
for branch in branch_data:
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination."""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': '-target.date', # Sort by most recent commit date, descending
|
||||
}
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
# Bitbucket provides pagination info in the response
|
||||
has_next_page = response.get('next') is not None
|
||||
total_count = response.get('size') # Total number of items
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches by name using Bitbucket API with `q` param."""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
# Bitbucket filtering: name ~ "query"
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'q': f'name~"{query}"',
|
||||
'sort': '-target.date',
|
||||
}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch in response.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False,
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
@@ -0,0 +1,45 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
from openhands.integrations.service_types import ResourceNotFoundError
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
|
||||
|
||||
class BitBucketFeaturesMixin(BitBucketMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket feature operations (microagents, cursor rules, etc.)
|
||||
"""
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
@@ -0,0 +1,100 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
from openhands.integrations.service_types import RequestMethod
|
||||
|
||||
|
||||
class BitBucketPRsMixin(BitBucketMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket pull request operations
|
||||
"""
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> str:
|
||||
"""Creates a pull request in Bitbucket.
|
||||
|
||||
Args:
|
||||
repo_name: The repository name in the format "workspace/repo"
|
||||
source_branch: The source branch name
|
||||
target_branch: The target branch name
|
||||
title: The title of the pull request
|
||||
body: The description of the pull request
|
||||
draft: Whether to create a draft pull request
|
||||
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
"""
|
||||
owner, repo = self._extract_owner_and_repo(repo_name)
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'description': body or '',
|
||||
'source': {'branch': {'name': source_branch}},
|
||||
'destination': {'branch': {'name': target_branch}},
|
||||
'close_source_branch': False,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
data, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw Bitbucket API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a Bitbucket pull request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (OPEN), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# Bitbucket API response structure
|
||||
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
|
||||
if 'state' in pr_details:
|
||||
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
|
||||
return pr_details['state'] == 'OPEN'
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
@@ -0,0 +1,256 @@
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
from openhands.integrations.service_types import Repository, SuggestedTask
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class BitBucketReposMixin(BitBucketMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket repository-related operations
|
||||
"""
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories."""
|
||||
repositories = []
|
||||
|
||||
if public:
|
||||
# Extract workspace and repo from URL using robust URL parsing
|
||||
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
|
||||
try:
|
||||
parsed_url = urlparse(query)
|
||||
# Remove leading slash and split path into segments
|
||||
path_segments = [
|
||||
segment for segment in parsed_url.path.split('/') if segment
|
||||
]
|
||||
|
||||
# We need at least 2 path segments: workspace and repo
|
||||
if len(path_segments) >= 2:
|
||||
workspace_slug = path_segments[0]
|
||||
repo_name = path_segments[1]
|
||||
|
||||
repo = await self.get_repository_details_from_repo_name(
|
||||
f'{workspace_slug}/{repo_name}'
|
||||
)
|
||||
repositories.append(repo)
|
||||
except (ValueError, IndexError):
|
||||
# If URL parsing fails or doesn't have expected structure,
|
||||
# return empty list for public search
|
||||
pass
|
||||
|
||||
return repositories
|
||||
|
||||
# Search for repos once workspace prefix exists
|
||||
if '/' in query:
|
||||
workspace_slug, repo_query = query.split('/', 1)
|
||||
return await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, repo_query
|
||||
)
|
||||
|
||||
all_installations = await self.get_installations()
|
||||
|
||||
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
|
||||
matching_workspace_slugs = [
|
||||
installation for installation in all_installations if query in installation
|
||||
]
|
||||
for workspace_slug in matching_workspace_slugs:
|
||||
# Get repositories where query matches workspace name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for workspace_slug in all_installations:
|
||||
# Get repositories in all workspaces where query matches repo name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, query
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return repositories
|
||||
|
||||
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
|
||||
"""Get all workspaces the user has access to"""
|
||||
url = f'{self.BASE_URL}/workspaces'
|
||||
data, _ = await self._make_request(url)
|
||||
return data.get('values', [])
|
||||
|
||||
async def get_installations(
|
||||
self, query: str | None = None, limit: int = 100
|
||||
) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
params = {}
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract next URL from response
|
||||
next_link = response.get('next', '')
|
||||
|
||||
# Format the link header in a way that the frontend can understand
|
||||
# The frontend expects a format like: <url>; rel="next"
|
||||
# where the URL contains a page parameter
|
||||
formatted_link_header = ''
|
||||
if next_link:
|
||||
# Extract the page number from the next URL if possible
|
||||
page_match = re.search(r'[?&]page=(\d+)', next_link)
|
||||
if page_match:
|
||||
next_page = page_match.group(1)
|
||||
# Format it in a way that extractNextPageFromLink in frontend can parse
|
||||
formatted_link_header = (
|
||||
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
|
||||
)
|
||||
else:
|
||||
# If we can't extract the page, just use the next URL as is
|
||||
formatted_link_header = f'<{next_link}>; rel="next"'
|
||||
|
||||
repositories = [
|
||||
self._parse_repository(repo, link_header=formatted_link_header)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
by iterating through their workspaces and fetching repositories from each workspace.
|
||||
This approach is more comprehensive and efficient than the previous implementation
|
||||
that made separate calls for public and private repositories.
|
||||
"""
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by Bitbucket API
|
||||
repositories: list[Repository] = []
|
||||
|
||||
# Get user's workspaces with pagination
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, {}, MAX_REPOS)
|
||||
|
||||
for workspace in workspaces:
|
||||
workspace_slug = workspace.get('slug')
|
||||
if not workspace_slug:
|
||||
continue
|
||||
|
||||
# Get repositories for this workspace with pagination
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values and ensure descending order
|
||||
# to show most recently changed repos at the top
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = (
|
||||
'-updated_on' # Use negative prefix for descending order
|
||||
)
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': PER_PAGE,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
# Fetch all repositories for this workspace with pagination
|
||||
workspace_repos = await self._fetch_paginated_data(
|
||||
workspace_repos_url, params, MAX_REPOS - len(repositories)
|
||||
)
|
||||
|
||||
for repo in workspace_repos:
|
||||
repositories.append(self._parse_repository(repo))
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories."""
|
||||
# TODO: implemented suggested tasks
|
||||
return []
|
||||
@@ -43,8 +43,6 @@ class GitHubService(
|
||||
|
||||
BASE_URL = 'https://api.github.com'
|
||||
GRAPHQL_URL = 'https://api.github.com/graphql'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# openhands/integrations/github/service/__init__.py
|
||||
|
||||
from .base import GitHubMixinBase
|
||||
from .branches_prs import GitHubBranchesMixin
|
||||
from .features import GitHubFeaturesMixin
|
||||
from .prs import GitHubPRsMixin
|
||||
@@ -7,6 +8,7 @@ from .repos import GitHubReposMixin
|
||||
from .resolver import GitHubResolverMixin
|
||||
|
||||
__all__ = [
|
||||
'GitHubMixinBase',
|
||||
'GitHubBranchesMixin',
|
||||
'GitHubFeaturesMixin',
|
||||
'GitHubPRsMixin',
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Any, Mapping
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
UnknownException,
|
||||
)
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
"""
|
||||
Thin HTTP/GraphQL wrapper for GitHub with correct base URLs, standard headers,
|
||||
shared AsyncClient, and basic retry/backoff for 429/5xx.
|
||||
|
||||
This component is internal. It does not alter existing behavior until wired
|
||||
into GitHubService/mixins in subsequent PRs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_domain: str | None = None,
|
||||
token: SecretStr | None = None,
|
||||
user_agent: str = "OpenHands-GitHubService",
|
||||
timeout: float = 15.0,
|
||||
) -> None:
|
||||
domain = (base_domain or "github.com").strip()
|
||||
if domain == "github.com":
|
||||
self.rest_base = "https://api.github.com"
|
||||
self.graphql_base = "https://api.github.com/graphql"
|
||||
else:
|
||||
self.rest_base = f"https://{domain}/api/v3"
|
||||
self.graphql_base = f"https://{domain}/api/graphql"
|
||||
|
||||
self._token = token.get_secret_value() if token else ""
|
||||
# Shared client for all requests through this API instance
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(timeout))
|
||||
|
||||
# Standard headers recommended by GitHub
|
||||
self._base_headers: dict[str, str] = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": user_agent,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if self._token:
|
||||
self._base_headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self) -> "GitHubAPI":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
await self.aclose()
|
||||
|
||||
def set_token(self, token: SecretStr | None) -> None:
|
||||
self._token = token.get_secret_value() if token else ""
|
||||
if self._token:
|
||||
self._base_headers["Authorization"] = f"Bearer {self._token}"
|
||||
elif "Authorization" in self._base_headers:
|
||||
del self._base_headers["Authorization"]
|
||||
|
||||
@property
|
||||
def headers(self) -> Mapping[str, str]:
|
||||
return dict(self._base_headers)
|
||||
|
||||
def _full_url(self, path_or_url: str) -> str:
|
||||
if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
|
||||
return path_or_url
|
||||
if not path_or_url.startswith("/"):
|
||||
path_or_url = "/" + path_or_url
|
||||
return f"{self.rest_base}{path_or_url}"
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: RequestMethod | str = RequestMethod.GET,
|
||||
path_or_url: str = "/",
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
max_retries: int = 2,
|
||||
backoff_base: float = 0.25,
|
||||
) -> tuple[Any, dict[str, str]]:
|
||||
"""
|
||||
Perform a REST request with basic retry for 429 and 5xx.
|
||||
Returns (json_body, response_headers).
|
||||
"""
|
||||
url = self._full_url(path_or_url)
|
||||
headers = {**self._base_headers, **(extra_headers or {})}
|
||||
meth = method.value if isinstance(method, RequestMethod) else method.lower()
|
||||
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
resp = await self._client.request(meth, url, headers=headers, params=params, json=json)
|
||||
# Map errors consistently
|
||||
if resp.status_code == 401:
|
||||
raise AuthenticationError("Invalid github token")
|
||||
if resp.status_code == 404:
|
||||
raise ResourceNotFoundError(f"Resource not found on GitHub API: {url}")
|
||||
if resp.status_code in (429, 500, 502, 503, 504):
|
||||
if attempt < max_retries:
|
||||
delay = backoff_base * (2**attempt) + random.uniform(0, 0.1)
|
||||
attempt += 1
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise RateLimitError("GitHub API rate limit or transient error")
|
||||
|
||||
resp.raise_for_status()
|
||||
headers_out: dict[str, str] = {}
|
||||
# copy interesting headers (Link, RateLimit, etc.) if present
|
||||
if "Link" in resp.headers:
|
||||
headers_out["Link"] = resp.headers["Link"]
|
||||
if "X-RateLimit-Remaining" in resp.headers:
|
||||
headers_out["X-RateLimit-Remaining"] = resp.headers["X-RateLimit-Remaining"]
|
||||
if "X-RateLimit-Reset" in resp.headers:
|
||||
headers_out["X-RateLimit-Reset"] = resp.headers["X-RateLimit-Reset"]
|
||||
return resp.json(), headers_out
|
||||
|
||||
except (httpx.HTTPError) as e: # network errors
|
||||
if attempt < max_retries:
|
||||
delay = backoff_base * (2**attempt) + random.uniform(0, 0.1)
|
||||
attempt += 1
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise UnknownException(f"HTTP error {type(e).__name__}: {e}") from e
|
||||
|
||||
async def graphql(
|
||||
self,
|
||||
query: str,
|
||||
variables: dict[str, Any] | None = None,
|
||||
*,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
headers = {**self._base_headers, **(extra_headers or {})}
|
||||
resp = await self._client.post(
|
||||
self.graphql_base,
|
||||
headers=headers,
|
||||
json={"query": query, "variables": variables or {}},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise AuthenticationError("Invalid github token")
|
||||
if resp.status_code == 404:
|
||||
raise ResourceNotFoundError("GraphQL endpoint not found")
|
||||
if resp.status_code in (429, 500, 502, 503, 504):
|
||||
raise RateLimitError("GitHub API rate limit or transient error")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "errors" in data:
|
||||
raise UnknownException(f"GraphQL query error: {data['errors']}")
|
||||
if not isinstance(data, dict):
|
||||
raise UnknownException("Unexpected GraphQL response type")
|
||||
return data
|
||||
@@ -4,6 +4,7 @@ from typing import Any, cast
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
RequestMethod,
|
||||
@@ -12,19 +13,15 @@ from openhands.integrations.service_types import (
|
||||
)
|
||||
|
||||
|
||||
class GitHubMixinBase(BaseGitService):
|
||||
class GitHubMixinBase(BaseGitService, HTTPClient):
|
||||
"""
|
||||
Declares common attributes and method signatures used across mixins.
|
||||
"""
|
||||
|
||||
BASE_URL: str
|
||||
GRAPHQL_URL: str
|
||||
token: SecretStr
|
||||
refresh: bool
|
||||
external_auth_id: str | None
|
||||
base_domain: str | None
|
||||
|
||||
async def _get_github_headers(self) -> dict:
|
||||
async def _get_headers(self) -> dict:
|
||||
"""Retrieve the GH Token from settings store to construct the headers."""
|
||||
if not self.token:
|
||||
latest_token = await self.get_latest_token()
|
||||
@@ -47,7 +44,7 @@ class GitHubMixinBase(BaseGitService):
|
||||
) -> tuple[Any, dict]: # type: ignore[override]
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_github_headers()
|
||||
github_headers = await self._get_headers()
|
||||
|
||||
# Make initial request
|
||||
response = await self.execute_request(
|
||||
@@ -61,7 +58,7 @@ class GitHubMixinBase(BaseGitService):
|
||||
# Handle token refresh if needed
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
github_headers = await self._get_github_headers()
|
||||
github_headers = await self._get_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
@@ -87,7 +84,7 @@ class GitHubMixinBase(BaseGitService):
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_github_headers()
|
||||
github_headers = await self._get_headers()
|
||||
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.service import (
|
||||
GitLabBranchesMixin,
|
||||
GitLabFeaturesMixin,
|
||||
GitLabPRsMixin,
|
||||
GitLabReposMixin,
|
||||
GitLabResolverMixin,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
Comment,
|
||||
GitService,
|
||||
OwnerType,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class GitLabService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for GitLab integration.
|
||||
class GitLabService(
|
||||
GitLabBranchesMixin,
|
||||
GitLabFeaturesMixin,
|
||||
GitLabPRsMixin,
|
||||
GitLabReposMixin,
|
||||
GitLabResolverMixin,
|
||||
BaseGitService,
|
||||
GitService,
|
||||
):
|
||||
"""
|
||||
Assembled GitLab service class combining mixins by feature area.
|
||||
|
||||
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
|
||||
This is an extension point in OpenHands that allows applications to customize GitLab
|
||||
@@ -41,8 +41,6 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
BASE_URL = 'https://gitlab.com/api/v4'
|
||||
GRAPHQL_URL = 'https://gitlab.com/api/graphql'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -52,9 +50,11 @@ class GitLabService(BaseGitService, GitService):
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.external_token_manager = external_token_manager
|
||||
self.external_auth_id = external_auth_id
|
||||
self.external_auth_token = external_auth_token
|
||||
|
||||
if token:
|
||||
self.token = token
|
||||
@@ -74,850 +74,6 @@ class GitLabService(BaseGitService, GitService):
|
||||
def provider(self) -> str:
|
||||
return ProviderType.GITLAB.value
|
||||
|
||||
async def _get_gitlab_headers(self) -> dict[str, Any]:
|
||||
"""Retrieve the GitLab Token to construct the headers"""
|
||||
if not self.token:
|
||||
latest_token = await self.get_latest_token()
|
||||
if latest_token:
|
||||
self.token = latest_token
|
||||
|
||||
return {
|
||||
'Authorization': f'Bearer {self.token.get_secret_value()}',
|
||||
}
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
return self.token
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw'
|
||||
)
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return f'{self.BASE_URL}/projects/{project_id}/repository/tree'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict:
|
||||
"""Get parameters for the microagents directory request."""
|
||||
return {'path': microagents_path, 'recursive': 'true'}
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'blob'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
gitlab_headers = await self._get_gitlab_headers()
|
||||
|
||||
# Make initial request
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=gitlab_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
# Handle token refresh if needed
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
gitlab_headers = await self._get_gitlab_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=gitlab_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
headers = {}
|
||||
if 'Link' in response.headers:
|
||||
headers['Link'] = response.headers['Link']
|
||||
|
||||
if 'X-Total' in response.headers:
|
||||
headers['X-Total'] = response.headers['X-Total']
|
||||
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'application/json' in content_type:
|
||||
return response.json(), headers
|
||||
else:
|
||||
return response.text, headers
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
"""Execute a GraphQL query against the GitLab GraphQL API
|
||||
|
||||
Args:
|
||||
query: The GraphQL query string
|
||||
variables: Optional variables for the GraphQL query
|
||||
|
||||
Returns:
|
||||
The data portion of the GraphQL response
|
||||
"""
|
||||
if variables is None:
|
||||
variables = {}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
gitlab_headers = await self._get_gitlab_headers()
|
||||
# Add content type header for GraphQL
|
||||
gitlab_headers['Content-Type'] = 'application/json'
|
||||
|
||||
payload = {
|
||||
'query': query,
|
||||
'variables': variables if variables is not None else {},
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
|
||||
)
|
||||
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
gitlab_headers = await self._get_gitlab_headers()
|
||||
gitlab_headers['Content-Type'] = 'application/json'
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Check for GraphQL errors
|
||||
if 'errors' in result:
|
||||
error_message = result['errors'][0].get(
|
||||
'message', 'Unknown GraphQL error'
|
||||
)
|
||||
raise UnknownException(f'GraphQL error: {error_message}')
|
||||
|
||||
return result.get('data')
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def get_user(self) -> User:
|
||||
url = f'{self.BASE_URL}/user'
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
# Use a default avatar URL if not provided
|
||||
# In some self-hosted GitLab instances, the avatar_url field may be returned as None.
|
||||
avatar_url = response.get('avatar_url') or ''
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=response.get('username'), # type: ignore[call-arg]
|
||||
avatar_url=avatar_url,
|
||||
name=response.get('name'),
|
||||
email=response.get('email'),
|
||||
company=response.get('organization'),
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a GitLab API project response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Project data from GitLab API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
main_branch=repo.get('default_branch'),
|
||||
)
|
||||
|
||||
def _parse_gitlab_url(self, url: str) -> str | None:
|
||||
"""Parse a GitLab URL to extract the repository path.
|
||||
|
||||
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
|
||||
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
|
||||
"""
|
||||
try:
|
||||
# Remove protocol and domain
|
||||
if '://' in url:
|
||||
url = url.split('://', 1)[1]
|
||||
if '/' in url:
|
||||
path = url.split('/', 1)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Clean up the path
|
||||
path = path.strip('/')
|
||||
if not path:
|
||||
return None
|
||||
|
||||
# Split the path and remove empty parts
|
||||
path_parts = [part for part in path.split('/') if part]
|
||||
|
||||
# We need at least 2 parts: group/repo
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
# Join all parts to form the full repository path
|
||||
return '/'.join(path_parts)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
sort: str = 'updated',
|
||||
order: str = 'desc',
|
||||
public: bool = False,
|
||||
) -> list[Repository]:
|
||||
if public:
|
||||
# When public=True, query is a GitLab URL that we need to parse
|
||||
repo_path = self._parse_gitlab_url(query)
|
||||
if not repo_path:
|
||||
return [] # Invalid URL format
|
||||
|
||||
repository = await self.get_repository_details_from_repo_name(repo_path)
|
||||
return [repository]
|
||||
|
||||
return await self.get_paginated_repos(1, per_page, sort, None, query)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': True, # Include projects user is a member of
|
||||
}
|
||||
|
||||
if query:
|
||||
params['search'] = query
|
||||
params['search_namespaces'] = True
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
repos = [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
return repos
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
page = 1
|
||||
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
# Map GitHub's sort values to GitLab's order_by values
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
while len(all_repos) < MAX_REPOS:
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(PER_PAGE),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': 1, # Use 1 instead of True
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response: # No more repositories
|
||||
break
|
||||
|
||||
all_repos.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
# Trim to MAX_REPOS if needed and convert to Repository objects
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- Merge requests authored by the user.
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
username = user.login
|
||||
|
||||
# GraphQL query to get merge requests
|
||||
query = """
|
||||
query GetUserTasks {
|
||||
currentUser {
|
||||
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
|
||||
nodes {
|
||||
id
|
||||
iid
|
||||
title
|
||||
project {
|
||||
fullPath
|
||||
}
|
||||
conflicts
|
||||
mergeStatus
|
||||
pipelines(first: 1) {
|
||||
nodes {
|
||||
status
|
||||
}
|
||||
}
|
||||
discussions(first: 100) {
|
||||
nodes {
|
||||
notes {
|
||||
nodes {
|
||||
resolvable
|
||||
resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
# Get merge requests using GraphQL
|
||||
response = await self.execute_graphql_query(query)
|
||||
data = response.get('currentUser', {})
|
||||
|
||||
# Process merge requests
|
||||
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
|
||||
for mr in merge_requests:
|
||||
repo_name = mr.get('project', {}).get('fullPath', '')
|
||||
mr_number = mr.get('iid')
|
||||
title = mr.get('title', '')
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if mr.get('conflicts'):
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
mr.get('pipelines', {}).get('nodes', [])
|
||||
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
|
||||
== 'FAILED'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
else:
|
||||
# Check for unresolved comments
|
||||
has_unresolved_comments = False
|
||||
for discussion in mr.get('discussions', {}).get('nodes', []):
|
||||
for note in discussion.get('notes', {}).get('nodes', []):
|
||||
if note.get('resolvable') and not note.get('resolved'):
|
||||
has_unresolved_comments = True
|
||||
break
|
||||
if has_unresolved_comments:
|
||||
break
|
||||
|
||||
if has_unresolved_comments:
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
|
||||
# Only add the task if it's not OPEN_PR
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=mr_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Get assigned issues using REST API
|
||||
url = f'{self.BASE_URL}/issues'
|
||||
params = {
|
||||
'assignee_username': username,
|
||||
'state': 'opened',
|
||||
'scope': 'assigned_to_me',
|
||||
}
|
||||
|
||||
issues_response, _ = await self._make_request(
|
||||
method=RequestMethod.GET, url=url, params=params
|
||||
)
|
||||
|
||||
# Process issues
|
||||
for issue in issues_response:
|
||||
repo_name = (
|
||||
issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
)
|
||||
issue_number = issue.get('iid')
|
||||
title = issue.get('title', '')
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(repo)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
# Set maximum branches to fetch (10 pages with 100 per page)
|
||||
MAX_BRANCHES = 1000
|
||||
PER_PAGE = 100
|
||||
|
||||
all_branches: list[Branch] = []
|
||||
page = 1
|
||||
|
||||
# Fetch up to 10 pages of branches
|
||||
while page <= 10 and len(all_branches) < MAX_BRANCHES:
|
||||
params = {'per_page': str(PER_PAGE), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response: # No more branches
|
||||
break
|
||||
|
||||
for branch_data in response:
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
all_branches.append(branch)
|
||||
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
return all_branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination"""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
branches.append(branch)
|
||||
|
||||
has_next_page = False
|
||||
total_count = None
|
||||
if headers.get('Link', ''):
|
||||
has_next_page = True
|
||||
|
||||
if 'X-Total' in headers:
|
||||
try:
|
||||
total_count = int(headers['X-Total'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches using GitLab API which supports `search` param."""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'search': query}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
async def create_mr(
|
||||
self,
|
||||
id: int | str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Creates a merge request in GitLab
|
||||
|
||||
Args:
|
||||
id: The ID or URL-encoded path of the project
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes merged into
|
||||
title: The title of the merge request (optional, defaults to a generic title)
|
||||
description: The description of the merge request (optional)
|
||||
labels: A list of labels to apply to the merge request (optional)
|
||||
|
||||
Returns:
|
||||
- MR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
# Convert string ID to URL-encoded path if needed
|
||||
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
|
||||
|
||||
# Set default description if none provided
|
||||
if not description:
|
||||
description = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'source_branch': source_branch,
|
||||
'target_branch': target_branch,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# Add labels if provided
|
||||
if labels and len(labels) > 0:
|
||||
payload['labels'] = ','.join(labels)
|
||||
|
||||
# Make the POST request to create the MR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
return response['web_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
Raw GitLab API response for the merge request
|
||||
"""
|
||||
project_id = self._extract_project_id(repository)
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
|
||||
mr_data, _ = await self._make_request(url)
|
||||
|
||||
return mr_data
|
||||
|
||||
def _extract_project_id(self, repository: str) -> str:
|
||||
"""Extract project_id from repository name for GitLab API calls.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
|
||||
Returns:
|
||||
URL-encoded project ID for GitLab API
|
||||
"""
|
||||
if '/' in repository:
|
||||
parts = repository.split('/')
|
||||
if len(parts) >= 3 and '.' in parts[0]:
|
||||
# Self-hosted GitLab: 'domain/owner/repo' -> 'owner/repo'
|
||||
project_id = '/'.join(parts[1:]).replace('/', '%2F')
|
||||
else:
|
||||
# Regular GitLab: 'owner/repo' -> 'owner/repo'
|
||||
project_id = repository.replace('/', '%2F')
|
||||
else:
|
||||
project_id = repository
|
||||
|
||||
return project_id
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitLab repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Extract project_id from repository name
|
||||
project_id = self._extract_project_id(repository)
|
||||
|
||||
encoded_file_path = file_path.replace('/', '%2F')
|
||||
base_url = f'{self.BASE_URL}/projects/{project_id}'
|
||||
file_url = f'{base_url}/repository/files/{encoded_file_path}/raw'
|
||||
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
async def get_review_thread_comments(
|
||||
self, project_id: str, issue_iid: int, discussion_id: str
|
||||
) -> list[Comment]:
|
||||
url = (
|
||||
f'{self.BASE_URL}/projects/{project_id}'
|
||||
f'/merge_requests/{issue_iid}/discussions/{discussion_id}'
|
||||
)
|
||||
|
||||
# Single discussion fetch; notes are returned inline.
|
||||
response, _ = await self._make_request(url)
|
||||
notes = response.get('notes') or []
|
||||
return self._process_raw_comments(notes)
|
||||
|
||||
async def get_issue_or_mr_title_and_body(
|
||||
self, project_id: str, issue_number: int, is_mr: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Get the title and body of an issue or merge request.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
issue_number: The issue/MR IID within the project
|
||||
is_mr: If True, treat as merge request; if False, treat as issue;
|
||||
if None, try issue first then merge request (default behavior)
|
||||
|
||||
Returns:
|
||||
A tuple of (title, body)
|
||||
"""
|
||||
if is_mr:
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('description') or ''
|
||||
return title, body
|
||||
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('description') or ''
|
||||
return title, body
|
||||
|
||||
async def get_issue_or_mr_comments(
|
||||
self,
|
||||
project_id: str,
|
||||
issue_number: int,
|
||||
max_comments: int = 10,
|
||||
is_mr: bool = False,
|
||||
) -> list[Comment]:
|
||||
"""Get comments for an issue or merge request.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
issue_number: The issue/MR IID within the project
|
||||
max_comments: Maximum number of comments to retrieve
|
||||
is_pr: If True, treat as merge request; if False, treat as issue;
|
||||
if None, try issue first then merge request (default behavior)
|
||||
|
||||
Returns:
|
||||
List of Comment objects ordered by creation date
|
||||
"""
|
||||
all_comments: list[Comment] = []
|
||||
page = 1
|
||||
per_page = min(max_comments, 10)
|
||||
|
||||
url = (
|
||||
f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}/discussions'
|
||||
if is_mr
|
||||
else f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/notes'
|
||||
)
|
||||
|
||||
while len(all_comments) < max_comments:
|
||||
params = {
|
||||
'per_page': per_page,
|
||||
'page': page,
|
||||
'order_by': 'created_at',
|
||||
'sort': 'asc',
|
||||
}
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
if not response:
|
||||
break
|
||||
|
||||
if is_mr:
|
||||
for discussions in response:
|
||||
# Keep root level comments
|
||||
all_comments.append(discussions['notes'][0])
|
||||
else:
|
||||
all_comments.extend(response)
|
||||
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return self._process_raw_comments(all_comments)
|
||||
|
||||
def _process_raw_comments(
|
||||
self, comments: list, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Helper method to fetch comments from a given URL with pagination."""
|
||||
all_comments: list[Comment] = []
|
||||
for comment_data in comments:
|
||||
comment = Comment(
|
||||
id=str(comment_data.get('id', 'unknown')),
|
||||
body=self._truncate_comment(comment_data.get('body', '')),
|
||||
author=comment_data.get('author', {}).get('username', 'unknown'),
|
||||
created_at=datetime.fromisoformat(
|
||||
comment_data.get('created_at', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment_data.get('created_at')
|
||||
else datetime.fromtimestamp(0),
|
||||
updated_at=datetime.fromisoformat(
|
||||
comment_data.get('updated_at', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment_data.get('updated_at')
|
||||
else datetime.fromtimestamp(0),
|
||||
system=comment_data.get('system', False),
|
||||
)
|
||||
all_comments.append(comment)
|
||||
|
||||
# Sort comments by creation date and return the most recent ones
|
||||
all_comments.sort(key=lambda c: c.created_at)
|
||||
return all_comments[-max_comments:]
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitLab merge request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
True if MR is active (opened), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
mr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitLab API response structure
|
||||
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
|
||||
if 'state' in mr_details:
|
||||
return mr_details['state'] == 'opened'
|
||||
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
|
||||
# Check if MR is merged or closed
|
||||
return not (mr_details['merged_at'] or mr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the MR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# openhands/integrations/gitlab/service/__init__.py
|
||||
|
||||
from .base import GitLabMixinBase
|
||||
from .branches import GitLabBranchesMixin
|
||||
from .features import GitLabFeaturesMixin
|
||||
from .prs import GitLabPRsMixin
|
||||
from .repos import GitLabReposMixin
|
||||
from .resolver import GitLabResolverMixin
|
||||
|
||||
__all__ = [
|
||||
'GitLabMixinBase',
|
||||
'GitLabBranchesMixin',
|
||||
'GitLabFeaturesMixin',
|
||||
'GitLabPRsMixin',
|
||||
'GitLabReposMixin',
|
||||
'GitLabResolverMixin',
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
RequestMethod,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class GitLabMixinBase(BaseGitService, HTTPClient):
|
||||
"""
|
||||
Declares common attributes and method signatures used across mixins.
|
||||
"""
|
||||
|
||||
BASE_URL: str
|
||||
GRAPHQL_URL: str
|
||||
|
||||
async def _get_headers(self) -> dict[str, Any]:
|
||||
"""Retrieve the GitLab Token to construct the headers"""
|
||||
if not self.token:
|
||||
latest_token = await self.get_latest_token()
|
||||
if latest_token:
|
||||
self.token = latest_token
|
||||
|
||||
return {
|
||||
'Authorization': f'Bearer {self.token.get_secret_value()}',
|
||||
}
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None: # type: ignore[override]
|
||||
return self.token
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]: # type: ignore[override]
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
gitlab_headers = await self._get_headers()
|
||||
|
||||
# Make initial request
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=gitlab_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
# Handle token refresh if needed
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
gitlab_headers = await self._get_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=gitlab_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
headers = {}
|
||||
if 'Link' in response.headers:
|
||||
headers['Link'] = response.headers['Link']
|
||||
|
||||
if 'X-Total' in response.headers:
|
||||
headers['X-Total'] = response.headers['X-Total']
|
||||
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'application/json' in content_type:
|
||||
return response.json(), headers
|
||||
else:
|
||||
return response.text, headers
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
"""Execute a GraphQL query against the GitLab GraphQL API
|
||||
|
||||
Args:
|
||||
query: The GraphQL query string
|
||||
variables: Optional variables for the GraphQL query
|
||||
|
||||
Returns:
|
||||
The data portion of the GraphQL response
|
||||
"""
|
||||
if variables is None:
|
||||
variables = {}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
gitlab_headers = await self._get_headers()
|
||||
# Add content type header for GraphQL
|
||||
gitlab_headers['Content-Type'] = 'application/json'
|
||||
|
||||
payload = {
|
||||
'query': query,
|
||||
'variables': variables if variables is not None else {},
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
|
||||
)
|
||||
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
gitlab_headers = await self._get_headers()
|
||||
gitlab_headers['Content-Type'] = 'application/json'
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Check for GraphQL errors
|
||||
if 'errors' in result:
|
||||
error_message = result['errors'][0].get(
|
||||
'message', 'Unknown GraphQL error'
|
||||
)
|
||||
raise UnknownException(f'GraphQL error: {error_message}')
|
||||
|
||||
return result.get('data')
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def get_user(self) -> User:
|
||||
url = f'{self.BASE_URL}/user'
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
# Use a default avatar URL if not provided
|
||||
# In some self-hosted GitLab instances, the avatar_url field may be returned as None.
|
||||
avatar_url = response.get('avatar_url') or ''
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=response.get('username'), # type: ignore[call-arg]
|
||||
avatar_url=avatar_url,
|
||||
name=response.get('name'),
|
||||
email=response.get('email'),
|
||||
company=response.get('organization'),
|
||||
)
|
||||
|
||||
def _extract_project_id(self, repository: str) -> str:
|
||||
"""Extract project_id from repository name for GitLab API calls.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
|
||||
Returns:
|
||||
URL-encoded project ID for GitLab API
|
||||
"""
|
||||
if '/' in repository:
|
||||
parts = repository.split('/')
|
||||
if len(parts) >= 3 and '.' in parts[0]:
|
||||
# Self-hosted GitLab: 'domain/owner/repo' -> 'owner/repo'
|
||||
project_id = '/'.join(parts[1:]).replace('/', '%2F')
|
||||
else:
|
||||
# Regular GitLab: 'owner/repo' -> 'owner/repo'
|
||||
project_id = repository.replace('/', '%2F')
|
||||
else:
|
||||
project_id = repository
|
||||
|
||||
return project_id
|
||||
@@ -0,0 +1,107 @@
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
class GitLabBranchesMixin(GitLabMixinBase):
|
||||
"""
|
||||
Methods for interacting with GitLab branches
|
||||
"""
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
# Set maximum branches to fetch (10 pages with 100 per page)
|
||||
MAX_BRANCHES = 1000
|
||||
PER_PAGE = 100
|
||||
|
||||
all_branches: list[Branch] = []
|
||||
page = 1
|
||||
|
||||
# Fetch up to 10 pages of branches
|
||||
while page <= 10 and len(all_branches) < MAX_BRANCHES:
|
||||
params = {'per_page': str(PER_PAGE), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response: # No more branches
|
||||
break
|
||||
|
||||
for branch_data in response:
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
all_branches.append(branch)
|
||||
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
return all_branches
|
||||
|
||||
async def get_paginated_branches(
|
||||
self, repository: str, page: int = 1, per_page: int = 30
|
||||
) -> PaginatedBranchesResponse:
|
||||
"""Get branches for a repository with pagination"""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'page': str(page)}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
branches.append(branch)
|
||||
|
||||
has_next_page = False
|
||||
total_count = None
|
||||
if headers.get('Link', ''):
|
||||
has_next_page = True
|
||||
|
||||
if 'X-Total' in headers:
|
||||
try:
|
||||
total_count = int(headers['X-Total'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=total_count,
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches using GitLab API which supports `search` param."""
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
|
||||
|
||||
params = {'per_page': str(per_page), 'search': query}
|
||||
response, _ = await self._make_request(url, params)
|
||||
|
||||
branches: list[Branch] = []
|
||||
for branch_data in response:
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('id', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=branch_data.get('commit', {}).get('committed_date'),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
@@ -0,0 +1,207 @@
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
)
|
||||
|
||||
|
||||
class GitLabFeaturesMixin(GitLabMixinBase):
|
||||
"""
|
||||
Methods used for custom features in UI driven via GitLab integration
|
||||
"""
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw'
|
||||
)
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return f'{self.BASE_URL}/projects/{project_id}/repository/tree'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict:
|
||||
"""Get parameters for the microagents directory request."""
|
||||
return {'path': microagents_path, 'recursive': 'true'}
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'blob'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- Merge requests authored by the user.
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
username = user.login
|
||||
|
||||
# GraphQL query to get merge requests
|
||||
query = """
|
||||
query GetUserTasks {
|
||||
currentUser {
|
||||
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
|
||||
nodes {
|
||||
id
|
||||
iid
|
||||
title
|
||||
project {
|
||||
fullPath
|
||||
}
|
||||
conflicts
|
||||
mergeStatus
|
||||
pipelines(first: 1) {
|
||||
nodes {
|
||||
status
|
||||
}
|
||||
}
|
||||
discussions(first: 100) {
|
||||
nodes {
|
||||
notes {
|
||||
nodes {
|
||||
resolvable
|
||||
resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
# Get merge requests using GraphQL
|
||||
response = await self.execute_graphql_query(query)
|
||||
data = response.get('currentUser', {})
|
||||
|
||||
# Process merge requests
|
||||
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
|
||||
for mr in merge_requests:
|
||||
repo_name = mr.get('project', {}).get('fullPath', '')
|
||||
mr_number = mr.get('iid')
|
||||
title = mr.get('title', '')
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if mr.get('conflicts'):
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
mr.get('pipelines', {}).get('nodes', [])
|
||||
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
|
||||
== 'FAILED'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
else:
|
||||
# Check for unresolved comments
|
||||
has_unresolved_comments = False
|
||||
for discussion in mr.get('discussions', {}).get('nodes', []):
|
||||
for note in discussion.get('notes', {}).get('nodes', []):
|
||||
if note.get('resolvable') and not note.get('resolved'):
|
||||
has_unresolved_comments = True
|
||||
break
|
||||
if has_unresolved_comments:
|
||||
break
|
||||
|
||||
if has_unresolved_comments:
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
|
||||
# Only add the task if it's not OPEN_PR
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=mr_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Get assigned issues using REST API
|
||||
url = f'{self.BASE_URL}/issues'
|
||||
params = {
|
||||
'assignee_username': username,
|
||||
'state': 'opened',
|
||||
'scope': 'assigned_to_me',
|
||||
}
|
||||
|
||||
issues_response, _ = await self._make_request(
|
||||
method=RequestMethod.GET, url=url, params=params
|
||||
)
|
||||
|
||||
# Process issues
|
||||
for issue in issues_response:
|
||||
repo_name = (
|
||||
issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
)
|
||||
issue_number = issue.get('iid')
|
||||
title = issue.get('title', '')
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitLab repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Extract project_id from repository name
|
||||
project_id = self._extract_project_id(repository)
|
||||
|
||||
encoded_file_path = file_path.replace('/', '%2F')
|
||||
base_url = f'{self.BASE_URL}/projects/{project_id}'
|
||||
file_url = f'{base_url}/repository/files/{encoded_file_path}/raw'
|
||||
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
@@ -0,0 +1,111 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import RequestMethod
|
||||
|
||||
|
||||
class GitLabPRsMixin(GitLabMixinBase):
|
||||
"""
|
||||
Methods for interacting with GitLab merge requests (PRs)
|
||||
"""
|
||||
|
||||
async def create_mr(
|
||||
self,
|
||||
id: int | str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Creates a merge request in GitLab
|
||||
|
||||
Args:
|
||||
id: The ID or URL-encoded path of the project
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes merged into
|
||||
title: The title of the merge request (optional, defaults to a generic title)
|
||||
description: The description of the merge request (optional)
|
||||
labels: A list of labels to apply to the merge request (optional)
|
||||
|
||||
Returns:
|
||||
- MR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
# Convert string ID to URL-encoded path if needed
|
||||
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
|
||||
|
||||
# Set default description if none provided
|
||||
if not description:
|
||||
description = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'source_branch': source_branch,
|
||||
'target_branch': target_branch,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# Add labels if provided
|
||||
if labels and len(labels) > 0:
|
||||
payload['labels'] = ','.join(labels)
|
||||
|
||||
# Make the POST request to create the MR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
return response['web_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
Raw GitLab API response for the merge request
|
||||
"""
|
||||
project_id = self._extract_project_id(repository)
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
|
||||
mr_data, _ = await self._make_request(url)
|
||||
|
||||
return mr_data
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitLab merge request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
True if MR is active (opened), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
mr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitLab API response structure
|
||||
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
|
||||
if 'state' in mr_details:
|
||||
return mr_details['state'] == 'opened'
|
||||
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
|
||||
# Check if MR is merged or closed
|
||||
return not (mr_details['merged_at'] or mr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the MR status, include the conversation to be safe
|
||||
return True
|
||||
@@ -0,0 +1,176 @@
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import OwnerType, ProviderType, Repository
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class GitLabReposMixin(GitLabMixinBase):
|
||||
"""
|
||||
Methods for interacting with GitLab repositories
|
||||
"""
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a GitLab API project response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Project data from GitLab API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
main_branch=repo.get('default_branch'),
|
||||
)
|
||||
|
||||
def _parse_gitlab_url(self, url: str) -> str | None:
|
||||
"""Parse a GitLab URL to extract the repository path.
|
||||
|
||||
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
|
||||
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
|
||||
"""
|
||||
try:
|
||||
# Remove protocol and domain
|
||||
if '://' in url:
|
||||
url = url.split('://', 1)[1]
|
||||
if '/' in url:
|
||||
path = url.split('/', 1)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Clean up the path
|
||||
path = path.strip('/')
|
||||
if not path:
|
||||
return None
|
||||
|
||||
# Split the path and remove empty parts
|
||||
path_parts = [part for part in path.split('/') if part]
|
||||
|
||||
# We need at least 2 parts: group/repo
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
# Join all parts to form the full repository path
|
||||
return '/'.join(path_parts)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
sort: str = 'updated',
|
||||
order: str = 'desc',
|
||||
public: bool = False,
|
||||
) -> list[Repository]:
|
||||
if public:
|
||||
# When public=True, query is a GitLab URL that we need to parse
|
||||
repo_path = self._parse_gitlab_url(query)
|
||||
if not repo_path:
|
||||
return [] # Invalid URL format
|
||||
|
||||
repository = await self.get_repository_details_from_repo_name(repo_path)
|
||||
return [repository]
|
||||
|
||||
return await self.get_paginated_repos(1, per_page, sort, None, query)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': True, # Include projects user is a member of
|
||||
}
|
||||
|
||||
if query:
|
||||
params['search'] = query
|
||||
params['search_namespaces'] = True
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
repos = [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
return repos
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
page = 1
|
||||
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
# Map GitHub's sort values to GitLab's order_by values
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
while len(all_repos) < MAX_REPOS:
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(PER_PAGE),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': 1, # Use 1 instead of True
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response: # No more repositories
|
||||
break
|
||||
|
||||
all_repos.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
# Trim to MAX_REPOS if needed and convert to Repository objects
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
encoded_name = repository.replace('/', '%2F')
|
||||
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(repo)
|
||||
@@ -0,0 +1,134 @@
|
||||
from datetime import datetime
|
||||
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import Comment
|
||||
|
||||
|
||||
class GitLabResolverMixin(GitLabMixinBase):
|
||||
"""
|
||||
Helper methods used for the GitLab Resolver
|
||||
"""
|
||||
|
||||
async def get_review_thread_comments(
|
||||
self, project_id: str, issue_iid: int, discussion_id: str
|
||||
) -> list[Comment]:
|
||||
url = (
|
||||
f'{self.BASE_URL}/projects/{project_id}'
|
||||
f'/merge_requests/{issue_iid}/discussions/{discussion_id}'
|
||||
)
|
||||
|
||||
# Single discussion fetch; notes are returned inline.
|
||||
response, _ = await self._make_request(url)
|
||||
notes = response.get('notes') or []
|
||||
return self._process_raw_comments(notes)
|
||||
|
||||
async def get_issue_or_mr_title_and_body(
|
||||
self, project_id: str, issue_number: int, is_mr: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Get the title and body of an issue or merge request.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
issue_number: The issue/MR IID within the project
|
||||
is_mr: If True, treat as merge request; if False, treat as issue;
|
||||
if None, try issue first then merge request (default behavior)
|
||||
|
||||
Returns:
|
||||
A tuple of (title, body)
|
||||
"""
|
||||
if is_mr:
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('description') or ''
|
||||
return title, body
|
||||
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('description') or ''
|
||||
return title, body
|
||||
|
||||
async def get_issue_or_mr_comments(
|
||||
self,
|
||||
project_id: str,
|
||||
issue_number: int,
|
||||
max_comments: int = 10,
|
||||
is_mr: bool = False,
|
||||
) -> list[Comment]:
|
||||
"""Get comments for an issue or merge request.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
issue_number: The issue/MR IID within the project
|
||||
max_comments: Maximum number of comments to retrieve
|
||||
is_pr: If True, treat as merge request; if False, treat as issue;
|
||||
if None, try issue first then merge request (default behavior)
|
||||
|
||||
Returns:
|
||||
List of Comment objects ordered by creation date
|
||||
"""
|
||||
all_comments: list[Comment] = []
|
||||
page = 1
|
||||
per_page = min(max_comments, 10)
|
||||
|
||||
url = (
|
||||
f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}/discussions'
|
||||
if is_mr
|
||||
else f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/notes'
|
||||
)
|
||||
|
||||
while len(all_comments) < max_comments:
|
||||
params = {
|
||||
'per_page': per_page,
|
||||
'page': page,
|
||||
'order_by': 'created_at',
|
||||
'sort': 'asc',
|
||||
}
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
if not response:
|
||||
break
|
||||
|
||||
if is_mr:
|
||||
for discussions in response:
|
||||
# Keep root level comments
|
||||
all_comments.append(discussions['notes'][0])
|
||||
else:
|
||||
all_comments.extend(response)
|
||||
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return self._process_raw_comments(all_comments)
|
||||
|
||||
def _process_raw_comments(
|
||||
self, comments: list, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Helper method to fetch comments from a given URL with pagination."""
|
||||
all_comments: list[Comment] = []
|
||||
for comment_data in comments:
|
||||
comment = Comment(
|
||||
id=str(comment_data.get('id', 'unknown')),
|
||||
body=self._truncate_comment(comment_data.get('body', '')),
|
||||
author=comment_data.get('author', {}).get('username', 'unknown'),
|
||||
created_at=datetime.fromisoformat(
|
||||
comment_data.get('created_at', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment_data.get('created_at')
|
||||
else datetime.fromtimestamp(0),
|
||||
updated_at=datetime.fromisoformat(
|
||||
comment_data.get('updated_at', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment_data.get('updated_at')
|
||||
else datetime.fromtimestamp(0),
|
||||
system=comment_data.get('system', False),
|
||||
)
|
||||
all_comments.append(comment)
|
||||
|
||||
# Sort comments by creation date and return the most recent ones
|
||||
all_comments.sort(key=lambda c: c.created_at)
|
||||
return all_comments[-max_comments:]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""HTTP Client Protocol for Git Service Integrations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient, HTTPError, HTTPStatusError
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
UnknownException,
|
||||
)
|
||||
|
||||
|
||||
class HTTPClient(ABC):
|
||||
"""Abstract base class defining the HTTP client interface for Git service integrations.
|
||||
|
||||
This class abstracts the common HTTP client functionality needed by all
|
||||
Git service providers (GitHub, GitLab, BitBucket) while keeping inheritance in place.
|
||||
"""
|
||||
|
||||
# Default attributes (subclasses may override)
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh: bool = False
|
||||
external_auth_id: str | None = None
|
||||
external_auth_token: SecretStr | None = None
|
||||
external_token_manager: bool = False
|
||||
base_domain: str | None = None
|
||||
|
||||
# Provider identification must be implemented by subclasses
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider(self) -> str: ...
|
||||
|
||||
# Abstract methods that concrete classes must implement
|
||||
@abstractmethod
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get the latest working token for the service."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_headers(self) -> dict[str, Any]:
|
||||
"""Get HTTP headers for API requests."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
"""Make an HTTP request to the Git service API."""
|
||||
...
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
"""Check if the token has expired based on HTTP status code."""
|
||||
return status_code == 401
|
||||
|
||||
async def execute_request(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
url: str,
|
||||
headers: dict,
|
||||
params: dict | None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
):
|
||||
"""Execute an HTTP request using the provided client."""
|
||||
if method == RequestMethod.POST:
|
||||
return await client.post(url, headers=headers, json=params)
|
||||
return await client.get(url, headers=headers, params=params)
|
||||
|
||||
def handle_http_status_error(
|
||||
self, e: HTTPStatusError
|
||||
) -> (
|
||||
AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException
|
||||
):
|
||||
"""Handle HTTP status errors and convert them to appropriate exceptions."""
|
||||
if e.response.status_code == 401:
|
||||
return AuthenticationError(f'Invalid {self.provider} token')
|
||||
elif e.response.status_code == 404:
|
||||
return ResourceNotFoundError(
|
||||
f'Resource not found on {self.provider} API: {e}'
|
||||
)
|
||||
elif e.response.status_code == 429:
|
||||
logger.warning(f'Rate limit exceeded on {self.provider} API: {e}')
|
||||
return RateLimitError(f'{self.provider} API rate limit exceeded')
|
||||
|
||||
logger.warning(f'Status error on {self.provider} API: {e}')
|
||||
return UnknownException(f'Unknown error: {e}')
|
||||
|
||||
def handle_http_error(self, e: HTTPError) -> UnknownException:
|
||||
"""Handle general HTTP errors."""
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
@@ -4,7 +4,6 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from httpx import AsyncClient, HTTPError, HTTPStatusError
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
@@ -242,40 +241,6 @@ class BaseGitService(ABC):
|
||||
"""Extract file path from directory item."""
|
||||
...
|
||||
|
||||
async def execute_request(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
url: str,
|
||||
headers: dict,
|
||||
params: dict | None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
):
|
||||
if method == RequestMethod.POST:
|
||||
return await client.post(url, headers=headers, json=params)
|
||||
return await client.get(url, headers=headers, params=params)
|
||||
|
||||
def handle_http_status_error(
|
||||
self, e: HTTPStatusError
|
||||
) -> (
|
||||
AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException
|
||||
):
|
||||
if e.response.status_code == 401:
|
||||
return AuthenticationError(f'Invalid {self.provider} token')
|
||||
elif e.response.status_code == 404:
|
||||
return ResourceNotFoundError(
|
||||
f'Resource not found on {self.provider} API: {e}'
|
||||
)
|
||||
elif e.response.status_code == 429:
|
||||
logger.warning(f'Rate limit exceeded on {self.provider} API: {e}')
|
||||
return RateLimitError('GitHub API rate limit exceeded')
|
||||
|
||||
logger.warning(f'Status error on {self.provider} API: {e}')
|
||||
return UnknownException(f'Unknown error: {e}')
|
||||
|
||||
def handle_http_error(self, e: HTTPError) -> UnknownException:
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
|
||||
def _determine_microagents_path(self, repository_name: str) -> str:
|
||||
"""Determine the microagents directory path based on repository name."""
|
||||
actual_repo_name = repository_name.split('/')[-1]
|
||||
@@ -462,9 +427,6 @@ class BaseGitService(ABC):
|
||||
return comment_body[:max_comment_length] + '...'
|
||||
return comment_body
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
|
||||
class InstallationsService(Protocol):
|
||||
async def get_installations(self) -> list[str]:
|
||||
|
||||
@@ -2,7 +2,7 @@ import io
|
||||
import re
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import ClassVar, Union
|
||||
|
||||
import frontmatter
|
||||
from pydantic import BaseModel
|
||||
@@ -23,6 +23,31 @@ class BaseMicroagent(BaseModel):
|
||||
source: str # path to the file
|
||||
type: MicroagentType
|
||||
|
||||
PATH_TO_THIRD_PARTY_MICROAGENT_NAME: ClassVar[dict[str, str]] = {
|
||||
'.cursorrules': 'cursorrules',
|
||||
'agents.md': 'agents',
|
||||
'agent.md': 'agents',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _handle_third_party(
|
||||
cls, path: Path, file_content: str
|
||||
) -> Union['RepoMicroagent', None]:
|
||||
# Determine the agent name based on file type
|
||||
microagent_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(path.name.lower())
|
||||
|
||||
# Create RepoMicroagent if we recognized the file type
|
||||
if microagent_name is not None:
|
||||
return RepoMicroagent(
|
||||
name=microagent_name,
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name=microagent_name),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
@@ -40,11 +65,10 @@ class BaseMicroagent(BaseModel):
|
||||
# Otherwise, we will rely on the name from metadata later
|
||||
derived_name = None
|
||||
if microagent_dir is not None:
|
||||
# Special handling for .cursorrules files which are not in microagent_dir
|
||||
if path.name == '.cursorrules':
|
||||
derived_name = 'cursorrules'
|
||||
else:
|
||||
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
# Special handling for files which are not in microagent_dir
|
||||
derived_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(
|
||||
path.name.lower()
|
||||
) or str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
|
||||
# Only load directly from path if file_content is not provided
|
||||
if file_content is None:
|
||||
@@ -61,15 +85,10 @@ class BaseMicroagent(BaseModel):
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
# Handle .cursorrules files
|
||||
if path.name == '.cursorrules':
|
||||
return RepoMicroagent(
|
||||
name='cursorrules',
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name='cursorrules'),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
# Handle third-party agent instruction files
|
||||
third_party_agent = cls._handle_third_party(path, file_content)
|
||||
if third_party_agent is not None:
|
||||
return third_party_agent
|
||||
|
||||
file_io = io.StringIO(file_content)
|
||||
loaded = frontmatter.load(file_io)
|
||||
@@ -276,31 +295,44 @@ def load_microagents_from_dir(
|
||||
|
||||
# Load all agents from microagents directory
|
||||
logger.debug(f'Loading agents from {microagent_dir}')
|
||||
if microagent_dir.exists():
|
||||
# Collect .cursorrules file from repo root and .md files from microagents dir
|
||||
cursorrules_files = []
|
||||
if (microagent_dir.parent.parent / '.cursorrules').exists():
|
||||
cursorrules_files = [microagent_dir.parent.parent / '.cursorrules']
|
||||
|
||||
# Always check for .cursorrules and AGENTS.md files in repo root, regardless of whether microagents_dir exists
|
||||
special_files = []
|
||||
repo_root = microagent_dir.parent.parent
|
||||
|
||||
# Check for .cursorrules
|
||||
if (repo_root / '.cursorrules').exists():
|
||||
special_files.append(repo_root / '.cursorrules')
|
||||
|
||||
# Check for AGENTS.md (case-insensitive)
|
||||
for agents_filename in ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']:
|
||||
agents_path = repo_root / agents_filename
|
||||
if agents_path.exists():
|
||||
special_files.append(agents_path)
|
||||
break # Only add the first one found to avoid duplicates
|
||||
|
||||
# Collect .md files from microagents directory if it exists
|
||||
md_files = []
|
||||
if microagent_dir.exists():
|
||||
md_files = [f for f in microagent_dir.rglob('*.md') if f.name != 'README.md']
|
||||
|
||||
# Process all files in one loop
|
||||
for file in chain(cursorrules_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
# Process all files in one loop
|
||||
for file in chain(special_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
logger.debug(
|
||||
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: '
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -15,6 +15,8 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
|
||||
|
||||
@dataclass
|
||||
class VSCodeRequirement(PluginRequirement):
|
||||
@@ -37,7 +39,7 @@ class VSCodePlugin(Plugin):
|
||||
)
|
||||
return
|
||||
|
||||
if username not in ['root', 'openhands']:
|
||||
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
|
||||
self.vscode_port = None
|
||||
self.vscode_connection_token = None
|
||||
logger.warning(
|
||||
|
||||
@@ -20,6 +20,8 @@ from openhands.events.observation.commands import (
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
|
||||
|
||||
def split_bash_commands(commands: str) -> list[str]:
|
||||
if not commands.strip():
|
||||
@@ -193,7 +195,7 @@ class BashSession:
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
_shell_command = '/bin/bash'
|
||||
if self.username in ['root', 'openhands']:
|
||||
if self.username in list(filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])):
|
||||
# This starts a non-login (new) shell for the given user
|
||||
_shell_command = f'su {self.username} -'
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
@@ -12,6 +14,9 @@ DEFAULT_PYTHON_PREFIX = [
|
||||
]
|
||||
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
RUNTIME_UID = os.getenv('RUNTIME_UID')
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
server_port: int,
|
||||
@@ -26,7 +31,10 @@ def get_action_execution_server_startup_command(
|
||||
sandbox_config = app_config.sandbox
|
||||
logger.debug(f'app_config {vars(app_config)}')
|
||||
logger.debug(f'sandbox_config {vars(sandbox_config)}')
|
||||
logger.debug(f'override_user_id {override_user_id}')
|
||||
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
|
||||
logger.debug(
|
||||
f'override_username {override_username}, override_user_id {override_user_id}'
|
||||
)
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
@@ -40,10 +48,15 @@ def get_action_execution_server_startup_command(
|
||||
'--browsergym-eval-env'
|
||||
] + sandbox_config.browsergym_eval_env.split(' ')
|
||||
|
||||
username = override_username or (
|
||||
'openhands' if app_config.run_as_openhands else 'root'
|
||||
username = (
|
||||
override_username
|
||||
or RUNTIME_USERNAME
|
||||
or ('openhands' if app_config.run_as_openhands else 'root')
|
||||
)
|
||||
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
|
||||
user_id = (
|
||||
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
|
||||
)
|
||||
logger.debug(f'username {username}, user_id {user_id}')
|
||||
|
||||
base_cmd = [
|
||||
*python_prefix,
|
||||
|
||||
@@ -13,7 +13,6 @@ class ServerConfig(ServerConfigInterface):
|
||||
enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
|
||||
hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
|
||||
# This config is used to hide the microagent management page from the users for now. We will remove this once we release the new microagent management page.
|
||||
hide_microagent_management = True
|
||||
settings_store_class: str = (
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore'
|
||||
)
|
||||
@@ -44,7 +43,6 @@ class ServerConfig(ServerConfigInterface):
|
||||
'FEATURE_FLAGS': {
|
||||
'ENABLE_BILLING': self.enable_billing,
|
||||
'HIDE_LLM_SETTINGS': self.hide_llm_settings,
|
||||
'HIDE_MICROAGENT_MANAGEMENT': self.hide_microagent_management,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.54.0"
|
||||
version = "0.55.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for Bitbucket repository service URL parsing."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
|
||||
from openhands.integrations.service_types import OwnerType, Repository
|
||||
from openhands.integrations.service_types import ProviderType as ServiceProviderType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bitbucket_service():
|
||||
"""Create a BitBucketService instance for testing."""
|
||||
return BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_repositories_url_parsing_standard_url(bitbucket_service):
|
||||
"""Test URL parsing with standard Bitbucket URL and verify correct workspace/repo extraction."""
|
||||
mock_repo = Repository(
|
||||
id='1',
|
||||
full_name='workspace/repo',
|
||||
name='repo',
|
||||
owner=OwnerType.USER,
|
||||
git_provider=ServiceProviderType.BITBUCKET,
|
||||
is_public=True,
|
||||
clone_url='https://bitbucket.org/workspace/repo.git',
|
||||
html_url='https://bitbucket.org/workspace/repo',
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
bitbucket_service,
|
||||
'get_repository_details_from_repo_name',
|
||||
return_value=mock_repo,
|
||||
) as mock_get_repo:
|
||||
url = 'https://bitbucket.org/workspace/repo'
|
||||
repositories = await bitbucket_service.search_repositories(
|
||||
query=url, per_page=10, sort='updated', order='desc', public=True
|
||||
)
|
||||
|
||||
# Verify the correct workspace/repo combination was extracted and passed
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'workspace/repo'
|
||||
mock_get_repo.assert_called_once_with('workspace/repo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_repositories_url_parsing_with_extra_path_segments(
|
||||
bitbucket_service,
|
||||
):
|
||||
"""Test URL parsing with additional path segments and verify correct workspace/repo extraction."""
|
||||
mock_repo = Repository(
|
||||
id='1',
|
||||
full_name='my-workspace/my-repo',
|
||||
name='my-repo',
|
||||
owner=OwnerType.USER,
|
||||
git_provider=ServiceProviderType.BITBUCKET,
|
||||
is_public=True,
|
||||
clone_url='https://bitbucket.org/my-workspace/my-repo.git',
|
||||
html_url='https://bitbucket.org/my-workspace/my-repo',
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
bitbucket_service,
|
||||
'get_repository_details_from_repo_name',
|
||||
return_value=mock_repo,
|
||||
) as mock_get_repo:
|
||||
# Test complex URL with query params, fragments, and extra paths
|
||||
url = 'https://bitbucket.org/my-workspace/my-repo/src/feature-branch/src/main.py?at=feature-branch&fileviewer=file-view-default#lines-25'
|
||||
repositories = await bitbucket_service.search_repositories(
|
||||
query=url, per_page=10, sort='updated', order='desc', public=True
|
||||
)
|
||||
|
||||
# Verify the correct workspace/repo combination was extracted from complex URL
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'my-workspace/my-repo'
|
||||
mock_get_repo.assert_called_once_with('my-workspace/my-repo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_repositories_url_parsing_invalid_url(bitbucket_service):
|
||||
"""Test URL parsing with invalid URL returns empty results."""
|
||||
with patch.object(
|
||||
bitbucket_service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
url = 'not-a-valid-url'
|
||||
repositories = await bitbucket_service.search_repositories(
|
||||
query=url, per_page=10, sort='updated', order='desc', public=True
|
||||
)
|
||||
|
||||
# Should return empty list for invalid URL and not call API
|
||||
assert len(repositories) == 0
|
||||
mock_get_repo.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_repositories_url_parsing_insufficient_path_segments(
|
||||
bitbucket_service,
|
||||
):
|
||||
"""Test URL parsing with insufficient path segments returns empty results."""
|
||||
with patch.object(
|
||||
bitbucket_service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
url = 'https://bitbucket.org/workspace'
|
||||
repositories = await bitbucket_service.search_repositories(
|
||||
query=url, per_page=10, sort='updated', order='desc', public=True
|
||||
)
|
||||
|
||||
# Should return empty list for insufficient path segments and not call API
|
||||
assert len(repositories) == 0
|
||||
mock_get_repo.assert_not_called()
|
||||
@@ -1,32 +0,0 @@
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.github.service.api import GitHubAPI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_urls_github_com():
|
||||
api = GitHubAPI(base_domain=None, token=SecretStr("t"))
|
||||
assert api.rest_base == "https://api.github.com"
|
||||
assert api.graphql_base == "https://api.github.com/graphql"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_urls_enterprise():
|
||||
api = GitHubAPI(base_domain="gh.example.com", token=SecretStr("t"))
|
||||
assert api.rest_base == "https://gh.example.com/api/v3"
|
||||
assert api.graphql_base == "https://gh.example.com/api/graphql"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_include_standard_and_auth():
|
||||
api = GitHubAPI(base_domain=None, token=SecretStr("t"))
|
||||
h = api.headers
|
||||
assert h["Accept"].startswith("application/vnd.github+")
|
||||
assert h["User-Agent"].startswith("OpenHands-GitHubService")
|
||||
assert h["X-GitHub-Api-Version"]
|
||||
assert h["Authorization"] == "Bearer t"
|
||||
|
||||
api.set_token(None)
|
||||
h2 = api.headers
|
||||
assert "Authorization" not in h2
|
||||
@@ -24,7 +24,7 @@ async def test_github_service_token_handling():
|
||||
assert service.token.get_secret_value() == 'test-token'
|
||||
|
||||
# Test headers contain the token correctly
|
||||
headers = await service._get_github_headers()
|
||||
headers = await service._get_headers()
|
||||
assert headers['Authorization'] == 'Bearer test-token'
|
||||
assert headers['Accept'] == 'application/vnd.github.v3+json'
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Unit tests for HTTPClient abstract base class (ABC)."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
UnknownException,
|
||||
)
|
||||
|
||||
|
||||
class TestableHTTPClient(HTTPClient):
|
||||
"""Testable concrete implementation of HTTPClient for unit testing."""
|
||||
|
||||
def __init__(self, provider_name: str = 'test-provider'):
|
||||
self.token = SecretStr('test-token')
|
||||
self.refresh = False
|
||||
self.external_auth_id = None
|
||||
self.external_auth_token = None
|
||||
self.external_token_manager = False
|
||||
self.base_domain = None
|
||||
self._provider_name = provider_name
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return self._provider_name
|
||||
|
||||
@provider.setter
|
||||
def provider(self, value: str) -> None:
|
||||
self._provider_name = value
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
return self.token
|
||||
|
||||
async def _get_headers(self) -> dict[str, Any]:
|
||||
return {'Authorization': f'Bearer {self.token.get_secret_value()}'}
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
):
|
||||
# Mock implementation for testing
|
||||
return {'test': 'data'}, {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHTTPClient:
|
||||
"""Test cases for HTTPClient ABC."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.client = TestableHTTPClient()
|
||||
|
||||
def test_default_attributes(self):
|
||||
"""Test default attribute values."""
|
||||
assert isinstance(self.client.token, SecretStr)
|
||||
assert self.client.refresh is False
|
||||
assert self.client.external_auth_id is None
|
||||
assert self.client.external_auth_token is None
|
||||
assert self.client.external_token_manager is False
|
||||
assert self.client.base_domain is None
|
||||
|
||||
def test_provider_property(self):
|
||||
"""Test provider property."""
|
||||
assert self.client.provider == 'test-provider'
|
||||
|
||||
def test_has_token_expired_default_implementation(self):
|
||||
"""Test default _has_token_expired implementation."""
|
||||
# The TestableHTTPClient inherits the default implementation from the protocol
|
||||
client = TestableHTTPClient()
|
||||
|
||||
assert client._has_token_expired(401) is True
|
||||
assert client._has_token_expired(200) is False
|
||||
assert client._has_token_expired(404) is False
|
||||
assert client._has_token_expired(500) is False
|
||||
|
||||
async def test_execute_request_get(self):
|
||||
"""Test execute_request with GET method."""
|
||||
client = TestableHTTPClient()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
url = 'https://api.example.com/user'
|
||||
headers = {'Authorization': 'Bearer token'}
|
||||
params = {'per_page': 10}
|
||||
|
||||
result = await client.execute_request(
|
||||
mock_client, url, headers, params, RequestMethod.GET
|
||||
)
|
||||
|
||||
assert result == mock_response
|
||||
mock_client.get.assert_called_once_with(url, headers=headers, params=params)
|
||||
|
||||
async def test_execute_request_post(self):
|
||||
"""Test execute_request with POST method."""
|
||||
client = TestableHTTPClient()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
url = 'https://api.example.com/issues'
|
||||
headers = {'Authorization': 'Bearer token'}
|
||||
params = {'title': 'Test Issue'}
|
||||
|
||||
result = await client.execute_request(
|
||||
mock_client, url, headers, params, RequestMethod.POST
|
||||
)
|
||||
|
||||
assert result == mock_response
|
||||
mock_client.post.assert_called_once_with(url, headers=headers, json=params)
|
||||
|
||||
def test_handle_http_status_error_401(self):
|
||||
"""Test handling of 401 HTTP status error."""
|
||||
client = TestableHTTPClient('github')
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
message='401 Unauthorized', request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
result = client.handle_http_status_error(error)
|
||||
assert isinstance(result, AuthenticationError)
|
||||
assert 'Invalid github token' in str(result)
|
||||
|
||||
def test_handle_http_status_error_404(self):
|
||||
"""Test handling of 404 HTTP status error."""
|
||||
client = TestableHTTPClient()
|
||||
client.provider = 'gitlab'
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
message='404 Not Found', request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
result = client.handle_http_status_error(error)
|
||||
assert isinstance(result, ResourceNotFoundError)
|
||||
assert 'Resource not found on gitlab API' in str(result)
|
||||
|
||||
def test_handle_http_status_error_429(self):
|
||||
"""Test handling of 429 HTTP status error."""
|
||||
client = TestableHTTPClient()
|
||||
client.provider = 'bitbucket'
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 429
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
message='429 Too Many Requests', request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
result = client.handle_http_status_error(error)
|
||||
assert isinstance(result, RateLimitError)
|
||||
assert 'bitbucket API rate limit exceeded' in str(result)
|
||||
|
||||
def test_handle_http_status_error_other(self):
|
||||
"""Test handling of other HTTP status errors."""
|
||||
client = TestableHTTPClient()
|
||||
client.provider = 'test-provider'
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
message='500 Internal Server Error', request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
result = client.handle_http_status_error(error)
|
||||
assert isinstance(result, UnknownException)
|
||||
assert 'Unknown error' in str(result)
|
||||
|
||||
def test_handle_http_error(self):
|
||||
"""Test handling of general HTTP errors."""
|
||||
client = TestableHTTPClient()
|
||||
client.provider = 'test-provider'
|
||||
|
||||
error = httpx.ConnectError('Connection failed')
|
||||
|
||||
result = client.handle_http_error(error)
|
||||
assert isinstance(result, UnknownException)
|
||||
assert 'HTTP error ConnectError' in str(result)
|
||||
|
||||
def test_handle_http_error_with_different_error_types(self):
|
||||
"""Test handling of different HTTP error types."""
|
||||
client = TestableHTTPClient()
|
||||
client.provider = 'test-provider'
|
||||
|
||||
# Test with different error types
|
||||
errors = [
|
||||
httpx.ConnectError('Connection failed'),
|
||||
httpx.TimeoutException('Request timed out'),
|
||||
httpx.ReadTimeout('Read timeout'),
|
||||
httpx.WriteTimeout('Write timeout'),
|
||||
]
|
||||
|
||||
for error in errors:
|
||||
result = client.handle_http_error(error)
|
||||
assert isinstance(result, UnknownException)
|
||||
assert f'HTTP error {type(error).__name__}' in str(result)
|
||||
|
||||
def test_runtime_checkable(self):
|
||||
"""Test that HTTPClient is runtime checkable."""
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
|
||||
# Test that our testable client implements the protocol
|
||||
assert isinstance(self.client, HTTPClient)
|
||||
|
||||
# Test that a class without the required methods doesn't implement the protocol
|
||||
class IncompleteClient:
|
||||
pass
|
||||
|
||||
incomplete = IncompleteClient()
|
||||
assert not isinstance(incomplete, HTTPClient)
|
||||
|
||||
def test_protocol_attributes_exist(self):
|
||||
"""Test that protocol defines expected attributes."""
|
||||
client = TestableHTTPClient()
|
||||
|
||||
# Test default attribute values from protocol
|
||||
assert hasattr(client, 'token')
|
||||
assert hasattr(client, 'refresh')
|
||||
assert hasattr(client, 'external_auth_id')
|
||||
assert hasattr(client, 'external_auth_token')
|
||||
assert hasattr(client, 'external_token_manager')
|
||||
assert hasattr(client, 'base_domain')
|
||||
|
||||
# Test TestableHTTPClient values
|
||||
assert client.token == SecretStr('test-token')
|
||||
assert client.refresh is False
|
||||
assert client.external_auth_id is None
|
||||
assert client.external_auth_token is None
|
||||
assert client.external_token_manager is False
|
||||
assert client.base_domain is None
|
||||
|
||||
def test_protocol_methods_exist(self):
|
||||
"""Test that protocol defines expected methods."""
|
||||
client = TestableHTTPClient()
|
||||
|
||||
# Test that methods exist
|
||||
assert hasattr(client, 'get_latest_token')
|
||||
assert hasattr(client, '_get_headers')
|
||||
assert hasattr(client, '_make_request')
|
||||
assert hasattr(client, '_has_token_expired')
|
||||
assert hasattr(client, 'execute_request')
|
||||
assert hasattr(client, 'handle_http_status_error')
|
||||
assert hasattr(client, 'handle_http_error')
|
||||
assert hasattr(client, 'provider')
|
||||
|
||||
def test_protocol_concrete_methods_work(self):
|
||||
"""Test that concrete protocol methods work correctly."""
|
||||
client = TestableHTTPClient()
|
||||
|
||||
# These methods should work since TestableHTTPClient implements them
|
||||
assert client.provider == 'test-provider'
|
||||
|
||||
# Test that the default implementations from the protocol are available
|
||||
assert hasattr(client, '_has_token_expired')
|
||||
assert hasattr(client, 'execute_request')
|
||||
assert hasattr(client, 'handle_http_status_error')
|
||||
assert hasattr(client, 'handle_http_error')
|
||||
|
||||
def test_provider_specific_error_messages(self):
|
||||
"""Test that error messages are provider-specific."""
|
||||
providers = ['github', 'gitlab', 'bitbucket']
|
||||
|
||||
for provider in providers:
|
||||
client = TestableHTTPClient()
|
||||
client.provider = provider
|
||||
|
||||
# Test 401 error
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
error = httpx.HTTPStatusError(
|
||||
message='401 Unauthorized', request=Mock(), response=mock_response
|
||||
)
|
||||
result = client.handle_http_status_error(error)
|
||||
assert f'Invalid {provider} token' in str(result)
|
||||
|
||||
# Test 404 error
|
||||
mock_response.status_code = 404
|
||||
error = httpx.HTTPStatusError(
|
||||
message='404 Not Found', request=Mock(), response=mock_response
|
||||
)
|
||||
result = client.handle_http_status_error(error)
|
||||
assert f'Resource not found on {provider} API' in str(result)
|
||||
|
||||
# Test 429 error
|
||||
mock_response.status_code = 429
|
||||
error = httpx.HTTPStatusError(
|
||||
message='429 Too Many Requests', request=Mock(), response=mock_response
|
||||
)
|
||||
result = client.handle_http_status_error(error)
|
||||
assert f'{provider} API rate limit exceeded' in str(result)
|
||||
@@ -364,3 +364,184 @@ def test_load_microagents_with_cursorrules(temp_microagents_dir_with_cursorrules
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use TypeScript for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_cursorrules_only():
|
||||
"""Create a temporary directory with only .cursorrules file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file in repository root
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_cursorrules_without_microagents_dir(temp_dir_with_cursorrules_only):
|
||||
"""Test loading .cursorrules file when .openhands/microagents directory doesn't exist.
|
||||
|
||||
This test reproduces the bug where .cursorrules is only loaded when
|
||||
.openhands/microagents directory exists.
|
||||
"""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_cursorrules_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the .cursorrules file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only .cursorrules
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check .cursorrules agent
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
def test_agents_md_file_load():
|
||||
"""Test loading AGENTS.md file as a RepoMicroagent."""
|
||||
agents_content = """# Project Setup
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `npm install`
|
||||
- Start dev server: `npm run dev`
|
||||
- Run tests: `npm test`
|
||||
|
||||
## Code style
|
||||
|
||||
- TypeScript strict mode
|
||||
- Single quotes, no semicolons
|
||||
- Use functional patterns where possible"""
|
||||
|
||||
agents_path = Path('AGENTS.md')
|
||||
|
||||
# Test loading AGENTS.md file directly
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
# Verify it's loaded as a RepoMicroagent
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
assert agent.metadata.name == 'agents'
|
||||
assert agent.source == str(agents_path)
|
||||
|
||||
|
||||
def test_agents_md_case_insensitive():
|
||||
"""Test that AGENTS.md loading is case-insensitive."""
|
||||
agents_content = """# Development Guide
|
||||
|
||||
Use TypeScript for all new files."""
|
||||
|
||||
test_cases = ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']
|
||||
|
||||
for filename in test_cases:
|
||||
agents_path = Path(filename)
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_agents_md_only():
|
||||
"""Create a temporary directory with only AGENTS.md file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create AGENTS.md file in repository root
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Start dev server: `poetry run python app.py`
|
||||
- Run tests: `poetry run pytest`
|
||||
|
||||
## Code style
|
||||
|
||||
- Python 3.12+
|
||||
- Follow PEP 8 guidelines
|
||||
- Use type hints everywhere"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_agents_md_without_microagents_dir(temp_dir_with_agents_md_only):
|
||||
"""Test loading AGENTS.md file when .openhands/microagents directory doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_agents_md_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the AGENTS.md file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only AGENTS.md
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check AGENTS.md agent
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert agents_agent.name == 'agents'
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
assert agents_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_both_cursorrules_and_agents():
|
||||
"""Create a temporary directory with both .cursorrules and AGENTS.md files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Create AGENTS.md file
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Run tests: `poetry run pytest`"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_both_cursorrules_and_agents_md(temp_dir_with_both_cursorrules_and_agents):
|
||||
"""Test loading both .cursorrules and AGENTS.md files when .openhands/microagents doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = (
|
||||
temp_dir_with_both_cursorrules_and_agents / '.openhands' / 'microagents'
|
||||
)
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find both files
|
||||
assert len(repo_agents) == 2 # .cursorrules + AGENTS.md
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check both agents
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
|
||||
Reference in New Issue
Block a user