diff --git a/.gitignore b/.gitignore
index 33703656f..54c802a21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,6 +132,7 @@ venv/
ENV/
env.bak/
venv.bak/
+/server/application-server/src/test/resources/application-github-integration-local.yml
# Spyder project settings
.spyderproject
diff --git a/AGENTS.md b/AGENTS.md
index d66d27007..45bf294b6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,6 +3,7 @@
This file governs the entire repository. Combine these guardrails with the scoped instructions under `.github/instructions/**` (general coding, TSX, Storybook, Java tests).
## 1. Architecture map
+
- `server/application-server/`: Spring Boot 3.5, Liquibase-managed PostgreSQL schema, synchronous + reactive APIs, generated OpenAPI spec in `openapi.yaml`.
- `webapp/`: React 19 + TanStack Router/Query, Tailwind 4 UI kit (`src/components/ui`), generated API client in `src/api/**`.
- `server/intelligence-service/`: FastAPI service orchestrating AI models. OpenAPI spec is exported via Poetry and mirrored into the Java client under the application server.
@@ -10,6 +11,7 @@ This file governs the entire repository. Combine these guardrails with the scope
- `docs/`: Contributor docs (including the ERD that `db:generate-erd-docs` regenerates).
## 2. Toolchain & environment prerequisites
+
- **Node.js**: Use the exact version from `.node-version` (currently 22.10.0). Stick with npm—the repo maintains `package-lock.json` and uses npm workspaces.
- **Java**: JDK 21 (see `pom.xml`). Maven wrapper is checked in; **always run builds through `./mvnw`** (Maven wrapper) to ensure consistent Maven versions.
- **Python**: Python 3.13 with Poetry 2.x. Both Python services keep virtualenvs inside their folders (`.venv`). Run `npm run bootstrap:py` before formatting/linting to ensure dev dependencies are installed.
@@ -18,6 +20,7 @@ This file governs the entire repository. Combine these guardrails with the scope
- **Environment variables**: When generating intelligence service OpenAPI specs locally, set `MODEL_NAME=fake:model` and `DETECTION_MODEL_NAME=fake:model` (the FastAPI settings expect a provider-qualified model name).
## 3. Quality gates & routine commands
+
Run the relevant commands locally before opening a PR:
| Concern | Commands |
@@ -27,13 +30,14 @@ Run the relevant commands locally before opening a PR:
| Webapp build | `npm --workspace webapp run build` (Vite build + `tsc --noEmit`) |
| Webapp tests | `npm --workspace webapp run test` (Vitest) and add focused unit tests when touching logic. |
| Storybook | `npm --workspace webapp run build-storybook` (Chromatic depends on a clean build). |
-| Application-server tests | Use Maven groups to mirror CI: `./mvnw test -Dgroups=unit`, `-Dgroups=integration`, `-Dgroups=architecture`. |
+| Application-server tests | Use Maven groups to mirror CI: `./mvnw test -Dgroups=unit`, `-Dgroups=integration`, `-Dgroups=architecture`. Live GitHub sync tests stay skipped unless you pass `-Dgroups=github-integration`, which activates the `github-integration-tests` profile. |
| Intelligence service lint/type check | `poetry run black --check .`, `poetry run flake8 .`, `poetry run mypy .` inside `server/intelligence-service`. |
| Webhook ingest lint | `poetry run black --check .` and `poetry run flake8 .` inside `server/webhook-ingest`. |
Document any skipped gate in the PR description with a rationale. Always finish a change set by running `npm run format` followed by `npm run lint` so both styling and type checks reflect the final state.
## 4. Code generation & forbidden edits
+
We rely heavily on generated artifacts. Never hand-edit these directories—regenerate instead:
| Artifact | Source command |
@@ -48,6 +52,7 @@ We rely heavily on generated artifacts. Never hand-edit these directories—rege
Regeneration is destructive; stash local edits before running these commands. Check diffs carefully—generated clients must be committed alongside API changes.
## 5. Database workflow (Liquibase)
+
- Liquibase changelog files live under `server/application-server/src/main/resources/db/changelog/` and are included via `master.xml`.
- Use `npm run db:draft-changelog` after changing JPA entities. The script will:
1. Spin up PostgreSQL through Docker (ensure Docker is running or set `CI=true` with a ready Postgres).
@@ -58,6 +63,7 @@ Regeneration is destructive; stash local edits before running these commands. Ch
- Never manually edit generated Liquibase diff sections unless you fully understand the implications. Prefer creating a follow-up changelog to fix mistakes.
## 6. Frontend (webapp) expectations
+
- Follow the container/presentation split already in place (route files under `src/routes/**` fetch data and pass it to components under `src/components/**`). Keep components pure and side-effect free.
- Fetch data exclusively with TanStack Query v5 and the generated helpers in `@/api/@tanstack/react-query.gen.ts`. Spread the option objects: `useQuery(getTeamsOptions({ ... }))`. Use the generated `*.QueryKey()` helpers for cache invalidation.
- Do not call `fetch` directly; reuse the generated `@hey-api` client configured in `src/api/client.ts` and the shared QueryClient from `src/integrations/tanstack-query/root-provider.tsx`.
@@ -70,6 +76,7 @@ Regeneration is destructive; stash local edits before running these commands. Ch
- Never hand-edit `routeTree.gen.ts`; it is generated by TanStack Router tooling.
## 7. Application server (Java/Spring) expectations
+
- Keep business logic in services annotated with `@Service` and transactional boundaries (`@Transactional`) where needed. Controllers should be thin (input validation + delegation).
- Use Lombok consistently (`@Getter`, `@Setter`, etc.) but prefer explicit builders or records when immutability helps.
- Group new tests under the proper JUnit tag so CI picks them up (`@Tag("unit")`, `@Tag("integration")`, or `@Tag("architecture")`). Follow the mantra in `.github/instructions/java-tests.instructions.md` (AAA structure, single assertion focus, deterministic data).
@@ -80,6 +87,7 @@ Regeneration is destructive; stash local edits before running these commands. Ch
- When integrating with the intelligence-service client, always regenerate (`npm run generate:api:intelligence-service:client`) after touching the spec and commit the updated Java files.
## 8. Python services expectations
+
- Both services rely on Poetry with in-project virtualenvs. Run `poetry install --with dev --no-root` before running tooling.
- Intelligence service:
- Settings live in `app/settings.py`; `MODEL_NAME` and `DETECTION_MODEL_NAME` must be provider-qualified (`openai:gpt-4o`, `fake:model`, etc.). For tooling/CI we rely on the `fake` provider.
@@ -91,12 +99,15 @@ Regeneration is destructive; stash local edits before running these commands. Ch
- Formatting: run `poetry run black .` (or `--check`) and `poetry run flake8 .`. Add type hints so mypy stays green in the intelligence service.
## 9. Documentation & assets
+
- ERD diagrams live under `docs/contributor/erd/`. Regenerate via `npm run db:generate-erd-docs` after schema changes.
- Contributor documentation should stay in `docs/` (GitHub Pages). Keep README/CONTRIBUTING updates concise and actionable.
- Screenshots or large binary assets belong under `docs/images/` or the Storybook stories, not inside source directories.
## 10. Commit & PR checklist
+
Before marking work ready for review:
+
- [ ] Regenerate and commit any impacted OpenAPI specs, clients, ERD docs, or generated SQLAlchemy models.
- [ ] Run the formatting/linting/typecheck/test commands relevant to the modified modules; capture output for the PR description if CI cannot run a job locally.
- [ ] Verify database migrations through `db:draft-changelog` when JPA entities change and inspect the produced XML.
@@ -104,6 +115,7 @@ Before marking work ready for review:
- [ ] Follow Conventional Commit semantics for PR titles (`feat(webapp): ...`, etc., see `CONTRIBUTING.md`).
## 11. Known command caveats
+
- `npm run db:draft-changelog` requires Docker to be installed and available on PATH. In CI we set `CI=true`; locally ensure Docker Desktop/daemon is running before invoking the script.
- `npm run generate:api:intelligence-service:specs` fails unless `MODEL_NAME` and `DETECTION_MODEL_NAME` are set (use the `fake:model` provider for tooling).
- `npm run generate:api:application-server:specs` performs a full Maven `verify` against the specs profile. The initial run downloads the entire Spring Boot dependency tree (~hundreds of MB); expect several minutes on a cold cache.
diff --git a/docs/contributor/testing.mdx b/docs/contributor/testing.mdx
index 2f64fcb4a..dbfe90644 100644
--- a/docs/contributor/testing.mdx
+++ b/docs/contributor/testing.mdx
@@ -96,3 +96,60 @@ Available examples include `label.created`, `repository.created`, `create`, `pus
Use the optional `-Dgroups=unit` or `-Dgroups=integration` flags once category support lands.
+## GitHub live sync integration tests
+
+Some regression scenarios can only be validated against GitHub itself. We ship a focused suite that exercises the live GitHub App installation and verifies end-to-end sync behaviour (repository metadata, labels, milestones, and teams).
+
+### Prerequisites
+
+1. **Sandbox installation** – the `Hephaestus IntegrationTests` GitHub App must be installed in a sandbox organisation you control. The tests create and delete repositories, milestones, labels, and teams on each run.
+2. **Credentials** – provide both a GitHub App private key and a Personal Access Token with the following scopes:
+ - `repo` (full)
+ - `admin:org`
+ - `read:packages`
+3. **Local config file** – copy the template that lives alongside the tests:
+
+ ```bash
+ cd server/application-server/src/test/resources
+ cp application-github-integration-local.example.yml application-github-integration-local.yml
+ ```
+
+ Fill in the placeholders with the sandbox organisation slug, the installation id, and either an inline PEM key (`github.app.privateKey`) or a readable `privateKeyLocation`. Keep this file out of version control—it is already listed in `.gitignore`.
+
+ Alternatively, export the matching environment variables:
+
+ ```bash
+ export GH_IT_APP_ID=2250297
+ export GH_IT_APP_PAT=ghp_xxx... # PAT with the scopes above
+ export GH_IT_INSTALLATION_ID=93512943
+ export GH_IT_ORGANIZATION=HephaestusTest
+ export GH_IT_APP_PRIVATE_KEY_PATH=/absolute/path/to/private-key.pem
+ ```
+
+ Use either the file _or_ the environment variables; the suite checks both and aborts if key material is missing.
+
+### Running the suite
+
+From `server/application-server/` run:
+
+```bash
+./mvnw test -Dgroups=github-integration
+```
+
+Passing the `groups` system property automatically activates the `github-integration-tests` Maven profile, which clears the default exclusion and runs only the live GitHub scenarios. The regular CI pipeline never sets this flag, so these tests remain skipped unless you opt in locally.
+
+The run takes roughly two minutes and prints the GitHub artefacts it provisions. Clean-up is handled automatically, but if a failure interrupts execution you can safely delete any `hephaestus-it-*` repositories, milestones, or teams that remain in the sandbox.
+
+### Authoring new GitHub sync tests
+
+1. Extend `AbstractGitHubSyncIntegrationTest` (or fall back to `BaseGitHubIntegrationTest` when repositories are not needed). These bases set up credential checks, provide the `workspaceRepository`, and expose helpers such as `createEphemeralRepository`, `registerRepositoryToMonitor`, `createEphemeralTeam`, and `seedOrganizationMembers`.
+2. Use the supplied helpers to create and track temporary GitHub artefacts. They automatically register clean-up handlers via `@AfterEach`, so add new resources to the provided lists instead of implementing manual deletion logic.
+3. Keep tests deterministic: rely on `databaseTestUtils.cleanDatabase()` in `@BeforeEach` (already invoked by the base), and generate unique slugs via `nextEphemeralSlug("suffix")` when naming repositories, branches, or teams.
+4. If a scenario needs extra Spring configuration, extend `application-github-integration-local.yml` in `server/application-server/src/test/resources/`. The checked-in `.example` file documents every property; copy it on demand and keep secrets out of version control.
+
+### Troubleshooting
+
+- **Skipping because of missing credentials** – check the console output; the base test class verifies that the App id, private key, PAT, and installation id are all present before executing.
+- **Hub4j rate-limit failures** – the suite creates several entities per run. Prefer a dedicated sandbox organisation so you do not clash with production automation limits.
+- **Longer runtimes** – each suite bootstraps a Testcontainers PostgreSQL instance and provisions GitHub resources. Expect higher runtimes than the pure Testcontainers integration tests; avoid running them on every PR and instead use them before releases or when touching the GitHub sync layer.
+
diff --git a/package-lock.json b/package-lock.json
index 61f82663f..e9ca69d98 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.16.3",
"npm-run-all": "^4.1.5",
+ "patch-package": "^8.0.1",
"prettier": "3.5.1",
"prettier-plugin-java": "2.6.7",
"prettier-plugin-kotlin": "2.1.0",
@@ -5700,6 +5701,13 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@yarnpkg/lockfile": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -6573,6 +6581,22 @@
}
}
},
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -8461,6 +8485,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/find-yarn-workspace-root": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+ "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "micromatch": "^4.0.2"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -10376,6 +10410,26 @@
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
+ "node_modules/json-stable-stringify": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+ "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -10401,6 +10455,16 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "license": "Public Domain",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/katex": {
"version": "0.16.22",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
@@ -10428,6 +10492,16 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
+ "node_modules/klaw-sync": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.11"
+ }
+ },
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -11952,6 +12026,33 @@
],
"license": "MIT"
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -12769,6 +12870,92 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/patch-package": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+ "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@yarnpkg/lockfile": "^1.1.0",
+ "chalk": "^4.1.2",
+ "ci-info": "^3.7.0",
+ "cross-spawn": "^7.0.3",
+ "find-yarn-workspace-root": "^2.0.0",
+ "fs-extra": "^10.0.0",
+ "json-stable-stringify": "^1.0.2",
+ "klaw-sync": "^6.0.0",
+ "minimist": "^1.2.6",
+ "open": "^7.4.2",
+ "semver": "^7.5.3",
+ "slash": "^2.0.0",
+ "tmp": "^0.2.4",
+ "yaml": "^2.2.2"
+ },
+ "bin": {
+ "patch-package": "index.js"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">5"
+ }
+ },
+ "node_modules/patch-package/node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-package/node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/patch-package/node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-package/node_modules/tmp": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
+ "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
@@ -14724,6 +14911,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -16802,6 +16999,19 @@
"node": ">=18"
}
},
+ "node_modules/yaml": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "devOptional": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ }
+ },
"node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
diff --git a/package.json b/package.json
index 305c74da0..70c122220 100644
--- a/package.json
+++ b/package.json
@@ -37,9 +37,10 @@
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.16.3",
"npm-run-all": "^4.1.5",
+ "patch-package": "^8.0.1",
"prettier": "3.5.1",
"prettier-plugin-java": "2.6.7",
"prettier-plugin-kotlin": "2.1.0",
"shx": "0.3.4"
}
-}
\ No newline at end of file
+}
diff --git a/server/application-server/pom.xml b/server/application-server/pom.xml
index 3106782f0..24d020917 100644
--- a/server/application-server/pom.xml
+++ b/server/application-server/pom.xml
@@ -35,7 +35,8 @@
false
false
- 60
+ 180
+ github-integration
true
@@ -433,7 +434,8 @@
5
${hephaestus.surefire.timeout}
- 10
+ 60
+ ${hephaestus.githubIntegrationExcludedGroups}
@@ -557,5 +559,16 @@
180
+
+ github-integration-tests
+
+
+ groups
+
+
+
+
+
+
diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/Application.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/Application.java
index 891f9f8a5..a79b8bb49 100644
--- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/Application.java
+++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/Application.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.hephaestus;
+import de.tum.in.www1.hephaestus.config.GitHubApiPatches;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,6 +11,9 @@
@EnableCaching
@EnableScheduling
public class Application {
+ static {
+ GitHubApiPatches.ensureApplied();
+ }
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"));
diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitHubApiPatches.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitHubApiPatches.java
index 3de9e023d..acdb4fa32 100644
--- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitHubApiPatches.java
+++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitHubApiPatches.java
@@ -1,12 +1,12 @@
package de.tum.in.www1.hephaestus.config;
import jakarta.annotation.PostConstruct;
+import java.lang.instrument.Instrumentation;
import net.bytebuddy.agent.ByteBuddyAgent;
import net.bytebuddy.agent.builder.AgentBuilder;
-import net.bytebuddy.implementation.MethodDelegation;
-import net.bytebuddy.implementation.bind.annotation.RuntimeType;
-import net.bytebuddy.implementation.bind.annotation.This;
+import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
+import org.kohsuke.github.GHUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
@@ -18,106 +18,121 @@
public class GitHubApiPatches {
private static final Logger logger = LoggerFactory.getLogger(GitHubApiPatches.class);
+ private static volatile boolean patchApplied;
@PostConstruct
public void applyPatches() {
- try {
- ByteBuddyAgent.install();
+ ensureApplied();
+ }
- patchGetUser("org.kohsuke.github.GHPullRequestReview");
- patchGetUser("org.kohsuke.github.GHPullRequestReviewComment");
- } catch (Throwable t) {
- logger.warn("Failed to apply Byte Buddy patches for github-api. Proceeding without patches.", t);
+ public static void ensureApplied() {
+ if (patchApplied) {
+ return;
}
+ synchronized (GitHubApiPatches.class) {
+ if (patchApplied) {
+ return;
+ }
+ try {
+ installPatches();
+ patchApplied = true;
+ } catch (Throwable t) {
+ logger.warn("Failed to apply Byte Buddy patches for github-api. Proceeding without patches.", t);
+ }
+ }
+ }
+
+ private static void installPatches() {
+ Instrumentation instrumentation = ByteBuddyAgent.install();
+ logger.info(
+ "ByteBuddy instrumentation supports retransformation: {}",
+ instrumentation.isRetransformClassesSupported()
+ );
+
+ patchGetUser(instrumentation, "org.kohsuke.github.GHPullRequestReview");
+ patchGetUser(instrumentation, "org.kohsuke.github.GHPullRequestReviewComment");
}
- private void patchGetUser(String className) throws Exception {
+ private static void patchGetUser(Instrumentation instrumentation, String className) {
new AgentBuilder.Default()
+ .disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.ignore(ElementMatchers.none())
.type(ElementMatchers.named(className))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
- builder
- .method(ElementMatchers.named("getUser"))
- .intercept(MethodDelegation.to(GetUserInterceptor.class))
+ builder.visit(Advice.to(GetUserAdvice.class).on(ElementMatchers.named("getUser")))
)
- .installOnByteBuddyAgent();
+ .installOn(instrumentation);
+
+ if (instrumentation.isRetransformClassesSupported()) {
+ try {
+ Class> targetClass = Class.forName(className, false, GitHubApiPatches.class.getClassLoader());
+ if (instrumentation.isModifiableClass(targetClass)) {
+ instrumentation.retransformClasses(targetClass);
+ }
+ } catch (ClassNotFoundException ignored) {
+ // Class will be transformed on first load.
+ } catch (UnsupportedOperationException | java.lang.instrument.UnmodifiableClassException ex) {
+ logger.warn("Instrumentation cannot retransform {}: {}", className, ex.getMessage());
+ }
+ }
logger.info("Patched {}#getUser via Byte Buddy", className);
}
/**
- * Interceptor implementing: return owner == null || owner.isOffline() ? user : owner.root().getUser(user.login)
+ * Advice returning the embedded user whenever possible to avoid remote API calls.
*/
- public static class GetUserInterceptor {
+ public static class GetUserAdvice {
- @RuntimeType
- public static Object intercept(@This Object self) throws Exception {
+ @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class)
+ public static GHUser enter(@Advice.This Object self) {
try {
- // Reflectively access fields: owner, user
var clazz = self.getClass();
- var ownerField = clazz.getDeclaredField("owner");
- ownerField.setAccessible(true);
- Object owner = ownerField.get(self);
var userField = clazz.getDeclaredField("user");
userField.setAccessible(true);
- Object user = userField.get(self);
+ GHUser user = (GHUser) userField.get(self);
+
+ var ownerField = clazz.getDeclaredField("owner");
+ ownerField.setAccessible(true);
+ Object owner = ownerField.get(self);
if (owner == null) {
return user;
}
- // owner has isOffline()
- var isOfflineMethod = owner.getClass().getMethod("isOffline");
- boolean isOffline = (Boolean) isOfflineMethod.invoke(owner);
- if (isOffline) {
+ Object offline = owner.getClass().getMethod("isOffline").invoke(owner);
+ if (Boolean.TRUE.equals(offline)) {
return user;
}
- if (user == null) {
- return null;
- }
-
- // user has login field (package-private), fallback to getLogin()
- String login = null;
- try {
- var loginField = user.getClass().getDeclaredField("login");
- loginField.setAccessible(true);
- Object v = loginField.get(user);
- login = v != null ? v.toString() : null;
- } catch (NoSuchFieldException ignore) {
- try {
- var getLogin = user.getClass().getMethod("getLogin");
- Object v = getLogin.invoke(user);
- login = v != null ? v.toString() : null;
- } catch (ReflectiveOperationException ex) {
- // ignore
- }
- }
-
- if (login == null) {
- return user;
- }
-
- // owner.root().getUser(login)
- var rootMethod = owner.getClass().getMethod("root");
- Object root = rootMethod.invoke(owner);
- if (root == null) {
+ if (user != null) {
return user;
}
- var getUserMethod = root.getClass().getMethod("getUser", String.class);
- return getUserMethod.invoke(root, login);
} catch (Throwable t) {
- // On any unexpected error, return the embedded user to be safe
try {
- var userField = self.getClass().getDeclaredField("user");
- userField.setAccessible(true);
- return userField.get(self);
- } catch (Throwable inner) {
+ var field = self.getClass().getDeclaredField("user");
+ field.setAccessible(true);
+ return (GHUser) field.get(self);
+ } catch (Throwable ignored) {
return null;
}
}
+
+ return null;
+ }
+
+ @Advice.OnMethodExit(onThrowable = Throwable.class)
+ public static void exit(
+ @Advice.Enter GHUser resolved,
+ @Advice.Return(readOnly = false) GHUser returnValue,
+ @Advice.Thrown(readOnly = false) Throwable throwable
+ ) {
+ if (resolved != null) {
+ returnValue = resolved;
+ throwable = null;
+ }
}
}
}
diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/app/GitHubAppTokenService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/app/GitHubAppTokenService.java
index a12c9b29c..7961af58c 100644
--- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/app/GitHubAppTokenService.java
+++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/app/GitHubAppTokenService.java
@@ -239,7 +239,14 @@ private static boolean isKeyMaterialPresent(long appId, Resource privateKeyRes,
if (privateKeyPem != null && !privateKeyPem.isBlank()) {
return true;
}
- return privateKeyRes != null && privateKeyRes.exists();
+ if (privateKeyRes == null) {
+ return false;
+ }
+ try {
+ return privateKeyRes.exists() && privateKeyRes.contentLength() > 0;
+ } catch (IOException ignored) {
+ return false;
+ }
}
private static PrivateKey generateEphemeralRsaKey() {
diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/label/LabelRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/label/LabelRepository.java
index eee1bf11f..007dbaf66 100644
--- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/label/LabelRepository.java
+++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/label/LabelRepository.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.hephaestus.gitprovider.label;
+import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -17,4 +18,6 @@ public interface LabelRepository extends JpaRepository