From 05c4b4b04ca83ac62a337869881f6e44c175e07c Mon Sep 17 00:00:00 2001 From: Erik Foris Date: Mon, 20 Apr 2026 14:48:33 +0200 Subject: [PATCH] initial ai implementation --- .env.example | 8 + README.md | 153 +++++++++++- SPEC.md | 119 +++++++++ config.yaml | 29 +++ pyproject.toml | 15 ++ requirements.txt | 9 + src/linear_gitea_integration/__init__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 250 bytes .../__pycache__/config.cpython-314.pyc | Bin 0 -> 6932 bytes .../__pycache__/main.cpython-314.pyc | Bin 0 -> 1977 bytes src/linear_gitea_integration/api/__init__.py | 8 + .../api/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 461 bytes .../api/__pycache__/routes.cpython-314.pyc | Bin 0 -> 4020 bytes .../api/__pycache__/webhooks.cpython-314.pyc | Bin 0 -> 3063 bytes src/linear_gitea_integration/api/routes.py | 79 ++++++ src/linear_gitea_integration/api/webhooks.py | 52 ++++ src/linear_gitea_integration/config.py | 87 +++++++ src/linear_gitea_integration/main.py | 50 ++++ .../models/__init__.py | 76 ++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 5457 bytes .../services/__init__.py | 11 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 477 bytes .../__pycache__/gitea_client.cpython-314.pyc | Bin 0 -> 10109 bytes .../__pycache__/linear_client.cpython-314.pyc | Bin 0 -> 12345 bytes .../services/gitea_client.py | 166 +++++++++++++ .../services/linear_client.py | 229 ++++++++++++++++++ .../webhooks/__init__.py | 8 + .../webhooks/gitea_handler.py | 176 ++++++++++++++ .../webhooks/linear_handler.py | 52 ++++ .../test_core.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 16798 bytes tests/test_core.py | 91 +++++++ 31 files changed, 1420 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 SPEC.md create mode 100644 config.yaml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/linear_gitea_integration/__init__.py create mode 100644 src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc create mode 100644 src/linear_gitea_integration/__pycache__/config.cpython-314.pyc create mode 100644 src/linear_gitea_integration/__pycache__/main.cpython-314.pyc create mode 100644 src/linear_gitea_integration/api/__init__.py create mode 100644 src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc create mode 100644 src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc create mode 100644 src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc create mode 100644 src/linear_gitea_integration/api/routes.py create mode 100644 src/linear_gitea_integration/api/webhooks.py create mode 100644 src/linear_gitea_integration/config.py create mode 100644 src/linear_gitea_integration/main.py create mode 100644 src/linear_gitea_integration/models/__init__.py create mode 100644 src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc create mode 100644 src/linear_gitea_integration/services/__init__.py create mode 100644 src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc create mode 100644 src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc create mode 100644 src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc create mode 100644 src/linear_gitea_integration/services/gitea_client.py create mode 100644 src/linear_gitea_integration/services/linear_client.py create mode 100644 src/linear_gitea_integration/webhooks/__init__.py create mode 100644 src/linear_gitea_integration/webhooks/gitea_handler.py create mode 100644 src/linear_gitea_integration/webhooks/linear_handler.py create mode 100644 tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/test_core.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e038f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +LINEAR_API_KEY=lin_api_xxx +LINEAR_WEBHOOK_SECRET=xxx + +GITEA_URL=https://gitea.example.com +GITEA_TOKEN=gitea_token_xxx +GITEA_WEBHOOK_SECRET=xxx + +CONFIG_PATH=config.yaml \ No newline at end of file diff --git a/README.md b/README.md index 5c96f1b..77a5af3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,154 @@ # LinearGiteaIntegration -Integrate Linear into gitea \ No newline at end of file +Integrate Linear issue tracking with Gitea - similar to the Linear-GitHub integration. + +## Features + +- **PR Linking**: Automatically link pull requests to Linear issues by including the issue ID (e.g., `LIN-123`) in branch names, PR titles, or descriptions +- **Workflow Automation**: Automatically update Linear issue status based on PR state changes (opened → In Progress, merged → Done, closed → Todo) +- **Issues Sync**: Synchronize issues between Gitea and Linear (one-way or two-way) +- **Commit Linking**: Link commits to Linear issues by including issue IDs in commit messages +- **Branch Suggestions**: Get suggested branch names for Linear issues via API + +## Requirements + +- Python 3.10+ +- Linear API key +- Gitea instance with API token + +## Installation + +```bash +pip install -r requirements.txt +``` + +Or with uv: + +```bash +uv sync +``` + +## Configuration + +1. Copy the example environment file: + +```bash +cp .env.example .env +``` + +2. Edit `config.yaml` to configure your settings: + +```yaml +server: + host: "0.0.0.0" + port: 8080 + +linear: + api_key: "your-linear-api-key" + webhook_secret: "your-linear-webhook-secret" + +gitea: + url: "https://gitea.yourdomain.com" + token: "your-gitea-token" + webhook_secret: "your-gitea-webhook-secret" + +sync: + enabled_repos: + - "your-org/your-repo" + default_team_id: "your-linear-team-id" + status_mappings: + pr_created: "In Progress" + pr_merged: "Done" + pr_closed: "Todo" + sync_direction: "one-way" # or "two-way" + +features: + pr_linking: true + workflow_automation: true + issues_sync: true + commit_linking: true + branch_suggestions: true +``` + +3. Set environment variables in `.env`: + +``` +LINEAR_API_KEY=lin_xxx +GITEA_URL=https://gitea.yourdomain.com +GITEA_TOKEN=gitea_token_xxx +CONFIG_PATH=config.yaml +``` + +### Getting API Keys + +**Linear API Key:** +1. Go to Linear workspace Settings > API +2. Create a new API key + +**Gitea Token:** +1. Go to Settings > Applications > Generate a new token +2. Requires `repo` and `write:issue` scopes + +## Running the Server + +```bash +# Development +python -m linear_gitea_integration + +# Or with uvicorn +uvicorn linear_gitea_integration.main:app --reload +``` + +The server will start on `http://0.0.0.0:8080` by default. + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/health` | GET | Health check | +| `/api/issues/{issue_id}/branch` | GET | Get branch suggestion for issue | +| `/api/sync/issue` | POST | Manually sync issue to Gitea | +| `/webhooks/gitea` | POST | Gitea webhook endpoint | +| `/webhooks/linear` | POST | Linear webhook endpoint | + +## Setting Up Webhooks + +### Gitea Webhook + +1. Go to your repository Settings > Webhooks +2. Add a new webhook: + - **URL**: `https://your-server.com/webhooks/gitea` + - **Secret**: Use the value from `gitea.webhook_secret` + - **Trigger**: Select events: Push, Pull Request, Issues + +### Linear Webhook + +1. Go to Linear workspace Settings > Webhooks +2. Add a new webhook: + - **URL**: `https://your-server.com/webhooks/linear` + - **Secret**: Use the value from `linear.webhook_secret` + - **Events**: Issues (create, update) + +## Development + +Run tests: + +```bash +PYTHONPATH=src pytest tests/ +``` + +Project structure: + +``` +LinearGiteaIntegration/ +├── src/linear_gitea_integration/ +│ ├── api/ # REST API routes +│ ├── config.py # Configuration management +│ ├── models.py # Pydantic models +│ ├── services/ # API clients +│ ├── webhooks/ # Webhook handlers +│ └── main.py # Application entry point +├── tests/ # Unit tests +├── config.yaml # Configuration file +└── .env.example # Environment variables template +``` \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..40f1da2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,119 @@ +# LinearGiteaIntegration Specification + +## Project Overview + +- **Project Name**: LinearGiteaIntegration +- **Type**: Web service / Integration middleware +- **Core Functionality**: Integrate Linear issue tracking with Gitea, enabling PR/commit linking, workflow automation, and bidirectional issue sync +- **Target Users**: Development teams using Linear for issue tracking and Gitea for Git hosting + +## Architecture + +### Components +1. **Webhook Server**: Receives events from Gitea (push, pull request, issues) +2. **Linear API Client**: Communicates with Linear API to create/update issues +3. **Gitea API Client**: Communicates with Gitea API to create branches, PRs, update issues +4. **Configuration Manager**: Handles API keys, webhooks, and settings + +### Technology Stack +- **Language**: Python +- **Web Framework**: FastAPI (modern, async Python web framework) +- **Configuration**: YAML-based config file + environment variables + +## Functionality Specification + +### 1. PR Linking +- Detect Linear issue ID (e.g., `LIN-123`) in: + - Branch name + - PR title + - PR description +- When PR is opened/linked, add PR link to Linear issue +- Support magic words: "closes", "fixes", "resolves" + issue ID + +### 2. Workflow Automation +- Map Gitea PR states to Linear issue statuses: + - PR created → Move issue to "In Progress" + - PR merged → Move issue to "Done" + - PR closed (not merged) → Move issue back to "Todo" +- Configurable status mappings per team + +### 3. Issues Sync +- One-way sync: Gitea issues → Linear issues +- Two-way sync: Changes sync both ways +- Sync properties: title, description, status, assignee, labels, comments +- Configurable per-repository settings + +### 4. Commit Linking +- Detect Linear issue ID in commit messages +- Add commit reference to Linear issue +- Support multiple issues per commit + +### 5. Branch Suggestions +- API endpoint to get suggested branch name for a Linear issue +- Format: `{issue-id}-{slugified-title}` (e.g., `LIN-123-add-login-feature`) + +## Configuration + +```yaml +server: + host: "0.0.0.0" + port: 8080 + +linear: + api_key: "${LINEAR_API_KEY}" + webhook_secret: "${LINEAR_WEBHOOK_SECRET}" + +gitea: + url: "https://gitea.example.com" + token: "${GITEA_TOKEN}" + webhook_secret: "${GITEA_WEBHOOK_SECRET}" + +sync: + enabled_repos: + - "my-org/my-repo" + default_team_id: "team_xxx" + status_mappings: + pr_created: "In Progress" + pr_merged: "Done" + pr_closed: "Todo" + sync_direction: "one-way" # or "two-way" + +features: + pr_linking: true + workflow_automation: true + issues_sync: true + commit_linking: true + branch_suggestions: true +``` + +## API Endpoints + +### Webhooks +- `POST /webhooks/gitea` - Receive Gitea events +- `POST /webhooks/linear` - Receive Linear events + +### REST API +- `GET /api/health` - Health check +- `GET /api/issues/:id/branch` - Get suggested branch name for issue +- `POST /api/sync/issue` - Manually trigger issue sync + +## Webhook Events to Handle + +### Gitea Events +- `push` - Parse commit messages for issue IDs +- `pull_request` - Link PRs, update status +- `issues` - Sync issues to Linear + +### Linear Events +- `Issue` - Sync issue changes to Gitea (for two-way sync) + +## Acceptance Criteria + +1. Server starts and listens on configured port +2. Gitea webhook endpoint accepts and processes events +3. PR linking works when issue ID found in branch/PR title +4. Issue status updates based on PR state changes +5. Issues created in Gitea appear in Linear (when sync enabled) +6. Branch suggestion endpoint returns proper format +7. Configuration loads from YAML file and environment variables +8. Unit tests cover core functionality \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c2b8c46 --- /dev/null +++ b/config.yaml @@ -0,0 +1,29 @@ +server: + host: "0.0.0.0" + port: 8080 + +linear: + api_key: "" + webhook_secret: "" + +gitea: + url: "https://gitea.example.com" + token: "" + webhook_secret: "" + +sync: + enabled_repos: + - "my-org/my-repo" + default_team_id: "" + status_mappings: + pr_created: "In Progress" + pr_merged: "Done" + pr_closed: "Todo" + sync_direction: "one-way" + +features: + pr_linking: true + workflow_automation: true + issues_sync: true + commit_linking: true + branch_suggestions: true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d52cb0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +name = "LinearGiteaIntegration" +version = "0.1.0" +description = "Integrate Linear issue tracking with Gitea" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "httpx>=0.26.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "PyYAML>=6.0.0", + "python-dotenv>=1.0.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..493e2dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +httpx>=0.26.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +PyYAML>=6.0.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 \ No newline at end of file diff --git a/src/linear_gitea_integration/__init__.py b/src/linear_gitea_integration/__init__.py new file mode 100644 index 0000000..f01a49c --- /dev/null +++ b/src/linear_gitea_integration/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] \ No newline at end of file diff --git a/src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26a762171817da5684f7fd9a0ac31fe95e88ff2d GIT binary patch literal 250 zcmdPqPO4oIC(tI4yNdaN#0O?ZM#j4gq7S$QI@Fuki`ao; E0B-L>H2?qr literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/__pycache__/config.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5494d526637ec74745a0acf6b8b418757c63cb3 GIT binary patch literal 6932 zcmb_hUr-yz8Q+smI{iZe1W5d|gdw>253&;vacttoHeh6IOb#k14)}VO4v;M*_3q?9 zaWc82nb@6XTHK_KGf8LMnNDlJw0Y=5AKOkJ(l;y67;eZUnPl3@LuZW544FRl`}R)a zM2K;xT}I#SeY<=6?e1@X-?z&Sf0c(o+TQYy^xxcse1R{e`;AxB72JNanf;xMg9dxJ;nrGrp)d@N_&JDU$7u7l9?m>_08T6{&2&pG+ zgm;Dr&rNvt+Ks+cAJw`*>$YgCsMZ5ouSM&pS|4buEZP9o`av78XsfBV8niVQZ4K4d zf;MQ;*7A*f9q8&qBvYMX)F96&brUeY^Gt;B4Nwxo61ARZ+DTW_S7`ch7o)QyqB;#v z*U7w^&Sk|6Au6FB8^I_n$AusdTl9pB$u5`PxYmfYSNr-`mmWV5B5R^ zr+$K{3{O`EGWSrCKi4;{SO`o(aUHgLC)Cry-r99v@PUWW}1nSQRW45z{WG3PzUl1 z+jauJAb)D9?ist{2$n~X)6*wzz@i8c_z*)e+*#rxll;Lf>{Yev(s>9T=7M+vg z&_Qv?@lMqRICS$Ipu?s6jAb-%s1x0PJU-lih!+lx#D$^$=Wp9|E+tKhg^a2@#7tUL z^m?<#ss6)*Cr=Iuqy0zt{;~fVvjsx?-UhU{&Q{kk4k_u$hZGsGzcUX}@(Zcdhiw;-{Sc{`*aq%wI4QTQ3Za|Ai z_tNcdl2+ris^*pbu^8Rw-O?p-CZCbIlern)eKbDSe@OTqe_U<{3*|Nt(3mR6P7d`C z>vhJ~RA{UVo8&av68_vXj(@i){Qp2QOAgdA}V}$n)|OC>{k@yRae(Y7VwFa22S# z)4K0Z-ub|B?Z{$H!wvaX>wL|VTEB_0!TF{*U<}PFu@8Qv0%InlkO%vqqyl42m0pgw zs|LfcCc`*PhH(OBINd)A^jT0&i1|Ev*B8CV> z@5##oum?DtlcCNGW-9G&EO^r2-Jh3Kl?A2a9aG0f<0tk!>yk0`<+` z0K~=|vmZVwA3)KIViS((!8$0YC8o~=Z`07O9b2ex*5XuDEIQWSc+m%-q+-#d#-eu< z2yGZ}GtM@G;SVO2gJ1`oN;%kZL$Kok+VGmej%W08HfgX1rGghZSoK{O#mgJ7`Dys( z!!|sII6HBWX4)w9fjTzOQvxtUBGx1Whd~BaQdT^hky3&z<#UQ2Fx{L0G0TjQPU!)3 z`vpaqF@dZ56&Qt(O3M)bK%~=U*V#-OA}RDmblxA%B%!rQsK8AWnk!F%Fo+}=de{E* zr;2QwfgR+J))ICaI*M%1GVa=C7iE_A`|fqj;@gl}4xx#MQBaT^!CW7TO(6AMtb-y7 zKLx3r(#t}mO*^sB+^U^i?A$xo*`u9YXle&OL1RjMf~_`&)-zC20j+HoJ^^R}yqxgk z;Aeo!MtQ{zJY#^01zvIJ{sGv_1z-^aSl<$XB%rg0VabA>4V{PSNGNv9yu-wi)4&n( z3WSp9f!y@q#hiS8GLyR~hy^tVVKv}H_ofx406`Z78xWWjHk!*~l`k z;IfBcwf6hoN5a-I=tmwxaU8`76vHSs!PX$wK|!rWVPGrN40ILkxWk4P!Y$g+LUTkL zUWl~cVH;M_pc1~YWd**<(efKmQh~47Lt>%|8k=xgNDYp-mN)N&&+=9^;kUd6DD@fM zubOwLH9&o}yy5+vX0XI|OW6y$O%UM7<$^36K{AqFhq)}QJJ3){%A^4qaLmFJ1pY9U z&wu{~kjv-4btGs9u$O?$LVg{PoeDjY>P|cuiL&mXah=Xm+`1;s-PYwWjx|v*jz%7o zALv3p9I!!SCMtj$gO4caYA8@4T&e;Tc9kG$yqJ*mL2TZ+j0jlv5pO*@*D8GRlK_A< zc0aHVticV5kb}N-9~=y&jF6;HAnJIVz(#R^QV``UCy#L&7bx0 zO?XVU!|%ZjFjynwrA<(dH1bS>Ot#_0$J&ra%7ZXIf^r1cd0oPT@?LWy@?;#(<}@Y9 zUbCOPEnyqMljpirigS>oFs1%UEvPcy2uavVUpT-BOu<&L3u_kJ$1y&jX#|g}>rxv@ z*wXynTJ^6s$0;1D=S@mI-bIx5m1ZaF)hbx<`zVA;4zBxyiUX%yIo_ z&5l!-)>~oB)DRrbEtXtLxGjBwyLOW;Wat;hlPCDMR-S-+Li|O^4(f`~{3)2v7Lv9_ zNGq%Y{*6J61NK&uX1ds6-FxKZ@Id^iFmh;Y@I`aKbYB){GP>X3mJ4uKl7gc~m!C3$ zZi5Ju1F}!c5D?g>BvnS7>rUwsM38U{%H^f3&eAWq6>(A$GC47&JHQ>xK*MQK!OJI| zgN8;%F)}iGK)9d~=*NtG3%7i@ahXY-1q(!-gG&<{hd99~h(mO*sSt8!&*^r&UvU^E zxcxe#r9?Ni?nOgNRCx(1(9QiX$Y^A#D){5)w1LmK+K;%VrH1f3=dPc7Cwo16bK-*& za}9^DdB5}qX1#OX7P#%=yjQQhdgbcNuf4ofRWsZ1)6;LBzOiR17<%X6&ko*fn+tZ_ z4Q{^^+e0R@KanI0)19N*uKMs$5wte^Sci!IlyY6?7-8ixo z>AZR6Ze-tlWZzPB+b!=OANx4^%mWA6@f7%xj@@wI;t$^o6JM=%;#!Y(Vzm+8e{$N? z{9B2@i2vMvpl_e!FYSJmJ%?K1@z-!2%6;B`x4VL$k!UED0g7R&zIkhF<>wfH3!EoX zkNkZQK(3o{>?l-IkQb0+SthuQ;_U(YFo+e(%BgJ4v(aqD;DUe6YQ$g)h|TH-)_j@O$CbkSwfTJs+QQMT8SZa$*;E>LhL|bcMPv94 z7s6=HrYnH9Q&?!^s{2*<>KPkh+CC!DH^Xmt{@vYJB+>f`l zFNpUO()1}gxZ<=i^|QSz1Rg6M8#BZhFGI|V$IcvMW_PX-e6INHOv~&zzO=0PSf*pP sWd#u1vEp|?9`x{7spgp7*B}p%)jEh1?sb#aHjSGj(N!m)n@;3^0H|JbkN^Mx literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/__pycache__/main.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ab434cef7f8b2e494532d2e044a17929970599d GIT binary patch literal 1977 zcmZ`(TW=dh6rR0!9ba=xn(E-nC{Y zX&b4MRUtveL;DVYpb`%tA@LW=6A7vmYf2Faij=oRDXMtm%&zU2UPjt8-#K&k?0#py znd8|^62bU->~AuoBJ`(lI-{=*_P&)6YNBOiN=3xPEElDDnaN`UD@A2qWon4kVr*Vx z8eqwc731@LED>U@n4C|s)EFwG(t%F-{dru(5AFLMWX8?DX*u%XUD-^GA~QLP+=0{W z6qYTka+-u94*`JrpfzS#xI30`Dp?-g9qBl3f<-}=V zF9u``nM3D3MyRrg(BY^bI@8Okan${vWLYmAi}HId{G(n@mZo}R5iXs0G4_hg{EL~u zM4lQgiNCHTA{f?ZplK?00KF-unL5)^u@my0~%n{RiU@=6=5T)5WdD<;}(A zjRk9C@RNtC{hONjHxSLcJv%acK;k1Wr?EcOQ3q}l&5(;PcfoiLPvlu9R&j?8oRz-F%D26TApm* z2QIlr#rfm$6}S%U2BG3I-udB>*#75x)ukI;Bn29Ooq6;#~n(axu?*#U7b zgqH?ai?xI$MwIxNa}`P@fT zXrir^^L4+n>f(3kDTs-46>u;+iX=&oQ1&sJdV*emj7~j4laJAn-%)%E>AxVol^nc# z;?9XyYWAO)BFQ^xBo9B8;TZ2JfOZ7hO`~|Wl|0%SINwU^t<3P#L@X{hm7Oe-)Gai$ ziH2J0P)i@VH`y9F6wSifNg+vpiZrM?(9OIz^AH``(U6+{a{Zxl@R2(7N2KHyD0$)b jLaXn1E2G~n-zjfp#x^r!|9ATJXNg!$+f5+#P{{p11A)zH literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/api/__init__.py b/src/linear_gitea_integration/api/__init__.py new file mode 100644 index 0000000..6a6f366 --- /dev/null +++ b/src/linear_gitea_integration/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from .routes import router as api_router +from .webhooks import router as webhooks_router + +router = APIRouter() +router.include_router(api_router) +router.include_router(webhooks_router) \ No newline at end of file diff --git a/src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..057b5fd4ef8dd8ded804d4ca0fa36dce367ee299 GIT binary patch literal 461 zcmZut%SyvQ6ur~Lm=sNJ`FJ)+o@?LOr{jUMfYy}0Dr*G z5u{3>3lRl(LVv)SHX$y&i@E3AbMAvVtZr98#$)4)&Sm~kBo8eE%R32X&;tkA&_Mz< zp@*E3UFv8;&+vxCMiX4rf_By>G*|1^@?k}G^)^~F>Hpth6b?t*155u_4HinvXIL;iOM8{sc?4 zGIjBgjR_8DJS03IVTfbSMkEjkzMv8D`8gHDwfSCThSnn(YL@)qpL z3KgDr6yK)i$(Z#gAvxt$xlMgbax9-84I#9E#wQ#s0Kb=a?ssR#8yK%pe}?+Q=mlB@ LV{_Q&Te9mHn4EQW literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d88bee7b39df500a4b64f2b4b51f5e7ad806d117 GIT binary patch literal 4020 zcmcgvO>7(25q``4;m=YsDUzaX$rdH+kI@fpNscVVNa5P5Mb}m7wM+!HXqF;Z(#90Y zzFpQ9jRHzqplNf6+6IV@>Z$S}XpT9x23qvIgalCD#(<2vHGGrnAcb@4%q}TWw9}%O zK5*y#&6_vxy_s)@1IH7!ON$rB`! zH%PLcEVD1k3zB;3ceL74%f2MLDam&rDPfKH4@O39QUGMPHpuo#%qbEMK0}c+Va9NL zzo04kTq+yp4CjSdRLU1MnHo*gv$L_OyJ^{+HMm$xTQc}-MVYR^BG;m2gl&dUpz?xp*Wk3&qFQqI-@GvNgT%nlTxK9#vPYKW zR90I`*B=+(=%?_#XaUkiI=~Jju!9I_ewI}j6sc+{t*9DyYxT-~LOr+uK4RRAEajKw zNLnc@$uuozvylSLzbmITHFDL|^0K1Isc24<7ikI)A5m#KQa!*T&Lx$)NjOA=;;pE| zg%!hX?U+n2$?4nF2m3X{r{eCOv52CoMOls9GwY<1 z`AKA+rgG_};ZmQaQ^=eNDH*svWkHc?s9zpl93Bi^jm`|685s@3MT;3(O;g2uk~A7= z)ldmu7z^KvZXl~94z6(SXM|*6tesqAmKcedgBh#fHHK}^aE;-p>2sekSXHC2iLj8T z%PCDo*I2WaPjSAt_B#UiAc7ZYb`Nz04-LjIaYy* zGcmC?dQitn&9Mr$xNToPoV`Il8z*ieaU<-ZecUxkF*JpS0*=Z{LfjTZ3u!1ZG@pi2 z({&8r6H0-Nv$25HuBVc)uQLnMTkGenHfSouS;;q9Q~rXZIji<=C+#?x+C$#-?QcKTm9aS`&h+&rtCiRS^t*% zt<|ZgqQ_+XY~k~FKELwdU487@cC(~Aq$gt2M{oY}&9$Ko--i6V+rPbC=^Zckj&Jo& zY>AVrmv+P>A5H#ZaxMMgw^lFh298$(J>@`;?(f}5ZE~B}KXq>0(_2PYzq=<8r*Cbl z;_oi|yLTLcwR=d4pWOS^y*)eU7WN3o3oljl_xCThT|B{*#E80*OI!F?3Vs9Dl}?zD z9HMd|uPR!eu7vvYcXBd~;7Iro)*&L8Sa%v4{|)OTTOID%0QXpmo?<;9hr;Pm=!Y4K zPEvrv^llt7y?u?Gv(6aCs)jOapLNV)CguWQ z#dmEea9R;&PBsif7r>K)nb4~hf>xMzD4Q4)k*;qgyo!x z*Z{^fgc4{ca8ukL5-tydmz5h?DW2gfq^K$xwUGdIt}Br zLzU@|m9(tVap=->I3_GZ)XA!J0z2-sshS18Wl5*UNZn-5U5507oMT2!4+UY1=KJ+i3$C#L(o51 z5eIc~unPUN+s!w0$BkXP`0?a#&R2ZhWnXv2H&XVEY}-dH)W7|p`$4OIc}^coY&YN1 z9k>4HIPsJlf5e3!2Rk>qw}O3T&c8bK(06Lf*Ry(cx8-D|WuV+Lu;Xajad;m(j_&va zYjUNvx7^yR2l_VXX3y^j?hkC9&|AiLysZ^)SJ~TD@%HQ9{*C!BdDiKAVYmAPh$4RC z46gH)qy6Qh{SZL@*tXui-n#yyjkZeXXt{G#4~^-;vwJ*?0fps--77N(otG-+r0-um zduf>Y)434PKOYMK4g2XEFkuMVNLh>%>B*iZEz5^G>Wfj{tGdK zffe%|=fVDpS@~^x9cJN`iTWv!J(gjZzY^yo()N^0Y>|n-la5EE|FN@Wy?2w}xU=ny ztO~onz$cTxo?Opw`G$4kfzWGn{QUTP$3KW}F|E&S4Ac37CAQ!mw)Py=N!{JI#q>Y+ zA6@t9Z(QH@&whm&IWmU#Y|z>}jf`#YwSAD-V%i@2kFB552QF>4*&oF literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..349f13539d680e335697f8498e9c2775e3a7d293 GIT binary patch literal 3063 zcmbtW&2JmW6`%d!Eq0bNQ5hG8d$OGq;(_P71=5>yA9|ZY!hp8MAD|n z?d;021q5K+0;%AGtrn)~RUtq^4!4tMV0HH(96OAj7JZEuv-s1CuCFDg* zOq27R#uoy4$&%CL95+S|duo<+PAU%hAKPxv$%i?&2kWKcp6wZ5(gc5nCJqxVFie_> z!B@loLq{6u$eA0S1Q!78ts-XYxMU^ z)U@oNMopvLGHH&pCFZPMj;n9d3-xyUlBw1j7S$_w4DTp}2N@l5#`H#;K2xy*unQTQE$VbEANSHva+sTgSlM3`sj@FZy1=6(0Ek zoFy8$2wu6ub%ePAr79ufF;AzQP;{3ESq#t0Hyu&WJ#DiUzeY=dH z7SuvoxD-_RERo6FwP~Tj%bmD_T5j7+?rdR`^$Vkmg&s?Tb#Y07`_!>jrSAT^+dLN@W|a{FY!vJ@Nr~pZM+|u z{wOm2X>8=S&^Bsy_njW-B40DtAER{)w+?Xt~m8*gv7JomJb6l#6@EWLBal{KjpRLF~bN%qg+`@w@atRsgs86jpD#0iBVBUB0%pYzvjx23_& zX@q}(FT*>087aZNf95^kE<@2GnxaKZiU)C<4#dS6UM+CJ?e$>JyQI8QhKVqs2;fs| zp;4P(PPo_GHoCDCs4vTAU4L=LCmY;F&=t&3A7{-8&0E1iH!Iaz{ zb^yl?Ib-f#>?ha}>x+5?5KTAh&Bg_W5ppmPu2?(f*;qDdg#prGc-Sbe5L)z2TcRx& zRbAWcP@P_>Hfs2gh*jE)hR$f&?K>FhARfzrdM@e!q+^X^1yk|MFCd#jmPK|Dm>r~7 z47l}a)z!v8vgeUa0yDAF20)2;>4If!w~N`&U>SPDd<+cH@=NiB&&2E}V*C>^`5-!c z{cPt~8@mAXz8rn$t+(Im%USq!d_4) zdE>Q}*ZRuDx-!vM)OAJeDL(}OMGT!^nctGdRQPd_L{clc>xVW&Bs8=cCW%x(o>`A) z*3R61 Config: + config = Config() + + if config_path is None: + config_path = os.environ.get("CONFIG_PATH", "config.yaml") + + path = Path(config_path) + if path.exists(): + with open(path) as f: + data = yaml.safe_load(f) or {} + for section, values in data.items(): + if values and hasattr(config, section): + section_obj = getattr(config, section) + if hasattr(section_obj, "model_validate"): + setattr(config, section, section_obj.model_validate(values)) + else: + for key, value in values.items(): + if hasattr(section_obj, key): + setattr(section_obj, key, value) + + return config + + +_config: Optional[Config] = None + + +def get_config() -> Config: + global _config + if _config is None: + _config = load_config() + return _config \ No newline at end of file diff --git a/src/linear_gitea_integration/main.py b/src/linear_gitea_integration/main.py new file mode 100644 index 0000000..40e8a3b --- /dev/null +++ b/src/linear_gitea_integration/main.py @@ -0,0 +1,50 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .api import router as api_router +from .config import get_config + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = get_config() + logging.info(f"Starting LinearGiteaIntegration on {config.server.host}:{config.server.port}") + yield + + +app = FastAPI( + title="LinearGiteaIntegration", + description="Integrate Linear issue tracking with Gitea", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router) + + +if __name__ == "__main__": + import uvicorn + + config = get_config() + uvicorn.run( + "linear_gitea_integration.main:app", + host=config.server.host, + port=config.server.port, + reload=True, + ) \ No newline at end of file diff --git a/src/linear_gitea_integration/models/__init__.py b/src/linear_gitea_integration/models/__init__.py new file mode 100644 index 0000000..a5d6582 --- /dev/null +++ b/src/linear_gitea_integration/models/__init__.py @@ -0,0 +1,76 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class LinearIssue(BaseModel): + id: str + identifier: str + title: str + description: Optional[str] = None + state: Optional[str] = None + team_id: Optional[str] = None + assignee_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class LinearIssueCreate(BaseModel): + team_id: str + title: str + description: Optional[str] = None + state_id: Optional[str] = None + + +class LinearIssueUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + state_id: Optional[str] = None + assignee_id: Optional[str] = None + + +class GiteaIssue(BaseModel): + id: int + number: int + title: str + body: Optional[str] = None + state: str + repository: Optional[str] = None + + +class GiteaIssueCreate(BaseModel): + title: str + body: Optional[str] = None + labels: Optional[str] = None + + +class GiteaPullRequest(BaseModel): + id: int + number: int + title: str + body: Optional[str] = None + state: str + head_branch: str + base_branch: str + html_url: str + merged: bool = False + repository: Optional[str] = None + + +class PRStatus(str, Enum): + OPEN = "open" + CLOSED = "closed" + MERGED = "merged" + + +class SyncDirection(str, Enum): + ONE_WAY = "one-way" + TWO_WAY = "two-way" + + +class WebhookEvent(BaseModel): + event: str + action: str + payload: dict \ No newline at end of file diff --git a/src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f238f642a949c3ed98da6d03414ced38c60f9a1 GIT binary patch literal 5457 zcmb_g-EZ606~Ck?ilRPj%a%Wq_%pFFHEq2v8PKh1ur=~m8r!M7B^U~ar6tB@CyDAM zwZeUpyPzEg7;qj6w0}Zg`=97xfOmla9R_shTNDe+9(T^AD3b~!8*K^j@bKKr`*nWz zch2S2bV?;~tzG)wJc$zWZ+!7Cxucv&0wE3ZfE0u^vPv8>jk>rdtcs4v^~hReRdOV* zOKZ_p*^yUcPHdWF$t)>E$4Eimmaj+L_8f&i}2>ebyAW4uLhZo!%C&OPx^+aQiUWM*{XiZqI^!)Yr;TVeB%=jsJij=LDvd zbjNVaiUGQ`RIOLgwpnvbtE!iCB8$JL+s3+8GRiEvY#QZK(e2fhV&~-~4~h;;yGm;f}$rz#YFwt_cNkjFc5eEkvBeJ>nz_0t`%Ii92T1(CM0O z*Nu%LIE>(p7fu+L{^WZQH-rYcM;?f8KO&?A)=T6AVNX~jTf&Fr9kC!RK%I^*`t(Rg z7qIO03s9+Z!8cN$vS^<&xldWlS0?uKr}Qa{V;QW6`U9>p6U`D+&5}`d%!j5yS=2F| zvcVE1!!A;jyNg9_2Ykt5j-gjHsGrbn+uW@h2EM38YCvg8(;cSPYv^^5U>US|1xl$6j@z5rlsY4j*i`qM)^8k{md=-HM^Ekj#Z|E`K%Il;&G; zG^ivw^7z)_yG?1jl|Vz1q(>hY54W4rY%7I^wC|GvGz^lViEqaLF*w(ha;+h>WPFzA z!(cgq0g)#pmtu;hRrLx^LDe*vX1$C$OzxAqUhb3(Xxc-Q+72#H)zUON0AJHV6hkO7 zD27ptfM7$u`9G{zi_l&cJOe9b7QvYgHFxTcVS^cJc72CV(zGW69fLM(+)Tftz+%{NG?t%;X$WROrw}V!J+mG)Mio4p}36V3J5<;kclV?J}g9z z3lrIe$<$n9^#%6mK?19WL*fRM1R!xS2njDr_@JQxG$2|)lz15$f8;dw3TXTt`sxP_ z;D4cqkGnA=q^ui5LdrboV7%#VlgE@(=yXv-`O4$|2BdKIMd+L#HSXL4sKNbcFM)7T zlNk2cjk6*!SBL#N`02vsC4TJ1Pa+gQAyYXENk}mi1t02+JJbeonTC@Lf{D{j08#i! zIHF!6nYkdMd`Rjg8PyF`ebS82q1%2CdPSNcfZcEb z$3gz^3V6GA_(oHj>V{X0y`9Jk=o|hj)s(J0$H;mnH(i5%Df;#-MEw%$py0zjf}r?3d+QBYVJ#~a2@7b|>k;vzD+ ztnWaAMRV}dpIs(3l! zvt0c))PJ5;Y0_gG>FPKVx$vgcIF zTAh}eTrudbQQ|uoAo*)-rkfcdZCP-%?3W+8SoAG&0E=%yd-fL~eimxVWSi1RH{s_m zon(I?bFl8E`Kh6%G~U9@w>5$}e}~PoL5QoNk&-Xod$DM`pAM$6zUkz9r-R8BOKt5}i+Pj6xfG6|pT(@I z@y4h6{%0}gQwtU9PA3*Ro!I8al6L?1e`2ve+}}hcbSmpG4vHcVw~f&mrjj0m<5GI; z(TY0@EDp><1Or|tn9>ATXw79I&O8EV9_}Jw9!Ok}*;(?wv9o7ce_r|v{00c;99-3( z!a3at!lDND!epHt*UaiJPtKWA z+b`+x^QA~-D5KbjdwgC%4V}re5FVfS=5_gw!<_?#4Bl1@Mvcu z!tBw0o4});RKX59JlYo`!sy@Zqdb<3wv(bTest^Wkv0LPtww~*(Qp0>B^i$0{{w`V BCtd&m literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/services/__init__.py b/src/linear_gitea_integration/services/__init__.py new file mode 100644 index 0000000..86ed705 --- /dev/null +++ b/src/linear_gitea_integration/services/__init__.py @@ -0,0 +1,11 @@ +from .gitea_client import GiteaClient, parse_repo, get_client as get_gitea_client +from .linear_client import LinearClient, extract_issue_ids, get_client as get_linear_client + +__all__ = [ + "GiteaClient", + "LinearClient", + "extract_issue_ids", + "parse_repo", + "get_gitea_client", + "get_linear_client", +] \ No newline at end of file diff --git a/src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33ccde9306b3011f7ecba3b4a3e2ac93d5cfa628 GIT binary patch literal 477 zcmY*VJx{|h5VeyuY10q28v{}YrV6Rvm=FvMq!I$PYbC6Zo?4?ciR>$a4e@XIIbFR} z2nmS|(LaElrc&|neE06|J)ciI?U0!Bar`ANDIuRW*&nA0wr3`ICHHhfMtefuERMX zPfFgjwQfWPv8?)f@QgA}kPD?&z{O0-nvuI5_|i4E^eqGyJOjFGrRLVB64X7*q^0EE zBh;Q_kPX)}%x~utmNEmWW~jGJ!N@h0GrzLB)34 zl&g<2gt?4SkVU@-1E#j~U@<}A180R7r$oa5%(e?9XK3k=5A-l%X{;4`L@Z(ZS XH)Omf;|-av$@JF?n$BAwJEr&tMfit) literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f63765043844cf0243ebd006fd472e4fe8b20baf GIT binary patch literal 10109 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 literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..167c94fe975ab67ad5f860e8315d30952691be65 GIT binary patch literal 12345 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 UAbPQ%e;apz;;pzqaO@TT1$B)~cK`qY literal 0 HcmV?d00001 diff --git a/src/linear_gitea_integration/services/gitea_client.py b/src/linear_gitea_integration/services/gitea_client.py new file mode 100644 index 0000000..f41f31e --- /dev/null +++ b/src/linear_gitea_integration/services/gitea_client.py @@ -0,0 +1,166 @@ +import os +from typing import Optional + +import httpx + +from ..config import get_config +from ..models import GiteaIssue, GiteaIssueCreate, GiteaPullRequest + + +class GiteaClient: + def __init__(self, base_url: Optional[str] = None): + config = get_config() + self.base_url = base_url or config.gitea.url + self.token = config.gitea.token + self.headers = { + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + } + + def _request( + self, + method: str, + endpoint: str, + params: Optional[dict] = None, + json_data: Optional[dict] = None, + ) -> dict: + url = f"{self.base_url}{endpoint}" + with httpx.Client() as client: + response = client.request( + method, + url, + headers=self.headers, + params=params, + json=json_data, + ) + response.raise_for_status() + return response.json() + + def get_repo(self, owner: str, repo: str) -> dict: + return self._request("GET", f"/api/v1/repos/{owner}/{repo}") + + def get_issue(self, owner: str, repo: str, issue_number: int) -> GiteaIssue: + data = self._request("GET", f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}") + return GiteaIssue( + id=data["id"], + number=data["number"], + title=data["title"], + body=data.get("body"), + state=data["state"], + repository=f"{owner}/{repo}", + ) + + def create_issue( + self, owner: str, repo: str, issue: GiteaIssueCreate + ) -> Optional[GiteaIssue]: + data = self._request( + "POST", + f"/api/v1/repos/{owner}/{repo}/issues", + json_data={ + "title": issue.title, + "body": issue.body, + "labels": issue.labels, + }, + ) + return GiteaIssue( + id=data["id"], + number=data["number"], + title=data["title"], + body=data.get("body"), + state=data["state"], + repository=f"{owner}/{repo}", + ) + + def update_issue( + self, + owner: str, + repo: str, + issue_number: int, + title: Optional[str] = None, + body: Optional[str] = None, + state: Optional[str] = None, + ) -> dict: + json_data = {} + if title is not None: + json_data["title"] = title + if body is not None: + json_data["body"] = body + if state is not None: + json_data["state"] = state + return self._request( + "PATCH", + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}", + json_data=json_data, + ) + + def get_pull_request( + self, owner: str, repo: str, pr_number: int + ) -> Optional[GiteaPullRequest]: + try: + data = self._request( + "GET", f"/api/v1/repos/{owner}/{repo}/pulls/{pr_number}" + ) + return GiteaPullRequest( + id=data["id"], + number=data["number"], + title=data["title"], + body=data.get("body"), + state=data["state"], + head_branch=data["head"]["ref"], + base_branch=data["base"]["ref"], + html_url=data["html_url"], + merged=data.get("merged", False), + repository=f"{owner}/{repo}", + ) + except httpx.HTTPStatusError: + return None + + def create_branch(self, owner: str, repo: str, branch: str, from_branch: str) -> bool: + try: + self._request( + "POST", + f"/api/v1/repos/{owner}/{repo}/git/refs", + json_data={ + "name": f"refs/heads/{branch}", + "sha": self._get_commit_sha(owner, repo, from_branch), + }, + ) + return True + except Exception: + return False + + def _get_commit_sha(self, owner: str, repo: str, branch: str) -> str: + data = self._request( + "GET", f"/api/v1/repos/{owner}/{repo}/branches/{branch}" + ) + return data["commit"]["sha"] + + def create_issue_comment( + self, owner: str, repo: str, issue_number: int, body: str + ) -> dict: + return self._request( + "POST", + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", + json_data={"body": body}, + ) + + def get_webhook_events(self, owner: str, repo: str) -> list[dict]: + return self._request("GET", f"/api/v1/repos/{owner}/{repo}/hooks") + + def test_connection(self) -> bool: + try: + self._request("GET", "/api/v1/user") + return True + except Exception: + return False + + +def parse_repo(full_name: str) -> tuple[str, str]: + parts = full_name.split("/") + if len(parts) != 2: + raise ValueError(f"Invalid repository format: {full_name}") + return parts[0], parts[1] + + +def get_client() -> GiteaClient: + return GiteaClient() \ No newline at end of file diff --git a/src/linear_gitea_integration/services/linear_client.py b/src/linear_gitea_integration/services/linear_client.py new file mode 100644 index 0000000..30d9d10 --- /dev/null +++ b/src/linear_gitea_integration/services/linear_client.py @@ -0,0 +1,229 @@ +import re +from typing import Optional + +import httpx + +from ..config import get_config +from ..models import LinearIssue, LinearIssueCreate, LinearIssueUpdate + + +ISSUE_ID_PATTERN = re.compile(r"([A-Z]+-\d+)") + + +class LinearClient: + def __init__(self, base_url: str = "https://api.linear.app/graphql"): + config = get_config() + self.api_key = config.linear.api_key + self.base_url = base_url + self.headers = { + "Authorization": self.api_key, + "Content-Type": "application/json", + } + + def _graphql_request(self, query: str, variables: Optional[dict] = None) -> dict: + with httpx.Client() as client: + response = client.post( + self.base_url, + json={"query": query, "variables": variables or {}}, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + if "errors" in data: + raise Exception(f"GraphQL error: {data['errors']}") + return data.get("data", {}) + + def get_issue(self, issue_id: str) -> Optional[LinearIssue]: + query = """ + query GetIssue($issueId: String!) { + issue(id: $issueId) { + id + identifier + title + description + state { + name + } + team { + id + } + assignee { + id + } + createdAt + updatedAt + } + } + """ + data = self._graphql_request(query, {"issueId": issue_id}) + issue_data = data.get("issue") + if issue_data: + return LinearIssue( + id=issue_data["id"], + identifier=issue_data["identifier"], + title=issue_data["title"], + description=issue_data.get("description"), + state=issue_data.get("state", {}).get("name"), + team_id=issue_data.get("team", {}).get("id"), + assignee_id=issue_data.get("assignee", {}).get("id"), + created_at=issue_data.get("createdAt"), + updated_at=issue_data.get("updatedAt"), + ) + return None + + def get_issue_by_identifier(self, identifier: str) -> Optional[LinearIssue]: + query = """ + query GetIssueByIdentifier($identifier: String!) { + issueFilter(filter: { identifier: { eq: $identifier } }) { + nodes { + id + identifier + title + description + state { + name + } + team { + id + } + assignee { + id + } + createdAt + updatedAt + } + } + } + """ + data = self._graphql_request(query, {"identifier": identifier}) + nodes = data.get("issueFilter", {}).get("nodes", []) + if nodes: + issue_data = nodes[0] + return LinearIssue( + id=issue_data["id"], + identifier=issue_data["identifier"], + title=issue_data["title"], + description=issue_data.get("description"), + state=issue_data.get("state", {}).get("name"), + team_id=issue_data.get("team", {}).get("id"), + assignee_id=issue_data.get("assignee", {}).get("id"), + created_at=issue_data.get("createdAt"), + updated_at=issue_data.get("updatedAt"), + ) + return None + + def create_issue(self, issue: LinearIssueCreate) -> Optional[LinearIssue]: + query = """ + mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + description + state { + name + } + team { + id + } + createdAt + updatedAt + } + } + } + """ + variables = { + "input": { + "teamId": issue.team_id, + "title": issue.title, + } + } + if issue.description: + variables["input"]["description"] = issue.description + + data = self._graphql_request(query, variables) + created = data.get("issueCreate", {}) + if created.get("success"): + issue_data = created["issue"] + return LinearIssue( + id=issue_data["id"], + identifier=issue_data["identifier"], + title=issue_data["title"], + description=issue_data.get("description"), + state=issue_data.get("state", {}).get("name"), + team_id=issue_data.get("team", {}).get("id"), + created_at=issue_data.get("createdAt"), + updated_at=issue_data.get("updatedAt"), + ) + return None + + def update_issue(self, issue_id: str, update: LinearIssueUpdate) -> bool: + query = """ + mutation UpdateIssue($issueId: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $issueId, input: $input) { + success + } + } + """ + variables = {"issueId": issue_id, "input": {}} + if update.title is not None: + variables["input"]["title"] = update.title + if update.description is not None: + variables["input"]["description"] = update.description + if update.state_id is not None: + variables["input"]["stateId"] = update.state_id + if update.assignee_id is not None: + variables["input"]["assigneeId"] = update.assignee_id + + data = self._graphql_request(query, variables) + return data.get("issueUpdate", {}).get("success", False) + + def get_states_for_team(self, team_id: str) -> list[dict]: + query = """ + query GetTeamStates($teamId: String!) { + team(id: $teamId) { + states { + nodes { + id + name + } + } + } + } + """ + data = self._graphql_request(query, {"teamId": team_id}) + return data.get("team", {}).get("states", {}).get("nodes", []) + + def link_pull_request( + self, issue_id: str, pr_url: str, pr_title: Optional[str] = None + ) -> bool: + query = """ + mutation LinkPullRequest($issueId: String!, $prUrl: String!) { + issueLinkPullRequest(issueId: $issueId, prUrl: $prUrl) { + success + } + } + """ + data = self._graphql_request(query, {"issueId": issue_id, "prUrl": pr_url}) + return data.get("issueLinkPullRequest", {}).get("success", False) + + def add_comment(self, issue_id: str, body: str) -> bool: + query = """ + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + } + """ + data = self._graphql_request(query, {"issueId": issue_id, "body": body}) + return data.get("commentCreate", {}).get("success", False) + + +def extract_issue_ids(text: str) -> list[str]: + return ISSUE_ID_PATTERN.findall(text) + + +def get_client() -> LinearClient: + return LinearClient() \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/__init__.py b/src/linear_gitea_integration/webhooks/__init__.py new file mode 100644 index 0000000..3fb0c57 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/__init__.py @@ -0,0 +1,8 @@ +from .gitea_handler import handle_gitea_webhook, verify_gitea_signature +from .linear_handler import handle_linear_webhook + +__all__ = [ + "handle_gitea_webhook", + "handle_linear_webhook", + "verify_gitea_signature", +] \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/gitea_handler.py b/src/linear_gitea_integration/webhooks/gitea_handler.py new file mode 100644 index 0000000..71e2449 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/gitea_handler.py @@ -0,0 +1,176 @@ +import hashlib +import hmac +import logging +from typing import Optional + +from ..config import get_config +from ..models import GiteaPullRequest, WebhookEvent +from ..services import extract_issue_ids, get_gitea_client, get_linear_client +from ..services.linear_client import LinearIssueUpdate + +logger = logging.getLogger(__name__) + +MAGIC_WORDS = ["closes", "closes", "fixes", "resolves", "addresses"] + + +def verify_gitea_signature(payload: bytes, signature: str) -> bool: + config = get_config() + secret = config.gitea.webhook_secret + if not secret: + return True + expected = hmac.new( + secret.encode(), payload, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(f"sha256={expected}", signature) + + +def handle_push_event(payload: dict) -> list[str]: + if not get_config().features.commit_linking: + return [] + + commits = payload.get("commits", []) + repo = payload.get("repository", {}).get("full_name", "") + linked_issues = [] + + linear_client = get_linear_client() + + for commit in commits: + message = commit.get("message", "") + issue_ids = extract_issue_ids(message) + + for issue_id in issue_ids: + issue = linear_client.get_issue_by_identifier(issue_id) + if issue: + comment = f"Commit linked: {commit.get('id', '')[:7]} - {message[:100]}" + linear_client.add_comment(issue.id, comment) + linked_issues.append(issue_id) + logger.info(f"Linked commit to {issue_id}") + + return linked_issues + + +def handle_pull_request_event(payload: dict) -> list[str]: + action = payload.get("action", "") + pr_data = payload.get("pull_request", {}) + repo = payload.get("repository", {}).get("full_name", "") + + if not repo or repo not in get_config().sync.enabled_repos: + return [] + + pr = GiteaPullRequest( + id=pr_data.get("id", 0), + number=pr_data.get("number", 0), + title=pr_data.get("title", ""), + body=pr_data.get("body"), + state=pr_data.get("state", "open"), + head_branch=pr_data.get("head", {}).get("ref", ""), + base_branch=pr_data.get("base", {}).get("ref", ""), + html_url=pr_data.get("html_url", ""), + merged=pr_data.get("merged", False), + repository=repo, + ) + + linked_issues = [] + + issue_ids = extract_issue_ids(pr.title) + if pr.body: + issue_ids.extend(extract_issue_ids(pr.body)) + issue_ids.extend(extract_issue_ids(pr.head_branch)) + + issue_ids = list(set(issue_ids)) + + linear_client = get_linear_client() + + for issue_id in issue_ids: + issue = linear_client.get_issue_by_identifier(issue_id) + if not issue: + continue + + if get_config().features.pr_linking: + linear_client.link_pull_request(issue.id, pr.html_url, pr.title) + logger.info(f"Linked PR to {issue_id}") + + if get_config().features.workflow_automation: + status_map = get_config().sync.status_mappings + + if action == "opened" or action == "reopened": + new_state = status_map.pr_created + elif action == "closed" and pr.merged: + new_state = status_map.pr_merged + elif action == "closed" and not pr.merged: + new_state = status_map.pr_closed + else: + continue + + states = linear_client.get_states_for_team(issue.team_id or "") + state_id = next( + (s["id"] for s in states if s["name"] == new_state), None + ) + + if state_id: + linear_client.update_issue( + issue.id, LinearIssueUpdate(state_id=state_id) + ) + logger.info(f"Updated {issue_id} status to {new_state}") + + linked_issues.append(issue_id) + + return linked_issues + + +def handle_issues_event(payload: dict) -> Optional[str]: + if not get_config().features.issues_sync: + return None + + action = payload.get("action", "") + if action not in ["opened", "closed"]: + return None + + repo = payload.get("repository", {}).get("full_name", "") + if not repo or repo not in get_config().sync.enabled_repos: + return None + + issue_data = payload.get("issue", {}) + issue_id = issue_data.get("id", 0) + issue_number = issue_data.get("number", 0) + title = issue_data.get("title", "") + body = issue_data.get("body", "") + + linear_client = get_linear_client() + gitea_client = get_gitea_client() + + config = get_config() + sync_direction = config.sync.sync_direction + + if sync_direction == "one-way": + existing_issue = linear_client.get_issue_by_identifier(f"GIT-{issue_number}") + if existing_issue: + return None + + from ..models import LinearIssueCreate + + linear_issue = linear_client.create_issue( + LinearIssueCreate( + team_id=config.sync.default_team_id, + title=title, + description=f"Synced from Gitea\n\n{body}", + ) + ) + if linear_issue: + logger.info(f"Created Linear issue {linear_issue.identifier} from Gitea issue #{issue_number}") + return linear_issue.identifier + + return None + + +def handle_gitea_webhook(event: str, payload: dict) -> list[str]: + results = [] + + if event == "push": + results = handle_push_event(payload) + elif event == "pull_request": + results = handle_pull_request_event(payload) + elif event == "issues": + results = [handle_issues_event(payload)] if handle_issues_event(payload) else [] + + return results \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/linear_handler.py b/src/linear_gitea_integration/webhooks/linear_handler.py new file mode 100644 index 0000000..9198d82 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/linear_handler.py @@ -0,0 +1,52 @@ +import logging +from typing import Optional + +from ..config import get_config +from ..models import GiteaIssueCreate +from ..services import get_gitea_client, get_linear_client, parse_repo +from ..services.linear_client import extract_issue_ids + +logger = logging.getLogger(__name__) + + +def handle_linear_issue_update(payload: dict) -> Optional[str]: + action = payload.get("action", "") + if action not in ["create", "update"]: + return None + + if get_config().sync.sync_direction != "two-way": + return None + + issue_data = payload.get("issue", {}) + issue_identifier = issue_data.get("identifier", "") + title = issue_data.get("title", "") + description = issue_data.get("description", "") + state = issue_data.get("state", {}).get("name", "") + + config = get_config() + if not config.features.issues_sync: + return None + + for repo in config.sync.enabled_repos: + owner, repo_name = parse_repo(repo) + + gitea_client = get_gitea_client() + gitea_issue = gitea_client.create_issue( + owner, + repo_name, + GiteaIssueCreate( + title=f"[{issue_identifier}] {title}", + body=f"Synced from Linear\n\n{description}", + ), + ) + if gitea_issue: + logger.info(f"Created Gitea issue #{gitea_issue.number} from Linear {issue_identifier}") + return f"{repo}#{gitea_issue.number}" + + return None + + +def handle_linear_webhook(event: str, payload: dict) -> Optional[str]: + if event == "Issue": + return handle_linear_issue_update(payload) + return None \ No newline at end of file diff --git a/tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a35cf27278871b22e0cca013342863db4bd475a7 GIT binary patch literal 16798 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 literal 0 HcmV?d00001 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..a6fd6bc --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,91 @@ +import pytest +from linear_gitea_integration.services.linear_client import extract_issue_ids + + +class TestExtractIssueIds: + def test_simple_issue_id(self): + text = "This is a fix for LIN-123" + assert extract_issue_ids(text) == ["LIN-123"] + + def test_multiple_issue_ids(self): + text = "Fixes LIN-123 and fixes PROJ-456" + ids = extract_issue_ids(text) + assert "LIN-123" in ids + assert "PROJ-456" in ids + assert len(ids) == 2 + + def test_no_issue_id(self): + text = "This is just a regular commit" + assert extract_issue_ids(text) == [] + + def test_issue_id_in_branch(self): + text = "feature/LIN-123-add-login" + assert extract_issue_ids(text) == ["LIN-123"] + + def test_issue_id_in_pr_title(self): + text = "[LIN-456] Add new feature" + assert extract_issue_ids(text) == ["LIN-456"] + + def test_issue_id_with_underscore(self): + text = "MYPROJ-789 fix" + assert extract_issue_ids(text) == ["MYPROJ-789"] + + +class TestConfig: + def test_default_config(self): + from linear_gitea_integration.config import Config + + config = Config() + assert config.server.port == 8080 + assert config.server.host == "0.0.0.0" + + def test_status_mappings(self): + from linear_gitea_integration.config import StatusMappings + + mappings = StatusMappings() + assert mappings.pr_created == "In Progress" + assert mappings.pr_merged == "Done" + assert mappings.pr_closed == "Todo" + + +class TestModels: + def test_gitea_pull_request_model(self): + from linear_gitea_integration.models import GiteaPullRequest + from linear_gitea_integration.services.linear_client import extract_issue_ids + + pr = GiteaPullRequest( + id=1, + number=123, + title="Test PR", + state="open", + head_branch="feature/LIN-1", + base_branch="main", + html_url="https://gitea.com/org/repo/pulls/123", + ) + issue_ids = extract_issue_ids(pr.head_branch) + assert "LIN-1" in issue_ids + + def test_linear_issue_model(self): + from linear_gitea_integration.models import LinearIssue + + issue = LinearIssue( + id="issue_123", + identifier="LIN-123", + title="Test Issue", + ) + assert issue.identifier == "LIN-123" + + +class TestParseRepo: + def test_valid_repo(self): + from linear_gitea_integration.services.gitea_client import parse_repo + + owner, repo = parse_repo("my-org/my-repo") + assert owner == "my-org" + assert repo == "my-repo" + + def test_invalid_repo(self): + from linear_gitea_integration.services.gitea_client import parse_repo + + with pytest.raises(ValueError): + parse_repo("invalid-repo") \ No newline at end of file