Compare commits
16 Commits
3b6b4d6dba
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bd26c8894f | |||
| 1f4c3d35ef | |||
| 38e06da3a6 | |||
| a7d23cd79a | |||
| 22c8e915e9 | |||
| def9084981 | |||
| 42fc8318fa | |||
| 4c931bae1a | |||
| b305544f63 | |||
| faad70872b | |||
| fba494dada | |||
| ac284d9dc9 | |||
| d8f17ff7e7 | |||
| a25361f5fc | |||
| d54319a5bb | |||
| 05c1a4bc97 |
@@ -1,4 +1,5 @@
|
|||||||
/.cache/
|
/.cache/
|
||||||
|
/.venv/
|
||||||
/__pycache__/
|
/__pycache__/
|
||||||
/redmine-copy/
|
/redmine-copy/
|
||||||
/dist/*.tar.gz
|
/dist/*.tar.gz
|
||||||
@@ -7,4 +8,5 @@ redMCP/test.env
|
|||||||
redMCP/vendor/
|
redMCP/vendor/
|
||||||
redMCP/composer.phar
|
redMCP/composer.phar
|
||||||
.env
|
.env
|
||||||
|
semantic_index/.env
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -77,25 +77,25 @@ embedding calls.
|
|||||||
|
|
||||||
Top-level docs:
|
Top-level docs:
|
||||||
|
|
||||||
- [README.md](/home/iadnah/redmine/README.md:1)
|
- [README.md](README.md)
|
||||||
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
|
- [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
|
||||||
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
|
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
|
||||||
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
|
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
|
||||||
|
|
||||||
Main scripts:
|
Main scripts:
|
||||||
|
|
||||||
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
|
- [redmine_contacts.py](redmine_contacts.py)
|
||||||
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
|
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
|
||||||
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
|
- [redmine_outbox_worker.py](redmine_outbox_worker.py)
|
||||||
|
|
||||||
Local Redmine copy:
|
Local Redmine copy:
|
||||||
|
|
||||||
- [redmine-copy](/home/iadnah/redmine/redmine-copy)
|
- [redmine-copy](redmine-copy)
|
||||||
|
|
||||||
Important local plugin paths:
|
Important local plugin paths:
|
||||||
|
|
||||||
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
|
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
|
||||||
- [redmine-copy/plugins/redmine_contacts_helpdesk](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk)
|
- [redmine-copy/plugins/redmine_contacts_helpdesk](redmine-copy/plugins/redmine_contacts_helpdesk)
|
||||||
|
|
||||||
## What Has Already Been Done
|
## What Has Already Been Done
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ Existing rollback archives:
|
|||||||
|
|
||||||
Read:
|
Read:
|
||||||
|
|
||||||
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
|
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
|
||||||
|
|
||||||
Especially remember:
|
Especially remember:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
## Cleanup Notes ~ May 6, 2026
|
||||||
|
|
||||||
|
This repository currently mixes multiple partially finished workstreams. The
|
||||||
|
goal is to recover to a clean, reviewable git state with focused commits so
|
||||||
|
normal development can continue.
|
||||||
|
|
||||||
|
## Scope and constraints
|
||||||
|
|
||||||
|
- `TODO.md` is long-horizon context and is out of scope for this cleanup pass.
|
||||||
|
- `redMCP/` is actively used on this machine; do not delete files in that tree
|
||||||
|
and do not stop running `redMCP` processes during cleanup.
|
||||||
|
- `redMCP/startProd.sh` is a local convenience script and is intentionally not a
|
||||||
|
project artifact for this cleanup. Ignore it.
|
||||||
|
- Use `plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md` as a primary anchor
|
||||||
|
for reconstructing intent and grouping related changes.
|
||||||
|
|
||||||
|
## Recovered change groups
|
||||||
|
|
||||||
|
The current dirty tree appears to contain these distinct units:
|
||||||
|
|
||||||
|
1. Helpdesk issue API `include=helpdesk` patch and docs/manifest.
|
||||||
|
2. Post-import automation and validator/worker hardening.
|
||||||
|
3. Semantic index service, deployment assets, tests, and runbooks.
|
||||||
|
4. redMCP feature expansion (HTTP handler/server, client/dispatcher updates,
|
||||||
|
tests, docs).
|
||||||
|
5. Skill metadata/docs under `skills/redmine-communicator/`.
|
||||||
|
|
||||||
|
## Working checklist
|
||||||
|
|
||||||
|
- [x] Inventory all modified and untracked files.
|
||||||
|
- [x] Identify likely project groupings for clean commits.
|
||||||
|
- [x] Confirm `LOCAL_CHANGELOG.md` aligns with Helpdesk API patch files.
|
||||||
|
- [x] Stage and commit Helpdesk API patch as a focused unit.
|
||||||
|
- [x] Stage and commit post-import automation as a focused unit.
|
||||||
|
- [x] Stage and commit semantic index files as a focused unit.
|
||||||
|
- [x] Stage and commit redMCP feature updates as a focused unit.
|
||||||
|
- [x] Stage and commit redmine-communicator skill files (optional split).
|
||||||
|
- [x] Run targeted syntax/tests for each committed unit.
|
||||||
|
- [x] Confirm final worktree state and note any intentionally uncommitted files.
|
||||||
|
|
||||||
|
## Cleanup result
|
||||||
|
|
||||||
|
Committed units:
|
||||||
|
|
||||||
|
- `fba494d` Add Helpdesk issue API include serializer
|
||||||
|
- `faad708` Automate post-import refresh and validation workflow
|
||||||
|
- `b305544` Add semantic-index service, deployment assets, and tests
|
||||||
|
- `4c931ba` Expand redMCP safe issue operations and HTTP handling
|
||||||
|
- `42fc831` Add redmine-communicator skill docs and setup tooling
|
||||||
|
- `def9084` Handoff notes for next agent/workflow
|
||||||
|
- `22c8e91` Sanitize noisy MCP text fields by default
|
||||||
|
- `a7d23cd` Resolve human project names in MCP project_id args
|
||||||
|
|
||||||
|
Intentionally untracked local files:
|
||||||
|
|
||||||
|
- `redMCP/startProd.sh`
|
||||||
|
- `roadmap/`
|
||||||
|
|
||||||
|
Recent validation run for redMCP changes:
|
||||||
|
|
||||||
|
- `php -l app/McpDispatcher.php`
|
||||||
|
- `php -l app/McpEnvironment.php`
|
||||||
|
- `php -l app/mcp-http-router.php`
|
||||||
|
- `php -l bin/redmcp-server.php`
|
||||||
|
- `php -l bin/test-redmine-structure.php`
|
||||||
|
- `php bin/test-redmine-structure.php` (`OK 90 assertions`)
|
||||||
|
|
||||||
|
## Handoff notes for next session
|
||||||
|
|
||||||
|
- Gitea private repo is created and current history was pushed.
|
||||||
|
- Monorepo approach is acceptable; keep path-scoped commits and deployment-unit
|
||||||
|
boundaries.
|
||||||
|
- Production semantic-index target is a separate host from production Redmine.
|
||||||
|
- redMCP improvement focus is operational quality:
|
||||||
|
- useful error/access logging without console spam,
|
||||||
|
- easy background operation,
|
||||||
|
- simple install/remove/status workflow.
|
||||||
|
- A single fixed systemd service is not preferred for redMCP because multiple
|
||||||
|
concurrent identities/API keys may be needed. Prefer an instance model.
|
||||||
|
- If systemd is used for redMCP, implement a simple operator script with
|
||||||
|
`install`, `remove`, and `status` flows.
|
||||||
|
- Production plugin rollout milestone completed:
|
||||||
|
- `deploy_redmine_prod_patches.sh` was used to deploy plugin patches on
|
||||||
|
production.
|
||||||
|
- Upload bundle used: `dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz`.
|
||||||
|
- User-reported outcome: production deploy completed and appears to be
|
||||||
|
working.
|
||||||
|
|
||||||
|
## Notes to keep in mind
|
||||||
|
|
||||||
|
- Do not commit secrets (`.env`, tokens, credentials).
|
||||||
|
- `semantic_index/search.sh.md` looks like conversational scratch text; treat as
|
||||||
|
optional/non-essential unless deliberately kept.
|
||||||
|
- If a file belongs to multiple units, prefer smallest safe unit first and
|
||||||
|
document rationale in commit messages.
|
||||||
@@ -37,7 +37,7 @@ The old RedmineUP plugin stack is effectively local legacy code now:
|
|||||||
|
|
||||||
Tracked local plugin source lives under:
|
Tracked local plugin source lives under:
|
||||||
|
|
||||||
- [plugins](/home/iadnah/redmine/plugins)
|
- [plugins](plugins)
|
||||||
|
|
||||||
The full `redmine-copy/` tree is an ignored working/reference copy of the legacy
|
The full `redmine-copy/` tree is an ignored working/reference copy of the legacy
|
||||||
install. Make local plugin changes in `plugins/` first, then deploy or copy them
|
install. Make local plugin changes in `plugins/` first, then deploy or copy them
|
||||||
@@ -45,7 +45,7 @@ into the test Redmine instance or `redmine-copy/` as needed.
|
|||||||
|
|
||||||
The Redmine API/MCP wrapper project now lives in:
|
The Redmine API/MCP wrapper project now lives in:
|
||||||
|
|
||||||
- [redMCP](/home/iadnah/redmine/redMCP)
|
- [redMCP](redMCP)
|
||||||
|
|
||||||
That subproject contains the PHP wrapper that composes normal Redmine issue API
|
That subproject contains the PHP wrapper that composes normal Redmine issue API
|
||||||
responses with local Helpdesk metadata. Its dependencies are managed by Composer;
|
responses with local Helpdesk metadata. Its dependencies are managed by Composer;
|
||||||
@@ -109,7 +109,7 @@ The old RedmineUP contacts plugin already exposes useful JSON routes such as:
|
|||||||
|
|
||||||
That led to the first standalone helper:
|
That led to the first standalone helper:
|
||||||
|
|
||||||
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
|
- [redmine_contacts.py](redmine_contacts.py)
|
||||||
|
|
||||||
It currently supports:
|
It currently supports:
|
||||||
|
|
||||||
@@ -122,14 +122,14 @@ It currently supports:
|
|||||||
|
|
||||||
A small plugin was created at:
|
A small plugin was created at:
|
||||||
|
|
||||||
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
|
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
|
||||||
|
|
||||||
It records local database events into `event_outbox_events`.
|
It records local database events into `event_outbox_events`.
|
||||||
|
|
||||||
Known-good archive:
|
Known-good archive:
|
||||||
|
|
||||||
- [dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz)
|
- [dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz](dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz)
|
||||||
- [manifest](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md:1)
|
- [manifest](dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md)
|
||||||
|
|
||||||
Tested event types on the LAN copy:
|
Tested event types on the LAN copy:
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ and worker-derived documents without marking rows processed.
|
|||||||
We made targeted changes to the local fork of `redmine_contacts_helpdesk`:
|
We made targeted changes to the local fork of `redmine_contacts_helpdesk`:
|
||||||
|
|
||||||
- added a read-only JSON controller:
|
- added a read-only JSON controller:
|
||||||
- [helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
|
- [helpdesk_search_controller.rb](redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb)
|
||||||
- added routes for:
|
- added routes for:
|
||||||
- ticket by issue
|
- ticket by issue
|
||||||
- issues by contact
|
- issues by contact
|
||||||
@@ -174,24 +174,24 @@ successfully.
|
|||||||
|
|
||||||
Before touching the RedmineUP plugin forks, rollback archives were created:
|
Before touching the RedmineUP plugin forks, rollback archives were created:
|
||||||
|
|
||||||
- [redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz)
|
- [redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz](dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz)
|
||||||
- [redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz)
|
- [redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz](dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz)
|
||||||
|
|
||||||
Manifests:
|
Manifests:
|
||||||
|
|
||||||
- [contacts manifest](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
|
- [contacts manifest](dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md)
|
||||||
- [helpdesk manifest](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
|
- [helpdesk manifest](dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md)
|
||||||
|
|
||||||
Change tracking docs:
|
Change tracking docs:
|
||||||
|
|
||||||
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
|
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
|
||||||
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md:1)
|
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md)
|
||||||
|
|
||||||
### 5. Read-Only Helpdesk Export/Search CLI
|
### 5. Read-Only Helpdesk Export/Search CLI
|
||||||
|
|
||||||
We also built:
|
We also built:
|
||||||
|
|
||||||
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
|
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ intentionally stopped short of treating CLI speed optimization as the main goal.
|
|||||||
|
|
||||||
The first external worker prototype is:
|
The first external worker prototype is:
|
||||||
|
|
||||||
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
|
- [redmine_outbox_worker.py](redmine_outbox_worker.py)
|
||||||
|
|
||||||
It runs outside Redmine and consumes `event_outbox_events` over SSH/MySQL. The
|
It runs outside Redmine and consumes `event_outbox_events` over SSH/MySQL. The
|
||||||
initial output target is deterministic local JSONL rather than a live search
|
initial output target is deterministic local JSONL rather than a live search
|
||||||
@@ -237,7 +237,7 @@ Current behavior:
|
|||||||
|
|
||||||
The worker processing policy is documented in:
|
The worker processing policy is documented in:
|
||||||
|
|
||||||
- [docs/outbox_worker_policy.md](/home/iadnah/redmine/docs/outbox_worker_policy.md:1)
|
- [docs/outbox_worker_policy.md](docs/outbox_worker_policy.md)
|
||||||
|
|
||||||
### 7. Test Helpdesk Mail Reset
|
### 7. Test Helpdesk Mail Reset
|
||||||
|
|
||||||
@@ -245,11 +245,11 @@ After importing a production database into the LAN test instance, reset all
|
|||||||
active projects to use the local Mailpit test mailbox for Helpdesk settings
|
active projects to use the local Mailpit test mailbox for Helpdesk settings
|
||||||
with:
|
with:
|
||||||
|
|
||||||
- [reset_helpdesk_mail_settings.py](/home/iadnah/redmine/reset_helpdesk_mail_settings.py:1)
|
- [reset_helpdesk_mail_settings.py](reset_helpdesk_mail_settings.py)
|
||||||
|
|
||||||
The complete post-import workflow is documented in:
|
The complete post-import workflow is documented in:
|
||||||
|
|
||||||
- [docs/test_instance_post_import.md](/home/iadnah/redmine/docs/test_instance_post_import.md:1)
|
- [docs/test_instance_post_import.md](docs/test_instance_post_import.md)
|
||||||
|
|
||||||
Use the read-only validator to check the test instance without changing it:
|
Use the read-only validator to check the test instance without changing it:
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ Run the Helpdesk/redMCP live smoke test after the post-import checks pass:
|
|||||||
|
|
||||||
That test is documented in:
|
That test is documented in:
|
||||||
|
|
||||||
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1)
|
- [docs/helpdesk_smoke_test.md](docs/helpdesk_smoke_test.md)
|
||||||
|
|
||||||
Run the Helpdesk outbox worker validation when changing outbox hooks, worker
|
Run the Helpdesk outbox worker validation when changing outbox hooks, worker
|
||||||
enrichment, or Helpdesk/redMCP behavior:
|
enrichment, or Helpdesk/redMCP behavior:
|
||||||
@@ -276,7 +276,7 @@ enrichment, or Helpdesk/redMCP behavior:
|
|||||||
|
|
||||||
That test is documented in:
|
That test is documented in:
|
||||||
|
|
||||||
- [docs/helpdesk_outbox_worker_validation.md](/home/iadnah/redmine/docs/helpdesk_outbox_worker_validation.md:1)
|
- [docs/helpdesk_outbox_worker_validation.md](docs/helpdesk_outbox_worker_validation.md)
|
||||||
|
|
||||||
Preview the affected projects and settings:
|
Preview the affected projects and settings:
|
||||||
|
|
||||||
@@ -308,12 +308,21 @@ If Mailpit moves, pass the host that Redmine can reach:
|
|||||||
|
|
||||||
The redMCP wrapper now makes Helpdesk behavior explicit:
|
The redMCP wrapper now makes Helpdesk behavior explicit:
|
||||||
|
|
||||||
- `redMCP/bin/redmcp-server.php` runs as a stdio MCP server for live client
|
- `redMCP/bin/redmcp-server.php` runs as a stdio MCP server.
|
||||||
testing.
|
- `redMCP/bin/redmcp-http-server.php` runs as a bearer-token-protected
|
||||||
|
Streamable HTTP MCP server for network client testing, with PID/status/stop
|
||||||
|
helpers and optional debug JSONL logging.
|
||||||
|
- `redMCP/bin/generate-bearer-token.php` generates local MCP bearer tokens.
|
||||||
|
- `projects()` and `project()` expose Redmine's built-in `/projects.json`
|
||||||
|
project list/detail APIs.
|
||||||
|
- `users()`, `user()`, and `projectMemberships()` expose Redmine's built-in
|
||||||
|
user and project membership APIs.
|
||||||
- `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json`
|
- `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json`
|
||||||
issue filters.
|
issue filters.
|
||||||
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
|
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
|
||||||
text search.
|
text search.
|
||||||
|
- MCP list tools accept friendly `limit`, `page`, `offset`, `sort`, status, and
|
||||||
|
date options while still allowing raw Redmine `filters`/`params` overrides.
|
||||||
- `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message
|
- `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message
|
||||||
metadata.
|
metadata.
|
||||||
- `updateIssue()` is safe by default and does not send customer email.
|
- `updateIssue()` is safe by default and does not send customer email.
|
||||||
@@ -373,7 +382,7 @@ We discovered old plugin issues while working:
|
|||||||
|
|
||||||
These are logged in:
|
These are logged in:
|
||||||
|
|
||||||
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
|
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
|
||||||
|
|
||||||
They are important context but are not the primary search deliverable.
|
They are important context but are not the primary search deliverable.
|
||||||
|
|
||||||
@@ -381,21 +390,21 @@ They are important context but are not the primary search deliverable.
|
|||||||
|
|
||||||
Project docs:
|
Project docs:
|
||||||
|
|
||||||
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
|
- [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
|
||||||
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
|
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
|
||||||
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1)
|
- [docs/helpdesk_smoke_test.md](docs/helpdesk_smoke_test.md)
|
||||||
- [docs/test_instance_post_import.md](/home/iadnah/redmine/docs/test_instance_post_import.md:1)
|
- [docs/test_instance_post_import.md](docs/test_instance_post_import.md)
|
||||||
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
|
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
|
||||||
|
|
||||||
Tooling:
|
Tooling:
|
||||||
|
|
||||||
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
|
- [redmine_contacts.py](redmine_contacts.py)
|
||||||
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
|
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
|
||||||
|
|
||||||
Local plugin work:
|
Local plugin work:
|
||||||
|
|
||||||
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
|
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
|
||||||
- [redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
|
- [redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb)
|
||||||
|
|
||||||
## Current Recommended Next Steps
|
## Current Recommended Next Steps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
This file contains mostly longer-horizon ideas for this project.
|
||||||
|
|
||||||
|
## redMCP
|
||||||
|
|
||||||
|
[ ] Basic search backed by Redmine's existing API, full text searching:
|
||||||
|
[ ] Issues
|
||||||
|
[ ] Contacts
|
||||||
|
|
||||||
|
[ ] Paged getting/fetching of issues matching simple criteria such as:
|
||||||
|
[ ] Project
|
||||||
|
[ ] Last updated or created
|
||||||
|
[ ] Status
|
||||||
|
|
||||||
|
## Other / uncertain
|
||||||
|
|
||||||
|
[ ] Contact disambiguation with Gmail/Google
|
||||||
|
[ ] Cross-reference contacts for a given project in Redmine with Google Contacts, then
|
||||||
|
intelligently update them so they match the same information.
|
||||||
|
|
||||||
|
[ ] Contacts present in Redmine but missing from Gmail should be added to Gmail
|
||||||
|
|
||||||
|
## Redmine (plugins)
|
||||||
|
|
||||||
|
- [ ] /contacts/<contact_id>/tabs/helpdesk helpdesk tickets list improvements:
|
||||||
|
- [ ] Show dates created and last updated
|
||||||
|
- [ ] Simple search function
|
||||||
|
- [ ] Paging / don't shit the bed for contacts who have hundreds of closed tickets
|
||||||
|
- [ ] LLM summary showing the key things the person has mentioned recently
|
||||||
|
|
||||||
|
- [ ] Issue tagging - free-form (?) tagging for issues, where IDs are shared across projects
|
||||||
|
|
||||||
|
[ ] Watchers notification settings
|
||||||
|
If there are mechanisms available, other than email, to notify people who are watches on
|
||||||
|
a ticket, they should be able to configure some settings about that.
|
||||||
|
|
||||||
|
Examples might include:
|
||||||
|
|
||||||
|
- Webhook
|
||||||
|
- Text message
|
||||||
|
|
||||||
|
[ ] Better send/initiate email support
|
||||||
|
As it stands, the two ways to initiate email using the Helpdesk plugin are clunky.
|
||||||
|
|
||||||
|
Method 1: Find the contact under contacts, click to send them an email, and it makes the
|
||||||
|
user fill out the from/reply-to address -- which defaults to the one they used
|
||||||
|
to sign up with redmine. That's typically an issue for two different reasons.
|
||||||
|
|
||||||
|
a. It means that if the contact responds to it, the ticket probably won't show up
|
||||||
|
in redmine. It will just go to the employee's inbox.
|
||||||
|
|
||||||
|
b. Doing this doesn't create a ticket/issue unless the contact responds, which can
|
||||||
|
be a problem if we need records of who has been emailed and about what.
|
||||||
|
|
||||||
|
Method 2: The user can click "New Issue" inside of a project, select that it's a support
|
||||||
|
ticket, and then they get an option asking about what kind of ticket. They have a
|
||||||
|
confusing drop-down that defaults to not sending an email. It's just extra headache
|
||||||
|
to teach and manage employees to deal with this.
|
||||||
|
|
||||||
|
Another issue with this is that when it does send an email, it doesn't put the header/footer
|
||||||
|
from Helpdesk on it, and the user isn't exactly told that's the case.
|
||||||
|
|
||||||
|
A very simple "New Ticket -> Email" option would be very useful that just acts like people
|
||||||
|
who use Gmail would expect it to act.
|
||||||
|
|
||||||
|
[ ] Helpdesk: better footers/signatures
|
||||||
|
As it stands, email footers/signatures are project based and can't easily be customized on
|
||||||
|
a per-user basis (not quite). It would be nice if they had support for macros that will add
|
||||||
|
the employee's job title, contact info, etc. I think it already has this feature to some
|
||||||
|
extent, but it's not very good.
|
||||||
|
|
||||||
|
[ ] Helpdesk: more direct spam / blocklist / filtering ability
|
||||||
|
Helpdesk might not be the best place to implement this, but it seems like it's worth
|
||||||
|
thinking about.
|
||||||
|
|
||||||
|
Here are common issues we run into (over 10 years of using this):
|
||||||
|
|
||||||
|
- We _do_ want some emails from @amazon.com, but not everything. Existing features do not
|
||||||
|
really cover anything about this. Amazon.com is just an example.
|
||||||
|
|
||||||
|
|
||||||
|
[ ] Rules engine for email -> ticket imports
|
||||||
|
The rules provided by the existing plugin are not very useful. It would be much more useful
|
||||||
|
if we were able to do things like this:
|
||||||
|
|
||||||
|
[ ] Based on some criteria, send a different auto response template to certain contacts
|
||||||
|
|
||||||
|
[ ] Import a ticket as normal, but don't send an auto-reply. Instead, pass the imported data
|
||||||
|
to some other program which then decides whether or not to send an autoresponse, what it
|
||||||
|
should include, etc. If a response is sent, the ticket should be updated to include it.
|
||||||
|
|
||||||
|
|
||||||
|
[ ] Draft
|
||||||
|
A very simple way to save a draft response to a ticket, so it does not get emailed to
|
||||||
|
anybody. However, any legit/authorized user who can see/edit the ticket can see the
|
||||||
|
draft and choose to edit or send it.
|
||||||
|
|
||||||
|
[ ] We should be able to search by whether or not tickets have drafts.
|
||||||
|
This is for these reasons:
|
||||||
|
[ ] We may want to present the user with a list of ticket drafts they need to
|
||||||
|
approve/deny/edit before they will be sent.
|
||||||
|
|
||||||
|
[ ] We may have an agent write drafts, and in doing so, it should be able to focus
|
||||||
|
on tickets that need a draft and don't have one already.
|
||||||
|
|
||||||
|
[ ] It's completely fine if this is implemented using something more like a custom
|
||||||
|
field, but I suspect a plugin will be what we want.
|
||||||
Executable
+183
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
deploy/semantic-index/install.sh [--dry-run] [--apply] [--start] [--no-system] [--skip-deps]
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
--dry-run Print commands that would run. This is the default.
|
||||||
|
--apply Install files, venv, dependencies, env template, and systemd units.
|
||||||
|
--start With --apply, reload systemd and start only semantic-index.service.
|
||||||
|
--no-system Skip sudo/systemd operations. Useful for tests and local validation.
|
||||||
|
--skip-deps Skip venv creation and dependency install.
|
||||||
|
|
||||||
|
The installer never runs backfill, never enables the refresh timer, and never
|
||||||
|
passes --force-rebuild.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
mode=dry-run
|
||||||
|
start_service=0
|
||||||
|
system_ops=1
|
||||||
|
skip_deps=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run)
|
||||||
|
mode=dry-run
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
mode=apply
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--start)
|
||||||
|
start_service=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-system)
|
||||||
|
system_ops=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-deps)
|
||||||
|
skip_deps=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$start_service" -eq 1 && "$mode" != "apply" ]]; then
|
||||||
|
echo "--start requires --apply" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
install_dir=${SEMANTIC_INDEX_INSTALL_DIR:-/opt/semantic-index}
|
||||||
|
env_file=${SEMANTIC_INDEX_ENV_FILE:-/etc/semantic-index.env}
|
||||||
|
state_dir=${SEMANTIC_INDEX_STATE_DIR:-/var/lib/semantic-index}
|
||||||
|
log_dir=${SEMANTIC_INDEX_LOG_DIR:-/var/log/semantic-index}
|
||||||
|
systemd_dir=${SEMANTIC_INDEX_SYSTEMD_DIR:-/etc/systemd/system}
|
||||||
|
python_bin=${PYTHON:-python3}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
printf 'would run:'
|
||||||
|
printf ' %q' "$@"
|
||||||
|
printf '\n'
|
||||||
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sudo() {
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
run "$@"
|
||||||
|
else
|
||||||
|
run sudo "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_env_template() {
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
echo "would copy env template only if missing: $env_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -e "$env_file" ]]; then
|
||||||
|
echo "keeping existing $env_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
mkdir -p "$(dirname "$env_file")"
|
||||||
|
cp "$repo_root/deploy/semantic-index/semantic-index.env.example" "$env_file"
|
||||||
|
else
|
||||||
|
sudo install -m 0640 "$repo_root/deploy/semantic-index/semantic-index.env.example" "$env_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_next_steps_warning() {
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Semantic Index installed, but deployment is not complete.
|
||||||
|
|
||||||
|
Required manual steps:
|
||||||
|
1. Edit $env_file and fill real secrets/URLs.
|
||||||
|
2. Start or restart the HTTP service:
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
3. Validate:
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
$install_dir/semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
4. Before enabling scheduled refresh, run:
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' $install_dir/semantic_index/refresh.sh
|
||||||
|
$install_dir/semantic_index/refresh.sh --apply
|
||||||
|
5. Create/confirm a Qdrant snapshot before any production-scale backfill.
|
||||||
|
|
||||||
|
The refresh timer was NOT enabled automatically.
|
||||||
|
Do not use --force-rebuild unless you intentionally want to pay to re-embed unchanged documents.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "mode=$mode"
|
||||||
|
echo "install_dir=$install_dir"
|
||||||
|
echo "env_file=$env_file"
|
||||||
|
echo "state_dir=$state_dir"
|
||||||
|
echo "log_dir=$log_dir"
|
||||||
|
|
||||||
|
run_sudo mkdir -p "$install_dir" "$state_dir" "$log_dir" "$systemd_dir"
|
||||||
|
run_sudo rsync -a \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude "__pycache__/" \
|
||||||
|
--exclude "*.pyc" \
|
||||||
|
"$repo_root/semantic_index" \
|
||||||
|
"$repo_root/tests" \
|
||||||
|
"$repo_root/docs" \
|
||||||
|
"$repo_root/deploy" \
|
||||||
|
"$repo_root/dist" \
|
||||||
|
"$install_dir/"
|
||||||
|
|
||||||
|
if [[ "$skip_deps" -eq 1 ]]; then
|
||||||
|
echo "skipping venv/dependency install because --skip-deps was used"
|
||||||
|
elif [[ "$mode" == "apply" && "$system_ops" -eq 0 ]]; then
|
||||||
|
run "$python_bin" -m venv "$install_dir/.venv"
|
||||||
|
run "$install_dir/.venv/bin/pip" install openai qdrant-client fastapi uvicorn
|
||||||
|
else
|
||||||
|
run_sudo "$python_bin" -m venv "$install_dir/.venv"
|
||||||
|
run_sudo "$install_dir/.venv/bin/pip" install openai qdrant-client fastapi uvicorn
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_env_template
|
||||||
|
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index.service" "$systemd_dir/semantic-index.service"
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index-refresh.service" "$systemd_dir/semantic-index-refresh.service"
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index-refresh.timer" "$systemd_dir/semantic-index-refresh.timer"
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" && "$skip_deps" -eq 0 ]]; then
|
||||||
|
"$install_dir/.venv/bin/python" -m py_compile "$install_dir"/semantic_index/*.py
|
||||||
|
"$install_dir/.venv/bin/python" -m unittest discover -s "$install_dir/tests/semantic_index"
|
||||||
|
bash -n "$install_dir/semantic_index/refresh.sh"
|
||||||
|
elif [[ "$mode" == "apply" ]]; then
|
||||||
|
echo "skipping installed-code validation because --skip-deps was used"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" && "$start_service" -eq 1 ]]; then
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
echo "skipping systemctl start because --no-system was used"
|
||||||
|
else
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" ]]; then
|
||||||
|
print_next_steps_warning
|
||||||
|
fi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redmine Semantic Index Rolling Refresh
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/semantic-index
|
||||||
|
EnvironmentFile=/etc/semantic-index.env
|
||||||
|
ExecStart=/bin/bash -lc 'exec /opt/semantic-index/semantic_index/refresh.sh --apply'
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Redmine Semantic Index Rolling Refresh
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10min
|
||||||
|
OnUnitActiveSec=30min
|
||||||
|
Unit=semantic-index-refresh.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Copy to /etc/semantic-index.env and fill secrets on the target host.
|
||||||
|
# Do not commit real values.
|
||||||
|
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
QDRANT_URL=http://qdrant-host:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=redmine_semantic_sample
|
||||||
|
|
||||||
|
REDMINE_URL=http://redmine-host
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT_IDENTIFIER=
|
||||||
|
REDMINE_SAMPLE_LIMIT=500
|
||||||
|
|
||||||
|
SEMANTIC_INDEX_HOST=127.0.0.1
|
||||||
|
SEMANTIC_INDEX_PORT=8787
|
||||||
|
SEMANTIC_INDEX_API_KEY=
|
||||||
|
SEMANTIC_INDEX_REFRESH_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS=customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redmine Semantic Index HTTP API
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/semantic-index
|
||||||
|
EnvironmentFile=/etc/semantic-index.env
|
||||||
|
ExecStart=/bin/bash -lc 'exec /opt/semantic-index/.venv/bin/uvicorn semantic_index.app:app --host "${SEMANTIC_INDEX_HOST}" --port "${SEMANTIC_INDEX_PORT}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+449
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
REPO_ROOT="$SCRIPT_DIR"
|
||||||
|
REDMINE_ROOT="/usr/share/redmine"
|
||||||
|
BACKUP_ROOT="/root/redmine-plugin-backups"
|
||||||
|
|
||||||
|
REDMINE_URL="${REDMINE_URL:-}"
|
||||||
|
REDMINE_API_KEY="${REDMINE_API_KEY:-${REDMNINE_API_KEY:-}}"
|
||||||
|
HELPDESK_ISSUE_ID="${HELPDESK_ISSUE_ID:-}"
|
||||||
|
NON_HELPDESK_ISSUE_ID="${NON_HELPDESK_ISSUE_ID:-}"
|
||||||
|
|
||||||
|
SKIP_CONTACTS=0
|
||||||
|
SKIP_HELPDESK=0
|
||||||
|
SKIP_OUTBOX=0
|
||||||
|
|
||||||
|
ACTION="deploy"
|
||||||
|
ROLLBACK_DIR=""
|
||||||
|
APPLY=0
|
||||||
|
APPLY_SET=0
|
||||||
|
PRINT_CHANGE_MAP=0
|
||||||
|
|
||||||
|
PLUGINS=(redmine_contacts redmine_contacts_helpdesk redmine_event_outbox)
|
||||||
|
SELECTED_PLUGINS=()
|
||||||
|
BACKUP_DIR=""
|
||||||
|
ROLLBACK_RUNNING=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./deploy_redmine_prod_patches.sh [options]
|
||||||
|
|
||||||
|
Defaults to dry-run deploy mode.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
--apply Execute actions (default is dry-run)
|
||||||
|
--dry-run Print actions only
|
||||||
|
--rollback <backup_dir> Restore plugins from a prior backup directory
|
||||||
|
|
||||||
|
Core options:
|
||||||
|
--repo-root <path> Local repo root (default: script directory)
|
||||||
|
--redmine-root <path> Production Redmine root (default: /usr/share/redmine)
|
||||||
|
--backup-root <path> Backup root for deploy mode (default: /root/redmine-plugin-backups)
|
||||||
|
|
||||||
|
Verification options:
|
||||||
|
--redmine-url <url> Redmine base URL for API checks
|
||||||
|
--api-key <key> Redmine API key for API checks
|
||||||
|
--helpdesk-issue-id <id> Known Helpdesk issue id for include=helpdesk verification
|
||||||
|
--non-helpdesk-issue-id <id> Known non-Helpdesk issue id for include=helpdesk verification
|
||||||
|
|
||||||
|
Selection options:
|
||||||
|
--skip-contacts
|
||||||
|
--skip-helpdesk
|
||||||
|
--skip-outbox
|
||||||
|
|
||||||
|
Informational:
|
||||||
|
--print-change-map Show patch groups and key files, then exit
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./deploy_redmine_prod_patches.sh --dry-run
|
||||||
|
./deploy_redmine_prod_patches.sh --apply --redmine-url https://redmine.example.com --api-key ... --helpdesk-issue-id 39779 --non-helpdesk-issue-id 12345
|
||||||
|
./deploy_redmine_prod_patches.sh --rollback /root/redmine-plugin-backups/prod-plugin-rollout-20260506T120000Z
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
print_change_map() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Patch Groups -> Key Files
|
||||||
|
|
||||||
|
1) Helpdesk API include patch (semantic-index dependency)
|
||||||
|
- plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
|
||||||
|
2) Helpdesk search routes/controller in local helpdesk fork
|
||||||
|
- plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb
|
||||||
|
- plugins/redmine_contacts_helpdesk/config/routes.rb
|
||||||
|
- plugins/redmine_contacts_helpdesk/init.rb
|
||||||
|
|
||||||
|
3) POP3 compatibility fix in contacts fork
|
||||||
|
- plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb
|
||||||
|
|
||||||
|
4) Event outbox + Helpdesk-related hooks
|
||||||
|
- plugins/redmine_event_outbox/db/migrate/001_create_event_outbox_events.rb
|
||||||
|
- plugins/redmine_event_outbox/lib/redmine_event_outbox.rb
|
||||||
|
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/helpdesk_ticket_patch.rb
|
||||||
|
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/journal_message_patch.rb
|
||||||
|
|
||||||
|
Operational note:
|
||||||
|
This script deploys full plugin directories to match the post-import workflow:
|
||||||
|
- plugins/redmine_contacts/
|
||||||
|
- plugins/redmine_contacts_helpdesk/
|
||||||
|
- plugins/redmine_event_outbox/
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_string() {
|
||||||
|
local out=""
|
||||||
|
local arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
out+=" $(printf '%q' "$arg")"
|
||||||
|
done
|
||||||
|
printf '%s' "${out# }"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_cmd() {
|
||||||
|
log "+ $(cmd_string "$@")"
|
||||||
|
if [[ "$APPLY" -eq 1 ]]; then
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_path() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || die "missing required path: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -d "$path" ]] || die "missing required directory: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_selected_plugins() {
|
||||||
|
SELECTED_PLUGINS=()
|
||||||
|
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
|
||||||
|
SELECTED_PLUGINS+=("redmine_contacts")
|
||||||
|
fi
|
||||||
|
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
|
||||||
|
SELECTED_PLUGINS+=("redmine_contacts_helpdesk")
|
||||||
|
fi
|
||||||
|
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
|
||||||
|
SELECTED_PLUGINS+=("redmine_event_outbox")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#SELECTED_PLUGINS[@]}" -eq 0 ]]; then
|
||||||
|
die "nothing selected; remove skip flags or pick at least one plugin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_plugin() {
|
||||||
|
local plugin="$1"
|
||||||
|
local src="$REDMINE_ROOT/plugins/$plugin"
|
||||||
|
local dst="$BACKUP_DIR/$plugin"
|
||||||
|
local absent_marker="$BACKUP_DIR/.${plugin}.absent"
|
||||||
|
|
||||||
|
if [[ -d "$src" ]]; then
|
||||||
|
run_cmd mkdir -p "$dst"
|
||||||
|
run_cmd rsync -a "$src/" "$dst/"
|
||||||
|
else
|
||||||
|
run_cmd mkdir -p "$BACKUP_DIR"
|
||||||
|
run_cmd touch "$absent_marker"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_plugin() {
|
||||||
|
local plugin="$1"
|
||||||
|
local src="$ROLLBACK_DIR/$plugin"
|
||||||
|
local dst="$REDMINE_ROOT/plugins/$plugin"
|
||||||
|
local absent_marker="$ROLLBACK_DIR/.${plugin}.absent"
|
||||||
|
|
||||||
|
if [[ -f "$absent_marker" ]]; then
|
||||||
|
run_cmd rm -rf "$dst"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_dir "$src"
|
||||||
|
run_cmd mkdir -p "$dst"
|
||||||
|
run_cmd rsync -a --delete "$src/" "$dst/"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_plugin() {
|
||||||
|
local plugin="$1"
|
||||||
|
local src="$REPO_ROOT/plugins/$plugin"
|
||||||
|
local dst="$REDMINE_ROOT/plugins/$plugin"
|
||||||
|
|
||||||
|
require_dir "$src"
|
||||||
|
run_cmd mkdir -p "$dst"
|
||||||
|
run_cmd rsync -a --delete "$src/" "$dst/"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_contacts_syntax() {
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_helpdesk_syntax() {
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb"
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb"
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb"
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_outbox_syntax() {
|
||||||
|
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_event_outbox/lib/redmine_event_outbox.rb"
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate_plugins() {
|
||||||
|
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec rake redmine:plugins:migrate"
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_passenger() {
|
||||||
|
run_cmd touch "$REDMINE_ROOT/tmp/restart.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_outbox_table() {
|
||||||
|
if [[ "$SKIP_OUTBOX" -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec ruby -e \"require './config/environment'; abort('missing event_outbox_events') unless ActiveRecord::Base.connection.table_exists?(:event_outbox_events); puts 'OK event_outbox_events'\""
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_helpdesk_api() {
|
||||||
|
if [[ "$SKIP_HELPDESK" -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REDMINE_URL" || -z "$REDMINE_API_KEY" ]]; then
|
||||||
|
log "Skipping API verification (set --redmine-url and --api-key to enable)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp1 tmp2
|
||||||
|
tmp1="$(mktemp)"
|
||||||
|
tmp2="$(mktemp)"
|
||||||
|
|
||||||
|
if [[ -n "$HELPDESK_ISSUE_ID" ]]; then
|
||||||
|
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${HELPDESK_ISSUE_ID}.json?include=journals,helpdesk" -o "$tmp1"
|
||||||
|
run_cmd grep -q '"helpdesk_ticket"' "$tmp1"
|
||||||
|
else
|
||||||
|
log "Skipping Helpdesk issue include check (set --helpdesk-issue-id)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$NON_HELPDESK_ISSUE_ID" ]]; then
|
||||||
|
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${NON_HELPDESK_ISSUE_ID}.json?include=helpdesk" -o "$tmp2"
|
||||||
|
run_cmd grep -q '"issue"' "$tmp2"
|
||||||
|
else
|
||||||
|
log "Skipping non-Helpdesk include check (set --non-helpdesk-issue-id)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APPLY" -eq 1 ]]; then
|
||||||
|
rm -f "$tmp1" "$tmp2"
|
||||||
|
else
|
||||||
|
log "Temporary API response files (dry-run placeholders): $tmp1 $tmp2"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_selected_plugins() {
|
||||||
|
local plugin
|
||||||
|
for plugin in "${SELECTED_PLUGINS[@]}"; do
|
||||||
|
log "Restoring plugin: $plugin"
|
||||||
|
restore_plugin "$plugin"
|
||||||
|
done
|
||||||
|
restart_passenger
|
||||||
|
}
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
local line="$1"
|
||||||
|
local rc="$2"
|
||||||
|
if [[ "$ROLLBACK_RUNNING" -eq 1 ]]; then
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
log "Failure at line ${line} (exit ${rc})"
|
||||||
|
if [[ "$ACTION" == "deploy" && "$APPLY" -eq 1 && -n "$BACKUP_DIR" ]]; then
|
||||||
|
ROLLBACK_RUNNING=1
|
||||||
|
ROLLBACK_DIR="$BACKUP_DIR"
|
||||||
|
log "Attempting automatic rollback from: $ROLLBACK_DIR"
|
||||||
|
rollback_selected_plugins || true
|
||||||
|
log "Automatic rollback finished"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--apply)
|
||||||
|
APPLY=1
|
||||||
|
APPLY_SET=1
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
APPLY=0
|
||||||
|
APPLY_SET=1
|
||||||
|
;;
|
||||||
|
--rollback)
|
||||||
|
ACTION="rollback"
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--rollback requires a backup directory"
|
||||||
|
ROLLBACK_DIR="$1"
|
||||||
|
;;
|
||||||
|
--repo-root)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--repo-root requires a path"
|
||||||
|
REPO_ROOT="$1"
|
||||||
|
;;
|
||||||
|
--redmine-root)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--redmine-root requires a path"
|
||||||
|
REDMINE_ROOT="$1"
|
||||||
|
;;
|
||||||
|
--backup-root)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--backup-root requires a path"
|
||||||
|
BACKUP_ROOT="$1"
|
||||||
|
;;
|
||||||
|
--redmine-url)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--redmine-url requires a value"
|
||||||
|
REDMINE_URL="$1"
|
||||||
|
;;
|
||||||
|
--api-key)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--api-key requires a value"
|
||||||
|
REDMINE_API_KEY="$1"
|
||||||
|
;;
|
||||||
|
--helpdesk-issue-id)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--helpdesk-issue-id requires a value"
|
||||||
|
HELPDESK_ISSUE_ID="$1"
|
||||||
|
;;
|
||||||
|
--non-helpdesk-issue-id)
|
||||||
|
shift
|
||||||
|
[[ "$#" -gt 0 ]] || die "--non-helpdesk-issue-id requires a value"
|
||||||
|
NON_HELPDESK_ISSUE_ID="$1"
|
||||||
|
;;
|
||||||
|
--skip-contacts)
|
||||||
|
SKIP_CONTACTS=1
|
||||||
|
;;
|
||||||
|
--skip-helpdesk)
|
||||||
|
SKIP_HELPDESK=1
|
||||||
|
;;
|
||||||
|
--skip-outbox)
|
||||||
|
SKIP_OUTBOX=1
|
||||||
|
;;
|
||||||
|
--print-change-map)
|
||||||
|
PRINT_CHANGE_MAP=1
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
if [[ "$PRINT_CHANGE_MAP" -eq 1 ]]; then
|
||||||
|
print_change_map
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ACTION" == "rollback" && "$APPLY_SET" -eq 0 ]]; then
|
||||||
|
APPLY=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APPLY" -eq 1 && "$EUID" -ne 0 ]]; then
|
||||||
|
die "--apply and --rollback apply mode require root"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_dir "$REDMINE_ROOT"
|
||||||
|
require_dir "$REDMINE_ROOT/plugins"
|
||||||
|
require_path "$REDMINE_ROOT/tmp"
|
||||||
|
|
||||||
|
build_selected_plugins
|
||||||
|
trap 'on_error ${LINENO} $?' ERR
|
||||||
|
|
||||||
|
if [[ "$ACTION" == "rollback" ]]; then
|
||||||
|
require_dir "$ROLLBACK_DIR"
|
||||||
|
log "Mode: rollback ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
|
||||||
|
log "Redmine root: $REDMINE_ROOT"
|
||||||
|
log "Backup source: $ROLLBACK_DIR"
|
||||||
|
rollback_selected_plugins
|
||||||
|
trap - ERR
|
||||||
|
log "Rollback completed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_dir "$REPO_ROOT"
|
||||||
|
require_dir "$REPO_ROOT/plugins"
|
||||||
|
|
||||||
|
BACKUP_DIR="$BACKUP_ROOT/prod-plugin-rollout-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
|
||||||
|
log "Mode: deploy ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
|
||||||
|
log "Repo root: $REPO_ROOT"
|
||||||
|
log "Redmine root: $REDMINE_ROOT"
|
||||||
|
log "Backup dir: $BACKUP_DIR"
|
||||||
|
log "Selected plugins: ${SELECTED_PLUGINS[*]}"
|
||||||
|
|
||||||
|
run_cmd mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
local plugin
|
||||||
|
for plugin in "${SELECTED_PLUGINS[@]}"; do
|
||||||
|
log "Backing up plugin: $plugin"
|
||||||
|
backup_plugin "$plugin"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
|
||||||
|
log "Deploying plugin: redmine_contacts"
|
||||||
|
deploy_plugin "redmine_contacts"
|
||||||
|
log "Verifying syntax: redmine_contacts"
|
||||||
|
verify_contacts_syntax
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
|
||||||
|
log "Deploying plugin: redmine_contacts_helpdesk"
|
||||||
|
deploy_plugin "redmine_contacts_helpdesk"
|
||||||
|
log "Verifying syntax: redmine_contacts_helpdesk"
|
||||||
|
verify_helpdesk_syntax
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
|
||||||
|
log "Deploying plugin: redmine_event_outbox"
|
||||||
|
deploy_plugin "redmine_event_outbox"
|
||||||
|
log "Verifying syntax: redmine_event_outbox"
|
||||||
|
verify_outbox_syntax
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Running plugin migrations"
|
||||||
|
migrate_plugins
|
||||||
|
|
||||||
|
log "Restarting Passenger"
|
||||||
|
restart_passenger
|
||||||
|
|
||||||
|
log "Running runtime verifications"
|
||||||
|
verify_outbox_table
|
||||||
|
verify_helpdesk_api
|
||||||
|
|
||||||
|
trap - ERR
|
||||||
|
log "Deploy completed successfully"
|
||||||
|
log "Rollback command: $(cmd_string "$0" --rollback "$BACKUP_DIR")"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
## Distribution Files
|
||||||
|
|
||||||
|
This folder contains packaged copies of the plugins and scripts built by this project for easier distribution.
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# redmine_contacts_helpdesk 3.0.9 Helpdesk Issue API Local Patch
|
||||||
|
|
||||||
|
- Patch set: `redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z`
|
||||||
|
- Created: `2026-04-25T09:42:36Z`
|
||||||
|
- Purpose: production install manifest for the local `include=helpdesk` issue
|
||||||
|
API extension.
|
||||||
|
|
||||||
|
## Files To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
`GET /issues/:id.json?include=journals,helpdesk` keeps the normal Redmine issue
|
||||||
|
API response and adds Helpdesk ticket/contact metadata when the issue is also a
|
||||||
|
Helpdesk ticket. Ordinary issues must continue to respond successfully.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Local checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
```
|
||||||
|
|
||||||
|
LAN validation on `192.168.50.170` passed on 2026-04-25:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/issues/39779.json?include=journals,helpdesk
|
||||||
|
helpdesk_ticket.contact.id = 1890
|
||||||
|
helpdesk_ticket.contact.name = Callum Mackeonis
|
||||||
|
helpdesk_ticket.contact.email = callum@safetagtracking.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Production install and rollback details are documented in
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md`.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Semantic Index V1 Pre-Deployment Manifest
|
||||||
|
|
||||||
|
- Patch set: `semantic-index-v1-predeployment-20260425T150000Z`
|
||||||
|
- Created: `2026-04-25T15:00:00Z`
|
||||||
|
- Purpose: deployment manifest for the Redmine semantic index service and its
|
||||||
|
LAN/production preparation docs.
|
||||||
|
|
||||||
|
## Files To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
semantic_index/
|
||||||
|
tests/semantic_index/
|
||||||
|
deploy/semantic-index/
|
||||||
|
docs/semantic_index_deployment_runbook.md
|
||||||
|
docs/semantic_index_production_notes.md
|
||||||
|
docs/semantic_index_predeployment_validation.md
|
||||||
|
docs/redmine_issue_api_helpdesk_include.md
|
||||||
|
dist/semantic-index-v1-predeployment-20260425T150000Z.MANIFEST.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Not To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
semantic_index/.env
|
||||||
|
.cache/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep runtime secrets in `semantic_index/.env` or in the service manager
|
||||||
|
environment on the target host. Do not commit or copy local secrets into a
|
||||||
|
source bundle.
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
- Redmine Helpdesk API patch documented in
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md`
|
||||||
|
- Qdrant reachable through `QDRANT_URL`
|
||||||
|
- OpenAI API key for `text-embedding-3-small`
|
||||||
|
- Python packages: `openai`, `qdrant-client`, `fastapi`, `uvicorn`
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Before any production backfill, follow
|
||||||
|
`docs/semantic_index_deployment_runbook.md` and confirm Qdrant snapshot or
|
||||||
|
volume rollback is available.
|
||||||
|
|
||||||
|
## Operational Rules
|
||||||
|
|
||||||
|
- Run `semantic_index/refresh.sh` in dry-run mode before `--apply`.
|
||||||
|
- Do not schedule `--force-rebuild`; keep it manual-only.
|
||||||
|
- Review refresh logs for `detail_fetched_issues`, `would_embed_documents`, and
|
||||||
|
`embedded_documents`.
|
||||||
|
- Bind HTTP to localhost unless LAN access is explicitly required and protected
|
||||||
|
with `SEMANTIC_INDEX_API_KEY`.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Redmine Issue API Helpdesk Include Patch
|
||||||
|
|
||||||
|
This repository carries a local RedmineUP Helpdesk API extension so external
|
||||||
|
indexers can keep Redmine issues as the canonical object while still seeing
|
||||||
|
Helpdesk customer metadata.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
`GET /issues/:id.json?include=journals,helpdesk` returns the normal Redmine
|
||||||
|
issue API payload. When the issue also has a Helpdesk ticket, the issue object
|
||||||
|
includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"message_id": "...",
|
||||||
|
"source": 0,
|
||||||
|
"is_incoming": true,
|
||||||
|
"from_address": "customer@example.com",
|
||||||
|
"to_address": "contact@ldrprep.com",
|
||||||
|
"cc_address": "",
|
||||||
|
"ticket_date": "2026-04-14T10:18:38Z",
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Customer Name",
|
||||||
|
"company": "Customer Company",
|
||||||
|
"email": "customer@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For ordinary non-Helpdesk issues, callers must tolerate either
|
||||||
|
`"helpdesk_ticket": null` or an omitted `helpdesk_ticket` field. The semantic
|
||||||
|
indexer treats both as ordinary issue data with no Helpdesk contact metadata.
|
||||||
|
|
||||||
|
## Patch Locations
|
||||||
|
|
||||||
|
- `plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb`
|
||||||
|
overrides Redmine 3.4.4's issue API view and adds the optional
|
||||||
|
`include=helpdesk` block.
|
||||||
|
- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb`
|
||||||
|
serializes Helpdesk ticket/contact fields without changing controller logic.
|
||||||
|
- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb`
|
||||||
|
loads the serializer during plugin preparation.
|
||||||
|
|
||||||
|
## Production Install Checklist
|
||||||
|
|
||||||
|
Install these files into the production Redmine tree, preserving paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Before copying, create a production rollback directory and preserve any existing
|
||||||
|
versions of those files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
backup="$HOME/redmine-plugin-backups/helpdesk-issue-api-include-$stamp"
|
||||||
|
mkdir -p "$backup"
|
||||||
|
cd /usr/share/redmine
|
||||||
|
for path in \
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb \
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb \
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb \
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
do
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
mkdir -p "$backup/$(dirname "$path")"
|
||||||
|
cp -a "$path" "$backup/$path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf 'backup=%s\n' "$backup"
|
||||||
|
```
|
||||||
|
|
||||||
|
After copying, run syntax checks on the production host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /usr/share/redmine
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload Passenger:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /usr/share/redmine
|
||||||
|
touch tmp/restart.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then validate one known Helpdesk issue and one ordinary issue:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"$REDMINE_URL/issues/39779.json?include=journals,helpdesk" | jq '.issue.helpdesk_ticket'
|
||||||
|
|
||||||
|
curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"$REDMINE_URL/issues/<non_helpdesk_issue_id>.json?include=helpdesk" | jq '.issue.helpdesk_ticket'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Helpdesk issue must include a ticket object with `contact.id`,
|
||||||
|
`contact.name`, and `contact.email`. The non-Helpdesk issue should not error;
|
||||||
|
`helpdesk_ticket` may be `null` or absent.
|
||||||
|
|
||||||
|
Rollback is copying the backup files over the deployed files and touching
|
||||||
|
`tmp/restart.txt` again. If a file did not exist before deployment, remove it
|
||||||
|
during rollback.
|
||||||
|
|
||||||
|
## Reapplying After Redmine Core Upgrade
|
||||||
|
|
||||||
|
1. Compare the upgraded Redmine `app/views/issues/show.api.rsb` against this
|
||||||
|
repository's override.
|
||||||
|
2. Copy any new upstream issue API fields into the plugin override.
|
||||||
|
3. Keep the `include_in_api_response?('helpdesk')` block after the issue
|
||||||
|
timestamps and before optional child/attachment/relation/journal sections.
|
||||||
|
4. Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
5. On the LAN test instance, confirm:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"http://192.168.50.170/issues/39779.json?include=journals,helpdesk"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response should include `helpdesk_ticket.contact.id`,
|
||||||
|
`helpdesk_ticket.contact.name`, and `helpdesk_ticket.contact.email` for a
|
||||||
|
known Helpdesk issue.
|
||||||
|
|
||||||
|
## LAN Test Result
|
||||||
|
|
||||||
|
On 2026-04-25, the patch was deployed to the LAN Redmine copy at
|
||||||
|
`192.168.50.170`.
|
||||||
|
|
||||||
|
- Remote backup:
|
||||||
|
`/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z`
|
||||||
|
- Syntax checks passed on the LAN host for the loader, serializer, and API view.
|
||||||
|
- `GET /issues/39779.json?include=journals,helpdesk` returned contact
|
||||||
|
`#1890 Callum Mackeonis <callum@safetagtracking.com>`.
|
||||||
|
- `semantic_index inspect preview-redmine --limit 3 --project customer-service`
|
||||||
|
showed contact metadata on issue, journal, and contact chunks before Qdrant
|
||||||
|
rebuild.
|
||||||
@@ -32,32 +32,102 @@ environment. Before risky edits, archive the current plugin directories in
|
|||||||
- Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
|
- Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
|
||||||
then choose the external index target.
|
then choose the external index target.
|
||||||
|
|
||||||
## 2026-04-25 - redMCP Native Search And Filtering
|
## 2026-05-06 - Production Plugin Rollout Via Unified Script
|
||||||
|
|
||||||
|
- Touched areas:
|
||||||
|
- `plugins/redmine_contacts`
|
||||||
|
- `plugins/redmine_contacts_helpdesk`
|
||||||
|
- `plugins/redmine_event_outbox`
|
||||||
|
- `deploy_redmine_prod_patches.sh`
|
||||||
|
- Purpose:
|
||||||
|
- Apply the same plugin payload shape used by test-host post-import automation
|
||||||
|
to production in a controlled, repeatable sequence.
|
||||||
|
- Keep rollback simple by backing up plugin directories before each apply run.
|
||||||
|
- Behavior/deployment tooling:
|
||||||
|
- Added and used `deploy_redmine_prod_patches.sh` with one-command dry-run,
|
||||||
|
apply, and rollback modes.
|
||||||
|
- Deployment package assembled as
|
||||||
|
`dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz` for upload and
|
||||||
|
unpack on production.
|
||||||
|
- Script-level checks include Ruby syntax checks per plugin, plugin migration,
|
||||||
|
Passenger restart, and optional API verification for
|
||||||
|
`include=journals,helpdesk` behavior.
|
||||||
|
- Production result:
|
||||||
|
- Production rollout completed and reported working after deploy.
|
||||||
|
- This confirms the production host now has the local plugin fork updates in
|
||||||
|
place through the scripted deployment path.
|
||||||
|
|
||||||
|
## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations
|
||||||
|
|
||||||
- Touched areas:
|
- Touched areas:
|
||||||
- `redMCP`
|
- `redMCP`
|
||||||
- Purpose:
|
- Purpose:
|
||||||
- Make Redmine's existing issue filtering and built-in text search explicit
|
- Make Redmine's existing issue filtering and built-in text search explicit
|
||||||
before adding external search infrastructure.
|
before adding external search infrastructure.
|
||||||
- Make redMCP runnable as a stdio MCP server for live client testing.
|
- Make redMCP runnable as an MCP server for live client testing.
|
||||||
|
- Make the network MCP server easier to debug and restart during local tests.
|
||||||
- Behavior changed:
|
- Behavior changed:
|
||||||
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
||||||
filtering.
|
filtering.
|
||||||
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
||||||
- Added `searchIssues()` for issue-only Redmine text search.
|
- Added `searchIssues()` for issue-only Redmine text search.
|
||||||
- Added `redMCP/bin/redmcp-server.php`, a dependency-light stdio MCP server
|
- Added `projects()`, `listProjects()`, and `project()` for Redmine's
|
||||||
that exposes Redmine filtering/search, issue CRUD, Helpdesk-aware reads, and
|
`/projects.json` APIs.
|
||||||
explicit Helpdesk response tools.
|
- Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for
|
||||||
- Registered the MCP server as a Composer `bin` entry.
|
Redmine's user and membership APIs.
|
||||||
|
- Added `ListQueryNormalizer` so MCP list tools accept friendly paging,
|
||||||
|
sorting, status, and date options while preserving raw Redmine
|
||||||
|
`filters`/`params` overrides.
|
||||||
|
- Added `redMCP/bin/test-query-normalizer.php` for no-network checks of
|
||||||
|
Redmine query parameter normalization.
|
||||||
|
- Added a shared MCP dispatcher and transport-specific server wrappers.
|
||||||
|
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
|
||||||
|
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected
|
||||||
|
Streamable HTTP network clients on `/mcp`.
|
||||||
|
- Added PID/status/stop handling to the HTTP server.
|
||||||
|
- Added optional full-argument JSONL debug logging via `--debug-log` or
|
||||||
|
`MCP_DEBUG_LOG`.
|
||||||
|
- Added recursive credential redaction for MCP tool output and debug logs.
|
||||||
|
- Added `redMCP/bin/generate-bearer-token.php`.
|
||||||
|
- Both transports expose Redmine project reads, users, project memberships,
|
||||||
|
filtering/search, issue CRUD, Helpdesk-aware reads, and explicit Helpdesk
|
||||||
|
response tools.
|
||||||
|
- Registered all MCP helper commands as Composer `bin` entries.
|
||||||
- LAN test result:
|
- LAN test result:
|
||||||
- `php -l redMCP/app/RedmineClient.php` passed.
|
- `php -l redMCP/app/RedmineClient.php` passed.
|
||||||
- `php -l redMCP/bin/redmcp-server.php` passed.
|
- `php -l redMCP/bin/redmcp-server.php` passed.
|
||||||
|
- `php -l redMCP/bin/redmcp-http-server.php` passed.
|
||||||
|
- `php -l redMCP/bin/generate-bearer-token.php` passed.
|
||||||
- `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5
|
- `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5
|
||||||
deprecation notices from system Composer dependencies.
|
deprecation notices from system Composer dependencies.
|
||||||
- Live stdio MCP framing test passed for `initialize`, `tools/list`, and
|
- Live stdio MCP framing test passed for `initialize`, `tools/list`, and
|
||||||
`tools/call` using `redmine_search_issues` against `fud-helpdesk`.
|
`tools/call` using `redmine_search_issues` against `fud-helpdesk`.
|
||||||
- The live MCP tool call returned two issue search results from seven total
|
- Live Streamable HTTP test passed for authenticated `initialize`,
|
||||||
for `redMCP-smoke`.
|
`tools/list`, and `tools/call` using `redmine_search_issues`.
|
||||||
|
- `redmcp-http-server.php` refused to start without `MCP_SERVER_TOKEN`.
|
||||||
|
- Unauthenticated `/mcp` returned `401`; wrong path returned `404`.
|
||||||
|
- HTTP PID helpers reported stopped/running states, rejected a duplicate
|
||||||
|
start, stopped the live process, detected a stale PID file, and started
|
||||||
|
with `--force`.
|
||||||
|
- Live Streamable HTTP tests passed for `redmine_list_users`,
|
||||||
|
`redmine_get_user`, and `redmine_list_project_memberships`.
|
||||||
|
- `redmine_get_user` redacted the returned Redmine `api_key` field.
|
||||||
|
- `redmine_list_project_memberships` returned direct and inherited
|
||||||
|
memberships for `customer-service`; `fud-helpdesk` returned a valid empty
|
||||||
|
membership list.
|
||||||
|
- `php redMCP/bin/test-query-normalizer.php` passed with coverage for paging,
|
||||||
|
sort shortcuts, status aliases, date presets/ranges, free-text dates, and
|
||||||
|
raw override precedence.
|
||||||
|
- Live Streamable HTTP tests passed for friendly `redmine_list_issues`,
|
||||||
|
`redmine_search_issues`, `redmine_list_users`, `redmine_list_projects`, and
|
||||||
|
`redmine_list_project_memberships` arguments.
|
||||||
|
- Debug logging wrote JSONL records with full project-tool arguments and did
|
||||||
|
not include the bearer token, `Authorization`, or Redmine API key.
|
||||||
|
- Token generation passed default, `--bytes 48`, and `--env-line` modes.
|
||||||
|
- `redmine_list_projects` returned three projects from 117 total.
|
||||||
|
- `redmine_get_project` returned `fud-helpdesk` by identifier and by id 117.
|
||||||
|
- The live MCP tool calls returned issue search results from seven total for
|
||||||
|
`redMCP-smoke`.
|
||||||
|
|
||||||
## 2026-04-25 - Test Helpdesk Credential Sanitization
|
## 2026-04-25 - Test Helpdesk Credential Sanitization
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Semantic Index Deployment Runbook
|
||||||
|
|
||||||
|
This runbook captures the current deployment shape for the Redmine semantic
|
||||||
|
index. It is written for the LAN test server first, with the same steps intended
|
||||||
|
to carry forward to production after paths and secrets are adjusted.
|
||||||
|
The latest LAN validation record is in
|
||||||
|
`docs/semantic_index_predeployment_validation.md`.
|
||||||
|
|
||||||
|
## Deployable Files
|
||||||
|
|
||||||
|
Copy or update these tracked paths together:
|
||||||
|
|
||||||
|
- `semantic_index/`
|
||||||
|
- `tests/semantic_index/`
|
||||||
|
- `deploy/semantic-index/`
|
||||||
|
- `docs/semantic_index_production_notes.md`
|
||||||
|
- `docs/semantic_index_deployment_runbook.md`
|
||||||
|
- `docs/semantic_index_predeployment_validation.md`
|
||||||
|
- `docs/redmine_issue_api_helpdesk_include.md`
|
||||||
|
|
||||||
|
The Helpdesk contact metadata dependency is the Redmine plugin API patch
|
||||||
|
documented in `docs/redmine_issue_api_helpdesk_include.md`. Deploy that plugin
|
||||||
|
patch before expecting Helpdesk contact fields in indexed results.
|
||||||
|
|
||||||
|
Do not copy local-only runtime files:
|
||||||
|
|
||||||
|
- `semantic_index/.env`
|
||||||
|
- `.cache/`
|
||||||
|
- `.venv/`
|
||||||
|
- `__pycache__/`
|
||||||
|
- Qdrant storage snapshots or rollback tarballs unless deliberately restoring
|
||||||
|
|
||||||
|
## Runtime Prerequisites
|
||||||
|
|
||||||
|
Python runtime dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install openai qdrant-client fastapi uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Qdrant is expected to run on the larger host and be reachable from the semantic
|
||||||
|
index host through `QDRANT_URL`. The current collection default is
|
||||||
|
`redmine_semantic_sample`.
|
||||||
|
|
||||||
|
Qdrant Docker example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -p 6333:6333 -p 6334:6334 \
|
||||||
|
-v qdrant_storage:/qdrant/storage \
|
||||||
|
qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Before destructive maintenance, create a Qdrant snapshot or preserve the Docker
|
||||||
|
volume.
|
||||||
|
|
||||||
|
## WireGuard Topology
|
||||||
|
|
||||||
|
Current WireGuard endpoints:
|
||||||
|
|
||||||
|
- production server: `10.11.0.100`
|
||||||
|
- LAN server: `10.11.0.105`
|
||||||
|
|
||||||
|
When Qdrant is hosted on the LAN server, keep it reachable on the WireGuard
|
||||||
|
address and point production semantic-index traffic at:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
QDRANT_URL=http://10.11.0.105:6333
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not bind production-hosted Qdrant to `10.11.0.105`; that address belongs to
|
||||||
|
the LAN host.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
For a production-style install, use:
|
||||||
|
|
||||||
|
- code: `/opt/semantic-index`
|
||||||
|
- environment file: `/etc/semantic-index.env`
|
||||||
|
- refresh state: `/var/lib/semantic-index/refresh_state.json`
|
||||||
|
- refresh logs: `/var/log/semantic-index`
|
||||||
|
|
||||||
|
Create `/etc/semantic-index.env` from
|
||||||
|
`deploy/semantic-index/semantic-index.env.example` and fill secrets on the
|
||||||
|
target host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
QDRANT_URL=http://10.11.0.105:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=redmine_semantic_sample
|
||||||
|
REDMINE_URL=http://redmine-host
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT_IDENTIFIER=
|
||||||
|
REDMINE_SAMPLE_LIMIT=500
|
||||||
|
SEMANTIC_INDEX_HOST=127.0.0.1
|
||||||
|
SEMANTIC_INDEX_PORT=8787
|
||||||
|
SEMANTIC_INDEX_API_KEY=
|
||||||
|
SEMANTIC_INDEX_REFRESH_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended production-style refresh overrides:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100'
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `SEMANTIC_INDEX_API_KEY` set when binding outside localhost. Do not commit
|
||||||
|
API keys or `.env` files.
|
||||||
|
|
||||||
|
## Systemd Templates
|
||||||
|
|
||||||
|
Templates live in `deploy/semantic-index/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install.sh
|
||||||
|
semantic-index.service
|
||||||
|
semantic-index-refresh.service
|
||||||
|
semantic-index-refresh.timer
|
||||||
|
semantic-index.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the installer first. It defaults to dry-run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally start only the HTTP service after installing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh --apply --start
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer creates `/opt/semantic-index`, `/var/lib/semantic-index`, and
|
||||||
|
`/var/log/semantic-index`; copies the deploy unit; creates
|
||||||
|
`/etc/semantic-index.env` only if it does not already exist; installs systemd
|
||||||
|
unit files; and runs local validation. It does not run backfill, does not enable
|
||||||
|
the refresh timer, and never passes `--force-rebuild`.
|
||||||
|
|
||||||
|
Manual install shape, if the installer cannot be used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /opt/semantic-index /var/lib/semantic-index /var/log/semantic-index
|
||||||
|
sudo rsync -a \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude '__pycache__/' \
|
||||||
|
--exclude '*.pyc' \
|
||||||
|
semantic_index tests docs deploy dist /opt/semantic-index/
|
||||||
|
sudo cp deploy/semantic-index/semantic-index.env.example /etc/semantic-index.env
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index.service /etc/systemd/system/semantic-index.service
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index-refresh.service /etc/systemd/system/semantic-index-refresh.service
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index-refresh.timer /etc/systemd/system/semantic-index-refresh.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
After editing `/etc/semantic-index.env`, validate manually before enabling the
|
||||||
|
timer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
sudo systemctl status semantic-index.service
|
||||||
|
sudo systemctl start semantic-index-refresh.service
|
||||||
|
sudo journalctl -u semantic-index-refresh.service -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the timer only after manual dry-run and `--apply` logs look normal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl enable --now semantic-index-refresh.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Validation
|
||||||
|
|
||||||
|
Run syntax and test checks after copying code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm service startup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvicorn semantic_index.app:app --host 127.0.0.1 --port 8787
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
```
|
||||||
|
|
||||||
|
If `SEMANTIC_INDEX_API_KEY` is set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
http://127.0.0.1:8787/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Backfill
|
||||||
|
|
||||||
|
Preview Redmine mapping before writing to Qdrant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect preview-redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Backfill the current balanced sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index --backfill-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit the result:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect audit --source redmine --limit 5000
|
||||||
|
.venv/bin/python -m semantic_index inspect smoke-search --project customer-service
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected broad shape for the current LAN sample is roughly:
|
||||||
|
|
||||||
|
- Customer Service is the largest project.
|
||||||
|
- Helpdesk tickets have contact metadata.
|
||||||
|
- Internal projects may have no Helpdesk contact metadata.
|
||||||
|
- `attachments=0`.
|
||||||
|
|
||||||
|
## Routine Refresh
|
||||||
|
|
||||||
|
Use the wrapper for production-style refresh. It defaults to dry-run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Small smoke check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply refresh manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed wrappers can also be called by absolute path, for example
|
||||||
|
`/opt/semantic-index/semantic_index/refresh.sh`. The wrapper uses its own
|
||||||
|
install root as the working directory and reads defaults from
|
||||||
|
`/etc/semantic-index.env` when that file is readable.
|
||||||
|
|
||||||
|
Review the log path printed by the wrapper. For a healthy routine run after
|
||||||
|
state exists, expect:
|
||||||
|
|
||||||
|
- `scanned_issues` greater than or equal to `detail_fetched_issues`
|
||||||
|
- old issues counted under `skipped_issues`
|
||||||
|
- `would_embed_documents` and `embedded_documents` near zero when Redmine has
|
||||||
|
not changed
|
||||||
|
- no scheduled use of `--force-rebuild`
|
||||||
|
|
||||||
|
Only schedule the wrapper after manual dry-run and apply logs look normal.
|
||||||
|
|
||||||
|
Cron shape, when ready:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/30 * * * * cd /home/iadnah/redmine && semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Validation
|
||||||
|
|
||||||
|
HTTP search:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI inspection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect search "goods return" \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 3
|
||||||
|
|
||||||
|
.venv/bin/python -m semantic_index inspect list \
|
||||||
|
--source redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP stdio:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index --mcp-stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tools:
|
||||||
|
|
||||||
|
- `semantic_search`
|
||||||
|
- `semantic_get_document`
|
||||||
|
- `semantic_list_projects`
|
||||||
|
- `semantic_backfill_redmine_sample`
|
||||||
|
- `semantic_refresh_redmine`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Code rollback:
|
||||||
|
|
||||||
|
- Stop `uvicorn` or the service manager unit.
|
||||||
|
- Restore the previous `semantic_index/` code.
|
||||||
|
- Restore the previous Redmine Helpdesk plugin patch if contact metadata broke.
|
||||||
|
- Restart the service.
|
||||||
|
|
||||||
|
Index rollback options:
|
||||||
|
|
||||||
|
- Restore a Qdrant snapshot or preserved Docker volume.
|
||||||
|
- Or rebuild from Redmine with the known-good code using the multi-project
|
||||||
|
backfill command above.
|
||||||
|
|
||||||
|
Refresh rollback:
|
||||||
|
|
||||||
|
- Disable cron/systemd schedule if enabled.
|
||||||
|
- Preserve the failing log file for diagnosis.
|
||||||
|
- If the refresh state is wrong, move the state file aside rather than editing
|
||||||
|
it in place:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv .cache/semantic_index/refresh_state.json .cache/semantic_index/refresh_state.json.bad
|
||||||
|
```
|
||||||
|
|
||||||
|
The next refresh will behave like a first refresh for state purposes, while the
|
||||||
|
`source_hash` guard still prevents embedding unchanged documents.
|
||||||
|
|
||||||
|
## Production Readiness Checklist
|
||||||
|
|
||||||
|
- Redmine API key is scoped appropriately and stored outside git.
|
||||||
|
- Qdrant URL and collection are confirmed.
|
||||||
|
- Qdrant snapshot/export path is known.
|
||||||
|
- Helpdesk API patch is deployed and validated.
|
||||||
|
- HTTP service is bound only to trusted localhost/LAN as intended.
|
||||||
|
- `SEMANTIC_INDEX_API_KEY` is set for non-localhost use.
|
||||||
|
- Initial backfill audit and smoke searches pass.
|
||||||
|
- Refresh dry-run and apply logs show expected low embedding counts.
|
||||||
|
- `--force-rebuild` is documented as manual-only.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Semantic Index Pre-Deployment Validation
|
||||||
|
|
||||||
|
Validation date: `2026-04-25`
|
||||||
|
|
||||||
|
This records the current LAN pre-deployment checks for the semantic index. It
|
||||||
|
does not include secrets.
|
||||||
|
|
||||||
|
## Deploy Unit
|
||||||
|
|
||||||
|
Semantic-index deployable files are documented in:
|
||||||
|
|
||||||
|
- `dist/semantic-index-v1-predeployment-20260425T150000Z.MANIFEST.md`
|
||||||
|
- `docs/semantic_index_deployment_runbook.md`
|
||||||
|
|
||||||
|
Current known unrelated worktree changes are outside the semantic-index deploy
|
||||||
|
unit and should not be mixed into the semantic-index release package:
|
||||||
|
|
||||||
|
- `redMCP/README.md`
|
||||||
|
- `redMCP/app/McpDispatcher.php`
|
||||||
|
- `redMCP/app/RedmineClient.php`
|
||||||
|
- `redMCP/composer.json`
|
||||||
|
- `redMCP/bin/test-redmine-structure.php`
|
||||||
|
- `TODO.md`
|
||||||
|
|
||||||
|
## Local Verification
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed semantic test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ran 65 tests in 1.041s
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## LAN Redmine Preview
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect preview-redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Helpdesk issue chunks include contact id, name, email, and company metadata.
|
||||||
|
- Issue `39779` includes Callum Mackeonis and `callum@safetagtracking.com`.
|
||||||
|
- Journals are present as separate indexed documents.
|
||||||
|
- Contact documents are present as separate indexed documents.
|
||||||
|
|
||||||
|
## Qdrant Audit
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect audit --source redmine --limit 5000 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total_documents=2947
|
||||||
|
doc_type contact=714
|
||||||
|
doc_type issue=1208
|
||||||
|
doc_type journal=1025
|
||||||
|
project business-development=66
|
||||||
|
project customer-service=1684
|
||||||
|
project dock-scheduling=63
|
||||||
|
project hiring=409
|
||||||
|
project prep-standardization=25
|
||||||
|
project sales-inbox=192
|
||||||
|
project todo-jason=508
|
||||||
|
contact_metadata=2232
|
||||||
|
helpdesk_contact_metadata=2232/2232
|
||||||
|
attachments=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Validation
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unauthenticated `/projects` correctly returned unauthorized when
|
||||||
|
`SEMANTIC_INDEX_API_KEY` was configured.
|
||||||
|
|
||||||
|
Authenticated `/projects` passed and returned the expected seven projects:
|
||||||
|
|
||||||
|
```text
|
||||||
|
business-development
|
||||||
|
customer-service
|
||||||
|
dock-scheduling
|
||||||
|
hiring
|
||||||
|
prep-standardization
|
||||||
|
sales-inbox
|
||||||
|
todo-jason
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP search passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Top result was `redmine:issue:39779:chunk:0`.
|
||||||
|
- Citation included project `customer-service`.
|
||||||
|
- Citation included contact id `1890`, contact name, contact email, and Redmine
|
||||||
|
URL.
|
||||||
|
|
||||||
|
## Refresh Validation
|
||||||
|
|
||||||
|
Passed safe dry-run smoke check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mode=dry-run
|
||||||
|
issues=5
|
||||||
|
scanned_issues=5
|
||||||
|
detail_fetched_issues=0
|
||||||
|
skipped_issues=5
|
||||||
|
would_embed_documents=0
|
||||||
|
embedded_documents=0
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms the refresh state prefilter skips old issues before Redmine detail
|
||||||
|
fetch and before embedding.
|
||||||
|
|
||||||
|
## Qdrant Validation
|
||||||
|
|
||||||
|
Read-only collection check passed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
collection=redmine_semantic_sample
|
||||||
|
status=green
|
||||||
|
vector_size=1536
|
||||||
|
distance=Cosine
|
||||||
|
points_count=2947
|
||||||
|
update_queue.length=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only snapshot listing endpoint responded successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/collections/redmine_semantic_sample/snapshots
|
||||||
|
result=[]
|
||||||
|
```
|
||||||
|
|
||||||
|
No snapshot was created during this validation.
|
||||||
|
|
||||||
|
## Remaining Pre-Deployment Items
|
||||||
|
|
||||||
|
- Decide final target host paths for logs and refresh state.
|
||||||
|
- Decide service manager shape: manual `uvicorn`, systemd service, or another
|
||||||
|
supervisor.
|
||||||
|
- Create or confirm a Qdrant snapshot immediately before production backfill.
|
||||||
|
- Package only the semantic-index deploy unit, keeping unrelated `redMCP`
|
||||||
|
worktree changes out of the release.
|
||||||
|
- Keep scheduled refresh disabled until manual dry-run and `--apply` logs are
|
||||||
|
reviewed on the target host.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Semantic Index Production Notes
|
||||||
|
|
||||||
|
These notes capture the current production direction for the Redmine semantic
|
||||||
|
index. The service is still local-agent oriented, but the refresh command is now
|
||||||
|
shaped so it can later be run by cron or systemd without changing the command.
|
||||||
|
Use `docs/semantic_index_deployment_runbook.md` for the full deploy, validation,
|
||||||
|
and rollback checklist.
|
||||||
|
|
||||||
|
## Routine Refresh
|
||||||
|
|
||||||
|
Use the wrapper from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
By default this is a dry-run. It does not call OpenAI for document embeddings
|
||||||
|
and does not write to Qdrant. To apply a rolling refresh:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper writes a timestamped log under `.cache/semantic_index/logs` and uses
|
||||||
|
`.cache/semantic_index/refresh_state.json` for rolling refresh state.
|
||||||
|
|
||||||
|
## Production Overrides
|
||||||
|
|
||||||
|
Use environment variables rather than editing the script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100'
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `OPENAI_API_KEY`, `QDRANT_URL`, `REDMINE_URL`, and `REDMINE_API_KEY` in the
|
||||||
|
existing `.env` workflow or in the service manager environment.
|
||||||
|
|
||||||
|
Current WireGuard addressing used by this environment:
|
||||||
|
|
||||||
|
- production server: `10.11.0.100`
|
||||||
|
- LAN server: `10.11.0.105`
|
||||||
|
|
||||||
|
If Qdrant stays on the LAN server, set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
QDRANT_URL=http://10.11.0.105:6333
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep Qdrant bound to the WireGuard/LAN path only and protect it with
|
||||||
|
`QDRANT_API_KEY`.
|
||||||
|
|
||||||
|
From the production server, run `./validate_qdrant.py` to verify Qdrant liveness,
|
||||||
|
readiness, auth, and a minimal create/upsert/read/delete round trip.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
QDRANT_API_KEY=... ./validate_qdrant.py
|
||||||
|
./validate_qdrant.py --skip-write-test
|
||||||
|
```
|
||||||
|
|
||||||
|
For production-style deployment, use `/opt/semantic-index` for code,
|
||||||
|
`/etc/semantic-index.env` for service environment, `/var/lib/semantic-index`
|
||||||
|
for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd
|
||||||
|
templates live in `deploy/semantic-index/`.
|
||||||
|
|
||||||
|
## Embedding Cost Guard
|
||||||
|
|
||||||
|
Normal refresh embeds only documents that are new or whose Redmine-derived
|
||||||
|
`source_hash` changed. Unchanged documents are left alone. Stale indexed
|
||||||
|
documents for refreshed issues are deleted without embedding.
|
||||||
|
|
||||||
|
Do not schedule `--force-rebuild`. Use it only as a manual maintenance action
|
||||||
|
when intentionally re-embedding unchanged documents.
|
||||||
|
|
||||||
|
## Cron Shape
|
||||||
|
|
||||||
|
A later cron entry can call the same wrapper:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/30 * * * * cd /home/iadnah/redmine && semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Before adding a real schedule, run the wrapper manually and confirm the log
|
||||||
|
shows expected `embedded_documents`, `unchanged_documents`, and
|
||||||
|
`skipped_issues` counts.
|
||||||
|
|
||||||
|
For a quick wrapper smoke check, reduce the project limits:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
After refresh state exists, routine dry-runs should show old issues as
|
||||||
|
`skipped_issues` without matching `detail_fetched_issues`. That indicates the
|
||||||
|
refresh is avoiding unnecessary Redmine detail requests before it reaches the
|
||||||
|
embedding cost guard.
|
||||||
@@ -15,6 +15,92 @@ redMCP testing.
|
|||||||
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
||||||
- POP3 credentials: `test` / `testpass`
|
- POP3 credentials: `test` / `testpass`
|
||||||
- SMTP authentication: none
|
- SMTP authentication: none
|
||||||
|
- Shared scratch path: `/opt/lanscratch`
|
||||||
|
- Post-import payload path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/repo`
|
||||||
|
- Post-import status path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/status`
|
||||||
|
|
||||||
|
## Automated Daily Post-Import
|
||||||
|
|
||||||
|
Stage the post-import payload from this host into the shared LAN scratch folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The default staging mode is a dry run. Review the `rsync` command, then apply:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
After the fresh production database and `/usr/share/redmine` tree have been
|
||||||
|
copied onto the LAN test host, the test host should run the automation locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual review on the test host, omit `--apply` first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local
|
||||||
|
```
|
||||||
|
|
||||||
|
This host can check completion by reading:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest.json
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest-success.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation:
|
||||||
|
|
||||||
|
- verifies the tracked plugin source directories exist locally and that the
|
||||||
|
remote Redmine path exists;
|
||||||
|
- overlays remote dev-only files from `/home/reddev/redmine-dev-overrides` when
|
||||||
|
that directory exists;
|
||||||
|
- reapplies `redmine_event_outbox`, `redmine_contacts`, and
|
||||||
|
`redmine_contacts_helpdesk` from this repository into
|
||||||
|
`/usr/share/redmine/plugins/`;
|
||||||
|
- runs `RAILS_ENV=production bundle exec rake redmine:plugins:migrate`;
|
||||||
|
- fixes group-write permissions on attachment, `tmp`, and `log` paths;
|
||||||
|
- runs `reset_helpdesk_mail_settings.py` unless `--skip-helpdesk-reset` is
|
||||||
|
passed;
|
||||||
|
- restarts Passenger with `touch tmp/restart.txt`;
|
||||||
|
- runs `validate_test_instance.py`;
|
||||||
|
- checks outbox status and dry-runs a small outbox batch;
|
||||||
|
- runs a semantic-index dry-run smoke check only.
|
||||||
|
|
||||||
|
Each applied run writes status JSON under
|
||||||
|
`/opt/lanscratch/redmine-post-import/status/runs/`, updates `latest.json` after
|
||||||
|
each step, and updates `latest-success.json` only after every step exits
|
||||||
|
successfully. The JSON includes the run id, host, execution mode, Redmine path,
|
||||||
|
repo root, failed step when applicable, and per-command return codes.
|
||||||
|
|
||||||
|
Remote write and permission steps use `sudo` by default because a fresh
|
||||||
|
production file copy may leave `/usr/share/redmine` or attachment paths owned by
|
||||||
|
another user. This applies in both local and SSH modes. If the dev host already
|
||||||
|
gives the runner write access to those paths, pass `--no-remote-sudo`.
|
||||||
|
|
||||||
|
The older SSH orchestration path from this host remains available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./post_import_refresh.py
|
||||||
|
./post_import_refresh.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation deliberately does **not** run a semantic-index apply refresh,
|
||||||
|
does **not** use `--force-rebuild`, and does **not** enable the semantic-index
|
||||||
|
refresh timer. After a fresh database clone, treat semantic-index writes or a
|
||||||
|
Qdrant rebuild as a separate manual maintenance action with a snapshot or
|
||||||
|
isolated dev collection first.
|
||||||
|
|
||||||
|
Use the manual sections below for troubleshooting individual steps or for
|
||||||
|
running the sequence by hand.
|
||||||
|
|
||||||
## 1. Validate The Fresh Import
|
## 1. Validate The Fresh Import
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ This RedmineUP helpdesk plugin is maintained as local legacy code for the
|
|||||||
installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
|
installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
|
||||||
rollback archives, and LAN test status.
|
rollback archives, and LAN test status.
|
||||||
|
|
||||||
|
## 2026-04-25 - Issue API Helpdesk Contact Include
|
||||||
|
|
||||||
|
- Purpose: let external semantic indexing keep Redmine issues as the canonical
|
||||||
|
object while still receiving Helpdesk ticket/contact metadata.
|
||||||
|
- Touched behavior:
|
||||||
|
- Added `include=helpdesk` support to the Redmine issue API override.
|
||||||
|
- Added a Helpdesk issue API serializer for ticket/customer fields.
|
||||||
|
- Upgrade note: see `docs/redmine_issue_api_helpdesk_include.md` before
|
||||||
|
updating Redmine core issue API views.
|
||||||
|
- LAN test result:
|
||||||
|
- Deployed to `192.168.50.170` with rollback backup
|
||||||
|
`/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z`.
|
||||||
|
- Confirmed `/issues/39779.json?include=journals,helpdesk` returns
|
||||||
|
`helpdesk_ticket.contact` with id, name, company, and email.
|
||||||
|
|
||||||
## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
|
## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
|
||||||
|
|
||||||
- Purpose: make helpdesk ticket and message identity first-class for external
|
- Purpose: make helpdesk ticket and message identity first-class for external
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
api.issue do
|
||||||
|
api.id @issue.id
|
||||||
|
api.project(:id => @issue.project_id, :name => @issue.project.name) unless @issue.project.nil?
|
||||||
|
api.tracker(:id => @issue.tracker_id, :name => @issue.tracker.name) unless @issue.tracker.nil?
|
||||||
|
api.status(:id => @issue.status_id, :name => @issue.status.name) unless @issue.status.nil?
|
||||||
|
api.priority(:id => @issue.priority_id, :name => @issue.priority.name) unless @issue.priority.nil?
|
||||||
|
api.author(:id => @issue.author_id, :name => @issue.author.name) unless @issue.author.nil?
|
||||||
|
api.assigned_to(:id => @issue.assigned_to_id, :name => @issue.assigned_to.name) unless @issue.assigned_to.nil?
|
||||||
|
api.category(:id => @issue.category_id, :name => @issue.category.name) unless @issue.category.nil?
|
||||||
|
api.fixed_version(:id => @issue.fixed_version_id, :name => @issue.fixed_version.name) unless @issue.fixed_version.nil?
|
||||||
|
api.parent(:id => @issue.parent_id) unless @issue.parent.nil?
|
||||||
|
|
||||||
|
api.subject @issue.subject
|
||||||
|
api.description @issue.description
|
||||||
|
api.start_date @issue.start_date
|
||||||
|
api.due_date @issue.due_date
|
||||||
|
api.done_ratio @issue.done_ratio
|
||||||
|
api.is_private @issue.is_private
|
||||||
|
api.estimated_hours @issue.estimated_hours
|
||||||
|
api.total_estimated_hours @issue.total_estimated_hours
|
||||||
|
if User.current.allowed_to?(:view_time_entries, @project)
|
||||||
|
api.spent_hours(@issue.spent_hours)
|
||||||
|
api.total_spent_hours(@issue.total_spent_hours)
|
||||||
|
end
|
||||||
|
|
||||||
|
render_api_custom_values @issue.visible_custom_field_values, api
|
||||||
|
|
||||||
|
api.created_on @issue.created_on
|
||||||
|
api.updated_on @issue.updated_on
|
||||||
|
api.closed_on @issue.closed_on
|
||||||
|
|
||||||
|
if include_in_api_response?('helpdesk')
|
||||||
|
helpdesk_ticket = RedmineHelpdesk::IssueApiSerializer.serialize(@issue)
|
||||||
|
if helpdesk_ticket
|
||||||
|
api.helpdesk_ticket do
|
||||||
|
api.id helpdesk_ticket[:id]
|
||||||
|
api.contact_id helpdesk_ticket[:contact_id]
|
||||||
|
api.message_id helpdesk_ticket[:message_id]
|
||||||
|
api.source helpdesk_ticket[:source]
|
||||||
|
api.is_incoming helpdesk_ticket[:is_incoming]
|
||||||
|
api.from_address helpdesk_ticket[:from_address]
|
||||||
|
api.to_address helpdesk_ticket[:to_address]
|
||||||
|
api.cc_address helpdesk_ticket[:cc_address]
|
||||||
|
api.ticket_date helpdesk_ticket[:ticket_date]
|
||||||
|
if helpdesk_ticket[:contact]
|
||||||
|
api.contact do
|
||||||
|
api.id helpdesk_ticket[:contact][:id]
|
||||||
|
api.name helpdesk_ticket[:contact][:name]
|
||||||
|
api.company helpdesk_ticket[:contact][:company]
|
||||||
|
api.email helpdesk_ticket[:contact][:email]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
api.helpdesk_ticket nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render_api_issue_children(@issue, api) if include_in_api_response?('children')
|
||||||
|
|
||||||
|
api.array :attachments do
|
||||||
|
@issue.attachments.each do |attachment|
|
||||||
|
render_api_attachment(attachment, api)
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('attachments')
|
||||||
|
|
||||||
|
api.array :relations do
|
||||||
|
@relations.each do |relation|
|
||||||
|
api.relation(:id => relation.id, :issue_id => relation.issue_from_id, :issue_to_id => relation.issue_to_id, :relation_type => relation.relation_type, :delay => relation.delay)
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('relations') && @relations.present?
|
||||||
|
|
||||||
|
api.array :changesets do
|
||||||
|
@changesets.each do |changeset|
|
||||||
|
api.changeset :revision => changeset.revision do
|
||||||
|
api.user(:id => changeset.user_id, :name => changeset.user.name) unless changeset.user.nil?
|
||||||
|
api.comments changeset.comments
|
||||||
|
api.committed_on changeset.committed_on
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('changesets')
|
||||||
|
|
||||||
|
api.array :journals do
|
||||||
|
@journals.each do |journal|
|
||||||
|
api.journal :id => journal.id do
|
||||||
|
api.user(:id => journal.user_id, :name => journal.user.name) unless journal.user.nil?
|
||||||
|
api.notes journal.notes
|
||||||
|
api.created_on journal.created_on
|
||||||
|
api.private_notes journal.private_notes
|
||||||
|
api.array :details do
|
||||||
|
journal.visible_details.each do |detail|
|
||||||
|
api.detail :property => detail.property, :name => detail.prop_key do
|
||||||
|
api.old_value detail.old_value
|
||||||
|
api.new_value detail.value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('journals')
|
||||||
|
|
||||||
|
api.array :watchers do
|
||||||
|
@issue.watcher_users.each do |user|
|
||||||
|
api.user :id => user.id, :name => user.name
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('watchers') && User.current.allowed_to?(:view_issue_watchers, @issue.project)
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
ActionDispatch::Callbacks.to_prepare do
|
ActionDispatch::Callbacks.to_prepare do
|
||||||
|
require 'redmine_helpdesk/issue_api_serializer'
|
||||||
require 'redmine_helpdesk/patches/issues_controller_patch'
|
require 'redmine_helpdesk/patches/issues_controller_patch'
|
||||||
require 'redmine_helpdesk/patches/journals_controller_patch'
|
require 'redmine_helpdesk/patches/journals_controller_patch'
|
||||||
require 'redmine_helpdesk/patches/attachments_controller_patch'
|
require 'redmine_helpdesk/patches/attachments_controller_patch'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
require 'time'
|
||||||
|
|
||||||
|
module RedmineHelpdesk
|
||||||
|
module IssueApiSerializer
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def serialize(issue)
|
||||||
|
ticket = safe_send(issue, :helpdesk_ticket)
|
||||||
|
return nil unless ticket
|
||||||
|
|
||||||
|
contact = safe_send(ticket, :customer)
|
||||||
|
contact_id = safe_send(ticket, :contact_id) || safe_send(contact, :id)
|
||||||
|
contact_email = primary_email(contact)
|
||||||
|
|
||||||
|
{
|
||||||
|
:id => safe_send(ticket, :id),
|
||||||
|
:contact_id => contact_id,
|
||||||
|
:message_id => safe_send(ticket, :message_id),
|
||||||
|
:source => safe_send(ticket, :source),
|
||||||
|
:is_incoming => boolean_send(ticket, :is_incoming?),
|
||||||
|
:from_address => safe_send(ticket, :from_address),
|
||||||
|
:to_address => safe_send(ticket, :to_address),
|
||||||
|
:cc_address => safe_send(ticket, :cc_address),
|
||||||
|
:ticket_date => iso8601(safe_send(ticket, :ticket_date)),
|
||||||
|
:contact => contact_payload(contact, contact_id, contact_email)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_payload(contact, contact_id, contact_email)
|
||||||
|
return nil unless contact || contact_id || contact_email
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
:id => contact_id,
|
||||||
|
:name => safe_send(contact, :name),
|
||||||
|
:company => safe_send(contact, :company),
|
||||||
|
:email => contact_email
|
||||||
|
}
|
||||||
|
reject_blank_values(payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def primary_email(contact)
|
||||||
|
email = safe_send(contact, :primary_email)
|
||||||
|
return email unless blank?(email)
|
||||||
|
|
||||||
|
emails = safe_send(contact, :emails)
|
||||||
|
emails.respond_to?(:first) ? emails.first : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def iso8601(value)
|
||||||
|
return nil if blank?(value)
|
||||||
|
value.respond_to?(:utc) ? value.utc.iso8601 : value.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def boolean_send(object, method_name)
|
||||||
|
return nil unless object && object.respond_to?(method_name)
|
||||||
|
object.public_send(method_name) ? true : false
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_send(object, method_name)
|
||||||
|
return nil unless object && object.respond_to?(method_name)
|
||||||
|
object.public_send(method_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_blank_values(payload)
|
||||||
|
result = {}
|
||||||
|
payload.each do |key, value|
|
||||||
|
result[key] = value unless blank?(value)
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def blank?(value)
|
||||||
|
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
class CreateEventOutboxEvents < ActiveRecord::Migration
|
class CreateEventOutboxEvents < ActiveRecord::Migration
|
||||||
def change
|
def change
|
||||||
|
return if table_exists?(:event_outbox_events)
|
||||||
|
|
||||||
create_table :event_outbox_events do |t|
|
create_table :event_outbox_events do |t|
|
||||||
t.string :event_type, :null => false
|
t.string :event_type, :null => false
|
||||||
t.string :source_type, :null => false
|
t.string :source_type, :null => false
|
||||||
|
|||||||
Executable
+467
@@ -0,0 +1,467 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-import automation for the LAN Redmine development clone.
|
||||||
|
|
||||||
|
Run this after the production database and Redmine tree have been copied onto
|
||||||
|
the dev/test host. It reapplies this repository's tracked plugin forks, runs
|
||||||
|
plugin migrations, sanitizes Helpdesk mail settings, restarts Passenger, and
|
||||||
|
performs validation. The default mode is a dry run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
||||||
|
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
||||||
|
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
||||||
|
DEFAULT_REMOTE_OVERRIDES = "/home/reddev/redmine-dev-overrides"
|
||||||
|
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
||||||
|
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
||||||
|
DEFAULT_SEMANTIC_LIMITS = "customer-service=5"
|
||||||
|
DEFAULT_LANSCRATCH_ROOT = Path("/opt/lanscratch/redmine-post-import")
|
||||||
|
DEFAULT_STATUS_DIR = DEFAULT_LANSCRATCH_ROOT / "status"
|
||||||
|
DEFAULT_LOCAL_REPO_ROOT = DEFAULT_LANSCRATCH_ROOT / "repo"
|
||||||
|
|
||||||
|
TRACKED_PLUGINS = (
|
||||||
|
"redmine_event_outbox",
|
||||||
|
"redmine_contacts",
|
||||||
|
"redmine_contacts_helpdesk",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationConfig:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
apply=False,
|
||||||
|
local=False,
|
||||||
|
repo_root=None,
|
||||||
|
status_dir=DEFAULT_STATUS_DIR,
|
||||||
|
ssh_host=DEFAULT_SSH_HOST,
|
||||||
|
ssh_key=DEFAULT_SSH_KEY,
|
||||||
|
remote_redmine=DEFAULT_REMOTE_REDMINE,
|
||||||
|
remote_overrides=DEFAULT_REMOTE_OVERRIDES,
|
||||||
|
files_root=DEFAULT_FILES_ROOT,
|
||||||
|
mailpit_host=DEFAULT_MAILPIT_HOST,
|
||||||
|
semantic_limits=DEFAULT_SEMANTIC_LIMITS,
|
||||||
|
remote_sudo=True,
|
||||||
|
skip_semantic_check=False,
|
||||||
|
skip_helpdesk_reset=False,
|
||||||
|
):
|
||||||
|
self.apply = apply
|
||||||
|
self.local = local
|
||||||
|
self.repo_root = repo_root if repo_root is not None else (DEFAULT_LOCAL_REPO_ROOT if local else Path("."))
|
||||||
|
self.status_dir = status_dir
|
||||||
|
self.ssh_host = ssh_host
|
||||||
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.remote_overrides = remote_overrides
|
||||||
|
self.files_root = files_root
|
||||||
|
self.mailpit_host = mailpit_host
|
||||||
|
self.semantic_limits = semantic_limits
|
||||||
|
self.remote_sudo = remote_sudo
|
||||||
|
self.skip_semantic_check = skip_semantic_check
|
||||||
|
self.skip_helpdesk_reset = skip_helpdesk_reset
|
||||||
|
|
||||||
|
|
||||||
|
class Step:
|
||||||
|
def __init__(self, name, commands):
|
||||||
|
self.name = name
|
||||||
|
self.commands = commands
|
||||||
|
|
||||||
|
|
||||||
|
class StepResult:
|
||||||
|
def __init__(self, step, command, returncode):
|
||||||
|
self.step = step
|
||||||
|
self.command = command
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
config = AutomationConfig(
|
||||||
|
apply=args.apply,
|
||||||
|
local=args.local,
|
||||||
|
repo_root=args.repo_root or (DEFAULT_LOCAL_REPO_ROOT if args.local else Path(".")),
|
||||||
|
status_dir=args.status_dir,
|
||||||
|
ssh_host=args.ssh_host,
|
||||||
|
ssh_key=args.ssh_key,
|
||||||
|
remote_redmine=args.remote_redmine,
|
||||||
|
remote_overrides=args.remote_overrides,
|
||||||
|
files_root=args.files_root,
|
||||||
|
mailpit_host=args.mailpit_host,
|
||||||
|
semantic_limits=args.semantic_limits,
|
||||||
|
remote_sudo=not args.no_remote_sudo,
|
||||||
|
skip_semantic_check=args.skip_semantic_check,
|
||||||
|
skip_helpdesk_reset=args.skip_helpdesk_reset,
|
||||||
|
)
|
||||||
|
steps = build_steps(config)
|
||||||
|
run_id = utc_stamp()
|
||||||
|
results = [] # type: List[StepResult]
|
||||||
|
|
||||||
|
print(f"mode={'apply' if config.apply else 'dry-run'}")
|
||||||
|
print(f"execution={'local' if config.local else 'remote'}")
|
||||||
|
if config.apply:
|
||||||
|
write_status(config, run_id, "running", results)
|
||||||
|
for step in steps:
|
||||||
|
print(f"\n== {step.name} ==")
|
||||||
|
for command in step.commands:
|
||||||
|
if config.apply:
|
||||||
|
print(f"running: {command}")
|
||||||
|
result = subprocess.run(command, shell=True, check=False)
|
||||||
|
results.append(StepResult(step.name, command, result.returncode))
|
||||||
|
write_status(config, run_id, "running", results)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"error: command failed with exit {result.returncode}", file=sys.stderr)
|
||||||
|
write_status(config, run_id, "failed", results, failed_step=step.name)
|
||||||
|
return result.returncode
|
||||||
|
else:
|
||||||
|
print(f"would run: {command}")
|
||||||
|
|
||||||
|
if not config.apply:
|
||||||
|
print("\nDry run only. Re-run with --apply after reviewing the command list.")
|
||||||
|
else:
|
||||||
|
write_status(config, run_id, "success", results)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run daily post-import steps for the LAN Redmine dev clone."
|
||||||
|
)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Run commands. Default is dry-run.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--local",
|
||||||
|
action="store_true",
|
||||||
|
help="Run directly on the Redmine test host instead of orchestrating over SSH.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.environ["POST_IMPORT_REPO_ROOT"]) if "POST_IMPORT_REPO_ROOT" in os.environ else None,
|
||||||
|
help="Repository/payload root to copy plugins and run helper scripts from.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--status-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.getenv("POST_IMPORT_STATUS_DIR", str(DEFAULT_STATUS_DIR))),
|
||||||
|
)
|
||||||
|
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
||||||
|
parser.add_argument(
|
||||||
|
"--ssh-key",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-redmine",
|
||||||
|
default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-overrides",
|
||||||
|
default=os.getenv("REDMINE_REMOTE_OVERRIDES", DEFAULT_REMOTE_OVERRIDES),
|
||||||
|
help=(
|
||||||
|
"Remote directory containing dev-only files to overlay after the production copy. "
|
||||||
|
"If it is absent on the remote host, the step is skipped."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
||||||
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
||||||
|
parser.add_argument("--semantic-limits", default=DEFAULT_SEMANTIC_LIMITS)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-remote-sudo",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not use sudo for remote write/permission steps.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-semantic-check", action="store_true")
|
||||||
|
parser.add_argument("--skip-helpdesk-reset", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_steps(config):
|
||||||
|
steps = [
|
||||||
|
Step(
|
||||||
|
"preflight",
|
||||||
|
preflight_commands(config),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"restore dev-local overrides",
|
||||||
|
(
|
||||||
|
restore_overrides_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"reapply tracked plugins",
|
||||||
|
(
|
||||||
|
copy_plugins_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"run plugin migrations",
|
||||||
|
(
|
||||||
|
redmine_command(
|
||||||
|
config,
|
||||||
|
"RAILS_ENV=production bundle exec rake redmine:plugins:migrate",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"fix dev writable paths",
|
||||||
|
(
|
||||||
|
fix_permissions_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if not config.skip_helpdesk_reset:
|
||||||
|
steps.append(
|
||||||
|
Step(
|
||||||
|
"reset Helpdesk mail settings",
|
||||||
|
(
|
||||||
|
helpdesk_reset_command(config),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
steps.extend(
|
||||||
|
[
|
||||||
|
Step(
|
||||||
|
"restart Passenger",
|
||||||
|
(
|
||||||
|
redmine_command(config, "touch tmp/restart.txt"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"validate test instance",
|
||||||
|
(
|
||||||
|
validate_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"check outbox worker",
|
||||||
|
outbox_commands(config),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config.skip_semantic_check:
|
||||||
|
steps.append(
|
||||||
|
Step(
|
||||||
|
"validate semantic index dry-run",
|
||||||
|
(
|
||||||
|
semantic_check_command(config),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_commands(config):
|
||||||
|
commands = tuple(local_test(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
||||||
|
if config.local:
|
||||||
|
return commands + (f"test -d {q(config.remote_redmine)}",)
|
||||||
|
return commands + (ssh(config, f"test -d {q(config.remote_redmine)}"),)
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_check_command(config):
|
||||||
|
refresh = config.repo_root / "semantic_index" / "refresh.sh"
|
||||||
|
python_bin = config.repo_root / ".venv" / "bin" / "python"
|
||||||
|
command = "SEMANTIC_INDEX_PROJECT_LIMITS={limits} {refresh}".format(
|
||||||
|
limits=q(config.semantic_limits),
|
||||||
|
refresh=q(str(refresh)),
|
||||||
|
)
|
||||||
|
if not config.local:
|
||||||
|
return command
|
||||||
|
return (
|
||||||
|
"if test -x {python}; then PYTHON={python} {command}; "
|
||||||
|
"else echo 'semantic index runtime missing; skipping dry-run'; fi"
|
||||||
|
).format(
|
||||||
|
python=q(str(python_bin)),
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def local_test(path):
|
||||||
|
return f"test -d {q(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def restore_overrides_command(config):
|
||||||
|
command = "if [ -d {overrides} ]; then {sudo}rsync -a {overrides}/ {redmine}/; else echo 'no dev overrides directory: {overrides}'; fi".format(
|
||||||
|
sudo=remote_sudo_prefix(config),
|
||||||
|
overrides=q(config.remote_overrides),
|
||||||
|
redmine=q(config.remote_redmine),
|
||||||
|
)
|
||||||
|
return command if config.local else ssh(config, command)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_plugins_command(config):
|
||||||
|
sources = " ".join(q(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
||||||
|
if config.local:
|
||||||
|
return f"rsync -a --delete {sources} {q(config.remote_redmine + '/plugins/')}"
|
||||||
|
return (
|
||||||
|
"rsync -a --delete "
|
||||||
|
f"{rsync_path_option(config)}"
|
||||||
|
f"-e {q(ssh_transport(config))} "
|
||||||
|
f"{sources} "
|
||||||
|
f"{q(config.ssh_host + ':' + config.remote_redmine + '/plugins/')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redmine_command(config, command):
|
||||||
|
full = f"cd {q(config.remote_redmine)} && {command}"
|
||||||
|
return full if config.local else ssh(config, full)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_permissions_command(config):
|
||||||
|
command = "{sudo}mkdir -p {files} {redmine}/tmp {redmine}/log && {sudo}chmod -R g+rwX {files} {redmine}/tmp {redmine}/log && {sudo}find {files} -type d -exec chmod g+s {{}} +".format(
|
||||||
|
sudo=remote_sudo_prefix(config),
|
||||||
|
files=q(config.files_root),
|
||||||
|
redmine=q(config.remote_redmine),
|
||||||
|
)
|
||||||
|
return command if config.local else ssh(config, command)
|
||||||
|
|
||||||
|
|
||||||
|
def script(config, name):
|
||||||
|
path = config.repo_root / name
|
||||||
|
if str(config.repo_root) == ".":
|
||||||
|
return q(f"./{name}")
|
||||||
|
return q(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def helpdesk_reset_command(config):
|
||||||
|
base = f"{script(config, 'reset_helpdesk_mail_settings.py')} "
|
||||||
|
if config.local:
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--ssh-host {q(config.ssh_host)} "
|
||||||
|
f"--ssh-key {q(str(config.ssh_key))} "
|
||||||
|
f"--remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_command(config):
|
||||||
|
base = f"{script(config, 'validate_test_instance.py')} "
|
||||||
|
if config.local:
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)} "
|
||||||
|
f"--files-root {q(config.files_root)}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--ssh-host {q(config.ssh_host)} "
|
||||||
|
f"--ssh-key {q(str(config.ssh_key))} "
|
||||||
|
f"--remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)} "
|
||||||
|
f"--files-root {q(config.files_root)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def outbox_commands(config):
|
||||||
|
base = script(config, "redmine_outbox_worker.py")
|
||||||
|
local = " --local" if config.local else ""
|
||||||
|
return (
|
||||||
|
f"{base}{local} --status",
|
||||||
|
f"{base}{local} --dry-run --batch-size 10",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(config, remote_command):
|
||||||
|
return (
|
||||||
|
"ssh "
|
||||||
|
f"-i {q(str(config.ssh_key))} "
|
||||||
|
"-o IdentitiesOnly=yes "
|
||||||
|
f"{q(config.ssh_host)} "
|
||||||
|
f"{q(remote_command)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_transport(config):
|
||||||
|
return f"ssh -i {str(config.ssh_key)} -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
|
|
||||||
|
def remote_sudo_prefix(config):
|
||||||
|
return "sudo " if config.remote_sudo else ""
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_path_option(config):
|
||||||
|
return "--rsync-path 'sudo rsync' " if config.remote_sudo else ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_status(
|
||||||
|
config: AutomationConfig,
|
||||||
|
run_id: str,
|
||||||
|
status: str,
|
||||||
|
results,
|
||||||
|
failed_step=None,
|
||||||
|
):
|
||||||
|
now = utc_iso()
|
||||||
|
document = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"started_at": run_id_to_iso(run_id),
|
||||||
|
"updated_at": now,
|
||||||
|
"finished_at": now if status in {"success", "failed"} else None,
|
||||||
|
"mode": "apply" if config.apply else "dry-run",
|
||||||
|
"execution": "local" if config.local else "remote",
|
||||||
|
"host": socket.gethostname(),
|
||||||
|
"remote_redmine": config.remote_redmine,
|
||||||
|
"repo_root": str(config.repo_root),
|
||||||
|
"status": status,
|
||||||
|
"failed_step": failed_step,
|
||||||
|
"steps": [
|
||||||
|
{"step": item.step, "command": item.command, "returncode": item.returncode}
|
||||||
|
for item in results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
config.status_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
runs_dir = config.status_dir / "runs"
|
||||||
|
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_json(runs_dir / f"{run_id}.json", document)
|
||||||
|
write_json(config.status_dir / "latest.json", document)
|
||||||
|
if status == "success":
|
||||||
|
write_json(config.status_dir / "latest-success.json", document)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path, document):
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_stamp():
|
||||||
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def utc_iso():
|
||||||
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def run_id_to_iso(run_id):
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.strptime(run_id, "%Y%m%dT%H%M%SZ").replace(tzinfo=dt.timezone.utc)
|
||||||
|
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except ValueError:
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def q(value):
|
||||||
|
return shlex.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
REDMINE_URL=http://192.168.50.170
|
REDMINE_URL=http://192.168.50.170
|
||||||
REDMINE_API_KEY=
|
REDMINE_API_KEY=
|
||||||
|
MCP_TEXT_SANITIZATION=true
|
||||||
|
|||||||
+240
-7
@@ -45,7 +45,6 @@ $created = $client->createIssue([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
|
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
|
||||||
$client->deleteIssue((int) $created['id']);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Native Redmine search is exposed separately from issue filtering. Use
|
Native Redmine search is exposed separately from issue filtering. Use
|
||||||
@@ -65,6 +64,75 @@ $issueResults = $client->searchIssues('power supply', [
|
|||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
MCP list tools also accept friendly top-level query options so callers do not
|
||||||
|
need to know Redmine's raw parameter syntax. These are normalized into Redmine
|
||||||
|
params before the request is sent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_list_issues",
|
||||||
|
"arguments": {
|
||||||
|
"project_id": "customer-service",
|
||||||
|
"status": "open",
|
||||||
|
"updated": "last 7 days",
|
||||||
|
"sort": "newest",
|
||||||
|
"limit": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Friendly paging uses `limit`, `page`, and `offset`; the default limit is 25 and
|
||||||
|
the maximum is 100. Sort shortcuts include `newest`, `recent`, `oldest`,
|
||||||
|
`created_newest`, `created_oldest`, and `priority`. Issue date filters accept
|
||||||
|
exact dates, ranges such as `2026-04-01..2026-04-25`, objects with `from`/`to`,
|
||||||
|
phrases such as `since 2026-04-01`, and common presets such as `today`,
|
||||||
|
`yesterday`, `last 7 days`, `this_week`, and `last_month`. Raw `filters` or
|
||||||
|
`params` remain available and override friendly fields on conflict.
|
||||||
|
|
||||||
|
Project and user discovery is read-only:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$projects = $client->projects(['limit' => 25]);
|
||||||
|
$project = $client->project('fud-helpdesk', ['include' => 'trackers,enabled_modules']);
|
||||||
|
$members = $client->projectMemberships('fud-helpdesk');
|
||||||
|
|
||||||
|
$users = $client->users(['status' => 1, 'limit' => 25]);
|
||||||
|
$user = $client->user(1, ['include' => 'memberships,groups']);
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP clients that do not know the exact Redmine project identifier should call
|
||||||
|
`redmine_find_project` first. Redmine identifiers are often slug-like strings
|
||||||
|
and are not always the same as the display name.
|
||||||
|
|
||||||
|
If a tool receives a `project_id` that looks like a human project name (for
|
||||||
|
example it contains spaces or uppercase text), redMCP now attempts a safe
|
||||||
|
lookup first. When one clear match exists it uses that identifier
|
||||||
|
automatically; when matches are ambiguous it returns a guidance error that
|
||||||
|
points to `redmine_find_project` and candidate slugs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_find_project",
|
||||||
|
"arguments": {
|
||||||
|
"query": "Quality Tracker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `matches[0].project_id_to_use` or `recommended_project_id` when it is
|
||||||
|
non-null in later calls:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_create_issue",
|
||||||
|
"arguments": {
|
||||||
|
"project_id": "quality-tracker",
|
||||||
|
"subject": "Front warehouse deadbolt key gets stuck in lock",
|
||||||
|
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
`updateIssue()` is intentionally safe by default: on Helpdesk-backed issues, a
|
`updateIssue()` is intentionally safe by default: on Helpdesk-backed issues, a
|
||||||
normal Redmine note does **not** send an email to the customer. To send through
|
normal Redmine note does **not** send an email to the customer. To send through
|
||||||
the Helpdesk plugin, opt in explicitly:
|
the Helpdesk plugin, opt in explicitly:
|
||||||
@@ -84,16 +152,171 @@ Use the default non-email update for internal notes, status/category/assignee
|
|||||||
changes, and automation cleanup. Use the Helpdesk email path only when the
|
changes, and automation cleanup. Use the Helpdesk email path only when the
|
||||||
caller deliberately wants the customer to receive mail.
|
caller deliberately wants the customer to receive mail.
|
||||||
|
|
||||||
|
Issue structure operations are exposed explicitly. Issue create/update preserve
|
||||||
|
Redmine structure fields such as `parent_issue_id`, `parent_id`,
|
||||||
|
`category_id`, and `uploads`, so callers can create subtasks, categorize issues,
|
||||||
|
and attach previously uploaded files without falling through the bundled API
|
||||||
|
client's sanitized XML helpers.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$upload = $client->uploadAttachment([
|
||||||
|
'path' => '/tmp/redmine-note.txt',
|
||||||
|
'content_type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdfUpload = $client->uploadAttachment([
|
||||||
|
'data_url' => 'data:application/pdf;base64,...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fileEnvelopeUpload = $client->uploadAttachment([
|
||||||
|
'file' => [
|
||||||
|
'name' => 'quote.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'data' => 'JVBERi0xLjQK...',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parent = $client->createIssue([
|
||||||
|
'project_id' => 'fud-nohelpdesk',
|
||||||
|
'subject' => 'Parent example',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$child = $client->createIssue([
|
||||||
|
'project_id' => 'fud-nohelpdesk',
|
||||||
|
'subject' => 'Child example',
|
||||||
|
'parent_issue_id' => (int) $parent['id'],
|
||||||
|
'uploads' => [$upload],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->createIssueRelation((int) $parent['id'], [
|
||||||
|
'issue_to_id' => (int) $child['id'],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server exposes explicit tools for issue relations, children/parents,
|
||||||
|
project issue categories, and attachments. It intentionally does not expose
|
||||||
|
tools for deleting issues, projects, users, categories, or attachments. The only
|
||||||
|
removal tool is `redmine_remove_issue_relation`, which unlinks the relationship
|
||||||
|
only and does not delete either issue.
|
||||||
|
|
||||||
|
For MCP attachment uploads, prefer `redmine_upload_attachment` with `path`,
|
||||||
|
`base64_content`, `data_url`, or a `file` envelope. PDFs and other non-image
|
||||||
|
files should be passed as file/data URL inputs such as
|
||||||
|
`data:application/pdf;base64,...`, not as `image_url`.
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`redMCP` can also run as a stdio MCP server. It reads Redmine credentials from
|
`redMCP` can run as either a stdio MCP server or a network MCP server. It reads
|
||||||
environment variables or `redMCP/.env`:
|
Redmine credentials from environment variables or `redMCP/.env`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
redMCP/bin/redmcp-server.php
|
redMCP/bin/redmcp-server.php
|
||||||
```
|
```
|
||||||
|
|
||||||
Example client configuration:
|
The stdio server supports both MCP framing styles used by clients in the wild:
|
||||||
|
|
||||||
|
- `Content-Length` framed JSON-RPC messages
|
||||||
|
- line-delimited JSON messages (one JSON-RPC object per line)
|
||||||
|
|
||||||
|
Responses mirror the detected input framing mode for compatibility.
|
||||||
|
|
||||||
|
For local testing, run the Streamable HTTP server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
For LAN testing, pass `--host 0.0.0.0` deliberately. Browser-origin requests
|
||||||
|
from non-localhost origins require `MCP_ALLOWED_ORIGINS` as a comma-separated
|
||||||
|
allowlist.
|
||||||
|
|
||||||
|
Generate a bearer token with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
redMCP/bin/generate-bearer-token.php --env-line
|
||||||
|
```
|
||||||
|
|
||||||
|
The network endpoint defaults to `/mcp` and requires:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <MCP_SERVER_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example Streamable HTTP request:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS \
|
||||||
|
-H 'Authorization: Bearer test-token' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Accept: application/json, text/event-stream' \
|
||||||
|
http://127.0.0.1:8765/mcp \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
When the request `Accept` header includes `text/event-stream`, redMCP returns a
|
||||||
|
short SSE response with one `message` event per JSON-RPC response. Clients that
|
||||||
|
send only `Accept: application/json` receive the traditional JSON response.
|
||||||
|
`GET /mcp` returns `405 Method Not Allowed` with `Allow: POST`; redMCP does not
|
||||||
|
currently expose standalone server-to-client notification streams.
|
||||||
|
|
||||||
|
Issue create and update tools accept either canonical nested `fields` or common
|
||||||
|
issue fields at the top level. These two create calls are equivalent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_create_issue",
|
||||||
|
"arguments": {
|
||||||
|
"fields": {
|
||||||
|
"project_id": "quality-tracker",
|
||||||
|
"subject": "Front warehouse deadbolt key gets stuck in lock",
|
||||||
|
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_create_issue",
|
||||||
|
"arguments": {
|
||||||
|
"project_id": "quality-tracker",
|
||||||
|
"subject": "Front warehouse deadbolt key gets stuck in lock",
|
||||||
|
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP server process helpers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
redMCP/bin/redmcp-http-server.php --status
|
||||||
|
redMCP/bin/redmcp-http-server.php --stop
|
||||||
|
redMCP/bin/redmcp-http-server.php --pid-file /tmp/redmcp-http-server.pid --status
|
||||||
|
```
|
||||||
|
|
||||||
|
The default PID file is `/tmp/redmcp-http-server.pid`. A second server start
|
||||||
|
fails if the PID file points to a live process. Use `--force` only to replace a
|
||||||
|
stale PID file.
|
||||||
|
|
||||||
|
Debug logging is disabled by default. To record full MCP params/tool arguments
|
||||||
|
as JSONL during local testing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php \
|
||||||
|
--debug-log /tmp/redmcp-mcp.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Debug logs may include customer text, issue notes, search terms, email content,
|
||||||
|
and IDs. Authorization headers, bearer tokens, and Redmine API keys are not
|
||||||
|
logged. MCP tool output also redacts credential fields returned by Redmine, such
|
||||||
|
as `api_key`.
|
||||||
|
|
||||||
|
Tool output text sanitization is enabled by default to reduce token waste from
|
||||||
|
invisible/control junk in fetched issue text. This cleanup preserves readable
|
||||||
|
Unicode and targets fields such as `description`, `notes`, `content`, and
|
||||||
|
message body text. Set `MCP_TEXT_SANITIZATION=false` to disable it.
|
||||||
|
|
||||||
|
Example stdio client configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -105,12 +328,22 @@ Example client configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The server exposes tools for native Redmine filtering/search, issue CRUD,
|
Both transports expose tools for native Redmine project listing/detail, project
|
||||||
Helpdesk-aware issue reads, and explicit Helpdesk email responses. Tools that
|
memberships, users, filtering/search, issue create/update, issue relations,
|
||||||
can send customer-visible mail require an explicit tool call such as
|
subtasks/parents, project issue categories, attachments, Helpdesk-aware issue
|
||||||
|
reads, and explicit Helpdesk email responses. Tools that can send
|
||||||
|
customer-visible mail require an explicit tool call such as
|
||||||
`redmine_send_helpdesk_response` or `redmine_update_issue` with
|
`redmine_send_helpdesk_response` or `redmine_update_issue` with
|
||||||
`send_helpdesk_email=true`.
|
`send_helpdesk_email=true`.
|
||||||
|
|
||||||
|
Run the local no-network query normalizer checks with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php redMCP/bin/test-query-normalizer.php
|
||||||
|
php redMCP/bin/test-redmine-structure.php
|
||||||
|
php redMCP/bin/test-mcp-http-handler.php
|
||||||
|
```
|
||||||
|
|
||||||
## Test instance
|
## Test instance
|
||||||
|
|
||||||
A working test copy of Redmine is available on the LAN at `192.168.50.170`.
|
A working test copy of Redmine is available on the LAN at `192.168.50.170`.
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ListQueryNormalizer
|
||||||
|
{
|
||||||
|
private const DEFAULT_LIMIT = 25;
|
||||||
|
private const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function listParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function issueFilters(array $arguments, ?DateTimeImmutable $clock = null): array
|
||||||
|
{
|
||||||
|
$filters = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'project_id',
|
||||||
|
'tracker_id',
|
||||||
|
'assigned_to_id',
|
||||||
|
'author_id',
|
||||||
|
'priority_id',
|
||||||
|
'category_id',
|
||||||
|
'query_id',
|
||||||
|
] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$filters[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('status', $arguments)) {
|
||||||
|
$filters['status_id'] = self::statusValue($arguments['status']);
|
||||||
|
} elseif (array_key_exists('status_id', $arguments)) {
|
||||||
|
$filters['status_id'] = self::statusValue($arguments['status_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addDateFilter($filters, 'created_on', $arguments['created'] ?? null, $clock);
|
||||||
|
self::addDateFilter($filters, 'updated_on', $arguments['updated'] ?? null, $clock);
|
||||||
|
self::addDateFilter($filters, 'due_date', $arguments['due'] ?? null, $clock);
|
||||||
|
self::addSort($filters, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($filters, self::objectValue($arguments, 'filters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function userParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach (['name', 'group_id'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('status', $arguments)) {
|
||||||
|
$params['status'] = self::userStatusValue($arguments['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function searchParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach (['project_id', 'scope'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['all_words', 'titles_only', 'open_issues'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = self::booleanString($arguments[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function pagingParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$limit = self::positiveInt($arguments['limit'] ?? self::DEFAULT_LIMIT, self::DEFAULT_LIMIT);
|
||||||
|
$limit = min($limit, self::MAX_LIMIT);
|
||||||
|
|
||||||
|
$params = ['limit' => $limit];
|
||||||
|
if (array_key_exists('offset', $arguments)) {
|
||||||
|
$params['offset'] = max(0, (int) $arguments['offset']);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('page', $arguments)) {
|
||||||
|
$page = max(1, (int) $arguments['page']);
|
||||||
|
$params['offset'] = ($page - 1) * $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function positiveInt(mixed $value, int $default): int
|
||||||
|
{
|
||||||
|
$value = (int) $value;
|
||||||
|
|
||||||
|
return $value > 0 ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
* @param mixed $sort
|
||||||
|
*/
|
||||||
|
private static function addSort(array &$params, $sort): void
|
||||||
|
{
|
||||||
|
$normalized = self::sortValue($sort);
|
||||||
|
if ($normalized !== null) {
|
||||||
|
$params['sort'] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $sort
|
||||||
|
*/
|
||||||
|
private static function sortValue($sort): ?string
|
||||||
|
{
|
||||||
|
if ($sort === null || $sort === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($sort)) {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($sort as $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$field = trim((string) ($item['field'] ?? ''));
|
||||||
|
if ($field === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$direction = strtolower((string) ($item['direction'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
$parts[] = $field . ':' . $direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode(',', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = trim((string) $sort);
|
||||||
|
$shortcut = strtolower(str_replace([' ', '-'], '_', $sort));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'newest' => 'updated_on:desc',
|
||||||
|
'recent' => 'updated_on:desc',
|
||||||
|
'oldest' => 'created_on:asc',
|
||||||
|
'created_newest' => 'created_on:desc',
|
||||||
|
'created_oldest' => 'created_on:asc',
|
||||||
|
'priority' => 'priority:desc,updated_on:desc',
|
||||||
|
][$shortcut] ?? $sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function statusValue($value): mixed
|
||||||
|
{
|
||||||
|
$status = strtolower(trim((string) $value));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open' => 'open',
|
||||||
|
'opened' => 'open',
|
||||||
|
'active' => 'open',
|
||||||
|
'closed' => 'closed',
|
||||||
|
'all' => '*',
|
||||||
|
'any' => '*',
|
||||||
|
][$status] ?? $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function userStatusValue($value): mixed
|
||||||
|
{
|
||||||
|
$status = strtolower(trim((string) $value));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => 1,
|
||||||
|
'registered' => 2,
|
||||||
|
'locked' => 3,
|
||||||
|
'all' => '*',
|
||||||
|
'any' => '*',
|
||||||
|
][$status] ?? $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function addDateFilter(array &$params, string $redmineField, $value, ?DateTimeImmutable $clock): void
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[$redmineField] = self::dateValue($value, $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function dateValue($value, ?DateTimeImmutable $clock): string
|
||||||
|
{
|
||||||
|
$clock = $clock ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
$clock = $clock->setTimezone(new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$from = self::datePart($value['from'] ?? null, $clock);
|
||||||
|
$to = self::datePart($value['to'] ?? null, $clock);
|
||||||
|
|
||||||
|
return self::rangeValue($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '') {
|
||||||
|
throw new RuntimeException('Date filter cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(.+)\.\.(.+)$/', $text, $matches)) {
|
||||||
|
return self::rangeValue(self::datePart($matches[1], $clock), self::datePart($matches[2], $clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(since|after)\s+(.+)$/i', $text, $matches)) {
|
||||||
|
return '>=' . self::datePart($matches[2], $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(before|until)\s+(.+)$/i', $text, $matches)) {
|
||||||
|
return '<=' . self::datePart($matches[2], $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
$preset = strtolower(str_replace(['-', ' '], '_', $text));
|
||||||
|
if ($preset === 'today') {
|
||||||
|
return $clock->format('Y-m-d');
|
||||||
|
}
|
||||||
|
if ($preset === 'yesterday') {
|
||||||
|
return $clock->modify('-1 day')->format('Y-m-d');
|
||||||
|
}
|
||||||
|
if (preg_match('/^last_(\d+)_days$/', $preset, $matches)) {
|
||||||
|
$days = max(1, (int) $matches[1]);
|
||||||
|
return self::rangeValue($clock->modify('-' . ($days - 1) . ' days')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'this_week') {
|
||||||
|
return self::rangeValue($clock->modify('monday this week')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'last_week') {
|
||||||
|
return self::rangeValue(
|
||||||
|
$clock->modify('monday last week')->format('Y-m-d'),
|
||||||
|
$clock->modify('sunday last week')->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($preset === 'this_month') {
|
||||||
|
return self::rangeValue($clock->modify('first day of this month')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'last_month') {
|
||||||
|
return self::rangeValue(
|
||||||
|
$clock->modify('first day of last month')->format('Y-m-d'),
|
||||||
|
$clock->modify('last day of last month')->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::datePart($text, $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rangeValue(?string $from, ?string $to): string
|
||||||
|
{
|
||||||
|
if ($from !== null && $to !== null) {
|
||||||
|
return '><' . $from . '|' . $to;
|
||||||
|
}
|
||||||
|
if ($from !== null) {
|
||||||
|
return '>=' . $from;
|
||||||
|
}
|
||||||
|
if ($to !== null) {
|
||||||
|
return '<=' . $to;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Date range requires from, to, or both.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function datePart($value, DateTimeImmutable $clock): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || trim((string) $value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string) $value);
|
||||||
|
$timestamp = strtotime($text, $clock->getTimestamp());
|
||||||
|
if ($timestamp === false) {
|
||||||
|
throw new RuntimeException('Could not parse date filter: ' . $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gmdate('Y-m-d', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function booleanString($value): string
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function objectValue(array $arguments, string $key): array
|
||||||
|
{
|
||||||
|
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpDebugLogger
|
||||||
|
{
|
||||||
|
private ?string $path;
|
||||||
|
|
||||||
|
public function __construct(?string $path)
|
||||||
|
{
|
||||||
|
$this->path = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return $this->path !== null && $this->path !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $record
|
||||||
|
*/
|
||||||
|
public function log(array $record): void
|
||||||
|
{
|
||||||
|
if (!$this->enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = ['timestamp' => gmdate('c')] + $record;
|
||||||
|
$encoded = json_encode($record, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname((string) $this->path);
|
||||||
|
if ($dir !== '' && $dir !== '.' && !is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents((string) $this->path, $encoded . "\n", FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class McpDispatcher
|
||||||
|
{
|
||||||
|
private const PROJECT_ID_DESCRIPTION = 'Redmine project identifier or numeric id. If unsure, call redmine_find_project first and use project_id_to_use.';
|
||||||
|
|
||||||
|
private const ISSUE_FIELD_ARGUMENT_KEYS = [
|
||||||
|
'project_id',
|
||||||
|
'subject',
|
||||||
|
'description',
|
||||||
|
'tracker_id',
|
||||||
|
'status_id',
|
||||||
|
'priority_id',
|
||||||
|
'assigned_to_id',
|
||||||
|
'category_id',
|
||||||
|
'parent_issue_id',
|
||||||
|
'parent_id',
|
||||||
|
'uploads',
|
||||||
|
'due_date',
|
||||||
|
'start_date',
|
||||||
|
'notes',
|
||||||
|
'private_notes',
|
||||||
|
'custom_fields',
|
||||||
|
'watcher_user_ids',
|
||||||
|
'is_private',
|
||||||
|
'estimated_hours',
|
||||||
|
'done_ratio',
|
||||||
|
'fixed_version_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
private RedmineClient $redmine;
|
||||||
|
private McpDebugLogger $logger;
|
||||||
|
private bool $sanitizeToolText;
|
||||||
|
|
||||||
|
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null, bool $sanitizeToolText = true)
|
||||||
|
{
|
||||||
|
$this->redmine = $redmine;
|
||||||
|
$this->logger = $logger ?? new McpDebugLogger(null);
|
||||||
|
$this->sanitizeToolText = $sanitizeToolText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $message
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function handleMessage(array $message, array $context = []): ?array
|
||||||
|
{
|
||||||
|
$id = $message['id'] ?? null;
|
||||||
|
if ($id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = microtime(true);
|
||||||
|
$method = (string) ($message['method'] ?? '');
|
||||||
|
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->dispatch($method, $params);
|
||||||
|
$this->logCall($context, $method, $params, true, $started);
|
||||||
|
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->logCall($context, $method, $params, false, $started, $exception->getMessage());
|
||||||
|
return [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => $id,
|
||||||
|
'error' => [
|
||||||
|
'code' => -32000,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function dispatch(string $method, array $params): array
|
||||||
|
{
|
||||||
|
switch ($method) {
|
||||||
|
case 'initialize':
|
||||||
|
return [
|
||||||
|
'protocolVersion' => '2025-03-26',
|
||||||
|
'capabilities' => [
|
||||||
|
'tools' => ['listChanged' => false],
|
||||||
|
],
|
||||||
|
'serverInfo' => [
|
||||||
|
'name' => 'redMCP',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
case 'ping':
|
||||||
|
return [];
|
||||||
|
case 'tools/list':
|
||||||
|
return ['tools' => $this->tools()];
|
||||||
|
case 'tools/call':
|
||||||
|
return $this->callTool($params);
|
||||||
|
case 'resources/list':
|
||||||
|
return ['resources' => []];
|
||||||
|
case 'prompts/list':
|
||||||
|
return ['prompts' => []];
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unsupported MCP method: ' . $method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function tools(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->tool('redmine_list_projects', 'List available Redmine projects using native /projects.json. Use redmine_find_project to resolve a human project name.', [
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine project list params; overrides friendly fields on conflict.'],
|
||||||
|
]),
|
||||||
|
$this->tool('redmine_find_project', 'Find the Redmine project identifier to use from a human project name, identifier, or numeric id. Read-only; use before create/list tools when project_id is uncertain.', [
|
||||||
|
'query' => ['type' => 'string', 'description' => 'Human project name, project identifier, or numeric project id to resolve.'],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 25, 'description' => 'Maximum ranked matches to return. Defaults to 10.'],
|
||||||
|
], ['query']),
|
||||||
|
$this->tool('redmine_get_project', 'Fetch one Redmine project by id or identifier.', [
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Optional Redmine project params such as include=trackers,issue_categories,enabled_modules.'],
|
||||||
|
], ['project_id']),
|
||||||
|
$this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine membership list params; overrides friendly fields on conflict.'],
|
||||||
|
], ['project_id']),
|
||||||
|
$this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [
|
||||||
|
'status' => ['description' => 'User status such as active, registered, locked, all, or a Redmine status id.'],
|
||||||
|
'name' => ['type' => 'string', 'description' => 'Filter users by name.'],
|
||||||
|
'group_id' => ['type' => ['string', 'integer'], 'description' => 'Filter users by group id.'],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine user list params; overrides friendly fields on conflict.'],
|
||||||
|
]),
|
||||||
|
$this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
|
||||||
|
'user_id' => ['type' => 'integer'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
|
||||||
|
], ['user_id']),
|
||||||
|
$this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'status' => ['description' => 'Issue status such as open, closed, all, or a Redmine status id.'],
|
||||||
|
'status_id' => ['description' => 'Raw Redmine status id or status token.'],
|
||||||
|
'tracker_id' => ['type' => ['string', 'integer']],
|
||||||
|
'assigned_to_id' => ['type' => ['string', 'integer']],
|
||||||
|
'author_id' => ['type' => ['string', 'integer']],
|
||||||
|
'priority_id' => ['type' => ['string', 'integer']],
|
||||||
|
'category_id' => ['type' => ['string', 'integer']],
|
||||||
|
'query_id' => ['type' => ['string', 'integer']],
|
||||||
|
'created' => ['description' => 'Friendly created_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
||||||
|
'updated' => ['description' => 'Friendly updated_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
||||||
|
'due' => ['description' => 'Friendly due_date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
|
||||||
|
'filters' => ['type' => 'object', 'description' => 'Raw Redmine issue list filters; overrides friendly fields on conflict.'],
|
||||||
|
]),
|
||||||
|
$this->tool('redmine_search', 'Search Redmine using native /search.json.', [
|
||||||
|
'query' => ['type' => 'string'],
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'scope' => ['type' => 'string'],
|
||||||
|
'all_words' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'open_issues' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
|
||||||
|
], ['query']),
|
||||||
|
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
|
||||||
|
'query' => ['type' => 'string'],
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'scope' => ['type' => 'string'],
|
||||||
|
'all_words' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'open_issues' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
|
||||||
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
|
||||||
|
], ['query']),
|
||||||
|
$this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_list_issue_relations', 'List issue relations attached to one Redmine issue.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_get_issue_relation', 'Fetch one Redmine issue relation link by relation id.', [
|
||||||
|
'relation_id' => ['type' => 'integer'],
|
||||||
|
], ['relation_id']),
|
||||||
|
$this->tool('redmine_create_issue_relation', 'Create a Redmine issue relation link. Defaults relation_type to relates.', [
|
||||||
|
'issue_id' => ['type' => 'integer', 'description' => 'Source issue id.'],
|
||||||
|
'fields' => ['type' => 'object', 'description' => 'Relation fields including issue_to_id, optional relation_type, and optional delay.'],
|
||||||
|
], ['issue_id', 'fields']),
|
||||||
|
$this->tool('redmine_remove_issue_relation', 'Unlink one mistaken or explicitly unwanted issue relation. This removes only the relationship, not either issue.', [
|
||||||
|
'relation_id' => ['type' => 'integer'],
|
||||||
|
], ['relation_id']),
|
||||||
|
$this->tool('redmine_list_issue_children', 'List child issues whose parent_id is the given issue id.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
|
||||||
|
'page' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||||
|
'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
|
||||||
|
'filters' => ['type' => 'object', 'description' => 'Additional raw Redmine issue list filters. parent_id is controlled by issue_id.'],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_set_issue_parent', 'Set an issue parent/subtask link.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'parent_issue_id' => ['type' => 'integer'],
|
||||||
|
], ['issue_id', 'parent_issue_id']),
|
||||||
|
$this->tool('redmine_clear_issue_parent', 'Clear an issue parent/subtask link without deleting either issue.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_issue_with_helpdesk', 'Fetch one issue plus Helpdesk ticket/message context when available.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200],
|
||||||
|
'include' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_create_issue', 'Create a Redmine issue.', [
|
||||||
|
'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'],
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION . ' Flat value is copied into fields.'],
|
||||||
|
'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
|
||||||
|
'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
|
||||||
|
'tracker_id' => ['type' => ['string', 'integer']],
|
||||||
|
'status_id' => ['type' => ['string', 'integer']],
|
||||||
|
'priority_id' => ['type' => ['string', 'integer']],
|
||||||
|
'assigned_to_id' => ['type' => ['string', 'integer']],
|
||||||
|
'category_id' => ['type' => ['string', 'integer']],
|
||||||
|
'parent_issue_id' => ['type' => ['string', 'integer']],
|
||||||
|
'parent_id' => ['type' => ['string', 'integer']],
|
||||||
|
'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'due_date' => ['type' => 'string'],
|
||||||
|
'start_date' => ['type' => 'string'],
|
||||||
|
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
|
||||||
|
'is_private' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
|
||||||
|
'done_ratio' => ['type' => ['integer', 'string']],
|
||||||
|
'fixed_version_id' => ['type' => ['string', 'integer']],
|
||||||
|
]),
|
||||||
|
$this->tool('redmine_update_issue', 'Update a Redmine issue. Helpdesk email is opt-in.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'fields' => ['type' => 'object'],
|
||||||
|
'options' => ['type' => 'object', 'description' => 'Pass send_helpdesk_email=true only for customer-visible Helpdesk replies.'],
|
||||||
|
'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
|
||||||
|
'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
|
||||||
|
'notes' => ['type' => 'string', 'description' => 'Flat issue note; copied into fields.'],
|
||||||
|
'tracker_id' => ['type' => ['string', 'integer']],
|
||||||
|
'status_id' => ['type' => ['string', 'integer']],
|
||||||
|
'priority_id' => ['type' => ['string', 'integer']],
|
||||||
|
'assigned_to_id' => ['type' => ['string', 'integer']],
|
||||||
|
'category_id' => ['type' => ['string', 'integer']],
|
||||||
|
'parent_issue_id' => ['type' => ['string', 'integer']],
|
||||||
|
'parent_id' => ['type' => ['string', 'integer']],
|
||||||
|
'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'due_date' => ['type' => 'string'],
|
||||||
|
'start_date' => ['type' => 'string'],
|
||||||
|
'private_notes' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
|
||||||
|
'is_private' => ['type' => ['boolean', 'string', 'integer']],
|
||||||
|
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
|
||||||
|
'done_ratio' => ['type' => ['integer', 'string']],
|
||||||
|
'fixed_version_id' => ['type' => ['string', 'integer']],
|
||||||
|
], ['issue_id']),
|
||||||
|
$this->tool('redmine_list_project_issue_categories', 'List issue categories for a Redmine project.', [
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
], ['project_id']),
|
||||||
|
$this->tool('redmine_get_issue_category', 'Fetch one Redmine issue category by id.', [
|
||||||
|
'category_id' => ['type' => 'integer'],
|
||||||
|
], ['category_id']),
|
||||||
|
$this->tool('redmine_create_issue_category', 'Create an issue category for a project. Category deletion is intentionally not exposed.', [
|
||||||
|
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
|
||||||
|
'fields' => ['type' => 'object', 'description' => 'Category fields including name and optional assigned_to_id.'],
|
||||||
|
], ['project_id', 'fields']),
|
||||||
|
$this->tool('redmine_update_issue_category', 'Update an issue category. Category deletion is intentionally not exposed.', [
|
||||||
|
'category_id' => ['type' => 'integer'],
|
||||||
|
'fields' => ['type' => 'object'],
|
||||||
|
], ['category_id', 'fields']),
|
||||||
|
$this->tool('redmine_get_attachment', 'Fetch Redmine attachment metadata by id.', [
|
||||||
|
'attachment_id' => ['type' => 'integer'],
|
||||||
|
], ['attachment_id']),
|
||||||
|
$this->tool('redmine_upload_attachment', 'Upload a local path, base64 content, data URL, or file envelope to Redmine and return an upload token for issue create/update uploads. Use this for PDFs and other non-image files instead of image_url.', [
|
||||||
|
'path' => ['type' => 'string', 'description' => 'Readable local file path to upload.'],
|
||||||
|
'base64_content' => ['type' => 'string', 'description' => 'Base64-encoded attachment bytes.'],
|
||||||
|
'base64' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
|
||||||
|
'data' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
|
||||||
|
'blob' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
|
||||||
|
'data_url' => ['type' => 'string', 'description' => 'Base64 data URL such as data:application/pdf;base64,....'],
|
||||||
|
'filename' => ['type' => 'string', 'description' => 'Required for plain base64_content; optional for path or data_url.'],
|
||||||
|
'name' => ['type' => 'string', 'description' => 'Alias for filename.'],
|
||||||
|
'content_type' => ['type' => 'string', 'description' => 'Attachment MIME type.'],
|
||||||
|
'mime_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
|
||||||
|
'mimeType' => ['type' => 'string', 'description' => 'Alias for content_type.'],
|
||||||
|
'media_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'file' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'description' => 'File envelope with name/filename, mime_type/content_type, and data/base64_content/blob, or a path/data_url.',
|
||||||
|
'additionalProperties' => true,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
$this->tool('redmine_download_attachment', 'Download an attachment to an explicit safe local path under /tmp or this repository. Base64 response content is optional and size-limited.', [
|
||||||
|
'attachment_id' => ['type' => 'integer'],
|
||||||
|
'path' => ['type' => 'string', 'description' => 'Destination path under /tmp or the repository tree.'],
|
||||||
|
'include_base64' => ['type' => 'boolean'],
|
||||||
|
'max_base64_bytes' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 1048576],
|
||||||
|
], ['attachment_id', 'path']),
|
||||||
|
$this->tool('redmine_update_attachment', 'Update Redmine attachment metadata such as filename or description.', [
|
||||||
|
'attachment_id' => ['type' => 'integer'],
|
||||||
|
'fields' => ['type' => 'object'],
|
||||||
|
], ['attachment_id', 'fields']),
|
||||||
|
$this->tool('redmine_send_helpdesk_response', 'Send a customer-visible Helpdesk email response.', [
|
||||||
|
'issue_id' => ['type' => 'integer'],
|
||||||
|
'content' => ['type' => 'string'],
|
||||||
|
'options' => ['type' => 'object', 'description' => 'Optional to_address, cc_address, bcc_address, and status_id.'],
|
||||||
|
], ['issue_id', 'content']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $properties
|
||||||
|
* @param array<int,string> $required
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function tool(string $name, string $description, array $properties, array $required = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $description,
|
||||||
|
'inputSchema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => $properties,
|
||||||
|
'required' => $required,
|
||||||
|
'additionalProperties' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function callTool(array $params): array
|
||||||
|
{
|
||||||
|
$name = (string) ($params['name'] ?? '');
|
||||||
|
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||||
|
|
||||||
|
switch ($name) {
|
||||||
|
case 'redmine_list_projects':
|
||||||
|
$result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
|
||||||
|
break;
|
||||||
|
case 'redmine_find_project':
|
||||||
|
$result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_project':
|
||||||
|
$result = $this->redmine->project($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_get_project'), $this->objectArg($arguments, 'params'));
|
||||||
|
break;
|
||||||
|
case 'redmine_list_project_memberships':
|
||||||
|
$result = $this->redmine->projectMemberships($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_memberships'), ListQueryNormalizer::listParams($arguments));
|
||||||
|
break;
|
||||||
|
case 'redmine_list_users':
|
||||||
|
$result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_user':
|
||||||
|
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
|
||||||
|
break;
|
||||||
|
case 'redmine_list_issues':
|
||||||
|
$result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($this->resolvedProjectArgument($arguments, 'redmine_list_issues')));
|
||||||
|
break;
|
||||||
|
case 'redmine_search':
|
||||||
|
$result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search')));
|
||||||
|
break;
|
||||||
|
case 'redmine_search_issues':
|
||||||
|
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search_issues')));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_issue':
|
||||||
|
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
|
||||||
|
break;
|
||||||
|
case 'redmine_list_issue_relations':
|
||||||
|
$result = $this->redmine->listIssueRelations($this->intArg($arguments, 'issue_id'));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_issue_relation':
|
||||||
|
$result = $this->redmine->issueRelation($this->intArg($arguments, 'relation_id'));
|
||||||
|
break;
|
||||||
|
case 'redmine_create_issue_relation':
|
||||||
|
$result = $this->redmine->createIssueRelation($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'));
|
||||||
|
break;
|
||||||
|
case 'redmine_remove_issue_relation':
|
||||||
|
$result = ['ok' => $this->redmine->removeIssueRelation($this->intArg($arguments, 'relation_id'))];
|
||||||
|
break;
|
||||||
|
case 'redmine_list_issue_children':
|
||||||
|
$filters = ListQueryNormalizer::issueFilters($arguments);
|
||||||
|
unset($filters['parent_id']);
|
||||||
|
$result = $this->redmine->listIssueChildren($this->intArg($arguments, 'issue_id'), $filters);
|
||||||
|
break;
|
||||||
|
case 'redmine_set_issue_parent':
|
||||||
|
$result = ['ok' => $this->redmine->setIssueParent($this->intArg($arguments, 'issue_id'), $this->intArg($arguments, 'parent_issue_id'))];
|
||||||
|
break;
|
||||||
|
case 'redmine_clear_issue_parent':
|
||||||
|
$result = ['ok' => $this->redmine->clearIssueParent($this->intArg($arguments, 'issue_id'))];
|
||||||
|
break;
|
||||||
|
case 'redmine_issue_with_helpdesk':
|
||||||
|
$result = $this->redmine->issueWithHelpdesk(
|
||||||
|
$this->intArg($arguments, 'issue_id'),
|
||||||
|
$this->intArg($arguments, 'message_limit', 100),
|
||||||
|
$this->stringListArg($arguments, 'include', ['journals', 'attachments'])
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'redmine_create_issue':
|
||||||
|
$result = $this->redmine->createIssue($this->issueFieldsArg($arguments, 'redmine_create_issue'));
|
||||||
|
break;
|
||||||
|
case 'redmine_update_issue':
|
||||||
|
$result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments, 'redmine_update_issue'), $this->objectArg($arguments, 'options'))];
|
||||||
|
break;
|
||||||
|
case 'redmine_list_project_issue_categories':
|
||||||
|
$result = $this->redmine->listProjectIssueCategories($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_issue_categories'));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_issue_category':
|
||||||
|
$result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id'));
|
||||||
|
break;
|
||||||
|
case 'redmine_create_issue_category':
|
||||||
|
$result = $this->redmine->createIssueCategory($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_create_issue_category'), $this->objectArg($arguments, 'fields'));
|
||||||
|
break;
|
||||||
|
case 'redmine_update_issue_category':
|
||||||
|
$result = $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields'));
|
||||||
|
break;
|
||||||
|
case 'redmine_get_attachment':
|
||||||
|
$result = $this->redmine->attachment($this->intArg($arguments, 'attachment_id'));
|
||||||
|
break;
|
||||||
|
case 'redmine_upload_attachment':
|
||||||
|
$result = $this->redmine->uploadAttachment($arguments);
|
||||||
|
break;
|
||||||
|
case 'redmine_download_attachment':
|
||||||
|
$result = $this->redmine->downloadAttachment(
|
||||||
|
$this->intArg($arguments, 'attachment_id'),
|
||||||
|
$this->stringArg($arguments, 'path'),
|
||||||
|
$this->boolArg($arguments, 'include_base64', false),
|
||||||
|
$this->intArg($arguments, 'max_base64_bytes', 262144)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'redmine_update_attachment':
|
||||||
|
$result = $this->redmine->updateAttachment($this->intArg($arguments, 'attachment_id'), $this->objectArg($arguments, 'fields'));
|
||||||
|
break;
|
||||||
|
case 'redmine_send_helpdesk_response':
|
||||||
|
$result = $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options'));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unknown tool: ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$prepared = $this->redactSensitive($result);
|
||||||
|
if ($this->sanitizeToolText) {
|
||||||
|
$prepared = $this->sanitizeToolResult($prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($prepared, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new RuntimeException('Could not encode tool result.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $encoded,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function objectArg(array $arguments, string $key): array
|
||||||
|
{
|
||||||
|
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function issueFieldsArg(array $arguments, string $toolName = ''): array
|
||||||
|
{
|
||||||
|
$fields = $this->objectArg($arguments, 'fields');
|
||||||
|
foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$fields[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('project_id', $fields) && (is_int($fields['project_id']) || is_string($fields['project_id']))) {
|
||||||
|
$fields['project_id'] = $this->resolveProjectIdValue($fields['project_id'], $toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function resolvedProjectArgument(array $arguments, string $toolName): array
|
||||||
|
{
|
||||||
|
if (!array_key_exists('project_id', $arguments) || (!is_int($arguments['project_id']) && !is_string($arguments['project_id']))) {
|
||||||
|
return $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments['project_id'] = $this->resolveProjectIdValue($arguments['project_id'], $toolName);
|
||||||
|
|
||||||
|
return $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function resolvedProjectIdArg(array $arguments, string $key, string $toolName): int|string
|
||||||
|
{
|
||||||
|
return $this->resolveProjectIdValue($this->projectIdArg($arguments, $key), $toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveProjectIdValue(int|string $projectId, string $toolName): int|string
|
||||||
|
{
|
||||||
|
if (is_int($projectId)) {
|
||||||
|
return $projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = trim($projectId);
|
||||||
|
if ($candidate === '') {
|
||||||
|
throw new RuntimeException('project_id is required.');
|
||||||
|
}
|
||||||
|
if (!$this->looksLikeHumanProjectName($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = $this->findProject($candidate, 5);
|
||||||
|
$recommended = $resolution['recommended_project_id'] ?? null;
|
||||||
|
if (is_int($recommended) || (is_string($recommended) && trim($recommended) !== '')) {
|
||||||
|
return $recommended;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException($this->projectIdGuidanceMessage($candidate, $toolName, $resolution));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function looksLikeHumanProjectName(string $projectId): bool
|
||||||
|
{
|
||||||
|
return preg_match('/\s/u', $projectId) === 1 || preg_match('/[A-Z]/', $projectId) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $resolution
|
||||||
|
*/
|
||||||
|
private function projectIdGuidanceMessage(string $projectId, string $toolName, array $resolution): string
|
||||||
|
{
|
||||||
|
$matches = is_array($resolution['matches'] ?? null) ? $resolution['matches'] : [];
|
||||||
|
$suggestions = [];
|
||||||
|
foreach (array_slice($matches, 0, 3) as $match) {
|
||||||
|
if (!is_array($match)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$identifier = trim((string) ($match['identifier'] ?? ''));
|
||||||
|
$name = trim((string) ($match['name'] ?? ''));
|
||||||
|
if ($identifier === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$suggestions[] = $name !== '' ? ($identifier . ' (' . $name . ')') : $identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $toolName . ' could not safely resolve project_id="' . $projectId . '". '
|
||||||
|
. 'Redmine expects a project identifier slug (for example quality-tracker) or numeric id. '
|
||||||
|
. 'Call redmine_find_project first and pass project_id_to_use.';
|
||||||
|
if ($suggestions !== []) {
|
||||||
|
$message .= ' Possible matches: ' . implode(', ', $suggestions) . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function findProject(string $query, int $limit): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min(25, $limit));
|
||||||
|
$projectsResponse = $this->redmine->listProjects(['limit' => 100]);
|
||||||
|
$projects = is_array($projectsResponse['projects'] ?? null) ? $projectsResponse['projects'] : [];
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
if (!is_array($project)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$match = $this->projectMatch($project, $query);
|
||||||
|
if ($match !== null) {
|
||||||
|
$matches[] = $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($matches, static function (array $a, array $b): int {
|
||||||
|
$scoreCompare = ($b['score'] <=> $a['score']);
|
||||||
|
if ($scoreCompare !== 0) {
|
||||||
|
return $scoreCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idCompare = ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||||
|
if ($idCompare !== 0) {
|
||||||
|
return $idCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) $a['project_id_to_use'], (string) $b['project_id_to_use']);
|
||||||
|
});
|
||||||
|
$matches = array_slice($matches, 0, $limit);
|
||||||
|
$recommended = null;
|
||||||
|
if (count($matches) === 1 || (isset($matches[0], $matches[1]) && $matches[0]['score'] > $matches[1]['score'])) {
|
||||||
|
$recommended = $matches[0]['project_id_to_use'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'recommended_project_id' => $recommended,
|
||||||
|
'matches' => $matches,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $project
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function projectMatch(array $project, string $query): ?array
|
||||||
|
{
|
||||||
|
$normalizedQuery = $this->normalizeProjectText($query);
|
||||||
|
$id = $project['id'] ?? null;
|
||||||
|
$identifier = trim((string) ($project['identifier'] ?? ''));
|
||||||
|
$name = trim((string) ($project['name'] ?? ''));
|
||||||
|
$normalizedId = $id === null ? '' : $this->normalizeProjectText((string) $id);
|
||||||
|
$normalizedIdentifier = $this->normalizeProjectText($identifier);
|
||||||
|
$normalizedName = $this->normalizeProjectText($name);
|
||||||
|
$score = 0;
|
||||||
|
$reason = '';
|
||||||
|
|
||||||
|
if ($normalizedId !== '' && $normalizedQuery === $normalizedId) {
|
||||||
|
$score = 100;
|
||||||
|
$reason = 'exact_id';
|
||||||
|
} elseif ($normalizedIdentifier !== '' && $normalizedQuery === $normalizedIdentifier) {
|
||||||
|
$score = 100;
|
||||||
|
$reason = 'exact_identifier';
|
||||||
|
} elseif ($normalizedName !== '' && $normalizedQuery === $normalizedName) {
|
||||||
|
$score = 90;
|
||||||
|
$reason = 'exact_name';
|
||||||
|
} elseif ($normalizedIdentifier !== '' && str_starts_with($normalizedIdentifier, $normalizedQuery)) {
|
||||||
|
$score = 80;
|
||||||
|
$reason = 'identifier_prefix';
|
||||||
|
} elseif ($normalizedName !== '' && str_starts_with($normalizedName, $normalizedQuery)) {
|
||||||
|
$score = 70;
|
||||||
|
$reason = 'name_prefix';
|
||||||
|
} elseif ($normalizedIdentifier !== '' && str_contains($normalizedIdentifier, $normalizedQuery)) {
|
||||||
|
$score = 60;
|
||||||
|
$reason = 'identifier_contains';
|
||||||
|
} elseif ($normalizedName !== '' && str_contains($normalizedName, $normalizedQuery)) {
|
||||||
|
$score = 50;
|
||||||
|
$reason = 'name_contains';
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'name' => $name,
|
||||||
|
'score' => $score,
|
||||||
|
'match_reason' => $reason,
|
||||||
|
'project_id_to_use' => $identifier !== '' ? $identifier : $id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeProjectText(string $value): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function stringArg(array $arguments, string $key): string
|
||||||
|
{
|
||||||
|
$value = trim((string) ($arguments[$key] ?? ''));
|
||||||
|
if ($value === '') {
|
||||||
|
throw new RuntimeException($key . ' is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function intArg(array $arguments, string $key, ?int $default = null): int
|
||||||
|
{
|
||||||
|
if (!isset($arguments[$key])) {
|
||||||
|
if ($default !== null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
throw new RuntimeException($key . ' is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $arguments[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function boolArg(array $arguments, string $key, bool $default = false): bool
|
||||||
|
{
|
||||||
|
if (!isset($arguments[$key])) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
if (is_bool($arguments[$key])) {
|
||||||
|
return $arguments[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(strtolower((string) $arguments[$key]), ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*/
|
||||||
|
private function projectIdArg(array $arguments, string $key): int|string
|
||||||
|
{
|
||||||
|
if (!isset($arguments[$key])) {
|
||||||
|
throw new RuntimeException($key . ' is required.');
|
||||||
|
}
|
||||||
|
if (is_int($arguments[$key])) {
|
||||||
|
return $arguments[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stringArg($arguments, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
* @param array<int,string> $default
|
||||||
|
*
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private function stringListArg(array $arguments, string $key, array $default): array
|
||||||
|
{
|
||||||
|
if (!isset($arguments[$key]) || !is_array($arguments[$key])) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map('strval', $arguments[$key])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
*/
|
||||||
|
private function logCall(
|
||||||
|
array $context,
|
||||||
|
string $method,
|
||||||
|
array $params,
|
||||||
|
bool $ok,
|
||||||
|
float $started,
|
||||||
|
?string $error = null
|
||||||
|
): void {
|
||||||
|
$record = [
|
||||||
|
'transport' => $context['transport'] ?? 'unknown',
|
||||||
|
'client_ip' => $context['client_ip'] ?? null,
|
||||||
|
'method' => $method,
|
||||||
|
'params' => $this->redactSensitive($params),
|
||||||
|
'ok' => $ok,
|
||||||
|
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
|
||||||
|
];
|
||||||
|
if (isset($params['name'])) {
|
||||||
|
$record['tool_name'] = $params['name'];
|
||||||
|
$arguments = $params['arguments'] ?? null;
|
||||||
|
$record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
|
||||||
|
}
|
||||||
|
if ($error !== null) {
|
||||||
|
$record['error'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->log($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function redactSensitive($value)
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$redacted = [];
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_string($key) && $this->isSensitiveKey($key)) {
|
||||||
|
$redacted[$key] = '[redacted]';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$redacted[$key] = $this->redactSensitive($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSensitiveKey(string $key): bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower(str_replace(['-', '_'], '', $key));
|
||||||
|
|
||||||
|
return in_array($normalized, [
|
||||||
|
'apikey',
|
||||||
|
'authorization',
|
||||||
|
'bearertoken',
|
||||||
|
'password',
|
||||||
|
'secret',
|
||||||
|
'token',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function sanitizeToolResult($value, string $key = '')
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
if (!$this->shouldSanitizeTextKey($key)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sanitizeText($value);
|
||||||
|
}
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized = [];
|
||||||
|
foreach ($value as $childKey => $childValue) {
|
||||||
|
$sanitized[$childKey] = $this->sanitizeToolResult(
|
||||||
|
$childValue,
|
||||||
|
is_string($childKey) ? $childKey : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSanitizeTextKey(string $key): bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower(trim($key));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($normalized, [
|
||||||
|
'description',
|
||||||
|
'notes',
|
||||||
|
'content',
|
||||||
|
'body',
|
||||||
|
'text',
|
||||||
|
'message',
|
||||||
|
'message_body',
|
||||||
|
'message_text',
|
||||||
|
'plain_text',
|
||||||
|
'plain_body',
|
||||||
|
'html_body',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeText(string $value): string
|
||||||
|
{
|
||||||
|
$value = str_replace(["\r\n", "\r"], "\n", $value);
|
||||||
|
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
|
||||||
|
$value = preg_replace('/\p{Cf}+/u', '', $value) ?? $value;
|
||||||
|
$value = preg_replace('/[^\S\n]{3,}/u', ' ', $value) ?? $value;
|
||||||
|
$value = preg_replace('/\n{4,}/u', "\n\n\n", $value) ?? $value;
|
||||||
|
$value = preg_replace('/([[:punct:]])\1{7,}/u', '$1$1$1$1$1$1', $value) ?? $value;
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class McpEnvironment
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string,mcp_debug_log:?string,mcp_text_sanitization:bool}
|
||||||
|
*/
|
||||||
|
public static function load(string $envFile): array
|
||||||
|
{
|
||||||
|
$env = self::loadFile($envFile);
|
||||||
|
$apiKey = getenv('REDMINE_API_KEY') ?: getenv('REDMNINE_API_KEY') ?: ($env['REDMINE_API_KEY'] ?? $env['REDMNINE_API_KEY'] ?? null);
|
||||||
|
if (!is_string($apiKey) || trim($apiKey) === '') {
|
||||||
|
throw new RuntimeException('REDMINE_API_KEY is required in the environment or redMCP/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'redmine_url' => rtrim((string) (getenv('REDMINE_URL') ?: ($env['REDMINE_URL'] ?? 'http://192.168.50.170')), '/'),
|
||||||
|
'redmine_api_key' => $apiKey,
|
||||||
|
'mcp_server_token' => self::optionalString(getenv('MCP_SERVER_TOKEN') ?: ($env['MCP_SERVER_TOKEN'] ?? null)),
|
||||||
|
'mcp_debug_log' => self::optionalString(getenv('MCP_DEBUG_LOG') ?: ($env['MCP_DEBUG_LOG'] ?? null)),
|
||||||
|
'mcp_text_sanitization' => self::boolSetting(getenv('MCP_TEXT_SANITIZATION') ?: ($env['MCP_TEXT_SANITIZATION'] ?? null), true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private static function loadFile(string $path): array
|
||||||
|
{
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$values[trim($key)] = trim(trim($value), "\"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (!is_string($value) || trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function boolSetting(mixed $value, bool $default): bool
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpHttpHandler
|
||||||
|
{
|
||||||
|
private McpDispatcher $dispatcher;
|
||||||
|
private string $token;
|
||||||
|
private string $path;
|
||||||
|
|
||||||
|
public function __construct(McpDispatcher $dispatcher, string $token, string $path = '/mcp')
|
||||||
|
{
|
||||||
|
$this->dispatcher = $dispatcher;
|
||||||
|
$this->token = $token;
|
||||||
|
$this->path = '/' . trim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== $this->path) {
|
||||||
|
$this->sendJson(404, ['error' => 'not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$this->originAllowed()) {
|
||||||
|
$this->sendJson(403, ['error' => 'origin not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$this->authorized()) {
|
||||||
|
header('WWW-Authenticate: Bearer');
|
||||||
|
$this->sendJson(401, ['error' => 'unauthorized']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
|
||||||
|
header('Allow: POST');
|
||||||
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
header('Allow: POST');
|
||||||
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
$this->sendJson(400, $this->errorResponse(null, -32700, 'Invalid JSON.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($decoded)) {
|
||||||
|
$responses = [];
|
||||||
|
foreach ($decoded as $message) {
|
||||||
|
if (!is_array($message)) {
|
||||||
|
$responses[] = $this->errorResponse(null, -32600, 'Invalid request.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$response = $this->dispatcher->handleMessage($message, $this->logContext());
|
||||||
|
if ($response !== null) {
|
||||||
|
$responses[] = $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($responses === []) {
|
||||||
|
http_response_code(202);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->acceptsEventStream()) {
|
||||||
|
$this->sendEventStream($responses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->sendJson(200, $responses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->dispatcher->handleMessage($decoded, $this->logContext());
|
||||||
|
if ($response === null) {
|
||||||
|
http_response_code(202);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->acceptsEventStream()) {
|
||||||
|
$this->sendEventStream([$response]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->sendJson(200, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorized(): bool
|
||||||
|
{
|
||||||
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
if (!is_string($header) || !str_starts_with($header, 'Bearer ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($this->token, substr($header, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function originAllowed(): bool
|
||||||
|
{
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
if (!is_string($origin) || trim($origin) === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = trim($origin);
|
||||||
|
foreach ($this->allowedOrigins() as $allowedOrigin) {
|
||||||
|
if (hash_equals($allowedOrigin, $origin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = parse_url($origin, PHP_URL_HOST);
|
||||||
|
return is_string($host) && in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private function allowedOrigins(): array
|
||||||
|
{
|
||||||
|
$raw = getenv('MCP_ALLOWED_ORIGINS') ?: '';
|
||||||
|
if (!is_string($raw) || trim($raw) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acceptsEventStream(): bool
|
||||||
|
{
|
||||||
|
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||||
|
return is_string($accept) && stripos($accept, 'text/event-stream') !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function logContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'transport' => 'http',
|
||||||
|
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $payload
|
||||||
|
*/
|
||||||
|
private function sendJson(int $status, $payload): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $messages
|
||||||
|
*/
|
||||||
|
private function sendEventStream(array $messages): void
|
||||||
|
{
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$encoded = json_encode($message, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo "event: message\n";
|
||||||
|
echo 'data: ' . $encoded . "\n\n";
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function errorResponse(mixed $id, int $code, string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => $id,
|
||||||
|
'error' => [
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpStdioServer
|
||||||
|
{
|
||||||
|
private McpDispatcher $dispatcher;
|
||||||
|
private string $wireMode = 'content-length';
|
||||||
|
|
||||||
|
public function __construct(McpDispatcher $dispatcher)
|
||||||
|
{
|
||||||
|
$this->dispatcher = $dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
while (($message = $this->readMessage(STDIN)) !== null) {
|
||||||
|
$response = $this->dispatcher->handleMessage($message, ['transport' => 'stdio']);
|
||||||
|
if ($response !== null) {
|
||||||
|
$this->writeMessage($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $stream
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function readMessage($stream): ?array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
while (($line = fgets($stream)) !== false) {
|
||||||
|
$line = rtrim($line, "\r\n");
|
||||||
|
if ($line === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[A-Za-z0-9-]+\s*:/', $line)) {
|
||||||
|
$this->wireMode = 'line';
|
||||||
|
$decoded = json_decode($line, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
[$name, $value] = explode(':', $line, 2);
|
||||||
|
$headers[strtolower(trim($name))] = trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line === false && $headers === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = isset($headers['content-length']) ? (int) $headers['content-length'] : 0;
|
||||||
|
if ($length <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = '';
|
||||||
|
while (strlen($body) < $length && !feof($stream)) {
|
||||||
|
$chunk = fread($stream, $length - strlen($body));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$body .= $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $message
|
||||||
|
*/
|
||||||
|
private function writeMessage(array $message): void
|
||||||
|
{
|
||||||
|
$body = json_encode($message, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($body === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->wireMode === 'line') {
|
||||||
|
fwrite(STDOUT, $body . "\n");
|
||||||
|
fflush(STDOUT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
|
||||||
|
fflush(STDOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+567
-19
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace RedMCP;
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use Redmine\Client\Client;
|
||||||
use Redmine\Client\NativeCurlClient;
|
use Redmine\Client\NativeCurlClient;
|
||||||
use Redmine\Http\HttpClient;
|
use Redmine\Http\HttpClient;
|
||||||
use Redmine\Http\HttpFactory;
|
use Redmine\Http\HttpFactory;
|
||||||
@@ -14,9 +15,9 @@ use Throwable;
|
|||||||
|
|
||||||
final class RedmineClient
|
final class RedmineClient
|
||||||
{
|
{
|
||||||
private NativeCurlClient $client;
|
private Client $client;
|
||||||
|
|
||||||
public function __construct(NativeCurlClient $client)
|
public function __construct(Client $client)
|
||||||
{
|
{
|
||||||
$this->client = $client;
|
$this->client = $client;
|
||||||
}
|
}
|
||||||
@@ -105,6 +106,114 @@ final class RedmineClient
|
|||||||
return $this->search($query, ['issues' => '1'] + $params);
|
return $this->search($query, ['issues' => '1'] + $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine projects.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function projects(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listProjects($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine projects.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listProjects(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/projects', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Redmine project by numeric id or identifier.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project show params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function project(int|string $projectId, array $params = []): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Fetching a project requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/projects/' . rawurlencode($projectId), $params);
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch project ' . $projectId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['project'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine users.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function users(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listUsers($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine users.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listUsers(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/users', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one Redmine user.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user show params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function user(int $userId, array $params = []): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching a user requires a positive user id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/users/' . rawurlencode((string) $userId), $params);
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch user #' . $userId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['user'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List memberships for a Redmine project.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine membership list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function projectMemberships(int|string $projectId, array $params = []): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Fetching project memberships requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getJson('/projects/' . rawurlencode($projectId) . '/memberships', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a normal Redmine issue.
|
* Fetch a normal Redmine issue.
|
||||||
*
|
*
|
||||||
@@ -128,8 +237,8 @@ final class RedmineClient
|
|||||||
* Create a Redmine issue.
|
* Create a Redmine issue.
|
||||||
*
|
*
|
||||||
* Typical fields include project_id, subject, description, tracker_id,
|
* Typical fields include project_id, subject, description, tracker_id,
|
||||||
* status_id, priority_id, assigned_to_id, category_id, due_date, and
|
* status_id, priority_id, assigned_to_id, category_id, parent_issue_id,
|
||||||
* start_date.
|
* parent_id, uploads, due_date, and start_date.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $fields
|
* @param array<string,mixed> $fields
|
||||||
*
|
*
|
||||||
@@ -141,11 +250,9 @@ final class RedmineClient
|
|||||||
throw new RuntimeException('Creating an issue requires at least project_id and subject.');
|
throw new RuntimeException('Creating an issue requires at least project_id and subject.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$issueApi = $this->client->getApi('issue');
|
$response = $this->postJson('/issues', ['issue' => $fields]);
|
||||||
$response = $issueApi->create($fields);
|
|
||||||
$this->assertLastApiResponseSucceeded($issueApi, 'create issue');
|
|
||||||
|
|
||||||
return $this->xmlResponseToArray($response);
|
return is_array($response['issue'] ?? null) ? $response['issue'] : $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +264,8 @@ final class RedmineClient
|
|||||||
* sendHelpdeskIssueResponse() directly.
|
* sendHelpdeskIssueResponse() directly.
|
||||||
*
|
*
|
||||||
* Typical fields include notes, subject, status_id, priority_id,
|
* Typical fields include notes, subject, status_id, priority_id,
|
||||||
* assigned_to_id, private_notes, due_date, and tracker_id.
|
* assigned_to_id, private_notes, parent_issue_id, parent_id, category_id,
|
||||||
|
* uploads, due_date, and tracker_id.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $fields
|
* @param array<string,mixed> $fields
|
||||||
*/
|
*/
|
||||||
@@ -186,13 +294,383 @@ final class RedmineClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$issueApi = $this->client->getApi('issue');
|
$this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]);
|
||||||
$issueApi->update($issueId, $fields);
|
|
||||||
$this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueWithStructure(int $issueId): array
|
||||||
|
{
|
||||||
|
return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listIssueChildren(int $issueId, array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listIssues(['parent_id' => $issueId] + $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIssueParent(int $issueId, int $parentIssueId): bool
|
||||||
|
{
|
||||||
|
if ($parentIssueId <= 0) {
|
||||||
|
throw new RuntimeException('Setting an issue parent requires a positive parent_issue_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->updateIssue($issueId, ['parent_issue_id' => $parentIssueId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearIssueParent(int $issueId): bool
|
||||||
|
{
|
||||||
|
return $this->updateIssue($issueId, ['parent_issue_id' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listIssueRelations(int $issueId): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueRelation(int $relationId): array
|
||||||
|
{
|
||||||
|
if ($relationId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an issue relation requires a positive relation id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/relations/' . rawurlencode((string) $relationId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch issue relation #' . $relationId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['relation'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function createIssueRelation(int $issueId, array $fields): array
|
||||||
|
{
|
||||||
|
if (!isset($fields['issue_to_id']) || (int) $fields['issue_to_id'] <= 0) {
|
||||||
|
throw new RuntimeException('Creating an issue relation requires a positive issue_to_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields += ['relation_type' => 'relates'];
|
||||||
|
$response = $this->postJson('/issues/' . rawurlencode((string) $issueId) . '/relations', ['relation' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['relation'] ?? null) ? $response['relation'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeIssueRelation(int $relationId): bool
|
||||||
|
{
|
||||||
|
if ($relationId <= 0) {
|
||||||
|
throw new RuntimeException('Removing an issue relation requires a positive relation id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteJson('/relations/' . rawurlencode((string) $relationId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listProjectIssueCategories(int|string $projectId): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Listing issue categories requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getJson('/projects/' . rawurlencode($projectId) . '/issue_categories') ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueCategory(int $categoryId): array
|
||||||
|
{
|
||||||
|
if ($categoryId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an issue category requires a positive category id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/issue_categories/' . rawurlencode((string) $categoryId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch issue category #' . $categoryId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['issue_category'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function createIssueCategory(int|string $projectId, array $fields): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Creating an issue category requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
if (!isset($fields['name']) || trim((string) $fields['name']) === '') {
|
||||||
|
throw new RuntimeException('Creating an issue category requires a non-empty name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->postJson('/projects/' . rawurlencode($projectId) . '/issue_categories', ['issue_category' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function updateIssueCategory(int $categoryId, array $fields): array
|
||||||
|
{
|
||||||
|
if ($fields === []) {
|
||||||
|
throw new RuntimeException('Updating an issue category requires at least one field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->putJson('/issue_categories/' . rawurlencode((string) $categoryId), ['issue_category' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function attachment(int $attachmentId): array
|
||||||
|
{
|
||||||
|
if ($attachmentId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an attachment requires a positive attachment id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/attachments/' . rawurlencode((string) $attachmentId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch attachment #' . $attachmentId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['attachment'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $source
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function uploadAttachment(array $source): array
|
||||||
|
{
|
||||||
|
$source = $this->normalizeAttachmentUploadSource($source);
|
||||||
|
$filename = isset($source['filename']) ? trim((string) $source['filename']) : '';
|
||||||
|
$contentType = trim((string) ($source['content_type'] ?? 'application/octet-stream'));
|
||||||
|
if ($contentType === '') {
|
||||||
|
$contentType = 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($source['path'])) {
|
||||||
|
$path = (string) $source['path'];
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
throw new RuntimeException('Uploading an attachment from path requires a readable local file.');
|
||||||
|
}
|
||||||
|
$bytes = file_get_contents($path);
|
||||||
|
if ($bytes === false) {
|
||||||
|
throw new RuntimeException('Could not read attachment file: ' . $path);
|
||||||
|
}
|
||||||
|
if ($filename === '') {
|
||||||
|
$filename = basename($path);
|
||||||
|
}
|
||||||
|
} elseif (isset($source['base64_content'])) {
|
||||||
|
if ($filename === '') {
|
||||||
|
throw new RuntimeException('Uploading base64 attachment content requires filename.');
|
||||||
|
}
|
||||||
|
$decoded = base64_decode((string) $source['base64_content'], true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw new RuntimeException('Attachment base64_content is not valid base64.');
|
||||||
|
}
|
||||||
|
$bytes = $decoded;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Uploading an attachment requires either path or base64_content.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filename === '') {
|
||||||
|
throw new RuntimeException('Uploading an attachment requires filename.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->rawRequest(
|
||||||
|
'POST',
|
||||||
|
PathSerializer::create('/uploads.json', ['filename' => $filename])->getPath(),
|
||||||
|
'application/octet-stream',
|
||||||
|
$bytes
|
||||||
|
);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine upload failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $this->decodeJsonResponse($response, 'Redmine upload');
|
||||||
|
$upload = is_array($decoded['upload'] ?? null) ? $decoded['upload'] : $decoded;
|
||||||
|
if (isset($source['description'])) {
|
||||||
|
$upload['description'] = (string) $source['description'];
|
||||||
|
}
|
||||||
|
$upload += [
|
||||||
|
'filename' => $filename,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $source
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeAttachmentUploadSource(array $source): array
|
||||||
|
{
|
||||||
|
if (isset($source['file']) && is_array($source['file'])) {
|
||||||
|
$file = $source['file'];
|
||||||
|
unset($source['file']);
|
||||||
|
foreach ([
|
||||||
|
'path' => 'path',
|
||||||
|
'filename' => 'filename',
|
||||||
|
'name' => 'filename',
|
||||||
|
'content_type' => 'content_type',
|
||||||
|
'mime_type' => 'content_type',
|
||||||
|
'mimeType' => 'content_type',
|
||||||
|
'media_type' => 'content_type',
|
||||||
|
'description' => 'description',
|
||||||
|
'base64_content' => 'base64_content',
|
||||||
|
'base64' => 'base64_content',
|
||||||
|
'data' => 'base64_content',
|
||||||
|
'blob' => 'base64_content',
|
||||||
|
'data_url' => 'data_url',
|
||||||
|
'url' => 'data_url',
|
||||||
|
] as $from => $to) {
|
||||||
|
if (!isset($source[$to]) && isset($file[$from])) {
|
||||||
|
$source[$to] = $file[$from];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'name' => 'filename',
|
||||||
|
'mime_type' => 'content_type',
|
||||||
|
'mimeType' => 'content_type',
|
||||||
|
'media_type' => 'content_type',
|
||||||
|
'base64' => 'base64_content',
|
||||||
|
'data' => 'base64_content',
|
||||||
|
'blob' => 'base64_content',
|
||||||
|
] as $from => $to) {
|
||||||
|
if (!isset($source[$to]) && isset($source[$from])) {
|
||||||
|
$source[$to] = $source[$from];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($source['data_url']) || (isset($source['base64_content']) && str_starts_with((string) $source['base64_content'], 'data:'))) {
|
||||||
|
$dataUrl = (string) ($source['data_url'] ?? $source['base64_content']);
|
||||||
|
$parsed = $this->parseAttachmentDataUrl($dataUrl);
|
||||||
|
$source['base64_content'] = $parsed['base64_content'];
|
||||||
|
if (!isset($source['content_type'])) {
|
||||||
|
$source['content_type'] = $parsed['content_type'];
|
||||||
|
}
|
||||||
|
if (!isset($source['filename']) || trim((string) $source['filename']) === '') {
|
||||||
|
$source['filename'] = $this->defaultAttachmentFilename((string) $source['content_type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{content_type:string,base64_content:string}
|
||||||
|
*/
|
||||||
|
private function parseAttachmentDataUrl(string $dataUrl): array
|
||||||
|
{
|
||||||
|
if (!preg_match('/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s', $dataUrl, $matches)) {
|
||||||
|
throw new RuntimeException('Attachment data_url must be a base64 data URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = trim($matches[1] !== '' ? $matches[1] : 'application/octet-stream');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream',
|
||||||
|
'base64_content' => $matches[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultAttachmentFilename(string $contentType): string
|
||||||
|
{
|
||||||
|
return match (strtolower($contentType)) {
|
||||||
|
'application/pdf' => 'attachment.pdf',
|
||||||
|
'text/plain' => 'attachment.txt',
|
||||||
|
'text/csv' => 'attachment.csv',
|
||||||
|
'application/json' => 'attachment.json',
|
||||||
|
'image/jpeg' => 'attachment.jpg',
|
||||||
|
'image/png' => 'attachment.png',
|
||||||
|
'image/gif' => 'attachment.gif',
|
||||||
|
default => 'attachment.bin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function updateAttachment(int $attachmentId, array $fields): array
|
||||||
|
{
|
||||||
|
if ($fields === []) {
|
||||||
|
throw new RuntimeException('Updating an attachment requires at least one field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->putJson('/attachments/' . rawurlencode((string) $attachmentId), ['attachment' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['attachment'] ?? null) ? $response['attachment'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function downloadAttachment(int $attachmentId, string $destinationPath, bool $includeBase64 = false, int $maxBase64Bytes = 262144): array
|
||||||
|
{
|
||||||
|
$destinationPath = $this->safeDownloadPath($destinationPath);
|
||||||
|
$response = $this->rawRequest('GET', '/attachments/download/' . rawurlencode((string) $attachmentId));
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine attachment download failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = $response->getContent();
|
||||||
|
if (file_put_contents($destinationPath, $bytes) === false) {
|
||||||
|
throw new RuntimeException('Could not write attachment download to ' . $destinationPath . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'attachment_id' => $attachmentId,
|
||||||
|
'path' => $destinationPath,
|
||||||
|
'bytes' => strlen($bytes),
|
||||||
|
'content_type' => $response->getContentType(),
|
||||||
|
];
|
||||||
|
if ($includeBase64 && strlen($bytes) <= $maxBase64Bytes) {
|
||||||
|
$result['base64_content'] = base64_encode($bytes);
|
||||||
|
} elseif ($includeBase64) {
|
||||||
|
$result['base64_omitted'] = true;
|
||||||
|
$result['base64_limit_bytes'] = $maxBase64Bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a Helpdesk email response for an existing Helpdesk-backed issue.
|
* Send a Helpdesk email response for an existing Helpdesk-backed issue.
|
||||||
*
|
*
|
||||||
@@ -390,22 +868,67 @@ final class RedmineClient
|
|||||||
*/
|
*/
|
||||||
private function postJson(string $path, array $payload): array
|
private function postJson(string $path, array $payload): array
|
||||||
{
|
{
|
||||||
if (!$this->client instanceof HttpClient) {
|
|
||||||
throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestPath = $this->buildPath($path, []);
|
$requestPath = $this->buildPath($path, []);
|
||||||
$encoded = json_encode($payload);
|
$encoded = json_encode($payload);
|
||||||
if ($encoded === false) {
|
if ($encoded === false) {
|
||||||
throw new RuntimeException('Could not encode Redmine POST payload.');
|
throw new RuntimeException('Could not encode Redmine POST payload.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->client->request(HttpFactory::makeJsonRequest('POST', $requestPath, $encoded));
|
$response = $this->rawRequest('POST', $requestPath, 'application/json', $encoded);
|
||||||
$status = $response->getStatusCode();
|
$status = $response->getStatusCode();
|
||||||
if ($status >= 400) {
|
if ($status >= 400) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function putJson(string $path, array $payload): array
|
||||||
|
{
|
||||||
|
$requestPath = $this->buildPath($path, []);
|
||||||
|
$encoded = json_encode($payload);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new RuntimeException('Could not encode Redmine PUT payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->rawRequest('PUT', $requestPath, 'application/json', $encoded);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine PUT ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->decodeJsonResponse($response, 'Redmine PUT ' . $requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteJson(string $path): void
|
||||||
|
{
|
||||||
|
$requestPath = $this->buildPath($path, []);
|
||||||
|
$response = $this->rawRequest('DELETE', $requestPath);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine DELETE ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rawRequest(string $method, string $path, string $contentType = '', string $content = ''): \Redmine\Http\Response
|
||||||
|
{
|
||||||
|
if (!$this->client instanceof HttpClient) {
|
||||||
|
throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->request(HttpFactory::makeRequest($method, $path, $contentType, $content));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array
|
||||||
|
{
|
||||||
$body = $response->getContent();
|
$body = $response->getContent();
|
||||||
if ($body === '') {
|
if ($body === '') {
|
||||||
return [];
|
return [];
|
||||||
@@ -414,11 +937,11 @@ final class RedmineClient
|
|||||||
try {
|
try {
|
||||||
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.', 0, $exception);
|
throw new RuntimeException($action . ' returned invalid JSON.', 0, $exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($decoded)) {
|
if (!is_array($decoded)) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.');
|
throw new RuntimeException($action . ' returned invalid JSON.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
@@ -432,6 +955,31 @@ final class RedmineClient
|
|||||||
return PathSerializer::create($path . '.json', $params)->getPath();
|
return PathSerializer::create($path . '.json', $params)->getPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function safeDownloadPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path === '' || str_contains($path, "\0")) {
|
||||||
|
throw new RuntimeException('Attachment download requires a safe local destination path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = dirname($path);
|
||||||
|
$realDirectory = realpath($directory);
|
||||||
|
if ($realDirectory === false || !is_dir($realDirectory)) {
|
||||||
|
throw new RuntimeException('Attachment download destination directory does not exist: ' . $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $realDirectory . DIRECTORY_SEPARATOR . basename($path);
|
||||||
|
$repoRoot = realpath(dirname(__DIR__, 2));
|
||||||
|
$tmpRoot = realpath(sys_get_temp_dir());
|
||||||
|
foreach (array_filter([$repoRoot, $tmpRoot]) as $allowedRoot) {
|
||||||
|
if ($resolved === $allowedRoot || str_starts_with($resolved, $allowedRoot . DIRECTORY_SEPARATOR)) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Attachment downloads must write under /tmp or the repository tree.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $api
|
* @param mixed $api
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpDebugLogger;
|
||||||
|
use RedMCP\McpEnvironment;
|
||||||
|
use RedMCP\McpHttpHandler;
|
||||||
|
use RedMCP\RedmineClient;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
|
$token = $env['mcp_server_token'];
|
||||||
|
if ($token === null) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'MCP_SERVER_TOKEN is required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler = new McpHttpHandler(
|
||||||
|
new McpDispatcher(
|
||||||
|
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']),
|
||||||
|
new McpDebugLogger($env['mcp_debug_log']),
|
||||||
|
$env['mcp_text_sanitization']
|
||||||
|
),
|
||||||
|
$token,
|
||||||
|
getenv('MCP_HTTP_PATH') ?: '/mcp'
|
||||||
|
);
|
||||||
|
$handler->handle();
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$options = getopt('', ['bytes:', 'env-line', 'help']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
fwrite(STDOUT, "Usage: generate-bearer-token.php [--bytes 32] [--env-line]\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = isset($options['bytes']) ? (int) $options['bytes'] : 32;
|
||||||
|
if ($bytes < 16) {
|
||||||
|
fwrite(STDERR, "--bytes must be at least 16.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
|
||||||
|
if (isset($options['env-line'])) {
|
||||||
|
fwrite(STDOUT, 'MCP_SERVER_TOKEN=' . $token . PHP_EOL);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, $token . PHP_EOL);
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpEnvironment;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$options = getopt('', ['host:', 'port:', 'path:', 'pid-file:', 'debug-log:', 'status', 'stop', 'force', 'help']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
fwrite(
|
||||||
|
STDOUT,
|
||||||
|
"Usage: redmcp-http-server.php [--host 127.0.0.1] [--port 8765] [--path /mcp] [--pid-file /tmp/redmcp-http-server.pid] [--debug-log /tmp/redmcp-mcp.log] [--status|--stop] [--force]\n"
|
||||||
|
);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string) ($options['host'] ?? '127.0.0.1');
|
||||||
|
$port = (int) ($options['port'] ?? 8765);
|
||||||
|
$path = (string) ($options['path'] ?? '/mcp');
|
||||||
|
$pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid');
|
||||||
|
$debugLog = isset($options['debug-log']) ? (string) $options['debug-log'] : null;
|
||||||
|
$force = isset($options['force']);
|
||||||
|
|
||||||
|
if (isset($options['status'])) {
|
||||||
|
showStatus($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['stop'])) {
|
||||||
|
stopServer($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLivePidFile($pidFile)) {
|
||||||
|
fwrite(STDERR, "redMCP HTTP server already appears to be running with PID " . trim((string) file_get_contents($pidFile)) . ".\n");
|
||||||
|
fwrite(STDERR, "Use --stop first, or --force to replace a stale PID file.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($pidFile)) {
|
||||||
|
if (!$force) {
|
||||||
|
fwrite(STDERR, "Stale PID file exists at {$pidFile}. Use --force to remove it.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
|
if ($env['mcp_server_token'] === null) {
|
||||||
|
throw new RuntimeException('MCP_SERVER_TOKEN is required for the network MCP server.');
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('MCP_HTTP_PATH=' . $path);
|
||||||
|
if ($debugLog !== null && $debugLog !== '') {
|
||||||
|
putenv('MCP_DEBUG_LOG=' . $debugLog);
|
||||||
|
}
|
||||||
|
$router = __DIR__ . '/../app/mcp-http-router.php';
|
||||||
|
$command = [
|
||||||
|
PHP_BINARY,
|
||||||
|
'-S',
|
||||||
|
$host . ':' . $port,
|
||||||
|
$router,
|
||||||
|
];
|
||||||
|
|
||||||
|
fwrite(STDERR, "redMCP HTTP server listening on http://{$host}:{$port}{$path}\n");
|
||||||
|
fwrite(STDERR, "Authorization: Bearer <MCP_SERVER_TOKEN> is required.\n");
|
||||||
|
if ($debugLog !== null && $debugLog !== '') {
|
||||||
|
fwrite(STDERR, "Debug log: {$debugLog}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptorSpec = [
|
||||||
|
0 => STDIN,
|
||||||
|
1 => STDOUT,
|
||||||
|
2 => STDERR,
|
||||||
|
];
|
||||||
|
$process = proc_open($command, $descriptorSpec, $pipes);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
fwrite(STDERR, "Could not start PHP built-in HTTP server.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = proc_get_status($process);
|
||||||
|
$pid = (int) ($status['pid'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
proc_terminate($process);
|
||||||
|
fwrite(STDERR, "Could not determine HTTP server PID.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pidDir = dirname($pidFile);
|
||||||
|
if ($pidDir !== '' && $pidDir !== '.' && !is_dir($pidDir)) {
|
||||||
|
mkdir($pidDir, 0775, true);
|
||||||
|
}
|
||||||
|
file_put_contents($pidFile, (string) $pid);
|
||||||
|
fwrite(STDERR, "PID file: {$pidFile} ({$pid})\n");
|
||||||
|
|
||||||
|
$exitCode = proc_close($process);
|
||||||
|
if (is_file($pidFile) && trim((string) file_get_contents($pidFile)) === (string) $pid) {
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
exit((int) $exitCode);
|
||||||
|
|
||||||
|
function showStatus(string $pidFile): void
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
if ($pid > 0 && pidAlive($pid)) {
|
||||||
|
fwrite(STDOUT, "running: PID {$pid} from {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "stale: PID file {$pidFile} points to non-running PID {$pid}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer(string $pidFile): void
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
if ($pid <= 0 || !pidAlive($pid)) {
|
||||||
|
unlink($pidFile);
|
||||||
|
fwrite(STDOUT, "removed stale PID file {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopPid($pid)) {
|
||||||
|
fwrite(STDERR, "could not stop PID {$pid}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = time() + 5;
|
||||||
|
while (pidAlive($pid) && time() < $deadline) {
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
if (pidAlive($pid)) {
|
||||||
|
fwrite(STDERR, "PID {$pid} did not stop within timeout\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($pidFile)) {
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, "stopped PID {$pid}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLivePidFile(string $pidFile): bool
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
return $pid > 0 && pidAlive($pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pidAlive(int $pid): bool
|
||||||
|
{
|
||||||
|
if (function_exists('posix_kill')) {
|
||||||
|
return posix_kill($pid, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('kill -0 ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode);
|
||||||
|
return $exitCode === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPid(int $pid): bool
|
||||||
|
{
|
||||||
|
if (function_exists('posix_kill')) {
|
||||||
|
return posix_kill($pid, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('kill ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode);
|
||||||
|
return $exitCode === 0;
|
||||||
|
}
|
||||||
@@ -3,11 +3,25 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpDebugLogger;
|
||||||
|
use RedMCP\McpEnvironment;
|
||||||
|
use RedMCP\McpStdioServer;
|
||||||
use RedMCP\RedmineClient;
|
use RedMCP\RedmineClient;
|
||||||
|
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
main();
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
|
$server = new McpStdioServer(
|
||||||
|
new McpDispatcher(
|
||||||
|
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']),
|
||||||
|
new McpDebugLogger($env['mcp_debug_log']),
|
||||||
|
$env['mcp_text_sanitization']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$server->run();
|
||||||
|
exit(0);
|
||||||
|
__halt_compiler();
|
||||||
|
|
||||||
function main(): void
|
function main(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$repoRoot = dirname(__DIR__);
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/redmcp-http-test-' . getmypid();
|
||||||
|
if (!mkdir($tmpDir, 0775, true) && !is_dir($tmpDir)) {
|
||||||
|
throw new RuntimeException('Could not create temp test dir.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$router = $tmpDir . '/router.php';
|
||||||
|
file_put_contents($router, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpHttpHandler;
|
||||||
|
use RedMCP\RedmineClient;
|
||||||
|
|
||||||
|
require '%AUTOLOAD%';
|
||||||
|
|
||||||
|
$handler = new McpHttpHandler(
|
||||||
|
new McpDispatcher(RedmineClient::fromCredentials('http://127.0.0.1', 'test-key')),
|
||||||
|
'test-token',
|
||||||
|
'/mcp'
|
||||||
|
);
|
||||||
|
$handler->handle();
|
||||||
|
PHP);
|
||||||
|
file_put_contents($router, str_replace('%AUTOLOAD%', addslashes($repoRoot . '/vendor/autoload.php'), (string) file_get_contents($router)));
|
||||||
|
|
||||||
|
$port = 18765 + (getmypid() % 1000);
|
||||||
|
$command = [PHP_BINARY, '-S', '127.0.0.1:' . $port, $router];
|
||||||
|
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $repoRoot);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
throw new RuntimeException('Could not start PHP built-in server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$assertions = 0;
|
||||||
|
try {
|
||||||
|
waitForServer($port);
|
||||||
|
|
||||||
|
$sse = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json, text/event-stream',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 200 OK', $sse['headers'], 'SSE POST returns 200', $assertions);
|
||||||
|
assertContains('text/event-stream', $sse['headers'], 'SSE POST returns event stream content type', $assertions);
|
||||||
|
assertContains('X-Accel-Buffering: no', $sse['headers'], 'SSE POST disables proxy buffering', $assertions);
|
||||||
|
assertContains("event: message\n", $sse['body'], 'SSE POST emits a message event', $assertions);
|
||||||
|
assertContains('data: {"jsonrpc":"2.0","id":1,"result":[]}', $sse['body'], 'SSE POST emits JSON-RPC response data', $assertions);
|
||||||
|
|
||||||
|
$json = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('application/json', $json['headers'], 'JSON POST preserves application/json content type', $assertions);
|
||||||
|
assertContains('"id":2', $json['body'], 'JSON POST emits JSON-RPC response body', $assertions);
|
||||||
|
|
||||||
|
$get = httpRequest(
|
||||||
|
'GET',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Accept: text/event-stream',
|
||||||
|
],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 405 Method Not Allowed', $get['headers'], 'GET returns method-not-allowed until standalone streams exist', $assertions);
|
||||||
|
assertContains('Allow: POST', $get['headers'], 'GET advertises supported method', $assertions);
|
||||||
|
|
||||||
|
$origin = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json',
|
||||||
|
'Origin: https://example.invalid',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":3,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 403 Forbidden', $origin['headers'], 'disallowed browser origin returns forbidden', $assertions);
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK {$assertions} assertions\n");
|
||||||
|
} finally {
|
||||||
|
proc_terminate($process);
|
||||||
|
proc_close($process);
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
if (is_resource($pipe)) {
|
||||||
|
fclose($pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unlink($router);
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForServer(int $port): void
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + 5;
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
$socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
|
||||||
|
if (is_resource($socket)) {
|
||||||
|
fclose($socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Timed out waiting for test HTTP server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $headers
|
||||||
|
* @return array{headers:string,body:string}
|
||||||
|
*/
|
||||||
|
function httpRequest(string $method, string $url, array $headers, ?string $body): array
|
||||||
|
{
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
throw new RuntimeException('Could not initialize curl.');
|
||||||
|
}
|
||||||
|
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($curl, CURLOPT_HEADER, true);
|
||||||
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$raw = curl_exec($curl);
|
||||||
|
if (!is_string($raw)) {
|
||||||
|
throw new RuntimeException('curl failed: ' . curl_error($curl));
|
||||||
|
}
|
||||||
|
$headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'headers' => substr($raw, 0, $headerSize),
|
||||||
|
'body' => substr($raw, $headerSize),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContains(string $needle, string $haystack, string $message, int &$assertions): void
|
||||||
|
{
|
||||||
|
$assertions++;
|
||||||
|
if (strpos($haystack, $needle) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\ListQueryNormalizer;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
final class QueryNormalizerTest
|
||||||
|
{
|
||||||
|
private int $assertions = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->testDefaultPagingAndRawOverride();
|
||||||
|
$this->testIssueFriendlyFilters();
|
||||||
|
$this->testSortShortcutsAndStructuredSort();
|
||||||
|
$this->testDatePresetsAndRanges();
|
||||||
|
$this->testUserParams();
|
||||||
|
$this->testSearchParams();
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK {$this->assertions} assertions\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDefaultPagingAndRawOverride(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::listParams([
|
||||||
|
'limit' => 500,
|
||||||
|
'page' => 3,
|
||||||
|
'params' => ['limit' => 75],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(75, $params['limit'], 'raw params override normalized limit');
|
||||||
|
$this->assertSame(200, $params['offset'], 'page offset uses clamped normalized limit before raw override');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testIssueFriendlyFilters(): void
|
||||||
|
{
|
||||||
|
$filters = ListQueryNormalizer::issueFilters([
|
||||||
|
'project_id' => 'customer-service',
|
||||||
|
'status' => 'open',
|
||||||
|
'assigned_to_id' => 25,
|
||||||
|
'updated' => 'since 2026-04-01',
|
||||||
|
'filters' => ['assigned_to_id' => 'me'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('customer-service', $filters['project_id'], 'project_id is copied');
|
||||||
|
$this->assertSame('open', $filters['status_id'], 'open status alias maps to Redmine open status');
|
||||||
|
$this->assertSame('me', $filters['assigned_to_id'], 'raw filters override normalized filters');
|
||||||
|
$this->assertSame('>=2026-04-01', $filters['updated_on'], 'since date maps to Redmine lower bound');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testSortShortcutsAndStructuredSort(): void
|
||||||
|
{
|
||||||
|
$newest = ListQueryNormalizer::listParams(['sort' => 'newest']);
|
||||||
|
$this->assertSame('updated_on:desc', $newest['sort'], 'newest shortcut sorts by updated_on descending');
|
||||||
|
|
||||||
|
$priority = ListQueryNormalizer::listParams(['sort' => 'priority']);
|
||||||
|
$this->assertSame('priority:desc,updated_on:desc', $priority['sort'], 'priority shortcut includes updated_on tie-breaker');
|
||||||
|
|
||||||
|
$structured = ListQueryNormalizer::listParams([
|
||||||
|
'sort' => [
|
||||||
|
['field' => 'created_on', 'direction' => 'desc'],
|
||||||
|
['field' => 'id', 'direction' => 'asc'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertSame('created_on:desc,id:asc', $structured['sort'], 'structured sort converts to Redmine sort string');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDatePresetsAndRanges(): void
|
||||||
|
{
|
||||||
|
$clock = new DateTimeImmutable('2026-04-25 12:00:00', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
$today = ListQueryNormalizer::issueFilters(['created' => 'today'], $clock);
|
||||||
|
$this->assertSame('2026-04-25', $today['created_on'], 'today maps to exact date');
|
||||||
|
|
||||||
|
$range = ListQueryNormalizer::issueFilters(['created' => '2026-04-01..2026-04-25'], $clock);
|
||||||
|
$this->assertSame('><2026-04-01|2026-04-25', $range['created_on'], 'range string maps to Redmine between syntax');
|
||||||
|
|
||||||
|
$objectRange = ListQueryNormalizer::issueFilters(['due' => ['from' => '2026-05-01', 'to' => '2026-05-31']], $clock);
|
||||||
|
$this->assertSame('><2026-05-01|2026-05-31', $objectRange['due_date'], 'object range maps to due_date');
|
||||||
|
|
||||||
|
$lastSeven = ListQueryNormalizer::issueFilters(['updated' => 'last 7 days'], $clock);
|
||||||
|
$this->assertSame('><2026-04-19|2026-04-25', $lastSeven['updated_on'], 'last N days includes today');
|
||||||
|
|
||||||
|
$freeText = ListQueryNormalizer::issueFilters(['created' => 'April 2 2026'], $clock);
|
||||||
|
$this->assertSame('2026-04-02', $freeText['created_on'], 'simple free text date parses');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testSearchParams(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::searchParams([
|
||||||
|
'project_id' => 'customer-service',
|
||||||
|
'all_words' => true,
|
||||||
|
'titles_only' => false,
|
||||||
|
'open_issues' => true,
|
||||||
|
'sort' => 'oldest',
|
||||||
|
'page' => 2,
|
||||||
|
'params' => ['offset' => 5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('customer-service', $params['project_id'], 'search project_id is copied');
|
||||||
|
$this->assertSame('1', $params['all_words'], 'true search flags map to Redmine 1');
|
||||||
|
$this->assertSame('0', $params['titles_only'], 'false search flags map to Redmine 0');
|
||||||
|
$this->assertSame('1', $params['open_issues'], 'open_issues flag maps to Redmine 1');
|
||||||
|
$this->assertSame('created_on:asc', $params['sort'], 'oldest shortcut maps to created_on ascending');
|
||||||
|
$this->assertSame(5, $params['offset'], 'raw search params override normalized paging');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testUserParams(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::userParams([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'danny',
|
||||||
|
'group_id' => 4,
|
||||||
|
'sort' => 'oldest',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $params['status'], 'active user status maps to Redmine active status id');
|
||||||
|
$this->assertSame('danny', $params['name'], 'user name filter is copied');
|
||||||
|
$this->assertSame(4, $params['group_id'], 'user group filter is copied');
|
||||||
|
$this->assertSame('created_on:asc', $params['sort'], 'user list accepts sort shortcuts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $expected
|
||||||
|
* @param mixed $actual
|
||||||
|
*/
|
||||||
|
private function assertSame($expected, $actual, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if ($expected === $actual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new QueryNormalizerTest())->run();
|
||||||
Executable
+647
@@ -0,0 +1,647 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\RedmineClient;
|
||||||
|
use RedMCP\McpDispatcher;
|
||||||
|
use Redmine\Api;
|
||||||
|
use Redmine\Client\Client;
|
||||||
|
use Redmine\Http\HttpClient;
|
||||||
|
use Redmine\Http\HttpFactory;
|
||||||
|
use Redmine\Http\Request;
|
||||||
|
use Redmine\Http\Response;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
final class RecordingClient implements Client, HttpClient
|
||||||
|
{
|
||||||
|
/** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
|
||||||
|
public array $requests = [];
|
||||||
|
|
||||||
|
/** @var array<int,Response> */
|
||||||
|
private array $responses = [];
|
||||||
|
|
||||||
|
public function queueJson(array $payload, int $status = 200): void
|
||||||
|
{
|
||||||
|
$encoded = json_encode($payload);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new RuntimeException('Could not encode fixture JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->responses[] = HttpFactory::makeResponse($status, 'application/json', $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queueBinary(string $content, string $contentType = 'application/octet-stream', int $status = 200): void
|
||||||
|
{
|
||||||
|
$this->responses[] = HttpFactory::makeResponse($status, $contentType, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->requests[] = [
|
||||||
|
'method' => $request->getMethod(),
|
||||||
|
'path' => $request->getPath(),
|
||||||
|
'content_type' => $request->getContentType(),
|
||||||
|
'content' => $request->getContent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_shift($this->responses) ?? HttpFactory::makeResponse(200, 'application/json', '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApi(string $name): Api
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Unexpected vendor API call for ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startImpersonateUser(string $username): void {}
|
||||||
|
public function stopImpersonateUser(): void {}
|
||||||
|
public function requestGet(string $path): bool { return false; }
|
||||||
|
public function requestPost(string $path, string $body): bool { return false; }
|
||||||
|
public function requestPut(string $path, string $body): bool { return false; }
|
||||||
|
public function requestDelete(string $path): bool { return false; }
|
||||||
|
public function getLastResponseStatusCode(): int { return 0; }
|
||||||
|
public function getLastResponseContentType(): string { return ''; }
|
||||||
|
public function getLastResponseBody(): string { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RedmineStructureTest
|
||||||
|
{
|
||||||
|
private int $assertions = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->testCreateIssuePreservesStructureFields();
|
||||||
|
$this->testUpdateIssuePreservesParentAndUploads();
|
||||||
|
$this->testMcpCreateIssueAcceptsFlatIssueFields();
|
||||||
|
$this->testMcpUpdateIssueAcceptsFlatIssueFields();
|
||||||
|
$this->testMcpFindProjectRecommendsExactIdentifier();
|
||||||
|
$this->testMcpFindProjectRecommendsExactName();
|
||||||
|
$this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended();
|
||||||
|
$this->testMcpGetProjectResolvesHumanProjectNameToIdentifier();
|
||||||
|
$this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName();
|
||||||
|
$this->testMcpSearchSanitizesNoisyTextFields();
|
||||||
|
$this->testMcpSearchCanDisableTextSanitization();
|
||||||
|
$this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
|
||||||
|
$this->testAttachmentUploadSupportsPathAndBase64();
|
||||||
|
$this->testAttachmentUploadAcceptsPdfDataUrl();
|
||||||
|
$this->testAttachmentUploadAcceptsFileEnvelope();
|
||||||
|
$this->testDownloadPathValidationRejectsUnsafePaths();
|
||||||
|
$this->testDownloadAttachmentWritesSafePathAndLimitsBase64();
|
||||||
|
$this->testMcpToolListExposesStructureToolsWithoutIssueDelete();
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK {$this->assertions} assertions\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testCreateIssuePreservesStructureFields(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['issue' => ['id' => 123]], 201);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$result = $client->createIssue([
|
||||||
|
'project_id' => 'fud-nohelpdesk',
|
||||||
|
'subject' => 'Child issue',
|
||||||
|
'parent_issue_id' => 99,
|
||||||
|
'category_id' => 4,
|
||||||
|
'uploads' => [
|
||||||
|
['token' => 'tok-1', 'filename' => 'note.txt', 'content_type' => 'text/plain'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = $http->requests[0];
|
||||||
|
$payload = $this->json($request['content']);
|
||||||
|
$this->assertSame('POST', $request['method'], 'create issue uses POST');
|
||||||
|
$this->assertSame('/issues.json', $request['path'], 'create issue uses raw JSON endpoint');
|
||||||
|
$this->assertSame(99, $payload['issue']['parent_issue_id'], 'create issue preserves parent_issue_id');
|
||||||
|
$this->assertSame(4, $payload['issue']['category_id'], 'create issue preserves category_id');
|
||||||
|
$this->assertSame('tok-1', $payload['issue']['uploads'][0]['token'], 'create issue preserves upload tokens');
|
||||||
|
$this->assertSame(123, $result['id'], 'create issue unwraps issue response');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testUpdateIssuePreservesParentAndUploads(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson([], 204);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$ok = $client->updateIssue(123, [
|
||||||
|
'parent_id' => 99,
|
||||||
|
'category_id' => 4,
|
||||||
|
'uploads' => [
|
||||||
|
['token' => 'tok-2', 'filename' => 'followup.txt'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = $http->requests[0];
|
||||||
|
$payload = $this->json($request['content']);
|
||||||
|
$this->assertSame(true, $ok, 'update issue returns true on 204');
|
||||||
|
$this->assertSame('PUT', $request['method'], 'update issue uses PUT');
|
||||||
|
$this->assertSame('/issues/123.json', $request['path'], 'update issue uses raw JSON endpoint');
|
||||||
|
$this->assertSame(99, $payload['issue']['parent_id'], 'update issue preserves parent_id');
|
||||||
|
$this->assertSame('tok-2', $payload['issue']['uploads'][0]['token'], 'update issue preserves upload tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpCreateIssueAcceptsFlatIssueFields(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['issue' => ['id' => 321]], 201);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$response = $dispatcher->handleMessage([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => 'redmine_create_issue',
|
||||||
|
'arguments' => [
|
||||||
|
'project_id' => 'quality-tracker',
|
||||||
|
'subject' => 'Front warehouse deadbolt key gets stuck in lock',
|
||||||
|
'description' => "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!is_array($response) || isset($response['error'])) {
|
||||||
|
throw new RuntimeException('Expected flat create issue call to succeed: ' . json_encode($response));
|
||||||
|
}
|
||||||
|
$payload = $this->json($http->requests[0]['content']);
|
||||||
|
$this->assertSame('quality-tracker', $payload['issue']['project_id'], 'flat MCP create preserves project_id');
|
||||||
|
$this->assertSame('Front warehouse deadbolt key gets stuck in lock', $payload['issue']['subject'], 'flat MCP create preserves subject');
|
||||||
|
$this->assertStringContains('~HermesBot', $payload['issue']['description'], 'flat MCP create preserves multiline description');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpUpdateIssueAcceptsFlatIssueFields(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson([], 204);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$response = $dispatcher->handleMessage([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => 'redmine_update_issue',
|
||||||
|
'arguments' => [
|
||||||
|
'issue_id' => 321,
|
||||||
|
'notes' => 'Locksmith has been scheduled.',
|
||||||
|
'status_id' => 2,
|
||||||
|
'options' => ['send_helpdesk_email' => false],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!is_array($response) || isset($response['error'])) {
|
||||||
|
throw new RuntimeException('Expected flat update issue call to succeed: ' . json_encode($response));
|
||||||
|
}
|
||||||
|
$payload = $this->json($http->requests[0]['content']);
|
||||||
|
$this->assertSame('/issues/321.json', $http->requests[0]['path'], 'flat MCP update uses requested issue id');
|
||||||
|
$this->assertSame('Locksmith has been scheduled.', $payload['issue']['notes'], 'flat MCP update preserves notes');
|
||||||
|
$this->assertSame(2, $payload['issue']['status_id'], 'flat MCP update preserves status_id');
|
||||||
|
$this->assertSame(false, isset($payload['issue']['options']), 'flat MCP update does not forward options as issue field');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpFindProjectRecommendsExactIdentifier(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality-tracker']);
|
||||||
|
|
||||||
|
$this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact identifier produces recommendation');
|
||||||
|
$this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'exact identifier match is first');
|
||||||
|
$this->assertSame('exact_identifier', $result['matches'][0]['match_reason'], 'exact identifier reason is reported');
|
||||||
|
$this->assertSame('quality-tracker', $result['matches'][0]['project_id_to_use'], 'identifier is preferred project_id_to_use');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpFindProjectRecommendsExactName(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'Quality Tracker']);
|
||||||
|
|
||||||
|
$this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact project name produces recommendation');
|
||||||
|
$this->assertSame('Quality Tracker', $result['matches'][0]['name'], 'exact name match is first');
|
||||||
|
$this->assertSame('exact_name', $result['matches'][0]['match_reason'], 'exact name reason is reported');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality']);
|
||||||
|
|
||||||
|
$this->assertSame(null, $result['recommended_project_id'], 'ambiguous project query has no recommendation');
|
||||||
|
$this->assertSame(2, count($result['matches']), 'ambiguous project query returns both matches');
|
||||||
|
$this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'first ambiguous match is ranked deterministically');
|
||||||
|
$this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpGetProjectResolvesHumanProjectNameToIdentifier(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||||
|
$http->queueJson(['project' => ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker']]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_get_project', ['project_id' => 'Quality Tracker']);
|
||||||
|
|
||||||
|
$this->assertSame(78, $result['id'], 'human project name resolves to expected project');
|
||||||
|
$this->assertSame('/projects/quality-tracker.json', $http->requests[1]['path'], 'resolved project lookup uses project identifier slug');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$response = $dispatcher->handleMessage([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => 'redmine_get_project',
|
||||||
|
'arguments' => [
|
||||||
|
'project_id' => 'Quality',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!is_array($response) || !isset($response['error']) || !is_array($response['error'])) {
|
||||||
|
throw new RuntimeException('Expected ambiguous project name to produce an MCP error.');
|
||||||
|
}
|
||||||
|
$message = (string) ($response['error']['message'] ?? '');
|
||||||
|
$this->assertStringContains('redmine_find_project', $message, 'ambiguous project error points to resolver tool');
|
||||||
|
$this->assertStringContains('quality-tracker', $message, 'ambiguous project error provides possible identifier matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpSearchSanitizesNoisyTextFields(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson([
|
||||||
|
'results' => [[
|
||||||
|
'title' => 'Ticket result',
|
||||||
|
'description' => "Caf\u{00E9}\u{200B} issue\x07 !!!!!!!!!!\n\n\n\nDone",
|
||||||
|
'notes' => "Agent\u{FEFF} note\x1F........",
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']);
|
||||||
|
$description = (string) $result['results'][0]['description'];
|
||||||
|
$notes = (string) $result['results'][0]['notes'];
|
||||||
|
|
||||||
|
$this->assertStringContains('Café issue', $description, 'sanitizer preserves readable unicode content');
|
||||||
|
$this->assertNotStringContains("\x07", $description, 'sanitizer removes control characters from description');
|
||||||
|
$this->assertNotStringContains("\u{200B}", $description, 'sanitizer removes zero-width characters from description');
|
||||||
|
$this->assertNotStringContains('!!!!!!!!!!', $description, 'sanitizer caps excessive repeated punctuation in description');
|
||||||
|
$this->assertNotStringContains("\n\n\n\n", $description, 'sanitizer caps excessive blank lines in description');
|
||||||
|
$this->assertNotStringContains("\x1F", $notes, 'sanitizer removes control characters from notes');
|
||||||
|
$this->assertNotStringContains('.........', $notes, 'sanitizer caps excessive repeated punctuation in notes');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpSearchCanDisableTextSanitization(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson([
|
||||||
|
'results' => [[
|
||||||
|
'description' => "Raw\u{200B} text\x07 !!!!!!!!!!",
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient($http), null, false);
|
||||||
|
|
||||||
|
$result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']);
|
||||||
|
$description = (string) $result['results'][0]['description'];
|
||||||
|
|
||||||
|
$this->assertStringContains("\u{200B}", $description, 'sanitization toggle off keeps zero-width characters untouched');
|
||||||
|
$this->assertStringContains("\x07", $description, 'sanitization toggle off keeps control characters untouched');
|
||||||
|
$this->assertStringContains('!!!!!!!!!!', $description, 'sanitization toggle off keeps repeated punctuation untouched');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['relation' => ['id' => 55]], 201);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$result = $client->createIssueRelation(10, ['issue_to_id' => 11]);
|
||||||
|
$request = $http->requests[0];
|
||||||
|
$payload = $this->json($request['content']);
|
||||||
|
|
||||||
|
$this->assertSame('/issues/10/relations.json', $request['path'], 'relation create uses issue relations endpoint');
|
||||||
|
$this->assertSame(11, $payload['relation']['issue_to_id'], 'relation create sends issue_to_id');
|
||||||
|
$this->assertSame('relates', $payload['relation']['relation_type'], 'relation create defaults to relates');
|
||||||
|
$this->assertSame(55, $result['id'], 'relation create unwraps relation response');
|
||||||
|
|
||||||
|
$this->assertThrows(
|
||||||
|
static fn() => $client->createIssueRelation(10, []),
|
||||||
|
'issue_to_id',
|
||||||
|
'relation create requires issue_to_id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testAttachmentUploadSupportsPathAndBase64(): void
|
||||||
|
{
|
||||||
|
$path = sys_get_temp_dir() . '/redmcp-upload-test.txt';
|
||||||
|
file_put_contents($path, 'from path');
|
||||||
|
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['upload' => ['token' => 'path-token']], 201);
|
||||||
|
$http->queueJson(['upload' => ['token' => 'base64-token']], 201);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$pathResult = $client->uploadAttachment(['path' => $path, 'content_type' => 'text/plain']);
|
||||||
|
$base64Result = $client->uploadAttachment([
|
||||||
|
'base64_content' => base64_encode('from base64'),
|
||||||
|
'filename' => 'base64.txt',
|
||||||
|
'content_type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('path-token', $pathResult['token'], 'path upload unwraps token');
|
||||||
|
$this->assertStringContains('filename=redmcp-upload-test.txt', $http->requests[0]['path'], 'path upload uses basename as filename');
|
||||||
|
$this->assertSame('from path', $http->requests[0]['content'], 'path upload sends file bytes');
|
||||||
|
$this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'path upload sends bytes as Redmine upload stream');
|
||||||
|
$this->assertSame('text/plain', $pathResult['content_type'], 'path upload preserves desired attachment content type metadata');
|
||||||
|
$this->assertSame('base64-token', $base64Result['token'], 'base64 upload unwraps token');
|
||||||
|
$this->assertStringContains('filename=base64.txt', $http->requests[1]['path'], 'base64 upload uses provided filename');
|
||||||
|
$this->assertSame('from base64', $http->requests[1]['content'], 'base64 upload sends decoded bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testAttachmentUploadAcceptsPdfDataUrl(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['upload' => ['token' => 'pdf-token']], 201);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$result = $client->uploadAttachment([
|
||||||
|
'data_url' => 'data:application/pdf;base64,' . base64_encode('%PDF-1.4 raw pdf bytes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('pdf-token', $result['token'], 'PDF data URL upload unwraps token');
|
||||||
|
$this->assertStringContains('filename=attachment.pdf', $http->requests[0]['path'], 'PDF data URL derives a useful filename');
|
||||||
|
$this->assertSame('%PDF-1.4 raw pdf bytes', $http->requests[0]['content'], 'PDF data URL upload sends decoded bytes');
|
||||||
|
$this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'PDF data URL upload sends bytes as Redmine upload stream');
|
||||||
|
$this->assertSame('application/pdf', $result['content_type'], 'PDF data URL preserves PDF content type metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testAttachmentUploadAcceptsFileEnvelope(): void
|
||||||
|
{
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueJson(['upload' => ['token' => 'file-token']], 201);
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$result = $client->uploadAttachment([
|
||||||
|
'file' => [
|
||||||
|
'name' => 'quote.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'data' => base64_encode('%PDF-1.7 envelope bytes'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('file-token', $result['token'], 'file envelope upload unwraps token');
|
||||||
|
$this->assertStringContains('filename=quote.pdf', $http->requests[0]['path'], 'file envelope uses provided name as filename');
|
||||||
|
$this->assertSame('%PDF-1.7 envelope bytes', $http->requests[0]['content'], 'file envelope sends decoded bytes');
|
||||||
|
$this->assertSame('application/pdf', $result['content_type'], 'file envelope preserves MIME type metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDownloadPathValidationRejectsUnsafePaths(): void
|
||||||
|
{
|
||||||
|
$client = new RedmineClient(new RecordingClient());
|
||||||
|
|
||||||
|
$this->assertThrows(
|
||||||
|
static fn() => $client->downloadAttachment(77, '/etc/redmcp-forbidden.txt'),
|
||||||
|
'under /tmp or the repository tree',
|
||||||
|
'download rejects paths outside safe roots'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDownloadAttachmentWritesSafePathAndLimitsBase64(): void
|
||||||
|
{
|
||||||
|
$destination = sys_get_temp_dir() . '/redmcp-download-test.txt';
|
||||||
|
if (file_exists($destination)) {
|
||||||
|
unlink($destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
$http = new RecordingClient();
|
||||||
|
$http->queueBinary('downloaded attachment', 'text/plain');
|
||||||
|
$client = new RedmineClient($http);
|
||||||
|
|
||||||
|
$result = $client->downloadAttachment(77, $destination, true, 4);
|
||||||
|
|
||||||
|
$this->assertSame('/attachments/download/77', $http->requests[0]['path'], 'download uses Redmine attachment download endpoint');
|
||||||
|
$this->assertSame('downloaded attachment', (string) file_get_contents($destination), 'download writes attachment bytes');
|
||||||
|
$this->assertSame(21, $result['bytes'], 'download reports byte length');
|
||||||
|
$this->assertSame(true, $result['base64_omitted'], 'download omits oversized base64 content');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testMcpToolListExposesStructureToolsWithoutIssueDelete(): void
|
||||||
|
{
|
||||||
|
$dispatcher = new McpDispatcher(new RedmineClient(new RecordingClient()));
|
||||||
|
$response = $dispatcher->handleMessage([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => 'tools/list',
|
||||||
|
]);
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Expected tools/list response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = array_map(
|
||||||
|
static fn(array $tool): string => (string) $tool['name'],
|
||||||
|
$response['result']['tools']
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'redmine_list_issue_relations',
|
||||||
|
'redmine_find_project',
|
||||||
|
'redmine_get_issue_relation',
|
||||||
|
'redmine_create_issue_relation',
|
||||||
|
'redmine_remove_issue_relation',
|
||||||
|
'redmine_list_issue_children',
|
||||||
|
'redmine_set_issue_parent',
|
||||||
|
'redmine_clear_issue_parent',
|
||||||
|
'redmine_list_project_issue_categories',
|
||||||
|
'redmine_get_issue_category',
|
||||||
|
'redmine_create_issue_category',
|
||||||
|
'redmine_update_issue_category',
|
||||||
|
'redmine_get_attachment',
|
||||||
|
'redmine_upload_attachment',
|
||||||
|
'redmine_download_attachment',
|
||||||
|
'redmine_update_attachment',
|
||||||
|
] as $expectedTool) {
|
||||||
|
$this->assertContains($expectedTool, $names, $expectedTool . ' is listed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotContains('redmine_delete_issue', $names, 'issue delete tool is not listed');
|
||||||
|
|
||||||
|
$uploadTool = null;
|
||||||
|
foreach ($response['result']['tools'] as $tool) {
|
||||||
|
if (($tool['name'] ?? '') === 'redmine_upload_attachment') {
|
||||||
|
$uploadTool = $tool;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($uploadTool)) {
|
||||||
|
throw new RuntimeException('Expected redmine_upload_attachment tool schema.');
|
||||||
|
}
|
||||||
|
$uploadProperties = array_keys($uploadTool['inputSchema']['properties']);
|
||||||
|
$this->assertContains('data_url', $uploadProperties, 'upload tool advertises data_url input');
|
||||||
|
$this->assertContains('file', $uploadProperties, 'upload tool advertises file envelope input');
|
||||||
|
|
||||||
|
$createIssueTool = null;
|
||||||
|
foreach ($response['result']['tools'] as $tool) {
|
||||||
|
if (($tool['name'] ?? '') === 'redmine_create_issue') {
|
||||||
|
$createIssueTool = $tool;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($createIssueTool)) {
|
||||||
|
throw new RuntimeException('Expected redmine_create_issue tool schema.');
|
||||||
|
}
|
||||||
|
$projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description'];
|
||||||
|
$this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function projectFixtures(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker'],
|
||||||
|
['id' => 79, 'identifier' => 'quality-archive', 'name' => 'Quality Archive'],
|
||||||
|
['id' => 80, 'identifier' => 'warehouse', 'name' => 'Warehouse Operations'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function callToolJson(McpDispatcher $dispatcher, string $name, array $arguments): array
|
||||||
|
{
|
||||||
|
$response = $dispatcher->handleMessage([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => $name,
|
||||||
|
'arguments' => $arguments,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
if (!is_array($response) || isset($response['error'])) {
|
||||||
|
throw new RuntimeException('Expected MCP tool call to succeed: ' . json_encode($response));
|
||||||
|
}
|
||||||
|
$content = $response['result']['content'][0]['text'] ?? null;
|
||||||
|
if (!is_string($content)) {
|
||||||
|
throw new RuntimeException('Expected MCP tool text content.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function json(string $content): array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Invalid JSON: ' . $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $expected
|
||||||
|
* @param mixed $actual
|
||||||
|
*/
|
||||||
|
private function assertSame($expected, $actual, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if ($expected === $actual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertStringContains(string $needle, string $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (strpos($haystack, $needle) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNotStringContains(string $needle, string $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (strpos($haystack, $needle) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nUnexpected needle: {$needle}\nHaystack: {$haystack}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $haystack
|
||||||
|
*/
|
||||||
|
private function assertContains(string $needle, array $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (in_array($needle, $haystack, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $haystack
|
||||||
|
*/
|
||||||
|
private function assertNotContains(string $needle, array $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (!in_array($needle, $haystack, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
try {
|
||||||
|
$callback();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (strpos($exception->getMessage(), $expectedMessagePart) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new RedmineStructureTest())->run();
|
||||||
@@ -8,7 +8,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bin": [
|
"bin": [
|
||||||
"bin/redmcp-server.php"
|
"bin/redmcp-server.php",
|
||||||
|
"bin/redmcp-http-server.php",
|
||||||
|
"bin/generate-bearer-token.php",
|
||||||
|
"bin/test-query-normalizer.php",
|
||||||
|
"bin/test-redmine-structure.php",
|
||||||
|
"bin/test-mcp-http-handler.php"
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"kbsali/redmine-api": "^2.9"
|
"kbsali/redmine-api": "^2.9"
|
||||||
|
|||||||
+48
-44
@@ -7,8 +7,6 @@ writes deterministic JSONL records, and marks rows processed only after the
|
|||||||
write succeeds.
|
write succeeds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +15,6 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
@@ -32,15 +29,16 @@ class OutboxWorkerError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.local = local
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
stdout = self.mysql(sql)
|
stdout = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in stdout.splitlines():
|
for line in stdout.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -52,7 +50,10 @@ class RemoteRedmine:
|
|||||||
raise OutboxWorkerError(f"Remote query returned non-hex row: {line[:200]}") from exc
|
raise OutboxWorkerError(f"Remote query returned non-hex row: {line[:200]}") from exc
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mysql(self, sql: str) -> str:
|
def mysql(self, sql):
|
||||||
|
command = self._mysql_runner_command()
|
||||||
|
shell = True
|
||||||
|
if not self.local:
|
||||||
command = [
|
command = [
|
||||||
"ssh",
|
"ssh",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -62,14 +63,16 @@ class RemoteRedmine:
|
|||||||
self.ssh_host,
|
self.ssh_host,
|
||||||
self._mysql_runner_command(),
|
self._mysql_runner_command(),
|
||||||
]
|
]
|
||||||
|
shell = False
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise OutboxWorkerError(f"Could not run ssh: {exc}") from exc
|
raise OutboxWorkerError(f"Could not run ssh: {exc}") from exc
|
||||||
@@ -78,7 +81,7 @@ class RemoteRedmine:
|
|||||||
raise OutboxWorkerError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise OutboxWorkerError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"c = YAML.load_file('config/database.yml')['production']; "
|
||||||
@@ -91,10 +94,11 @@ class RemoteRedmine:
|
|||||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Process Redmine event outbox rows into enriched JSONL documents.")
|
parser = argparse.ArgumentParser(description="Process Redmine event outbox rows into enriched JSONL documents.")
|
||||||
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
||||||
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
||||||
|
parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.")
|
||||||
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
parser.add_argument("--batch-size", type=int, default=20)
|
parser.add_argument("--batch-size", type=int, default=20)
|
||||||
@@ -111,7 +115,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--apply-purge", action="store_true", help="Actually delete rows selected by --purge-processed-days.")
|
parser.add_argument("--apply-purge", action="store_true", help="Actually delete rows selected by --purge-processed-days.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
worker_id = make_worker_id()
|
worker_id = make_worker_id()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -164,7 +168,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def pending_events(remote: RemoteRedmine, limit: int, max_attempts: int, stale_lock_minutes: int) -> list[dict[str, Any]]:
|
def pending_events(remote, limit, max_attempts, stale_lock_minutes):
|
||||||
return remote.mysql_json_lines(
|
return remote.mysql_json_lines(
|
||||||
f"""
|
f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
@@ -190,7 +194,7 @@ LIMIT {sql_int(limit)};
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def outbox_status(remote: RemoteRedmine, max_attempts: int, stale_lock_minutes: int) -> dict[str, Any]:
|
def outbox_status(remote, max_attempts, stale_lock_minutes):
|
||||||
rows = remote.mysql_json_lines(
|
rows = remote.mysql_json_lines(
|
||||||
f"""
|
f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
@@ -217,12 +221,12 @@ FROM event_outbox_events;
|
|||||||
|
|
||||||
|
|
||||||
def claim_events(
|
def claim_events(
|
||||||
remote: RemoteRedmine,
|
remote,
|
||||||
worker_id: str,
|
worker_id,
|
||||||
limit: int,
|
limit,
|
||||||
max_attempts: int,
|
max_attempts,
|
||||||
stale_lock_minutes: int,
|
stale_lock_minutes,
|
||||||
) -> list[dict[str, Any]]:
|
):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -258,7 +262,7 @@ LIMIT {sql_int(limit)};
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def purge_processed(remote: RemoteRedmine, days: int, apply: bool) -> int:
|
def purge_processed(remote, days, apply):
|
||||||
if days < 0:
|
if days < 0:
|
||||||
raise OutboxWorkerError("--purge-processed-days must be zero or greater.")
|
raise OutboxWorkerError("--purge-processed-days must be zero or greater.")
|
||||||
count_sql = f"""
|
count_sql = f"""
|
||||||
@@ -282,9 +286,9 @@ WHERE processed_at IS NOT NULL
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def enrich_event(remote: RemoteRedmine, event: dict[str, Any]) -> list[dict[str, Any]]:
|
def enrich_event(remote, event):
|
||||||
payload = parse_payload(event.get("payload"))
|
payload = parse_payload(event.get("payload"))
|
||||||
documents: list[dict[str, Any]] = [event_document(event, payload)]
|
documents = [event_document(event, payload)]
|
||||||
event_type = str(event.get("event_type") or "")
|
event_type = str(event.get("event_type") or "")
|
||||||
|
|
||||||
if event_type.startswith("helpdesk_ticket."):
|
if event_type.startswith("helpdesk_ticket."):
|
||||||
@@ -301,7 +305,7 @@ def enrich_event(remote: RemoteRedmine, event: dict[str, Any]) -> list[dict[str,
|
|||||||
return [with_event_context(document, event) for document in documents]
|
return [with_event_context(document, event) for document in documents]
|
||||||
|
|
||||||
|
|
||||||
def event_document(event: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
|
def event_document(event, payload):
|
||||||
return {
|
return {
|
||||||
"doc_type": "event",
|
"doc_type": "event",
|
||||||
"doc_id": f"event:{event.get('id')}",
|
"doc_id": f"event:{event.get('id')}",
|
||||||
@@ -318,35 +322,35 @@ def event_document(event: dict[str, Any], payload: dict[str, Any]) -> dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_ticket_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_ticket_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(ticket_sql(f"ht.id IN ({id_list})"))
|
return remote.mysql_json_lines(ticket_sql(f"ht.id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_tickets_by_issue(remote: RemoteRedmine, issue_ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_tickets_by_issue(remote, issue_ids):
|
||||||
id_list = sql_id_list(issue_ids)
|
id_list = sql_id_list(issue_ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(ticket_sql(f"ht.issue_id IN ({id_list})"))
|
return remote.mysql_json_lines(ticket_sql(f"ht.issue_id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_message_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_message_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(message_sql(f"jm.id IN ({id_list})"))
|
return remote.mysql_json_lines(message_sql(f"jm.id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_messages_by_journal(remote: RemoteRedmine, journal_ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_messages_by_journal(remote, journal_ids):
|
||||||
id_list = sql_id_list(journal_ids)
|
id_list = sql_id_list(journal_ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
return remote.mysql_json_lines(message_sql(f"jm.journal_id IN ({id_list})"))
|
return remote.mysql_json_lines(message_sql(f"jm.journal_id IN ({id_list})"))
|
||||||
|
|
||||||
|
|
||||||
def fetch_contact_documents(remote: RemoteRedmine, ids: Iterable[Any]) -> list[dict[str, Any]]:
|
def fetch_contact_documents(remote, ids):
|
||||||
id_list = sql_id_list(ids)
|
id_list = sql_id_list(ids)
|
||||||
if not id_list:
|
if not id_list:
|
||||||
return []
|
return []
|
||||||
@@ -375,7 +379,7 @@ ORDER BY c.id;
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ticket_sql(where_clause: str) -> str:
|
def ticket_sql(where_clause):
|
||||||
return f"""
|
return f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'doc_type', 'ticket',
|
'doc_type', 'ticket',
|
||||||
@@ -423,7 +427,7 @@ ORDER BY ht.id;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def message_sql(where_clause: str) -> str:
|
def message_sql(where_clause):
|
||||||
return f"""
|
return f"""
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'doc_type', 'message',
|
'doc_type', 'message',
|
||||||
@@ -474,7 +478,7 @@ ORDER BY jm.id;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def with_event_context(document: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
def with_event_context(document, event):
|
||||||
document["event_id"] = event.get("id")
|
document["event_id"] = event.get("id")
|
||||||
document["event_type"] = event.get("event_type")
|
document["event_type"] = event.get("event_type")
|
||||||
document["event_occurred_at"] = event.get("occurred_at")
|
document["event_occurred_at"] = event.get("occurred_at")
|
||||||
@@ -482,7 +486,7 @@ def with_event_context(document: dict[str, Any], event: dict[str, Any]) -> dict[
|
|||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
||||||
def append_jsonl(path: Path, documents: Iterable[dict[str, Any]]) -> None:
|
def append_jsonl(path, documents):
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with path.open("a", encoding="utf-8") as handle:
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
for document in documents:
|
for document in documents:
|
||||||
@@ -490,7 +494,7 @@ def append_jsonl(path: Path, documents: Iterable[dict[str, Any]]) -> None:
|
|||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
def mark_processed(remote: RemoteRedmine, event_id: Any, worker_id: str) -> None:
|
def mark_processed(remote, event_id, worker_id):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -501,7 +505,7 @@ WHERE id = {sql_int(event_id)}
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mark_failed(remote: RemoteRedmine, event_id: Any, worker_id: str, exc: Exception) -> None:
|
def mark_failed(remote, event_id, worker_id, exc):
|
||||||
message = f"{exc.__class__.__name__}: {exc}"
|
message = f"{exc.__class__.__name__}: {exc}"
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
@@ -516,7 +520,7 @@ WHERE id = {sql_int(event_id)}
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def release_claims(remote: RemoteRedmine, worker_id: str) -> None:
|
def release_claims(remote, worker_id):
|
||||||
remote.mysql(
|
remote.mysql(
|
||||||
f"""
|
f"""
|
||||||
UPDATE event_outbox_events
|
UPDATE event_outbox_events
|
||||||
@@ -527,7 +531,7 @@ WHERE processed_at IS NULL
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_payload(value: Any) -> dict[str, Any]:
|
def parse_payload(value):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return value
|
return value
|
||||||
if not value:
|
if not value:
|
||||||
@@ -539,7 +543,7 @@ def parse_payload(value: Any) -> dict[str, Any]:
|
|||||||
return parsed if isinstance(parsed, dict) else {}
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def sql_id_list(values: Iterable[Any]) -> str:
|
def sql_id_list(values):
|
||||||
ids = []
|
ids = []
|
||||||
for value in values:
|
for value in values:
|
||||||
try:
|
try:
|
||||||
@@ -551,22 +555,22 @@ def sql_id_list(values: Iterable[Any]) -> str:
|
|||||||
return ",".join(sorted(set(ids), key=int))
|
return ",".join(sorted(set(ids), key=int))
|
||||||
|
|
||||||
|
|
||||||
def sql_int(value: Any) -> int:
|
def sql_int(value):
|
||||||
try:
|
try:
|
||||||
return max(0, int(value))
|
return max(0, int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sql_string(value: str) -> str:
|
def sql_string(value):
|
||||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def make_worker_id() -> str:
|
def make_worker_id():
|
||||||
return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:12]}"
|
return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,17 @@ settings so test mail flows through Mailpit and imported real credentials cannot
|
|||||||
be used accidentally.
|
be used accidentally.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
||||||
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
DEFAULT_SSH_KEY = Path("/home/iadnah/reddev")
|
||||||
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
||||||
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
||||||
|
|
||||||
@@ -55,15 +52,16 @@ class ResetError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.local = local
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
stdout = self.mysql(sql)
|
stdout = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in stdout.splitlines():
|
for line in stdout.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -73,7 +71,10 @@ class RemoteRedmine:
|
|||||||
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
|
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mysql(self, sql: str) -> str:
|
def mysql(self, sql):
|
||||||
|
command = self._mysql_runner_command()
|
||||||
|
shell = True
|
||||||
|
if not self.local:
|
||||||
command = [
|
command = [
|
||||||
"ssh",
|
"ssh",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -83,14 +84,16 @@ class RemoteRedmine:
|
|||||||
self.ssh_host,
|
self.ssh_host,
|
||||||
self._mysql_runner_command(),
|
self._mysql_runner_command(),
|
||||||
]
|
]
|
||||||
|
shell = False
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise ResetError(f"Could not run ssh: {exc}") from exc
|
raise ResetError(f"Could not run ssh: {exc}") from exc
|
||||||
@@ -99,7 +102,7 @@ class RemoteRedmine:
|
|||||||
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"c = YAML.load_file('config/database.yml')['production']; "
|
||||||
@@ -112,12 +115,13 @@ class RemoteRedmine:
|
|||||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Reset Helpdesk mail settings for all active projects."
|
description="Reset Helpdesk mail settings for all active projects."
|
||||||
)
|
)
|
||||||
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
||||||
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
||||||
|
parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.")
|
||||||
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
|
||||||
parser.add_argument("--pop3-port", type=int, default=1110)
|
parser.add_argument("--pop3-port", type=int, default=1110)
|
||||||
@@ -139,7 +143,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
|
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
projects = find_active_projects(remote, args.project)
|
projects = find_active_projects(remote, args.project)
|
||||||
@@ -166,7 +170,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def find_active_projects(remote: RemoteRedmine, filters: list[str]) -> list[dict[str, Any]]:
|
def find_active_projects(remote, filters):
|
||||||
where = ["p.status = 1"]
|
where = ["p.status = 1"]
|
||||||
if filters:
|
if filters:
|
||||||
clauses = []
|
clauses = []
|
||||||
@@ -190,8 +194,8 @@ ORDER BY p.identifier;
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> list[tuple[int, str, str]]:
|
def build_values(args, projects):
|
||||||
rows: list[tuple[int, str, str]] = []
|
rows = []
|
||||||
for project in projects:
|
for project in projects:
|
||||||
project_id = int(project["id"])
|
project_id = int(project["id"])
|
||||||
answer_from = args.from_pattern.format(
|
answer_from = args.from_pattern.format(
|
||||||
@@ -227,7 +231,7 @@ def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> li
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def apply_values(remote: RemoteRedmine, rows: list[tuple[int, str, str]]) -> None:
|
def apply_values(remote, rows):
|
||||||
statements = ["START TRANSACTION;"]
|
statements = ["START TRANSACTION;"]
|
||||||
for project_id, name, value in rows:
|
for project_id, name, value in rows:
|
||||||
project_id_sql = sql_int(project_id)
|
project_id_sql = sql_int(project_id)
|
||||||
@@ -254,8 +258,8 @@ WHERE NOT EXISTS (
|
|||||||
remote.mysql("\n".join(statements))
|
remote.mysql("\n".join(statements))
|
||||||
|
|
||||||
|
|
||||||
def print_plan(rows: list[tuple[int, str, str]]) -> None:
|
def print_plan(rows):
|
||||||
current_project_id: int | None = None
|
current_project_id = None
|
||||||
for project_id, name, value in rows:
|
for project_id, name, value in rows:
|
||||||
if project_id != current_project_id:
|
if project_id != current_project_id:
|
||||||
current_project_id = project_id
|
current_project_id = project_id
|
||||||
@@ -264,18 +268,18 @@ def print_plan(rows: list[tuple[int, str, str]]) -> None:
|
|||||||
print(f" {name} = {display_value}")
|
print(f" {name} = {display_value}")
|
||||||
|
|
||||||
|
|
||||||
def sql_int(value: Any) -> int:
|
def sql_int(value):
|
||||||
try:
|
try:
|
||||||
return max(0, int(value))
|
return max(0, int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sql_string(value: Any) -> str:
|
def sql_string(value):
|
||||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## Roadmap 2026-05-06
|
||||||
|
|
||||||
|
redMCP is now being used in production, even though the changes to redmine's plugins and the semantic search feature are not yet in production. Since beginning production use, we have learned that the way the MCP server may return certain information wastes valuable tokens and context space for connected agents. This problem doesn't really seem to be an issue with the MCP server itself, but has more to do the contents of tickets themselves.
|
||||||
|
|
||||||
|
The issue is, the "description" field of many redmine tickets may contain a lot of extra non-printing characters, unicode, or things of that nature which do not seve any real purpose, and which waste tokens and context.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Develop a plan for trimming fetched data to minimize junk information without harming the meaning of the data. This should, in particular, strip needlessly repeating characters from fetched issues or notes.
|
||||||
|
|
||||||
|
## Other issues needing addressed
|
||||||
|
|
||||||
|
- [ ] MCP server response when client requests projects by human-name needs to be adjusted.
|
||||||
|
Problem: a client may call list_issues and for the project_id, they may pass "Quality Tracker", which is the human-facing name for that project in redmine. That fails because redmine expects to receive either the numeric project id or the slug (like quality-tracker). This is a mistake that humans can easily make as well.
|
||||||
|
|
||||||
|
Proposed solutions:
|
||||||
|
|
||||||
|
- [ ] When a "project_id" is passed to commands like list_issues and it contains any spaces or uppercase characters, that likely means the client is triggering this bug. If the subsequent call to redmine results in an empty response or error message, it would be helpful if the MCP server returns an error message that explains this problem.
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
OPENAI_API_KEY=
|
||||||
|
QDRANT_URL=http://localhost:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=redmine_semantic_sample
|
||||||
|
REDMINE_URL=http://192.168.50.170
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT_IDENTIFIER=fud-helpdesk
|
||||||
|
REDMINE_SAMPLE_LIMIT=500
|
||||||
|
SEMANTIC_INDEX_HOST=127.0.0.1
|
||||||
|
SEMANTIC_INDEX_PORT=8787
|
||||||
|
SEMANTIC_INDEX_API_KEY=
|
||||||
|
SEMANTIC_INDEX_REFRESH_STATE_PATH=.cache/semantic_index/refresh_state.json
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Redmine Semantic Index
|
||||||
|
|
||||||
|
Local semantic index service for a recent Redmine Helpdesk sample. V1 uses
|
||||||
|
OpenAI `text-embedding-3-small` embeddings and Qdrant vectors, with Redmine as
|
||||||
|
the first source adapter.
|
||||||
|
|
||||||
|
For deploy, validation, and rollback steps, see
|
||||||
|
`docs/semantic_index_deployment_runbook.md`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set local secrets there. Do not commit `.env`.
|
||||||
|
|
||||||
|
Required for live use:
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `QDRANT_URL`
|
||||||
|
- `REDMINE_URL`
|
||||||
|
- `REDMINE_API_KEY`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `QDRANT_API_KEY`
|
||||||
|
- `QDRANT_COLLECTION`
|
||||||
|
- `REDMINE_PROJECT_IDENTIFIER`
|
||||||
|
- `REDMINE_SAMPLE_LIMIT`
|
||||||
|
- `SEMANTIC_INDEX_API_KEY`
|
||||||
|
|
||||||
|
## HTTP
|
||||||
|
|
||||||
|
Install runtime dependencies in your chosen environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install openai qdrant-client fastapi uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvicorn semantic_index.app:app --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /sources/redmine/backfill-sample`
|
||||||
|
- `POST /search`
|
||||||
|
- `GET /documents/{id}`
|
||||||
|
- `GET /projects`
|
||||||
|
|
||||||
|
If `SEMANTIC_INDEX_API_KEY` is set, pass `Authorization: Bearer <key>`.
|
||||||
|
|
||||||
|
Search response shape is shared by HTTP, MCP, and the Python client:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "candidate follow up",
|
||||||
|
"filters": {"project_identifier": "hiring", "limit": 5},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:123:chunk:0",
|
||||||
|
"score": 0.72,
|
||||||
|
"snippet": "Candidate follow up...",
|
||||||
|
"payload": {},
|
||||||
|
"citation": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 123,
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"url": "http://redmine/issues/123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query":"candidate follow up","project_identifier":"hiring","limit":5}' \
|
||||||
|
http://127.0.0.1:8787/search
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
http://127.0.0.1:8787/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Client
|
||||||
|
|
||||||
|
Use the client in-process when running from this repo/environment:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
|
||||||
|
client = SemanticIndexClient.local()
|
||||||
|
results = client.search("callum@safetagtracking.com", project_identifier="customer-service", limit=5)
|
||||||
|
document = client.get_document(results["results"][0]["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Use HTTP mode from another local program:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
|
||||||
|
client = SemanticIndexClient(base_url="http://127.0.0.1:8787", api_key="...")
|
||||||
|
results = client.search("candidate follow up", project_identifier="hiring", limit=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
|
||||||
|
Refresh the configured Redmine sample from the command line:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-sample --limit 50
|
||||||
|
```
|
||||||
|
|
||||||
|
When `REDMINE_PROJECT_IDENTIFIER` is set, the rebuild deletes and replaces only
|
||||||
|
indexed Redmine documents for that project. Without a project identifier, it
|
||||||
|
rebuilds the Redmine source sample for the collection.
|
||||||
|
|
||||||
|
Refresh a balanced multi-project sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-projects \
|
||||||
|
--projects customer-service,hiring,todo-jason,sales-inbox,business-development,dock-scheduling,prep-standardization \
|
||||||
|
--per-project-limit 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Use project-specific limits when Customer Service should stay larger than the
|
||||||
|
internal project sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-project backfill rebuilds each project scope independently. Non-Helpdesk
|
||||||
|
projects are indexed as ordinary Redmine issues and journals; they are not
|
||||||
|
expected to have Helpdesk contact metadata.
|
||||||
|
|
||||||
|
## Rolling Refresh
|
||||||
|
|
||||||
|
Use rolling refresh for routine updates after an initial backfill:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --refresh-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100 \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run reports what would change without calling OpenAI or writing to Qdrant.
|
||||||
|
Remove `--dry-run` to apply the refresh.
|
||||||
|
|
||||||
|
The refresh maps each recent Redmine issue to stable document IDs, reads the
|
||||||
|
existing Qdrant payloads for that issue, and compares `source_hash` values.
|
||||||
|
Only new or changed documents are embedded and upserted. Unchanged documents
|
||||||
|
are left alone, and stale documents for refreshed issues are deleted without
|
||||||
|
embedding. Use `--force-rebuild` only when you explicitly want to re-embed
|
||||||
|
matching documents.
|
||||||
|
|
||||||
|
The default local state file is `.cache/semantic_index/refresh_state.json`.
|
||||||
|
After a successful refresh, later runs skip issues older than the previous
|
||||||
|
success timestamp minus `--overlap-minutes` unless `--force-rebuild` is used.
|
||||||
|
Override it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --refresh-redmine-projects \
|
||||||
|
--project-limits customer-service=500 \
|
||||||
|
--state-path /tmp/semantic-refresh-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTTP endpoint exposes the same behavior:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -X POST http://127.0.0.1:8787/sources/redmine/refresh \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"project_limits":{"customer-service":500},"dry_run":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
For production-style operation, use the wrapper script. It defaults to dry-run
|
||||||
|
and writes timestamped logs under `.cache/semantic_index/logs`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
For a quick smoke check of the wrapper path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Override project limits, state path, or log location through environment
|
||||||
|
variables:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=500,hiring=200' \
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index \
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json \
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not schedule `--force-rebuild`. Force rebuilds should stay manual because
|
||||||
|
they intentionally re-embed unchanged documents.
|
||||||
|
|
||||||
|
## MCP Stdio
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --mcp-stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
|
||||||
|
- `semantic_search`
|
||||||
|
- `semantic_get_document`
|
||||||
|
- `semantic_list_projects`
|
||||||
|
- `semantic_backfill_redmine_sample`
|
||||||
|
- `semantic_refresh_redmine`
|
||||||
|
|
||||||
|
For agent workflows, list projects first when the user has not named a project,
|
||||||
|
search broadly or with `project_identifier` when known, then call
|
||||||
|
`semantic_get_document` for any promising result. Treat returned citations and
|
||||||
|
Redmine URLs as the authoritative references. Backfill tools are operational and
|
||||||
|
should not be part of normal search behavior.
|
||||||
|
|
||||||
|
## Inspection CLI
|
||||||
|
|
||||||
|
Use the inspect commands before larger backfills to see what is already indexed
|
||||||
|
or preview what Redmine would produce without writing to Qdrant.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index inspect count --source redmine --project customer-service
|
||||||
|
python3 -m semantic_index inspect list --limit 20 --source redmine --project customer-service
|
||||||
|
python3 -m semantic_index inspect search "order status" --limit 5 --project customer-service
|
||||||
|
python3 -m semantic_index inspect search "customer@example.com" --limit 5 --project customer-service
|
||||||
|
python3 -m semantic_index inspect show redmine:issue:39778:chunk:0
|
||||||
|
python3 -m semantic_index inspect preview-redmine --limit 10 --project customer-service
|
||||||
|
python3 -m semantic_index inspect audit --source redmine --project customer-service --limit 500
|
||||||
|
python3 -m semantic_index inspect compare-redmine --project customer-service --limit 20
|
||||||
|
python3 -m semantic_index inspect smoke-search --project customer-service
|
||||||
|
```
|
||||||
|
|
||||||
|
`count`, `list`, `show`, and `preview-redmine` do not call OpenAI.
|
||||||
|
`search` embeds the query text. List/search output shows snippets by default;
|
||||||
|
pass `--full-text` when you need the full indexed text.
|
||||||
|
`audit` summarizes indexed document coverage without calling OpenAI.
|
||||||
|
`compare-redmine` previews live Redmine chunks and compares them to indexed
|
||||||
|
Qdrant documents without writing to Qdrant. `smoke-search` runs known search
|
||||||
|
checks and calls OpenAI for query embeddings. Pass `--json` to `audit`,
|
||||||
|
`compare-redmine`, or `smoke-search` for machine-readable output.
|
||||||
|
For mixed project samples, run `audit` without `--project` to see project-level
|
||||||
|
counts and Helpdesk-contact coverage separately from ordinary internal issues.
|
||||||
|
|
||||||
|
For Helpdesk tickets, Redmine issue ingestion expects
|
||||||
|
`/issues/:id.json?include=journals,helpdesk` to return `helpdesk_ticket`
|
||||||
|
metadata with an expanded contact. See
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md` for the Redmine API patch notes.
|
||||||
|
|
||||||
|
## Qdrant
|
||||||
|
|
||||||
|
For local Docker-hosted Qdrant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -p 6333:6333 -p 6334:6334 -v qdrant_storage:/qdrant/storage qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Create snapshots with Qdrant's snapshot API or mounted storage tooling before
|
||||||
|
destructive maintenance. The default collection name is
|
||||||
|
`redmine_semantic_sample`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Local semantic index service for Redmine and future source adapters."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"config",
|
||||||
|
"embeddings",
|
||||||
|
"ingest",
|
||||||
|
"mcp",
|
||||||
|
"models",
|
||||||
|
"qdrant_store",
|
||||||
|
"redmine",
|
||||||
|
"search",
|
||||||
|
]
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .app import build_services
|
||||||
|
from .config import Settings, load_settings
|
||||||
|
from .inspect import (
|
||||||
|
print_audit,
|
||||||
|
print_compare_redmine,
|
||||||
|
print_count,
|
||||||
|
print_list,
|
||||||
|
print_preview_redmine,
|
||||||
|
print_search,
|
||||||
|
print_show,
|
||||||
|
print_smoke_search,
|
||||||
|
)
|
||||||
|
from .mcp import SemanticMCP, serve_stdio
|
||||||
|
from .refresh import FileRefreshState
|
||||||
|
from .redmine import RedmineApiSource
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview_services(settings: Settings) -> Dict[str, object]:
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"redmine_source": RedmineApiSource(
|
||||||
|
redmine_url=settings.redmine_url,
|
||||||
|
api_key=settings.redmine_api_key or "",
|
||||||
|
project_identifier=settings.redmine_project_identifier,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_projects(raw: str) -> List[str]:
|
||||||
|
return [project.strip() for project in raw.split(",") if project.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_project_limits(raw: str) -> Dict[str, int]:
|
||||||
|
project_limits: Dict[str, int] = {}
|
||||||
|
for item in raw.split(","):
|
||||||
|
if not item.strip():
|
||||||
|
continue
|
||||||
|
project, limit = item.split("=", 1)
|
||||||
|
project_limits[project.strip()] = int(limit.strip())
|
||||||
|
return project_limits
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Optional[List[str]] = None,
|
||||||
|
service_builder: Callable[[], Dict[str, object]] = build_services,
|
||||||
|
preview_service_builder: Optional[Callable[[Settings], Dict[str, object]]] = None,
|
||||||
|
settings_loader: Callable[[], Settings] = load_settings,
|
||||||
|
) -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Semantic index helper", allow_abbrev=False)
|
||||||
|
parser.add_argument("--mcp-stdio", action="store_true", help="Run the MCP-compatible stdio tool server")
|
||||||
|
parser.add_argument("--backfill-redmine-sample", action="store_true", help="Backfill the configured Redmine sample")
|
||||||
|
parser.add_argument("--backfill-redmine-projects", action="store_true", help="Backfill multiple Redmine projects")
|
||||||
|
parser.add_argument("--refresh-redmine-projects", action="store_true", help="Refresh recent Redmine issues without re-embedding unchanged documents")
|
||||||
|
parser.add_argument("--projects", help="Comma-separated Redmine project identifiers for multi-project backfill")
|
||||||
|
parser.add_argument("--project-limits", help="Comma-separated project=limit pairs for multi-project backfill")
|
||||||
|
parser.add_argument("--per-project-limit", type=int, default=500)
|
||||||
|
parser.add_argument("--limit", type=int, default=500)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Report planned refresh work without embeddings or writes")
|
||||||
|
parser.add_argument("--force-rebuild", action="store_true", help="Embed and upsert refresh candidates even when source hashes match")
|
||||||
|
parser.add_argument("--overlap-minutes", type=int, default=15, help="Refresh overlap window for rolling update state")
|
||||||
|
parser.add_argument("--state-path", help="Override rolling refresh state file path")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
inspect_parser = subparsers.add_parser("inspect", help="Inspect indexed documents and preview Redmine chunks")
|
||||||
|
inspect_subparsers = inspect_parser.add_subparsers(dest="inspect_command", required=True)
|
||||||
|
|
||||||
|
def add_filters(command_parser: argparse.ArgumentParser) -> None:
|
||||||
|
command_parser.add_argument("--source", default="redmine")
|
||||||
|
command_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
command_parser.add_argument("--doc-type")
|
||||||
|
|
||||||
|
count_parser = inspect_subparsers.add_parser("count", help="Count indexed documents")
|
||||||
|
add_filters(count_parser)
|
||||||
|
|
||||||
|
list_parser = inspect_subparsers.add_parser("list", help="List indexed documents")
|
||||||
|
add_filters(list_parser)
|
||||||
|
list_parser.add_argument("--limit", type=int, default=20)
|
||||||
|
list_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
search_parser = inspect_subparsers.add_parser("search", help="Search indexed documents")
|
||||||
|
search_parser.add_argument("query")
|
||||||
|
add_filters(search_parser)
|
||||||
|
search_parser.add_argument("--limit", type=int, default=10)
|
||||||
|
search_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
show_parser = inspect_subparsers.add_parser("show", help="Show one indexed document")
|
||||||
|
show_parser.add_argument("document_id")
|
||||||
|
|
||||||
|
preview_parser = inspect_subparsers.add_parser("preview-redmine", help="Preview Redmine chunks without writing to Qdrant")
|
||||||
|
preview_parser.add_argument("--limit", type=int, default=10)
|
||||||
|
preview_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
preview_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
audit_parser = inspect_subparsers.add_parser("audit", help="Audit indexed documents for trust-check coverage")
|
||||||
|
add_filters(audit_parser)
|
||||||
|
audit_parser.add_argument("--limit", type=int, default=500)
|
||||||
|
audit_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
compare_parser = inspect_subparsers.add_parser("compare-redmine", help="Compare live Redmine preview chunks with indexed documents")
|
||||||
|
compare_parser.add_argument("--limit", type=int, default=20)
|
||||||
|
compare_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
compare_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
smoke_parser = inspect_subparsers.add_parser("smoke-search", help="Run repeatable search checks against indexed documents")
|
||||||
|
smoke_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
smoke_parser.add_argument("--email", default="callum@safetagtracking.com")
|
||||||
|
smoke_parser.add_argument("--issue-id", type=int, default=39779)
|
||||||
|
smoke_parser.add_argument("--order-token")
|
||||||
|
smoke_parser.add_argument("--natural-query", default="customer needs goods returned")
|
||||||
|
smoke_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.command and not args.backfill_redmine_sample and not args.backfill_redmine_projects and not args.refresh_redmine_projects and not args.mcp_stdio:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "inspect" and args.inspect_command == "preview-redmine":
|
||||||
|
if preview_service_builder is not None:
|
||||||
|
services = preview_service_builder(settings_loader())
|
||||||
|
elif service_builder is build_services:
|
||||||
|
services = build_preview_services(settings_loader())
|
||||||
|
else:
|
||||||
|
services = service_builder()
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_preview_redmine(services["redmine_source"], services["settings"].redmine_url, project, args.limit, args.full_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
services = service_builder()
|
||||||
|
if args.state_path and "refresh" in services and hasattr(services["refresh"], "state"):
|
||||||
|
services["refresh"].state = FileRefreshState(Path(args.state_path))
|
||||||
|
if args.backfill_redmine_sample:
|
||||||
|
print(services["backfill"].backfill_redmine_sample(limit=args.limit))
|
||||||
|
return
|
||||||
|
if args.backfill_redmine_projects:
|
||||||
|
if args.project_limits:
|
||||||
|
print(services["backfill"].backfill_redmine_project_limits(parse_project_limits(args.project_limits)))
|
||||||
|
return
|
||||||
|
projects = parse_projects(args.projects or "")
|
||||||
|
if not projects:
|
||||||
|
parser.error("--projects or --project-limits is required with --backfill-redmine-projects")
|
||||||
|
print(services["backfill"].backfill_redmine_projects(projects, per_project_limit=args.per_project_limit))
|
||||||
|
return
|
||||||
|
if args.refresh_redmine_projects:
|
||||||
|
if args.project_limits:
|
||||||
|
project_limits = parse_project_limits(args.project_limits)
|
||||||
|
else:
|
||||||
|
projects = parse_projects(args.projects or "")
|
||||||
|
if not projects:
|
||||||
|
parser.error("--projects or --project-limits is required with --refresh-redmine-projects")
|
||||||
|
project_limits = {project: args.per_project_limit for project in projects}
|
||||||
|
print(
|
||||||
|
services["refresh"].refresh_redmine_project_limits(
|
||||||
|
project_limits,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
force_rebuild=args.force_rebuild,
|
||||||
|
overlap_minutes=args.overlap_minutes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if args.mcp_stdio:
|
||||||
|
serve_stdio(SemanticMCP(search_service=services["search"], backfill_service=services["backfill"], store=services["store"], refresh_service=services.get("refresh")))
|
||||||
|
return
|
||||||
|
if args.command == "inspect":
|
||||||
|
if args.inspect_command == "count":
|
||||||
|
print_count(services["store"], args.source, args.project_identifier, args.doc_type)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "list":
|
||||||
|
print_list(services["store"], args.limit, args.source, args.project_identifier, args.doc_type, args.full_text)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "search":
|
||||||
|
print_search(services["search"], args.query, args.limit, args.source, args.project_identifier, args.doc_type, args.full_text)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "show":
|
||||||
|
print_show(services["search"], args.document_id)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "audit":
|
||||||
|
print_audit(services["store"], args.limit, args.source, args.project_identifier, args.doc_type, args.json)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "compare-redmine":
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_compare_redmine(services["store"], services["redmine_source"], services["settings"].redmine_url, project, args.limit, args.json)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "smoke-search":
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_smoke_search(
|
||||||
|
services["search"],
|
||||||
|
project,
|
||||||
|
args.email,
|
||||||
|
args.issue_id,
|
||||||
|
args.order_token,
|
||||||
|
args.natural_query,
|
||||||
|
args.json,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from .config import Settings, load_settings
|
||||||
|
from .embeddings import OpenAIEmbedder, OpenAIEmbeddingClient
|
||||||
|
from .ingest import BackfillService
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
from .qdrant_store import QdrantStore
|
||||||
|
from .refresh import FileRefreshState, RedmineRefreshService
|
||||||
|
from .redmine import RedmineApiSource, RedmineMapper
|
||||||
|
from .search import HybridSearchService
|
||||||
|
|
||||||
|
|
||||||
|
def build_services(settings: Optional[Settings] = None) -> Dict[str, Any]:
|
||||||
|
settings = settings or load_settings()
|
||||||
|
embedding_client = OpenAIEmbeddingClient(api_key=settings.openai_api_key)
|
||||||
|
embedder = OpenAIEmbedder(client=embedding_client)
|
||||||
|
store = QdrantStore(
|
||||||
|
url=settings.qdrant_url,
|
||||||
|
api_key=settings.qdrant_api_key,
|
||||||
|
collection=settings.qdrant_collection,
|
||||||
|
)
|
||||||
|
redmine_source = RedmineApiSource(
|
||||||
|
redmine_url=settings.redmine_url,
|
||||||
|
api_key=settings.redmine_api_key or "",
|
||||||
|
project_identifier=settings.redmine_project_identifier,
|
||||||
|
)
|
||||||
|
search_service = HybridSearchService(embedder=embedder, store=store)
|
||||||
|
backfill_service = BackfillService(
|
||||||
|
source=redmine_source,
|
||||||
|
embedder=embedder,
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier),
|
||||||
|
)
|
||||||
|
refresh_service = RedmineRefreshService(
|
||||||
|
source=redmine_source,
|
||||||
|
embedder=embedder,
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier),
|
||||||
|
state=FileRefreshState(settings.refresh_state_path),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"search": search_service,
|
||||||
|
"backfill": backfill_service,
|
||||||
|
"refresh": refresh_service,
|
||||||
|
"store": store,
|
||||||
|
"redmine_source": redmine_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Optional[Settings] = None, service_builder: Optional[Callable[[], Dict[str, Any]]] = None):
|
||||||
|
try:
|
||||||
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install fastapi and uvicorn to run the HTTP service") from exc
|
||||||
|
|
||||||
|
services: Optional[Dict[str, Any]] = None
|
||||||
|
app = FastAPI(title="Redmine Semantic Index", version="0.1.0")
|
||||||
|
|
||||||
|
def get_services() -> Dict[str, Any]:
|
||||||
|
nonlocal services
|
||||||
|
if services is None:
|
||||||
|
if service_builder is not None:
|
||||||
|
services = service_builder()
|
||||||
|
else:
|
||||||
|
services = build_services(settings)
|
||||||
|
return services
|
||||||
|
|
||||||
|
def authorize(authorization: Optional[str]) -> None:
|
||||||
|
api_key = get_services()["settings"].service_api_key
|
||||||
|
if not api_key:
|
||||||
|
return
|
||||||
|
expected = f"Bearer {api_key}"
|
||||||
|
if authorization != expected:
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> Dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/sources/redmine/backfill-sample")
|
||||||
|
def backfill(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
active_services = get_services()
|
||||||
|
limit = int((payload or {}).get("limit", active_services["settings"].sample_limit))
|
||||||
|
return active_services["backfill"].backfill_redmine_sample(limit=limit)
|
||||||
|
|
||||||
|
@app.post("/sources/redmine/refresh")
|
||||||
|
def refresh(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
payload = payload or {}
|
||||||
|
project_limits = payload.get("project_limits")
|
||||||
|
if not project_limits:
|
||||||
|
project = payload.get("project_identifier") or get_services()["settings"].redmine_project_identifier
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=400, detail="project_limits or project_identifier is required")
|
||||||
|
project_limits = {project: int(payload.get("limit", get_services()["settings"].sample_limit))}
|
||||||
|
return get_services()["refresh"].refresh_redmine_project_limits(
|
||||||
|
{str(project): int(limit) for project, limit in project_limits.items()},
|
||||||
|
dry_run=bool(payload.get("dry_run", False)),
|
||||||
|
force_rebuild=bool(payload.get("force_rebuild", False)),
|
||||||
|
overlap_minutes=int(payload.get("overlap_minutes", 15)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(payload: Dict[str, Any], authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
query = SearchQuery(
|
||||||
|
text=payload.get("query") or payload.get("text") or "",
|
||||||
|
source=payload.get("source"),
|
||||||
|
project_id=payload.get("project_id"),
|
||||||
|
project_identifier=payload.get("project_identifier"),
|
||||||
|
doc_type=payload.get("doc_type"),
|
||||||
|
issue_id=payload.get("issue_id"),
|
||||||
|
contact_id=payload.get("contact_id"),
|
||||||
|
contact_email=payload.get("contact_email"),
|
||||||
|
date_from=payload.get("date_from"),
|
||||||
|
date_to=payload.get("date_to"),
|
||||||
|
limit=int(payload.get("limit", 10)),
|
||||||
|
include_snippets=bool(payload.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
results = get_services()["search"].search(query)
|
||||||
|
return search_response(query, results)
|
||||||
|
|
||||||
|
@app.get("/projects")
|
||||||
|
def projects(authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
return {"projects": get_services()["store"].list_projects(source="redmine")}
|
||||||
|
|
||||||
|
@app.get("/documents/{document_id}")
|
||||||
|
def document(document_id: str, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
found = get_services()["search"].get_document(document_id)
|
||||||
|
if found is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
return found
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class LazyASGIApp:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._app = None
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if self._app is None:
|
||||||
|
self._app = create_app()
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
app = LazyASGIApp()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, max_chars: int = 3500, overlap: int = 300) -> List[str]:
|
||||||
|
cleaned = "\n".join(line.rstrip() for line in text.strip().splitlines()).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
if len(cleaned) <= max_chars:
|
||||||
|
return [cleaned]
|
||||||
|
|
||||||
|
chunks: List[str] = []
|
||||||
|
start = 0
|
||||||
|
while start < len(cleaned):
|
||||||
|
end = min(start + max_chars, len(cleaned))
|
||||||
|
if end < len(cleaned):
|
||||||
|
boundary = max(cleaned.rfind("\n\n", start, end), cleaned.rfind(". ", start, end))
|
||||||
|
if boundary > start + int(max_chars * 0.5):
|
||||||
|
end = boundary + 1
|
||||||
|
chunks.append(cleaned[start:end].strip())
|
||||||
|
if end >= len(cleaned):
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap)
|
||||||
|
return [chunk for chunk in chunks if chunk]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .app import build_services
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
search_service: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
|
self.api_key = api_key
|
||||||
|
self.search_service = search_service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def local(cls) -> "SemanticIndexClient":
|
||||||
|
return cls(search_service=build_services()["search"])
|
||||||
|
|
||||||
|
def search(self, query: str, **filters: Any) -> Dict[str, Any]:
|
||||||
|
if self.base_url:
|
||||||
|
return self._post_json("/search", {"query": query, **filters})
|
||||||
|
search_service = self.search_service or build_services()["search"]
|
||||||
|
search_query = SearchQuery(
|
||||||
|
text=query,
|
||||||
|
source=filters.get("source"),
|
||||||
|
project_id=filters.get("project_id"),
|
||||||
|
project_identifier=filters.get("project_identifier"),
|
||||||
|
doc_type=filters.get("doc_type"),
|
||||||
|
issue_id=filters.get("issue_id"),
|
||||||
|
contact_id=filters.get("contact_id"),
|
||||||
|
contact_email=filters.get("contact_email"),
|
||||||
|
date_from=filters.get("date_from"),
|
||||||
|
date_to=filters.get("date_to"),
|
||||||
|
limit=int(filters.get("limit", 10)),
|
||||||
|
include_snippets=bool(filters.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
return search_response(search_query, search_service.search(search_query))
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Dict[str, Any]:
|
||||||
|
if self.base_url:
|
||||||
|
return self._get_json(f"/documents/{document_id}")
|
||||||
|
search_service = self.search_service or build_services()["search"]
|
||||||
|
return search_service.get_document(document_id) or {"error": "not_found", "id": document_id}
|
||||||
|
|
||||||
|
def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=data,
|
||||||
|
headers=self._headers(),
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def _get_json(self, path: str) -> Dict[str, Any]:
|
||||||
|
request = urllib.request.Request(f"{self.base_url}{path}", headers=self._headers())
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return headers
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
openai_api_key: Optional[str]
|
||||||
|
qdrant_url: str
|
||||||
|
qdrant_api_key: Optional[str]
|
||||||
|
qdrant_collection: str
|
||||||
|
redmine_url: str
|
||||||
|
redmine_api_key: Optional[str]
|
||||||
|
redmine_project_identifier: Optional[str]
|
||||||
|
sample_limit: int
|
||||||
|
bind_host: str
|
||||||
|
bind_port: int
|
||||||
|
service_api_key: Optional[str]
|
||||||
|
refresh_state_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: str | Path = ".env") -> Dict[str, str]:
|
||||||
|
values: Dict[str, str] = {}
|
||||||
|
dotenv = Path(path)
|
||||||
|
if not dotenv.exists():
|
||||||
|
return values
|
||||||
|
for raw_line in dotenv.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dotenv_path(dotenv_path: str | Path = ".env") -> Path:
|
||||||
|
primary = Path(dotenv_path)
|
||||||
|
if primary.exists():
|
||||||
|
return primary
|
||||||
|
package_env = primary.parent / "semantic_index" / ".env"
|
||||||
|
if package_env.exists():
|
||||||
|
return package_env
|
||||||
|
return primary
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(dotenv_path: str | Path = ".env") -> Settings:
|
||||||
|
env = {**load_dotenv(resolve_dotenv_path(dotenv_path)), **os.environ}
|
||||||
|
return Settings(
|
||||||
|
openai_api_key=env.get("OPENAI_API_KEY"),
|
||||||
|
qdrant_url=env.get("QDRANT_URL", "http://localhost:6333"),
|
||||||
|
qdrant_api_key=env.get("QDRANT_API_KEY"),
|
||||||
|
qdrant_collection=env.get("QDRANT_COLLECTION", "redmine_semantic_sample"),
|
||||||
|
redmine_url=env.get("REDMINE_URL", "http://localhost"),
|
||||||
|
redmine_api_key=env.get("REDMINE_API_KEY"),
|
||||||
|
redmine_project_identifier=env.get("REDMINE_PROJECT_IDENTIFIER"),
|
||||||
|
sample_limit=int(env.get("REDMINE_SAMPLE_LIMIT", "500")),
|
||||||
|
bind_host=env.get("SEMANTIC_INDEX_HOST", "127.0.0.1"),
|
||||||
|
bind_port=int(env.get("SEMANTIC_INDEX_PORT", "8787")),
|
||||||
|
service_api_key=env.get("SEMANTIC_INDEX_API_KEY"),
|
||||||
|
refresh_state_path=Path(env.get("SEMANTIC_INDEX_REFRESH_STATE_PATH", ".cache/semantic_index/refresh_state.json")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from .models import IndexDocument
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingClient(Protocol):
|
||||||
|
def create_embeddings(self, model: str, inputs: Sequence[str], dimensions: Optional[int] = None) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbeddingClient:
|
||||||
|
def __init__(self, api_key: Optional[str] = None) -> None:
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install openai to use live embeddings") from exc
|
||||||
|
self.client = OpenAI(api_key=api_key)
|
||||||
|
|
||||||
|
def create_embeddings(self, model: str, inputs: Sequence[str], dimensions: Optional[int] = None) -> List[List[float]]:
|
||||||
|
kwargs = {"model": model, "input": list(inputs)}
|
||||||
|
if dimensions is not None:
|
||||||
|
kwargs["dimensions"] = dimensions
|
||||||
|
response = self.client.embeddings.create(**kwargs)
|
||||||
|
return [item.embedding for item in response.data]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbedder:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: EmbeddingClient,
|
||||||
|
model: str = "text-embedding-3-small",
|
||||||
|
dimensions: int = 1536,
|
||||||
|
batch_size: int = 100,
|
||||||
|
max_chars: int = 12000,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.model = model
|
||||||
|
self.dimensions = dimensions
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.max_chars = max_chars
|
||||||
|
|
||||||
|
def embed_documents(self, documents: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
return self.embed_texts([document.text for document in documents])
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
return self.embed_texts([text])[0]
|
||||||
|
|
||||||
|
def embed_texts(self, texts: Iterable[str]) -> List[List[float]]:
|
||||||
|
values = list(texts)
|
||||||
|
self._validate(values)
|
||||||
|
vectors: List[List[float]] = []
|
||||||
|
for start in range(0, len(values), self.batch_size):
|
||||||
|
batch = values[start : start + self.batch_size]
|
||||||
|
vectors.extend(self.client.create_embeddings(self.model, batch, dimensions=self.dimensions))
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
def _validate(self, texts: Sequence[str]) -> None:
|
||||||
|
for text in texts:
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError("embedding text cannot be empty")
|
||||||
|
if len(text) > self.max_chars:
|
||||||
|
raise ValueError(f"embedding text exceeds {self.max_chars} characters")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Protocol, Sequence
|
||||||
|
|
||||||
|
from .models import IndexDocument
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineSource(Protocol):
|
||||||
|
project_identifier: str | None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentEmbedder(Protocol):
|
||||||
|
def embed_documents(self, docs: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RebuildStore(Protocol):
|
||||||
|
def rebuild_source(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
docs: Sequence[IndexDocument],
|
||||||
|
vectors: Sequence[Sequence[float]],
|
||||||
|
project_identifier: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillService:
|
||||||
|
def __init__(self, source: RedmineSource, embedder: DocumentEmbedder, store: RebuildStore, mapper: RedmineMapper | None = None) -> None:
|
||||||
|
self.source = source
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
self.mapper = mapper or RedmineMapper(redmine_url="")
|
||||||
|
|
||||||
|
def backfill_redmine_sample(self, limit: int = 500) -> Dict[str, int | str]:
|
||||||
|
issues = list(self.source.recent_helpdesk_issues(limit))
|
||||||
|
documents: List[IndexDocument] = []
|
||||||
|
for issue in issues:
|
||||||
|
documents.extend(self.mapper.issue_to_documents(issue))
|
||||||
|
documents = deduplicate_documents(documents)
|
||||||
|
vectors = self.embedder.embed_documents(documents) if documents else []
|
||||||
|
self.store.rebuild_source("redmine", documents, vectors, project_identifier=self._project_identifier())
|
||||||
|
return {"source": "redmine", "issues": len(issues), "documents": len(documents)}
|
||||||
|
|
||||||
|
def backfill_redmine_projects(self, projects: Sequence[str], per_project_limit: int = 500) -> Dict[str, object]:
|
||||||
|
return self.backfill_redmine_project_limits({project: per_project_limit for project in projects})
|
||||||
|
|
||||||
|
def backfill_redmine_project_limits(self, project_limits: Dict[str, int]) -> Dict[str, object]:
|
||||||
|
previous_source_project = getattr(self.source, "project_identifier", None)
|
||||||
|
previous_mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
project_results: List[Dict[str, int | str]] = []
|
||||||
|
total_issues = 0
|
||||||
|
total_documents = 0
|
||||||
|
try:
|
||||||
|
for project, project_limit in project_limits.items():
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = project
|
||||||
|
issues = list(self.source.recent_helpdesk_issues(project_limit))
|
||||||
|
documents: List[IndexDocument] = []
|
||||||
|
for issue in issues:
|
||||||
|
documents.extend(self.mapper.issue_to_documents(issue))
|
||||||
|
documents = deduplicate_documents(documents)
|
||||||
|
vectors = self.embedder.embed_documents(documents) if documents else []
|
||||||
|
self.store.rebuild_source("redmine", documents, vectors, project_identifier=project)
|
||||||
|
project_results.append(
|
||||||
|
{"project_identifier": project, "issues": len(issues), "documents": len(documents)}
|
||||||
|
)
|
||||||
|
total_issues += len(issues)
|
||||||
|
total_documents += len(documents)
|
||||||
|
finally:
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = previous_source_project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = previous_mapper_project
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"issues": total_issues,
|
||||||
|
"documents": total_documents,
|
||||||
|
"project_results": project_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _project_identifier(self) -> str | None:
|
||||||
|
mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
if mapper_project:
|
||||||
|
return mapper_project
|
||||||
|
return getattr(self.source, "project_identifier", None)
|
||||||
|
|
||||||
|
|
||||||
|
def deduplicate_documents(documents: Sequence[IndexDocument]) -> List[IndexDocument]:
|
||||||
|
unique: Dict[str, IndexDocument] = {}
|
||||||
|
for document in documents:
|
||||||
|
unique[document.id] = document
|
||||||
|
return list(unique.values())
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .models import SearchQuery, SearchResult
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
def print_count(store: Any, source: Optional[str], project: Optional[str], doc_type: Optional[str]) -> None:
|
||||||
|
count = store.count_documents(source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
print(count)
|
||||||
|
|
||||||
|
|
||||||
|
def print_list(store: Any, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], full_text: bool) -> None:
|
||||||
|
documents = store.list_documents(limit=limit, source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
for document in documents:
|
||||||
|
print_document(document, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_search(search_service: Any, query_text: str, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], full_text: bool) -> None:
|
||||||
|
query = SearchQuery(
|
||||||
|
text=query_text,
|
||||||
|
source=source,
|
||||||
|
project_identifier=project,
|
||||||
|
doc_type=doc_type,
|
||||||
|
limit=limit,
|
||||||
|
include_snippets=not full_text,
|
||||||
|
)
|
||||||
|
for result in search_service.search(query):
|
||||||
|
print_result(result, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_show(search_service: Any, document_id: str) -> None:
|
||||||
|
document = search_service.get_document(document_id)
|
||||||
|
if document is None:
|
||||||
|
print(f"not found: {document_id}")
|
||||||
|
return
|
||||||
|
print_document(document, full_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def print_preview_redmine(source: Any, redmine_url: str, project: Optional[str], limit: int, full_text: bool) -> None:
|
||||||
|
previous_project = getattr(source, "project_identifier", None)
|
||||||
|
if project and hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = project
|
||||||
|
try:
|
||||||
|
mapper = RedmineMapper(redmine_url=redmine_url, project_identifier=project)
|
||||||
|
documents = []
|
||||||
|
for issue in source.recent_helpdesk_issues(limit):
|
||||||
|
documents.extend(mapper.issue_to_documents(issue))
|
||||||
|
finally:
|
||||||
|
if hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = previous_project
|
||||||
|
for document in documents:
|
||||||
|
print_document({"id": document.id, "text": document.text, "payload": document.payload}, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_audit(store: Any, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], as_json: bool) -> None:
|
||||||
|
documents = store.list_documents(limit=limit, source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
report = audit_documents(documents)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(f"documents={report['total_documents']}")
|
||||||
|
for name, count in sorted(report["doc_type_counts"].items()):
|
||||||
|
print(f"doc_type {name}={count}")
|
||||||
|
for name, count in sorted(report["project_counts"].items()):
|
||||||
|
print(f"project {name}={count}")
|
||||||
|
print(f"contact_metadata {report['contact_metadata_count']}/{report['total_documents']}")
|
||||||
|
print(f"helpdesk_contact_metadata {report['helpdesk_contact_metadata_count']}/{report['helpdesk_documents']}")
|
||||||
|
print(f"attachments={report['attachment_documents']}")
|
||||||
|
for document_id in report["missing_helpdesk_contact_metadata"]:
|
||||||
|
print(f"missing_contact {document_id}")
|
||||||
|
for document_id in report["unexpected_attachment_documents"]:
|
||||||
|
print(f"unexpected_attachment {document_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_compare_redmine(store: Any, source: Any, redmine_url: str, project: Optional[str], limit: int, as_json: bool) -> None:
|
||||||
|
preview_documents = preview_redmine_documents(source, redmine_url, project, limit)
|
||||||
|
indexed_documents = store.list_documents(limit=max(5000, limit * 100), source="redmine", project_identifier=project)
|
||||||
|
report = compare_documents(preview_documents, indexed_documents)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(f"preview_documents={report['preview_documents']}")
|
||||||
|
print(f"indexed_documents={report['indexed_documents']}")
|
||||||
|
for document_id in report["missing"]:
|
||||||
|
print(f"missing {document_id}")
|
||||||
|
for document_id in report["stale"]:
|
||||||
|
print(f"stale {document_id}")
|
||||||
|
for mismatch in report["contact_mismatches"]:
|
||||||
|
print(f"contact_mismatch {mismatch['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_smoke_search(
|
||||||
|
search_service: Any,
|
||||||
|
project: Optional[str],
|
||||||
|
email: str,
|
||||||
|
issue_id: Optional[int],
|
||||||
|
order_token: Optional[str],
|
||||||
|
natural_query: str,
|
||||||
|
as_json: bool,
|
||||||
|
) -> None:
|
||||||
|
checks = smoke_search(search_service, project, email, issue_id, order_token, natural_query)
|
||||||
|
report = {"project_identifier": project, "checks": checks}
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
for check in checks:
|
||||||
|
status = "PASS" if check["passed"] else "FAIL"
|
||||||
|
print(f"{status} {check['kind']} {check['query']}")
|
||||||
|
for result in check["results"]:
|
||||||
|
payload = result["payload"]
|
||||||
|
print(
|
||||||
|
f" {result['id']} score={result['score']:.4f} "
|
||||||
|
f"doc_type={payload.get('doc_type')} issue={payload.get('issue_id')} "
|
||||||
|
f"contact={contact_display(payload)} url={result['citation'].get('url')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_documents(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
doc_type_counts = Counter(str((document.get("payload") or {}).get("doc_type") or "unknown") for document in documents)
|
||||||
|
project_counts = Counter(str((document.get("payload") or {}).get("project_identifier") or "unknown") for document in documents)
|
||||||
|
missing_contact = []
|
||||||
|
missing_helpdesk_contact = []
|
||||||
|
contact_metadata_count = 0
|
||||||
|
helpdesk_documents = 0
|
||||||
|
helpdesk_contact_metadata_count = 0
|
||||||
|
unexpected_attachments = []
|
||||||
|
for document in documents:
|
||||||
|
payload = document.get("payload") or {}
|
||||||
|
doc_type = str(payload.get("doc_type") or "")
|
||||||
|
has_contact = bool(payload.get("contact_id") and payload.get("contact_email"))
|
||||||
|
has_helpdesk_ticket = bool(payload.get("has_helpdesk_ticket"))
|
||||||
|
if has_contact:
|
||||||
|
contact_metadata_count += 1
|
||||||
|
elif doc_type in {"issue", "journal", "message", "contact"} and has_helpdesk_ticket:
|
||||||
|
missing_contact.append(str(document.get("id")))
|
||||||
|
if has_helpdesk_ticket:
|
||||||
|
helpdesk_documents += 1
|
||||||
|
if has_contact:
|
||||||
|
helpdesk_contact_metadata_count += 1
|
||||||
|
elif doc_type in {"issue", "journal", "message", "contact"}:
|
||||||
|
missing_helpdesk_contact.append(str(document.get("id")))
|
||||||
|
if doc_type == "attachment":
|
||||||
|
unexpected_attachments.append(str(document.get("id")))
|
||||||
|
return {
|
||||||
|
"total_documents": len(documents),
|
||||||
|
"doc_type_counts": dict(doc_type_counts),
|
||||||
|
"project_counts": dict(project_counts),
|
||||||
|
"contact_metadata_count": contact_metadata_count,
|
||||||
|
"helpdesk_documents": helpdesk_documents,
|
||||||
|
"helpdesk_contact_metadata_count": helpdesk_contact_metadata_count,
|
||||||
|
"missing_contact_metadata": missing_contact,
|
||||||
|
"missing_helpdesk_contact_metadata": missing_helpdesk_contact,
|
||||||
|
"attachment_documents": len(unexpected_attachments),
|
||||||
|
"unexpected_attachment_documents": unexpected_attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def preview_redmine_documents(source: Any, redmine_url: str, project: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
||||||
|
previous_project = getattr(source, "project_identifier", None)
|
||||||
|
if project and hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = project
|
||||||
|
try:
|
||||||
|
mapper = RedmineMapper(redmine_url=redmine_url, project_identifier=project)
|
||||||
|
documents = []
|
||||||
|
for issue in source.recent_helpdesk_issues(limit):
|
||||||
|
documents.extend(mapper.issue_to_documents(issue))
|
||||||
|
return [{"id": document.id, "text": document.text, "payload": document.payload} for document in documents]
|
||||||
|
finally:
|
||||||
|
if hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = previous_project
|
||||||
|
|
||||||
|
|
||||||
|
def compare_documents(preview_documents: List[Dict[str, Any]], indexed_documents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
indexed_by_id = {str(document.get("id")): document for document in indexed_documents}
|
||||||
|
missing = []
|
||||||
|
stale = []
|
||||||
|
contact_mismatches = []
|
||||||
|
for preview in preview_documents:
|
||||||
|
document_id = str(preview.get("id"))
|
||||||
|
indexed = indexed_by_id.get(document_id)
|
||||||
|
if indexed is None:
|
||||||
|
missing.append(document_id)
|
||||||
|
continue
|
||||||
|
preview_payload = preview.get("payload") or {}
|
||||||
|
indexed_payload = indexed.get("payload") or {}
|
||||||
|
if preview_payload.get("source_hash") != indexed_payload.get("source_hash"):
|
||||||
|
stale.append(document_id)
|
||||||
|
contact_fields = ("contact_id", "contact_name", "contact_email", "contact_company")
|
||||||
|
if any(preview_payload.get(field) != indexed_payload.get(field) for field in contact_fields):
|
||||||
|
contact_mismatches.append({"id": document_id})
|
||||||
|
return {
|
||||||
|
"preview_documents": len(preview_documents),
|
||||||
|
"indexed_documents": len(indexed_documents),
|
||||||
|
"missing": missing,
|
||||||
|
"stale": stale,
|
||||||
|
"contact_mismatches": contact_mismatches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_search(
|
||||||
|
search_service: Any,
|
||||||
|
project: Optional[str],
|
||||||
|
email: str,
|
||||||
|
issue_id: Optional[int],
|
||||||
|
order_token: Optional[str],
|
||||||
|
natural_query: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
checks = [run_smoke_query(search_service, "email", email, project, expected_email=email)]
|
||||||
|
if issue_id is not None:
|
||||||
|
checks.append(run_smoke_query(search_service, "issue", str(issue_id), project, expected_issue_id=issue_id))
|
||||||
|
if order_token:
|
||||||
|
checks.append(run_smoke_query(search_service, "order", order_token, project))
|
||||||
|
if natural_query:
|
||||||
|
checks.append(run_smoke_query(search_service, "natural", natural_query, project))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def run_smoke_query(
|
||||||
|
search_service: Any,
|
||||||
|
kind: str,
|
||||||
|
text: str,
|
||||||
|
project: Optional[str],
|
||||||
|
expected_email: Optional[str] = None,
|
||||||
|
expected_issue_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = SearchQuery(text=text, source="redmine", project_identifier=project, issue_id=expected_issue_id, limit=5)
|
||||||
|
results = search_service.search(query)
|
||||||
|
result_dicts = [result.to_dict(include_snippet=True) for result in results]
|
||||||
|
passed = bool(result_dicts)
|
||||||
|
if expected_email:
|
||||||
|
passed = passed and any((result["payload"] or {}).get("contact_email") == expected_email for result in result_dicts)
|
||||||
|
if expected_issue_id is not None:
|
||||||
|
passed = passed and any((result["payload"] or {}).get("issue_id") == expected_issue_id for result in result_dicts)
|
||||||
|
return {"kind": kind, "query": text, "passed": passed, "results": result_dicts}
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(result: SearchResult, full_text: bool) -> None:
|
||||||
|
print(f"{result.id} score={result.score:.4f}")
|
||||||
|
print_metadata(result.payload)
|
||||||
|
print(f"url={result.citation.get('url')}")
|
||||||
|
print(result.text if full_text else snippet(result.text))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_document(document: Dict[str, Any], full_text: bool) -> None:
|
||||||
|
payload = document.get("payload") or {}
|
||||||
|
print(document.get("id"))
|
||||||
|
print_metadata(payload)
|
||||||
|
url = payload.get("redmine_url")
|
||||||
|
if url:
|
||||||
|
print(f"url={url}")
|
||||||
|
print(document.get("text", "") if full_text else snippet(document.get("text", "")))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_metadata(payload: Dict[str, Any]) -> None:
|
||||||
|
contact = contact_display(payload)
|
||||||
|
fields = [
|
||||||
|
("source", payload.get("source")),
|
||||||
|
("doc_type", payload.get("doc_type")),
|
||||||
|
("issue", payload.get("issue_id")),
|
||||||
|
("project", payload.get("project_identifier")),
|
||||||
|
("contact", contact),
|
||||||
|
("created", payload.get("created_on")),
|
||||||
|
("updated", payload.get("updated_on")),
|
||||||
|
]
|
||||||
|
print(" ".join(f"{name}={value}" for name, value in fields if value is not None))
|
||||||
|
|
||||||
|
|
||||||
|
def contact_display(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
contact_id = payload.get("contact_id")
|
||||||
|
pieces = []
|
||||||
|
if contact_id is not None:
|
||||||
|
pieces.append(f"#{contact_id}")
|
||||||
|
if payload.get("contact_name"):
|
||||||
|
pieces.append(str(payload["contact_name"]))
|
||||||
|
if payload.get("contact_email"):
|
||||||
|
pieces.append(str(payload["contact_email"]))
|
||||||
|
if payload.get("contact_company"):
|
||||||
|
pieces.append(str(payload["contact_company"]))
|
||||||
|
return " | ".join(pieces) if pieces else None
|
||||||
|
|
||||||
|
|
||||||
|
def snippet(text: str, max_chars: int = 240) -> str:
|
||||||
|
compact = " ".join(text.split())
|
||||||
|
if len(compact) <= max_chars:
|
||||||
|
return compact
|
||||||
|
return compact[: max_chars - 3].rstrip() + "..."
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticMCP:
|
||||||
|
def __init__(self, search_service: Any, backfill_service: Optional[Any], store: Optional[Any] = None, refresh_service: Optional[Any] = None) -> None:
|
||||||
|
self.search_service = search_service
|
||||||
|
self.backfill_service = backfill_service
|
||||||
|
self.store = store
|
||||||
|
self.refresh_service = refresh_service
|
||||||
|
|
||||||
|
def tools(self) -> Dict[str, Dict[str, str]]:
|
||||||
|
return {
|
||||||
|
"semantic_search": {"description": "Search the semantic index and return cited snippets."},
|
||||||
|
"semantic_get_document": {"description": "Fetch one indexed document by stable id."},
|
||||||
|
"semantic_list_projects": {"description": "List indexed project identifiers and document counts."},
|
||||||
|
"semantic_backfill_redmine_sample": {"description": "Rebuild the Redmine sample collection."},
|
||||||
|
"semantic_refresh_redmine": {"description": "Refresh recent Redmine issues without re-embedding unchanged documents."},
|
||||||
|
}
|
||||||
|
|
||||||
|
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if name == "semantic_search":
|
||||||
|
query = SearchQuery(
|
||||||
|
text=arguments.get("query") or arguments.get("text") or "",
|
||||||
|
source=arguments.get("source"),
|
||||||
|
project_id=arguments.get("project_id"),
|
||||||
|
project_identifier=arguments.get("project_identifier"),
|
||||||
|
doc_type=arguments.get("doc_type"),
|
||||||
|
issue_id=arguments.get("issue_id"),
|
||||||
|
contact_id=arguments.get("contact_id"),
|
||||||
|
contact_email=arguments.get("contact_email"),
|
||||||
|
date_from=arguments.get("date_from"),
|
||||||
|
date_to=arguments.get("date_to"),
|
||||||
|
limit=int(arguments.get("limit", 10)),
|
||||||
|
include_snippets=bool(arguments.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
results = self.search_service.search(query)
|
||||||
|
return search_response(query, results)
|
||||||
|
if name == "semantic_get_document":
|
||||||
|
return self.search_service.get_document(arguments["id"]) or {"error": "not_found", "id": arguments["id"]}
|
||||||
|
if name == "semantic_list_projects":
|
||||||
|
if self.store is None:
|
||||||
|
return {"error": "project_listing_unavailable"}
|
||||||
|
return {"projects": self.store.list_projects(source=arguments.get("source", "redmine"))}
|
||||||
|
if name == "semantic_backfill_redmine_sample":
|
||||||
|
if self.backfill_service is None:
|
||||||
|
return {"error": "backfill_unavailable"}
|
||||||
|
return self.backfill_service.backfill_redmine_sample(limit=int(arguments.get("limit", 500)))
|
||||||
|
if name == "semantic_refresh_redmine":
|
||||||
|
if self.refresh_service is None:
|
||||||
|
return {"error": "refresh_unavailable"}
|
||||||
|
project_limits = arguments.get("project_limits")
|
||||||
|
if not project_limits:
|
||||||
|
project = arguments.get("project_identifier")
|
||||||
|
if not project:
|
||||||
|
return {"error": "project_required"}
|
||||||
|
project_limits = {project: int(arguments.get("limit", 500))}
|
||||||
|
return self.refresh_service.refresh_redmine_project_limits(
|
||||||
|
{str(project): int(limit) for project, limit in project_limits.items()},
|
||||||
|
dry_run=bool(arguments.get("dry_run", False)),
|
||||||
|
force_rebuild=bool(arguments.get("force_rebuild", False)),
|
||||||
|
overlap_minutes=int(arguments.get("overlap_minutes", 15)),
|
||||||
|
)
|
||||||
|
raise ValueError(f"unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def serve_stdio(mcp: SemanticMCP) -> None:
|
||||||
|
for line in sys.stdin:
|
||||||
|
request = json.loads(line)
|
||||||
|
try:
|
||||||
|
result = mcp.call_tool(request["name"], request.get("arguments") or {})
|
||||||
|
response = {"id": request.get("id"), "result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
response = {"id": request.get("id"), "error": str(exc)}
|
||||||
|
print(json.dumps(response), flush=True)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
Payload = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IndexDocument:
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
payload: Payload = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.id.strip():
|
||||||
|
raise ValueError("document id is required")
|
||||||
|
if not self.text.strip():
|
||||||
|
raise ValueError("document text is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchQuery:
|
||||||
|
text: str
|
||||||
|
source: Optional[str] = None
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
project_identifier: Optional[str] = None
|
||||||
|
doc_type: Optional[str] = None
|
||||||
|
issue_id: Optional[int] = None
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
date_from: Optional[str] = None
|
||||||
|
date_to: Optional[str] = None
|
||||||
|
limit: int = 10
|
||||||
|
include_snippets: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.text.strip():
|
||||||
|
raise ValueError("search text is required")
|
||||||
|
if self.limit < 1 or self.limit > 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchResult:
|
||||||
|
id: str
|
||||||
|
score: float
|
||||||
|
text: str
|
||||||
|
payload: Payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snippet(self) -> str:
|
||||||
|
return self.text[:500]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def citation(self) -> Payload:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"source": self.payload.get("source"),
|
||||||
|
"doc_type": self.payload.get("doc_type"),
|
||||||
|
"issue_id": self.payload.get("issue_id"),
|
||||||
|
"project_identifier": self.payload.get("project_identifier"),
|
||||||
|
"contact_id": self.payload.get("contact_id"),
|
||||||
|
"contact_name": self.payload.get("contact_name"),
|
||||||
|
"contact_email": self.payload.get("contact_email"),
|
||||||
|
"url": self.payload.get("redmine_url"),
|
||||||
|
"record_id": self.payload.get("source_record_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self, include_snippet: bool = True) -> Payload:
|
||||||
|
data: Payload = {
|
||||||
|
"id": self.id,
|
||||||
|
"score": self.score,
|
||||||
|
"payload": self.payload,
|
||||||
|
"citation": self.citation,
|
||||||
|
}
|
||||||
|
if include_snippet:
|
||||||
|
data["snippet"] = self.snippet
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def search_response(query: SearchQuery, results: list[SearchResult]) -> Payload:
|
||||||
|
filters = {
|
||||||
|
"source": query.source,
|
||||||
|
"project_id": query.project_id,
|
||||||
|
"project_identifier": query.project_identifier,
|
||||||
|
"doc_type": query.doc_type,
|
||||||
|
"issue_id": query.issue_id,
|
||||||
|
"contact_id": query.contact_id,
|
||||||
|
"contact_email": query.contact_email,
|
||||||
|
"date_from": query.date_from,
|
||||||
|
"date_to": query.date_to,
|
||||||
|
"limit": query.limit,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"query": query.text,
|
||||||
|
"filters": {key: value for key, value in filters.items() if value is not None},
|
||||||
|
"results": [result.to_dict(include_snippet=query.include_snippets) for result in results],
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from .models import IndexDocument, SearchQuery, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
def point_id_for_document(document_id: str) -> str:
|
||||||
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, document_id))
|
||||||
|
|
||||||
|
|
||||||
|
def build_filter(query: SearchQuery) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
must: List[Dict[str, Any]] = []
|
||||||
|
equality_fields = {
|
||||||
|
"source": query.source,
|
||||||
|
"project_id": query.project_id,
|
||||||
|
"project_identifier": query.project_identifier,
|
||||||
|
"doc_type": query.doc_type,
|
||||||
|
"issue_id": query.issue_id,
|
||||||
|
"contact_id": query.contact_id,
|
||||||
|
"contact_email": query.contact_email,
|
||||||
|
}
|
||||||
|
for key, value in equality_fields.items():
|
||||||
|
if value is not None:
|
||||||
|
must.append({"key": key, "match": {"value": value}})
|
||||||
|
if query.date_from or query.date_to:
|
||||||
|
range_filter: Dict[str, str] = {}
|
||||||
|
if query.date_from:
|
||||||
|
range_filter["gte"] = query.date_from
|
||||||
|
if query.date_to:
|
||||||
|
range_filter["lte"] = query.date_to
|
||||||
|
must.append({"key": "created_on", "range": range_filter})
|
||||||
|
return {"must": must}
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantStore:
|
||||||
|
def __init__(self, url: str, api_key: Optional[str], collection: str, vector_size: int = 1536, upsert_batch_size: int = 64) -> None:
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qmodels
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install qdrant-client to use live Qdrant storage") from exc
|
||||||
|
self.client = QdrantClient(url=url, api_key=api_key)
|
||||||
|
self.collection = collection
|
||||||
|
self.vector_size = vector_size
|
||||||
|
self.upsert_batch_size = upsert_batch_size
|
||||||
|
self.qmodels = qmodels
|
||||||
|
|
||||||
|
def ensure_collection(self) -> None:
|
||||||
|
collections = self.client.get_collections().collections
|
||||||
|
if any(collection.name == self.collection for collection in collections):
|
||||||
|
return
|
||||||
|
self.client.create_collection(
|
||||||
|
collection_name=self.collection,
|
||||||
|
vectors_config=self.qmodels.VectorParams(size=self.vector_size, distance=self.qmodels.Distance.COSINE),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert(self, documents: Sequence[IndexDocument], vectors: Sequence[Sequence[float]]) -> None:
|
||||||
|
if len(documents) != len(vectors):
|
||||||
|
raise ValueError("documents and vectors length mismatch")
|
||||||
|
self.ensure_collection()
|
||||||
|
points = [
|
||||||
|
self.qmodels.PointStruct(
|
||||||
|
id=point_id_for_document(document.id),
|
||||||
|
vector=list(vector),
|
||||||
|
payload={**document.payload, "document_id": document.id, "text": document.text},
|
||||||
|
)
|
||||||
|
for document, vector in zip(documents, vectors)
|
||||||
|
]
|
||||||
|
for start in range(0, len(points), self.upsert_batch_size):
|
||||||
|
batch = points[start : start + self.upsert_batch_size]
|
||||||
|
if batch:
|
||||||
|
self.client.upsert(collection_name=self.collection, points=batch)
|
||||||
|
|
||||||
|
def delete_by_source(self, source: str, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier)
|
||||||
|
self.client.delete(
|
||||||
|
collection_name=self.collection,
|
||||||
|
points_selector=self.qmodels.FilterSelector(
|
||||||
|
filter=self._to_qdrant_filter(build_filter(query))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids: Sequence[str]) -> None:
|
||||||
|
self.ensure_collection()
|
||||||
|
if not document_ids:
|
||||||
|
return
|
||||||
|
self.client.delete(
|
||||||
|
collection_name=self.collection,
|
||||||
|
points_selector=self.qmodels.PointIdsList(
|
||||||
|
points=[point_id_for_document(document_id) for document_id in document_ids]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def rebuild_source(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
documents: Sequence[IndexDocument],
|
||||||
|
vectors: Sequence[Sequence[float]],
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.delete_by_source(source, project_identifier=project_identifier)
|
||||||
|
self.upsert(documents, vectors)
|
||||||
|
|
||||||
|
def search(self, vector: Sequence[float], query: SearchQuery, limit: int) -> List[SearchResult]:
|
||||||
|
self.ensure_collection()
|
||||||
|
qfilter = self._to_qdrant_filter(build_filter(query))
|
||||||
|
if hasattr(self.client, "query_points"):
|
||||||
|
response = self.client.query_points(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query=list(vector),
|
||||||
|
query_filter=qfilter,
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
results = response.points
|
||||||
|
else:
|
||||||
|
results = self.client.search(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query_vector=list(vector),
|
||||||
|
query_filter=qfilter,
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
return [self._point_to_result(point) for point in results]
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
self.ensure_collection()
|
||||||
|
points = self.client.retrieve(collection_name=self.collection, ids=[point_id_for_document(document_id)], with_payload=True)
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
payload = dict(points[0].payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
payload.pop("document_id", None)
|
||||||
|
return {"id": document_id, "text": text, "payload": payload}
|
||||||
|
|
||||||
|
def count_documents(
|
||||||
|
self,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier, doc_type=doc_type)
|
||||||
|
result = self.client.count(
|
||||||
|
collection_name=self.collection,
|
||||||
|
count_filter=self._to_qdrant_filter(build_filter(query)),
|
||||||
|
exact=True,
|
||||||
|
)
|
||||||
|
return int(result.count)
|
||||||
|
|
||||||
|
def list_documents(
|
||||||
|
self,
|
||||||
|
limit: int = 10,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
issue_id: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier, doc_type=doc_type, issue_id=issue_id)
|
||||||
|
qfilter = self._to_qdrant_filter(build_filter(query))
|
||||||
|
records = []
|
||||||
|
offset = None
|
||||||
|
while len(records) < limit:
|
||||||
|
batch_limit = limit - len(records)
|
||||||
|
batch, offset = self.client.scroll(
|
||||||
|
collection_name=self.collection,
|
||||||
|
scroll_filter=qfilter,
|
||||||
|
limit=batch_limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
records.extend(batch[:batch_limit])
|
||||||
|
if not offset or not batch:
|
||||||
|
break
|
||||||
|
return [self._record_to_document(record) for record in records]
|
||||||
|
|
||||||
|
def list_projects(self, source: Optional[str] = None, limit: int = 5000) -> List[Dict[str, Any]]:
|
||||||
|
documents = self.list_documents(limit=limit, source=source)
|
||||||
|
counts = Counter(
|
||||||
|
str((document.get("payload") or {}).get("project_identifier"))
|
||||||
|
for document in documents
|
||||||
|
if (document.get("payload") or {}).get("project_identifier")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"project_identifier": project, "document_count": count}
|
||||||
|
for project, count in sorted(counts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
def _to_qdrant_filter(self, raw_filter: Dict[str, List[Dict[str, Any]]]) -> Any:
|
||||||
|
conditions = []
|
||||||
|
for condition in raw_filter.get("must", []):
|
||||||
|
if "match" in condition:
|
||||||
|
conditions.append(
|
||||||
|
self.qmodels.FieldCondition(
|
||||||
|
key=condition["key"],
|
||||||
|
match=self.qmodels.MatchValue(value=condition["match"]["value"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif "range" in condition:
|
||||||
|
conditions.append(self.qmodels.FieldCondition(key=condition["key"], range=self.qmodels.DatetimeRange(**condition["range"])))
|
||||||
|
return self.qmodels.Filter(must=conditions) if conditions else None
|
||||||
|
|
||||||
|
def _point_to_result(self, point: Any) -> SearchResult:
|
||||||
|
payload = dict(point.payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
document_id = payload.pop("document_id", str(point.id))
|
||||||
|
return SearchResult(id=document_id, score=float(point.score), text=text, payload=payload)
|
||||||
|
|
||||||
|
def _record_to_document(self, record: Any) -> Dict[str, Any]:
|
||||||
|
payload = dict(record.payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
document_id = payload.pop("document_id", str(record.id))
|
||||||
|
return {"id": document_id, "text": text, "payload": payload}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .chunking import chunk_text
|
||||||
|
from .models import IndexDocument, Payload
|
||||||
|
|
||||||
|
|
||||||
|
Issue = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineMapper:
|
||||||
|
def __init__(self, redmine_url: str, chunk_chars: int = 3500, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.redmine_url = redmine_url.rstrip("/")
|
||||||
|
self.chunk_chars = chunk_chars
|
||||||
|
self.project_identifier = project_identifier
|
||||||
|
|
||||||
|
def issue_to_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
docs.extend(self._issue_documents(issue))
|
||||||
|
docs.extend(self._journal_documents(issue))
|
||||||
|
docs.extend(self._message_documents(issue))
|
||||||
|
docs.extend(self._contact_documents(issue))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _issue_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
subject = issue.get("subject") or ""
|
||||||
|
description = issue.get("description") or ""
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
contact_text = self._contact_text(contact)
|
||||||
|
text = f"Issue #{issue_id}: {subject}\n\n{description}\n\n{contact_text}".strip()
|
||||||
|
return self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}",
|
||||||
|
text=text,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="issue",
|
||||||
|
source_record_id=f"issue:{issue_id}",
|
||||||
|
record=issue,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _journal_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
for journal in issue.get("journals") or []:
|
||||||
|
notes = journal.get("notes") or ""
|
||||||
|
if not notes.strip():
|
||||||
|
continue
|
||||||
|
docs.extend(
|
||||||
|
self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}:journal:{journal['id']}",
|
||||||
|
text=notes,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="journal",
|
||||||
|
source_record_id=f"journal:{journal['id']}",
|
||||||
|
record=journal,
|
||||||
|
extra={
|
||||||
|
"journal_id": journal.get("id"),
|
||||||
|
"visibility": "private" if journal.get("private_notes") else "public",
|
||||||
|
"created_on": journal.get("created_on") or issue.get("updated_on"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _message_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
for message in issue.get("messages") or issue.get("journal_messages") or []:
|
||||||
|
body = message.get("body") or message.get("content") or message.get("message") or ""
|
||||||
|
if not body.strip():
|
||||||
|
continue
|
||||||
|
docs.extend(
|
||||||
|
self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}:message:{message['id']}",
|
||||||
|
text=body,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="message",
|
||||||
|
source_record_id=f"message:{message['id']}",
|
||||||
|
record=message,
|
||||||
|
extra={
|
||||||
|
"message_id": message.get("id"),
|
||||||
|
"direction": message.get("direction"),
|
||||||
|
"created_on": message.get("created_on") or issue.get("updated_on"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _contact_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
contact_id = contact.get("id")
|
||||||
|
if not contact_id:
|
||||||
|
return []
|
||||||
|
text = self._contact_text(contact)
|
||||||
|
if not text.strip():
|
||||||
|
return []
|
||||||
|
return self._documents_for_record(
|
||||||
|
base_id=f"redmine:contact:{contact_id}:issue:{issue['id']}",
|
||||||
|
text=text,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="contact",
|
||||||
|
source_record_id=f"contact:{contact_id}",
|
||||||
|
record=contact,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _documents_for_record(
|
||||||
|
self,
|
||||||
|
base_id: str,
|
||||||
|
text: str,
|
||||||
|
issue: Issue,
|
||||||
|
doc_type: str,
|
||||||
|
source_record_id: str,
|
||||||
|
record: Dict[str, Any],
|
||||||
|
extra: Optional[Payload] = None,
|
||||||
|
) -> List[IndexDocument]:
|
||||||
|
chunks = chunk_text(text, max_chars=self.chunk_chars)
|
||||||
|
payload = self._base_payload(issue, doc_type, source_record_id, record)
|
||||||
|
if extra:
|
||||||
|
payload.update({key: value for key, value in extra.items() if value is not None})
|
||||||
|
return [
|
||||||
|
IndexDocument(id=f"{base_id}:chunk:{index}", text=chunk, payload={**payload, "chunk_index": index})
|
||||||
|
for index, chunk in enumerate(chunks)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _base_payload(self, issue: Issue, doc_type: str, source_record_id: str, record: Dict[str, Any]) -> Payload:
|
||||||
|
project = issue.get("project") or {}
|
||||||
|
helpdesk_ticket = issue.get("helpdesk_ticket") or {}
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
redmine_url = issue.get("url") or f"{self.redmine_url}/issues/{issue_id}"
|
||||||
|
created_on = record.get("created_on") or issue.get("created_on")
|
||||||
|
updated_on = record.get("updated_on") or issue.get("updated_on")
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"project_id": project.get("id"),
|
||||||
|
"project_identifier": project.get("identifier") or self.project_identifier,
|
||||||
|
"project_name": project.get("name"),
|
||||||
|
"has_helpdesk_ticket": bool(helpdesk_ticket.get("id")),
|
||||||
|
"helpdesk_ticket_id": helpdesk_ticket.get("id"),
|
||||||
|
"contact_id": contact.get("id"),
|
||||||
|
"contact_email": contact.get("email"),
|
||||||
|
"contact_name": contact.get("name"),
|
||||||
|
"contact_company": contact.get("company"),
|
||||||
|
"created_on": created_on,
|
||||||
|
"updated_on": updated_on,
|
||||||
|
"visibility": "public",
|
||||||
|
"redmine_url": redmine_url,
|
||||||
|
"source_record_id": source_record_id,
|
||||||
|
"source_hash": stable_hash(record),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _issue_contact(self, issue: Issue) -> Payload:
|
||||||
|
contact = issue.get("contact") or issue.get("customer") or {}
|
||||||
|
helpdesk_ticket = issue.get("helpdesk_ticket") or {}
|
||||||
|
helpdesk_contact = helpdesk_ticket.get("contact") or {}
|
||||||
|
merged = {**helpdesk_contact, **contact}
|
||||||
|
if not merged.get("id"):
|
||||||
|
merged["id"] = helpdesk_ticket.get("contact_id")
|
||||||
|
if not merged.get("email"):
|
||||||
|
merged["email"] = helpdesk_ticket.get("contact_email") or helpdesk_ticket.get("from_address")
|
||||||
|
if not merged.get("name"):
|
||||||
|
merged["name"] = helpdesk_ticket.get("contact_name")
|
||||||
|
if not merged.get("company"):
|
||||||
|
merged["company"] = helpdesk_ticket.get("contact_company")
|
||||||
|
return {key: value for key, value in merged.items() if value not in (None, "")}
|
||||||
|
|
||||||
|
def _contact_text(self, contact: Payload) -> str:
|
||||||
|
text_parts = [
|
||||||
|
contact.get("name"),
|
||||||
|
contact.get("email"),
|
||||||
|
contact.get("phone"),
|
||||||
|
contact.get("company"),
|
||||||
|
]
|
||||||
|
return "\n".join(str(part) for part in text_parts if part)
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineApiSource:
|
||||||
|
def __init__(self, redmine_url: str, api_key: str, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.redmine_url = redmine_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.project_identifier = project_identifier
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Issue]:
|
||||||
|
for issue in self.recent_issue_summaries(limit):
|
||||||
|
yield self.issue_detail(int(issue["id"]), fallback=issue)
|
||||||
|
|
||||||
|
def recent_issue_summaries(self, limit: int) -> Iterable[Issue]:
|
||||||
|
yielded = 0
|
||||||
|
offset = 0
|
||||||
|
seen_issue_ids = set()
|
||||||
|
page_size = 100
|
||||||
|
while yielded < limit:
|
||||||
|
current_limit = min(page_size, limit - yielded)
|
||||||
|
params = {
|
||||||
|
"limit": str(current_limit),
|
||||||
|
"offset": str(offset),
|
||||||
|
"sort": "updated_on:desc,id:desc",
|
||||||
|
"include": "journals",
|
||||||
|
"status_id": "*",
|
||||||
|
}
|
||||||
|
if self.project_identifier:
|
||||||
|
params["project_id"] = self.project_identifier
|
||||||
|
params["subproject_id"] = "!*"
|
||||||
|
path = f"{self.redmine_url}/issues.json?{urllib.parse.urlencode(params)}"
|
||||||
|
payload = self._get_json(path)
|
||||||
|
issues = payload.get("issues", [])
|
||||||
|
if not issues:
|
||||||
|
break
|
||||||
|
for issue in issues:
|
||||||
|
issue_id = issue["id"]
|
||||||
|
if issue_id in seen_issue_ids:
|
||||||
|
continue
|
||||||
|
seen_issue_ids.add(issue_id)
|
||||||
|
issue.setdefault("url", f"{self.redmine_url}/issues/{issue_id}")
|
||||||
|
yield issue
|
||||||
|
yielded += 1
|
||||||
|
if yielded >= limit:
|
||||||
|
break
|
||||||
|
offset += len(issues)
|
||||||
|
|
||||||
|
def issue_detail(self, issue_id: int, fallback: Optional[Issue] = None) -> Issue:
|
||||||
|
detail_params = urllib.parse.urlencode({"include": "journals,helpdesk"})
|
||||||
|
detail = self._get_json(f"{self.redmine_url}/issues/{issue_id}.json?{detail_params}")
|
||||||
|
merged = {**(fallback or {}), **detail.get("issue", {})}
|
||||||
|
merged.setdefault("url", f"{self.redmine_url}/issues/{issue_id}")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _get_json(self, url: str) -> Dict[str, Any]:
|
||||||
|
request = urllib.request.Request(url, headers={"X-Redmine-API-Key": self.api_key, "Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def stable_hash(record: Dict[str, Any]) -> str:
|
||||||
|
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from .ingest import deduplicate_documents
|
||||||
|
from .models import IndexDocument
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshSource(Protocol):
|
||||||
|
project_identifier: str | None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshEmbedder(Protocol):
|
||||||
|
def embed_documents(self, docs: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshStore(Protocol):
|
||||||
|
def list_documents(
|
||||||
|
self,
|
||||||
|
limit: int = 10,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
issue_id: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def upsert(self, docs: Sequence[IndexDocument], vectors: Sequence[Sequence[float]]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids: Sequence[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class FileRefreshState:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def load(self) -> Dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def mark_success(self, project_identifier: str, timestamp: Optional[str] = None) -> None:
|
||||||
|
payload = self.load()
|
||||||
|
payload.setdefault("projects", {})
|
||||||
|
payload["projects"][project_identifier] = {
|
||||||
|
"last_successful_refresh_at": timestamp or datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: RedmineRefreshSource,
|
||||||
|
embedder: RefreshEmbedder,
|
||||||
|
store: RefreshStore,
|
||||||
|
mapper: Optional[RedmineMapper] = None,
|
||||||
|
state: Optional[FileRefreshState] = None,
|
||||||
|
) -> None:
|
||||||
|
self.source = source
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
self.mapper = mapper or RedmineMapper(redmine_url="")
|
||||||
|
self.state = state
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(
|
||||||
|
self,
|
||||||
|
project_limits: Dict[str, int],
|
||||||
|
dry_run: bool = False,
|
||||||
|
force_rebuild: bool = False,
|
||||||
|
overlap_minutes: int = 15,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
previous_source_project = getattr(self.source, "project_identifier", None)
|
||||||
|
previous_mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
project_results: List[Dict[str, Any]] = []
|
||||||
|
totals = {
|
||||||
|
"issues": 0,
|
||||||
|
"scanned_issues": 0,
|
||||||
|
"detail_fetched_issues": 0,
|
||||||
|
"skipped_issues": 0,
|
||||||
|
"documents": 0,
|
||||||
|
"unchanged_documents": 0,
|
||||||
|
"changed_documents": 0,
|
||||||
|
"new_documents": 0,
|
||||||
|
"stale_documents": 0,
|
||||||
|
"force_rebuilt_documents": 0,
|
||||||
|
"would_embed_documents": 0,
|
||||||
|
"embedded_documents": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
for project, limit in project_limits.items():
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = project
|
||||||
|
project_result = self._refresh_project(project, limit, dry_run, force_rebuild, overlap_minutes)
|
||||||
|
project_results.append(project_result)
|
||||||
|
for key in totals:
|
||||||
|
totals[key] += int(project_result.get(key, 0))
|
||||||
|
if not dry_run and self.state is not None:
|
||||||
|
self.state.mark_success(project)
|
||||||
|
finally:
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = previous_source_project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = previous_mapper_project
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"force_rebuild": force_rebuild,
|
||||||
|
"overlap_minutes": overlap_minutes,
|
||||||
|
**totals,
|
||||||
|
"project_results": project_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _refresh_project(self, project: str, limit: int, dry_run: bool, force_rebuild: bool, overlap_minutes: int) -> Dict[str, Any]:
|
||||||
|
summaries = list(self._recent_issue_summaries(limit))
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"project_identifier": project,
|
||||||
|
"issues": len(summaries),
|
||||||
|
"scanned_issues": len(summaries),
|
||||||
|
"detail_fetched_issues": 0,
|
||||||
|
"skipped_issues": 0,
|
||||||
|
"documents": 0,
|
||||||
|
"unchanged_documents": 0,
|
||||||
|
"changed_documents": 0,
|
||||||
|
"new_documents": 0,
|
||||||
|
"stale_documents": 0,
|
||||||
|
"force_rebuilt_documents": 0,
|
||||||
|
"would_embed_documents": 0,
|
||||||
|
"embedded_documents": 0,
|
||||||
|
}
|
||||||
|
cutoff = self._cutoff_for_project(project, overlap_minutes)
|
||||||
|
docs_to_embed: List[IndexDocument] = []
|
||||||
|
stale_ids: List[str] = []
|
||||||
|
for summary in summaries:
|
||||||
|
if cutoff is not None and not force_rebuild and not self._issue_is_in_refresh_window(summary, cutoff):
|
||||||
|
result["skipped_issues"] += 1
|
||||||
|
continue
|
||||||
|
issue = self._issue_detail(summary)
|
||||||
|
result["detail_fetched_issues"] += 1
|
||||||
|
candidates = deduplicate_documents(self.mapper.issue_to_documents(issue))
|
||||||
|
result["documents"] += len(candidates)
|
||||||
|
existing = self.store.list_documents(
|
||||||
|
limit=5000,
|
||||||
|
source="redmine",
|
||||||
|
project_identifier=project,
|
||||||
|
issue_id=int(issue["id"]),
|
||||||
|
)
|
||||||
|
existing_by_id = {document["id"]: document for document in existing}
|
||||||
|
candidate_by_id = {document.id: document for document in candidates}
|
||||||
|
for stale_id in sorted(set(existing_by_id) - set(candidate_by_id)):
|
||||||
|
stale_ids.append(stale_id)
|
||||||
|
result["stale_documents"] += 1
|
||||||
|
for document in candidates:
|
||||||
|
existing_document = existing_by_id.get(document.id)
|
||||||
|
if existing_document is None:
|
||||||
|
result["new_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
continue
|
||||||
|
existing_hash = (existing_document.get("payload") or {}).get("source_hash")
|
||||||
|
document_hash = document.payload.get("source_hash")
|
||||||
|
if force_rebuild:
|
||||||
|
result["force_rebuilt_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
elif existing_hash != document_hash:
|
||||||
|
result["changed_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
else:
|
||||||
|
result["unchanged_documents"] += 1
|
||||||
|
result["would_embed_documents"] = len(docs_to_embed)
|
||||||
|
if dry_run:
|
||||||
|
return result
|
||||||
|
if stale_ids:
|
||||||
|
self.store.delete_documents(stale_ids)
|
||||||
|
if docs_to_embed:
|
||||||
|
vectors = self.embedder.embed_documents(docs_to_embed)
|
||||||
|
self.store.upsert(docs_to_embed, vectors)
|
||||||
|
result["embedded_documents"] = len(docs_to_embed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _recent_issue_summaries(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
if hasattr(self.source, "recent_issue_summaries"):
|
||||||
|
return self.source.recent_issue_summaries(limit) # type: ignore[attr-defined]
|
||||||
|
return self.source.recent_helpdesk_issues(limit)
|
||||||
|
|
||||||
|
def _issue_detail(self, summary: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if hasattr(self.source, "issue_detail"):
|
||||||
|
return self.source.issue_detail(int(summary["id"])) # type: ignore[attr-defined]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _cutoff_for_project(self, project: str, overlap_minutes: int) -> Optional[datetime]:
|
||||||
|
if self.state is None:
|
||||||
|
return None
|
||||||
|
timestamp = ((self.state.load().get("projects") or {}).get(project) or {}).get("last_successful_refresh_at")
|
||||||
|
if not timestamp:
|
||||||
|
return None
|
||||||
|
parsed = parse_redmine_datetime(timestamp)
|
||||||
|
return parsed - timedelta(minutes=overlap_minutes)
|
||||||
|
|
||||||
|
def _issue_is_in_refresh_window(self, issue: Dict[str, Any], cutoff: datetime) -> bool:
|
||||||
|
updated_on = issue.get("updated_on")
|
||||||
|
if not updated_on:
|
||||||
|
return True
|
||||||
|
return parse_redmine_datetime(str(updated_on)) >= cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def parse_redmine_datetime(raw: str) -> datetime:
|
||||||
|
normalized = raw.replace("Z", "+00:00")
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
semantic_index/refresh.sh [--apply] [--dry-run]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS comma-separated project=limit pairs
|
||||||
|
SEMANTIC_INDEX_LOG_DIR default: .cache/semantic_index/logs
|
||||||
|
SEMANTIC_INDEX_STATE_PATH default: .cache/semantic_index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES default: 15
|
||||||
|
PYTHON default: <install-root>/.venv/bin/python
|
||||||
|
|
||||||
|
This wrapper never passes --force-rebuild. Run force rebuilds manually.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
install_root=$(cd "$script_dir/.." && pwd)
|
||||||
|
|
||||||
|
load_env_defaults() {
|
||||||
|
local file=$1
|
||||||
|
local key value
|
||||||
|
[[ -r "$file" ]] || return 0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line=${line#"${line%%[![:space:]]*}"}
|
||||||
|
line=${line%"${line##*[![:space:]]}"}
|
||||||
|
[[ -z "$line" || "$line" == \#* || "$line" != *=* ]] && continue
|
||||||
|
key=${line%%=*}
|
||||||
|
value=${line#*=}
|
||||||
|
key=${key%"${key##*[![:space:]]}"}
|
||||||
|
value=${value#"${value%%[![:space:]]*}"}
|
||||||
|
value=${value%"${value##*[![:space:]]}"}
|
||||||
|
value=${value%\"}
|
||||||
|
value=${value#\"}
|
||||||
|
value=${value%\'}
|
||||||
|
value=${value#\'}
|
||||||
|
if [[ -z "${!key+x}" ]]; then
|
||||||
|
export "$key=$value"
|
||||||
|
fi
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_defaults /etc/semantic-index.env
|
||||||
|
|
||||||
|
mode=dry-run
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--apply)
|
||||||
|
mode=apply
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
mode=dry-run
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
project_limits=${SEMANTIC_INDEX_PROJECT_LIMITS:-customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100}
|
||||||
|
log_dir=${SEMANTIC_INDEX_LOG_DIR:-.cache/semantic_index/logs}
|
||||||
|
state_path=${SEMANTIC_INDEX_STATE_PATH:-.cache/semantic_index/refresh_state.json}
|
||||||
|
overlap_minutes=${SEMANTIC_INDEX_OVERLAP_MINUTES:-15}
|
||||||
|
python_bin=${PYTHON:-$install_root/.venv/bin/python}
|
||||||
|
|
||||||
|
mkdir -p "$log_dir" "$(dirname "$state_path")"
|
||||||
|
timestamp=$(date -u +"%Y%m%dT%H%M%SZ")
|
||||||
|
log_file="$log_dir/redmine-refresh-$timestamp.log"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
-m semantic_index
|
||||||
|
--refresh-redmine-projects
|
||||||
|
--project-limits "$project_limits"
|
||||||
|
--state-path "$state_path"
|
||||||
|
--overlap-minutes "$overlap_minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
args+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'started_at=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
printf 'mode=%s\n' "$mode"
|
||||||
|
printf 'project_limits=%s\n' "$project_limits"
|
||||||
|
printf 'state_path=%s\n' "$state_path"
|
||||||
|
printf 'overlap_minutes=%s\n' "$overlap_minutes"
|
||||||
|
cd "$install_root"
|
||||||
|
"$python_bin" "${args[@]}"
|
||||||
|
printf '\nfinished_at=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
} 2>&1 | tee "$log_file"
|
||||||
|
|
||||||
|
printf 'log_file=%s\n' "$log_file"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Protocol
|
||||||
|
|
||||||
|
from .models import SearchQuery, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class QueryEmbedder(Protocol):
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SearchStore(Protocol):
|
||||||
|
def search(self, vector: List[float], query: SearchQuery, limit: int) -> List[SearchResult]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class HybridSearchService:
|
||||||
|
def __init__(self, embedder: QueryEmbedder, store: SearchStore) -> None:
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def search(self, query: SearchQuery) -> List[SearchResult]:
|
||||||
|
vector = self.embedder.embed_query(query.text)
|
||||||
|
candidates = self.store.search(vector, query, limit=query.limit)
|
||||||
|
rescored = [
|
||||||
|
SearchResult(
|
||||||
|
id=result.id,
|
||||||
|
score=result.score + keyword_boost(query.text, result),
|
||||||
|
text=result.text,
|
||||||
|
payload=result.payload,
|
||||||
|
)
|
||||||
|
for result in candidates
|
||||||
|
]
|
||||||
|
return sorted(rescored, key=lambda result: result.score, reverse=True)[: query.limit]
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
return self.store.get_document(document_id)
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_boost(query_text: str, result: SearchResult) -> float:
|
||||||
|
haystack = " ".join([result.text, " ".join(str(value) for value in result.payload.values() if value is not None)]).lower()
|
||||||
|
boost = 0.0
|
||||||
|
for phrase in re.findall(r'"([^"]+)"', query_text):
|
||||||
|
if phrase.lower() in haystack:
|
||||||
|
boost += 0.35
|
||||||
|
for email in re.findall(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", query_text):
|
||||||
|
if email.lower() in haystack:
|
||||||
|
boost += 0.3
|
||||||
|
for token in re.findall(r"\b(?:#?\d{2,}|[A-Z]{2,}[-_]\d{2,}|[A-Z0-9]{4,}-[A-Z0-9-]{2,})\b", query_text):
|
||||||
|
normalized = token.lower().lstrip("#")
|
||||||
|
if token.lower() in haystack or normalized in haystack:
|
||||||
|
boost += 0.25
|
||||||
|
for word in re.findall(r"\b[A-Za-z][\w.-]{2,}\b", query_text):
|
||||||
|
if word.lower() in haystack:
|
||||||
|
boost += 0.03
|
||||||
|
return boost
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
semantic_index/search.sh "query text" [project_identifier] [limit]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq '.results[] | {id, score, citation}'
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
SEMANTIC_INDEX_URL default: http://127.0.0.1:8787
|
||||||
|
SEMANTIC_INDEX_API_KEY optional; falls back to semantic_index/.env or .env
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
query=$1
|
||||||
|
project=${2:-}
|
||||||
|
limit=${3:-10}
|
||||||
|
base_url=${SEMANTIC_INDEX_URL:-http://127.0.0.1:8787}
|
||||||
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
install_root=$(cd "$script_dir/.." && pwd)
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key=$1
|
||||||
|
local file
|
||||||
|
for file in /etc/semantic-index.env "$install_root/semantic_index/.env" "$install_root/.env" semantic_index/.env .env; do
|
||||||
|
if [[ -f "$file" ]]; then
|
||||||
|
awk -F= -v key="$key" '
|
||||||
|
$1 == key {
|
||||||
|
value = substr($0, index($0, "=") + 1)
|
||||||
|
gsub(/^[ \t"'\''"]+|[ \t"'\''"]+$/, "", value)
|
||||||
|
print value
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
sed \
|
||||||
|
-e 's/\\/\\\\/g' \
|
||||||
|
-e 's/"/\\"/g' \
|
||||||
|
-e ':a;N;$!ba;s/\n/\\n/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped_query=$(printf '%s' "$query" | json_escape)
|
||||||
|
payload="{\"query\":\"$escaped_query\",\"limit\":$limit"
|
||||||
|
if [[ -n "$project" ]]; then
|
||||||
|
escaped_project=$(printf '%s' "$project" | json_escape)
|
||||||
|
payload="$payload,\"project_identifier\":\"$escaped_project\""
|
||||||
|
fi
|
||||||
|
payload="$payload}"
|
||||||
|
|
||||||
|
api_key=${SEMANTIC_INDEX_API_KEY:-$(read_env_value SEMANTIC_INDEX_API_KEY)}
|
||||||
|
|
||||||
|
args=(-sS -H "Content-Type: application/json" -d "$payload")
|
||||||
|
if [[ -n "${api_key:-}" ]]; then
|
||||||
|
args+=(-H "Authorization: Bearer $api_key")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl "${args[@]}" "$base_url/search"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
• Added semantic_index/search.sh. It outputs raw JSON from the running HTTP service.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq '.results[] | {id, score, citation}'
|
||||||
|
|
||||||
|
It defaults to http://127.0.0.1:8787, reads SEMANTIC_INDEX_API_KEY from the environment or semantic_i
|
||||||
|
ndex/.env, and accepts:
|
||||||
|
|
||||||
|
semantic_index/search.sh "query text" [project_identifier] [limit]
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
- Script works against the local service.
|
||||||
|
- Semantic tests still pass: 49 tests, OK.
|
||||||
|
- Python compile check passed.
|
||||||
|
|
||||||
|
|
||||||
|
› Okay yeah, that's nice and fast
|
||||||
|
|
||||||
|
|
||||||
|
• Good. That script is the right shape for quick manual probing: fast because it avoids Python startup,
|
||||||
|
plain JSON so it pipes cleanly, and thin enough that it won’t hide what the API is returning.
|
||||||
|
|
||||||
|
A couple useful examples:
|
||||||
|
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3 | jq '.results[].citation'
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq -r '.results[] | "\(.score)
|
||||||
|
\(.citation.url) \(.snippet)"'
|
||||||
|
semantic_index/search.sh "callum@safetagtracking.com" customer-service 3 | jq '.results[0]'
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: redmine-communicator
|
||||||
|
description: Use when an agent needs to install, configure, or operate the redMCP MCP server to communicate with Redmine, including Helpdesk-aware issue reads, safe issue updates, attachment handling, and explicit customer-visible Helpdesk responses.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Redmine Communicator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Use this skill to connect an agent to Redmine through `redMCP`, a PHP MCP server
|
||||||
|
that wraps Redmine's REST API and LDR's Helpdesk-aware extensions.
|
||||||
|
|
||||||
|
Use `redMCP` instead of ad hoc HTTP calls when the task involves Redmine issues,
|
||||||
|
projects, users, attachments, project categories, issue relations, or Helpdesk
|
||||||
|
customer communications.
|
||||||
|
|
||||||
|
## Setup Workflow
|
||||||
|
|
||||||
|
1. Install or stage `redMCP` from this repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 skills/redmine-communicator/scripts/setup_redmcp.py \
|
||||||
|
--redmine-url http://redmine.example.test \
|
||||||
|
--redmine-api-key "$REDMINE_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is dry-run. Add `--apply` to copy files and write `.env`.
|
||||||
|
|
||||||
|
2. Configure the MCP client with the printed stdio config. The command points to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<install-dir>/bin/redmcp-server.php
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the server from the agent or client by listing tools. If using the
|
||||||
|
Streamable HTTP transport, generate and configure `MCP_SERVER_TOKEN`.
|
||||||
|
|
||||||
|
4. Read [references/redmcp-tools.md](references/redmcp-tools.md) before making
|
||||||
|
customer-visible changes or using less common tools.
|
||||||
|
|
||||||
|
## Operating Rules
|
||||||
|
|
||||||
|
- Prefer read-only tools first: list/search projects, issues, users, categories,
|
||||||
|
memberships, and Helpdesk context before changing anything.
|
||||||
|
- For Helpdesk-backed issues, use `redmine_issue_with_helpdesk` instead of a
|
||||||
|
plain issue read when customer identity or email context matters.
|
||||||
|
- `redmine_update_issue` is internal-note safe by default. It does **not** send
|
||||||
|
Helpdesk customer email unless `options.send_helpdesk_email=true` is passed.
|
||||||
|
- Use `redmine_send_helpdesk_response` only when the user explicitly wants a
|
||||||
|
customer-visible Helpdesk email.
|
||||||
|
- Do not invent Redmine project identifiers, tracker ids, category ids, or user
|
||||||
|
ids. Discover them with redMCP tools first.
|
||||||
|
- Do not put Redmine API keys, MCP bearer tokens, passwords, or customer secrets
|
||||||
|
in logs, committed files, or final answers.
|
||||||
|
|
||||||
|
## Common Tool Choices
|
||||||
|
|
||||||
|
- Find work: `redmine_list_issues`, `redmine_search`, `redmine_search_issues`.
|
||||||
|
- Read one issue: `redmine_get_issue`; use `redmine_issue_with_helpdesk` for
|
||||||
|
Helpdesk/customer context.
|
||||||
|
- Internal update: `redmine_update_issue` with fields only.
|
||||||
|
- Customer reply: `redmine_send_helpdesk_response`, or
|
||||||
|
`redmine_update_issue` with `options.send_helpdesk_email=true`.
|
||||||
|
- Attachments: `redmine_upload_attachment`, then include returned upload token
|
||||||
|
in issue create/update; `redmine_download_attachment` only to safe local paths.
|
||||||
|
- Structure: issue relation, parent/child, project category, project membership,
|
||||||
|
project, and user tools are available through MCP.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `redmcp-server.php` fails immediately, check that `.env` contains
|
||||||
|
`REDMINE_URL` and `REDMINE_API_KEY`.
|
||||||
|
- If PHP autoloading fails, run `composer install` in the `redMCP` install
|
||||||
|
directory, or install from a package that includes `vendor/`.
|
||||||
|
- If HTTP transport is used, `MCP_SERVER_TOKEN` is required and clients must send
|
||||||
|
`Authorization: Bearer <token>`.
|
||||||
|
- Debug logging can include customer text and issue notes. Enable it only for
|
||||||
|
local troubleshooting and store logs somewhere private.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Redmine Communicator"
|
||||||
|
short_description: "Connect agents to Redmine through redMCP"
|
||||||
|
default_prompt: "Use $redmine-communicator to connect to Redmine, inspect issues, and make safe Helpdesk-aware updates."
|
||||||
|
dependencies:
|
||||||
|
tools:
|
||||||
|
- type: "mcp"
|
||||||
|
value: "redmcp"
|
||||||
|
description: "Local redMCP server exposing Redmine and Helpdesk-aware tools."
|
||||||
|
transport: "stdio"
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# redMCP Tool Reference
|
||||||
|
|
||||||
|
Use this reference after the `redmine-communicator` skill triggers and the task
|
||||||
|
requires specific tool selection or setup details.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
Required environment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REDMINE_URL=http://redmine.example.test
|
||||||
|
REDMINE_API_KEY=...
|
||||||
|
MCP_TEXT_SANITIZATION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
For Streamable HTTP MCP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP_SERVER_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Stdio server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
redMCP/bin/redmcp-server.php
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MCP_SERVER_TOKEN=... redMCP/bin/redmcp-http-server.php --host 0.0.0.0 --port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP endpoint defaults to `/mcp` and requires `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
## Read Tools
|
||||||
|
|
||||||
|
- `redmine_list_projects`: list projects.
|
||||||
|
- `redmine_get_project`: fetch one project by id or identifier.
|
||||||
|
- `redmine_list_project_memberships`: users/groups and roles for a project.
|
||||||
|
- `redmine_list_users`, `redmine_get_user`: user discovery.
|
||||||
|
- `redmine_list_issues`: structured issue filters with friendly fields like
|
||||||
|
`project_id`, `status`, `updated`, `created`, `sort`, `limit`, and `page`.
|
||||||
|
- `redmine_search`, `redmine_search_issues`: Redmine native text search.
|
||||||
|
- `redmine_get_issue`: plain issue read.
|
||||||
|
- `redmine_issue_with_helpdesk`: issue plus Helpdesk ticket/contact/messages.
|
||||||
|
- `redmine_list_project_issue_categories`, `redmine_get_issue_category`.
|
||||||
|
- `redmine_get_attachment`.
|
||||||
|
|
||||||
|
When a tool receives `project_id` values that look like human names (spaces or
|
||||||
|
uppercase), redMCP attempts to resolve to a slug automatically when there is one
|
||||||
|
clear match. For ambiguous names, it returns a guidance error and suggests using
|
||||||
|
`redmine_find_project`.
|
||||||
|
|
||||||
|
## Write Tools
|
||||||
|
|
||||||
|
- `redmine_create_issue`: create an issue.
|
||||||
|
- `redmine_update_issue`: update fields or add an internal note. Helpdesk email
|
||||||
|
is opt-in with `options.send_helpdesk_email=true`.
|
||||||
|
- `redmine_send_helpdesk_response`: send a customer-visible Helpdesk email.
|
||||||
|
- `redmine_create_issue_relation`, `redmine_remove_issue_relation`.
|
||||||
|
- `redmine_set_issue_parent`, `redmine_clear_issue_parent`.
|
||||||
|
- `redmine_create_issue_category`, `redmine_update_issue_category`.
|
||||||
|
- `redmine_upload_attachment`, `redmine_download_attachment`,
|
||||||
|
`redmine_update_attachment`.
|
||||||
|
|
||||||
|
## Safety Notes
|
||||||
|
|
||||||
|
- Customer-visible email requires explicit intent. Prefer internal notes unless
|
||||||
|
the user asks to email the customer.
|
||||||
|
- Deletion tools for issues, projects, users, categories, and attachments are
|
||||||
|
intentionally not exposed. Relation removal only unlinks the relationship.
|
||||||
|
- For Helpdesk workflows, read with `redmine_issue_with_helpdesk` before
|
||||||
|
replying so the agent sees customer/contact context.
|
||||||
|
- For file uploads, use `redmine_upload_attachment` with a path, base64 content,
|
||||||
|
data URL, or file envelope. Use data/file inputs for PDFs and non-image files.
|
||||||
|
- `redmine_download_attachment` requires an explicit path under `/tmp` or the
|
||||||
|
repository tree and limits optional base64 response size.
|
||||||
|
|
||||||
|
## Example MCP Client Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"redmcp": {
|
||||||
|
"command": "/path/to/redMCP/bin/redmcp-server.php"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Calls
|
||||||
|
|
||||||
|
Read Helpdesk-aware issue context:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_issue_with_helpdesk",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"include": ["journals", "attachments"],
|
||||||
|
"message_limit": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Internal note:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_update_issue",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"fields": {
|
||||||
|
"notes": "Internal follow-up note."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Customer-visible Helpdesk reply:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_send_helpdesk_response",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"content": "Customer-visible response text."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Install or stage redMCP for another agent's MCP client."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_INSTALL_DIR = Path.home() / ".local" / "share" / "redmcp"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Install redMCP and print MCP client configuration.")
|
||||||
|
parser.add_argument("--source-redmcp", type=Path, default=repo_root() / "redMCP")
|
||||||
|
parser.add_argument("--install-dir", type=Path, default=DEFAULT_INSTALL_DIR)
|
||||||
|
parser.add_argument("--redmine-url", required=True)
|
||||||
|
parser.add_argument("--redmine-api-key", required=True)
|
||||||
|
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
||||||
|
parser.add_argument("--mcp-server-token", default="")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Copy files and write .env. Default is dry-run.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.transport == "http" and not args.mcp_server_token:
|
||||||
|
print("error: --mcp-server-token is required for --transport http", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if not args.source_redmcp.is_dir():
|
||||||
|
print("error: source redMCP directory not found: {0}".format(args.source_redmcp), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("mode={0}".format("apply" if args.apply else "dry-run"))
|
||||||
|
print("source_redmcp={0}".format(args.source_redmcp))
|
||||||
|
print("install_dir={0}".format(args.install_dir))
|
||||||
|
|
||||||
|
if args.apply:
|
||||||
|
copy_redmcp(args.source_redmcp, args.install_dir)
|
||||||
|
write_env(args.install_dir / ".env", args)
|
||||||
|
ensure_executable(args.install_dir / "bin" / "redmcp-server.php")
|
||||||
|
ensure_executable(args.install_dir / "bin" / "redmcp-http-server.php")
|
||||||
|
else:
|
||||||
|
print("would copy redMCP files")
|
||||||
|
print("would write {0} with REDMINE_URL and REDMINE_API_KEY".format(args.install_dir / ".env"))
|
||||||
|
|
||||||
|
print_runtime_notes(args)
|
||||||
|
print_mcp_config(args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root():
|
||||||
|
return Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def copy_redmcp(source, target):
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(str(target))
|
||||||
|
ignore = shutil.ignore_patterns(".env", ".cache", "__pycache__", "*.pyc", "*.log")
|
||||||
|
shutil.copytree(str(source), str(target), ignore=ignore)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path, args):
|
||||||
|
lines = [
|
||||||
|
"REDMINE_URL={0}".format(args.redmine_url.rstrip("/")),
|
||||||
|
"REDMINE_API_KEY={0}".format(args.redmine_api_key),
|
||||||
|
]
|
||||||
|
if args.transport == "http":
|
||||||
|
lines.append("MCP_SERVER_TOKEN={0}".format(args.mcp_server_token))
|
||||||
|
path.write_text("\n".join(lines) + "\n")
|
||||||
|
os.chmod(str(path), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_executable(path):
|
||||||
|
if path.exists():
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
|
||||||
|
def print_runtime_notes(args):
|
||||||
|
composer = shutil.which("composer")
|
||||||
|
vendor = args.install_dir / "vendor" / "autoload.php"
|
||||||
|
if args.apply and not vendor.exists():
|
||||||
|
if composer:
|
||||||
|
print("vendor/autoload.php missing; run: cd {0} && composer install".format(args.install_dir))
|
||||||
|
else:
|
||||||
|
print("vendor/autoload.php missing and composer was not found on PATH")
|
||||||
|
elif not args.apply:
|
||||||
|
print("after apply, ensure vendor/autoload.php exists or run composer install in the install dir")
|
||||||
|
|
||||||
|
|
||||||
|
def print_mcp_config(args):
|
||||||
|
if args.transport == "stdio":
|
||||||
|
config = {
|
||||||
|
"mcpServers": {
|
||||||
|
"redmcp": {
|
||||||
|
"command": str(args.install_dir / "bin" / "redmcp-server.php")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps(config, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
command = "{0} --host {1} --port {2}".format(
|
||||||
|
args.install_dir / "bin" / "redmcp-http-server.php",
|
||||||
|
args.host,
|
||||||
|
args.port,
|
||||||
|
)
|
||||||
|
print("start_http_server={0}".format(command))
|
||||||
|
print("mcp_url=http://{0}:{1}/mcp".format(args.host, args.port))
|
||||||
|
print("authorization=Bearer <MCP_SERVER_TOKEN>")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage post-import automation files into the shared LAN scratch directory."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TARGET = Path("/opt/lanscratch/redmine-post-import/repo")
|
||||||
|
|
||||||
|
PAYLOAD_PATHS = (
|
||||||
|
"plugins",
|
||||||
|
"docs",
|
||||||
|
"semantic_index",
|
||||||
|
"deploy",
|
||||||
|
"dist",
|
||||||
|
"post_import_refresh.py",
|
||||||
|
"stage_post_import_payload.py",
|
||||||
|
"reset_helpdesk_mail_settings.py",
|
||||||
|
"validate_test_instance.py",
|
||||||
|
"redmine_outbox_worker.py",
|
||||||
|
"redMCP",
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDES = (
|
||||||
|
".env",
|
||||||
|
".venv",
|
||||||
|
".cache",
|
||||||
|
"__pycache__/",
|
||||||
|
"*.pyc",
|
||||||
|
"*.tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Copy the post-import automation payload into /opt/lanscratch."
|
||||||
|
)
|
||||||
|
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Run rsync. Default is dry-run.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parent
|
||||||
|
command = build_rsync_command(repo_root, args.target)
|
||||||
|
if not args.apply:
|
||||||
|
print("mode=dry-run")
|
||||||
|
print(f"would run: mkdir -p {shlex.quote(str(args.target))}")
|
||||||
|
print(f"would run: {command}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
args.target.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"running: {command}")
|
||||||
|
result = subprocess.run(command, shell=True, check=False)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def build_rsync_command(repo_root: Path, target: Path) -> str:
|
||||||
|
exclude_args = " ".join(f"--exclude {shlex.quote(pattern)}" for pattern in EXCLUDES)
|
||||||
|
sources = " ".join(shlex.quote(str(repo_root / path)) for path in PAYLOAD_PATHS)
|
||||||
|
return f"rsync -a --delete {exclude_args} {sources} {shlex.quote(str(target))}/"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
require 'minitest/autorun'
|
||||||
|
require_relative '../../plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer'
|
||||||
|
|
||||||
|
Contact = Struct.new(:id, :name, :company, :primary_email, :emails, keyword_init: true)
|
||||||
|
Ticket = Struct.new(:id, :contact_id, :message_id, :source, :from_address, :to_address, :cc_address, :ticket_date, :customer, keyword_init: true) do
|
||||||
|
def is_incoming?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Issue = Struct.new(:helpdesk_ticket, keyword_init: true)
|
||||||
|
|
||||||
|
class IssueApiSerializerTest < Minitest::Test
|
||||||
|
def test_serializes_helpdesk_ticket_with_expanded_contact
|
||||||
|
contact = Contact.new(
|
||||||
|
:id => 1890,
|
||||||
|
:name => 'Callum Mackeonis',
|
||||||
|
:company => 'SafeTag Tracking',
|
||||||
|
:primary_email => 'callum@safetagtracking.com',
|
||||||
|
:emails => ['callum@safetagtracking.com']
|
||||||
|
)
|
||||||
|
ticket = Ticket.new(
|
||||||
|
:id => 35159,
|
||||||
|
:contact_id => 1890,
|
||||||
|
:message_id => 'message-id',
|
||||||
|
:source => 0,
|
||||||
|
:from_address => 'callum@safetagtracking.com',
|
||||||
|
:to_address => 'contact@ldrprep.com',
|
||||||
|
:cc_address => '',
|
||||||
|
:ticket_date => Time.utc(2026, 4, 14, 10, 18, 38),
|
||||||
|
:customer => contact
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => ticket))
|
||||||
|
|
||||||
|
assert_equal 35159, payload[:id]
|
||||||
|
assert_equal 1890, payload[:contact_id]
|
||||||
|
assert_equal 'callum@safetagtracking.com', payload[:contact][:email]
|
||||||
|
assert_equal 'Callum Mackeonis', payload[:contact][:name]
|
||||||
|
assert_equal 'SafeTag Tracking', payload[:contact][:company]
|
||||||
|
assert_equal '2026-04-14T10:18:38Z', payload[:ticket_date]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_returns_nil_for_non_helpdesk_issue
|
||||||
|
assert_nil RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => nil))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.app import create_app
|
||||||
|
from semantic_index.config import Settings
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def search(self, query):
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:issue:1:chunk:0",
|
||||||
|
score=0.8,
|
||||||
|
text="Snippet text",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 1,
|
||||||
|
"redmine_url": "http://redmine/issues/1",
|
||||||
|
"source_record_id": "issue:1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Full text", "payload": {}}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def list_projects(self, source=None, limit=1000):
|
||||||
|
return [{"project_identifier": "customer-service", "document_count": 10}]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRefreshService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "dry_run": dry_run}
|
||||||
|
|
||||||
|
|
||||||
|
def fake_services():
|
||||||
|
refresh = FakeRefreshService()
|
||||||
|
return {
|
||||||
|
"settings": Settings(
|
||||||
|
openai_api_key="",
|
||||||
|
qdrant_url="http://qdrant",
|
||||||
|
qdrant_api_key=None,
|
||||||
|
qdrant_collection="semantic",
|
||||||
|
redmine_url="http://redmine",
|
||||||
|
redmine_api_key="",
|
||||||
|
redmine_project_identifier=None,
|
||||||
|
sample_limit=50,
|
||||||
|
bind_host="127.0.0.1",
|
||||||
|
bind_port=8787,
|
||||||
|
service_api_key=None,
|
||||||
|
refresh_state_path=Path(".cache/semantic_index/refresh_state.json"),
|
||||||
|
),
|
||||||
|
"search": FakeSearchService(),
|
||||||
|
"store": FakeStore(),
|
||||||
|
"refresh": refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexAppTest(unittest.TestCase):
|
||||||
|
def test_health_does_not_build_live_services(self):
|
||||||
|
def broken_builder():
|
||||||
|
raise AssertionError("health should not build live clients")
|
||||||
|
|
||||||
|
app = create_app(service_builder=broken_builder)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
self.assertEqual({"status": "ok"}, routes["/health"]())
|
||||||
|
|
||||||
|
def test_search_endpoint_returns_normalized_agent_response(self):
|
||||||
|
app = create_app(service_builder=fake_services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/search"]({"query": "printer", "project_identifier": "customer-service", "limit": 3})
|
||||||
|
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
self.assertEqual("customer-service", response["filters"]["project_identifier"])
|
||||||
|
self.assertEqual("customer-service", response["results"][0]["citation"]["project_identifier"])
|
||||||
|
|
||||||
|
def test_projects_endpoint_lists_indexed_projects(self):
|
||||||
|
app = create_app(service_builder=fake_services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/projects"]()
|
||||||
|
|
||||||
|
self.assertEqual("customer-service", response["projects"][0]["project_identifier"])
|
||||||
|
|
||||||
|
def test_refresh_endpoint_passes_project_limits_and_cost_flags(self):
|
||||||
|
services = fake_services()
|
||||||
|
app = create_app(service_builder=lambda: services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/sources/redmine/refresh"](
|
||||||
|
{
|
||||||
|
"project_limits": {"customer-service": 5},
|
||||||
|
"dry_run": True,
|
||||||
|
"force_rebuild": False,
|
||||||
|
"overlap_minutes": 30,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(response["dry_run"])
|
||||||
|
self.assertEqual(({"customer-service": 5}, True, False, 30), services["refresh"].calls[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.ingest import BackfillService
|
||||||
|
from semantic_index.mcp import SemanticMCP
|
||||||
|
from semantic_index.models import SearchQuery, SearchResult
|
||||||
|
from semantic_index.redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
project_identifier = None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"subject": "First",
|
||||||
|
"description": "First body",
|
||||||
|
"project": {"identifier": self.project_identifier},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"subject": "Second",
|
||||||
|
"description": "Second body",
|
||||||
|
"project": {"identifier": self.project_identifier},
|
||||||
|
},
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateDocumentRedmineSource:
|
||||||
|
project_identifier = "customer-service"
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{"id": 1, "subject": "First", "description": "First body", "project": {"identifier": "customer-service"}},
|
||||||
|
{"id": 1, "subject": "First duplicate", "description": "Duplicate body", "project": {"identifier": "customer-service"}},
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedder:
|
||||||
|
def embed_documents(self, docs):
|
||||||
|
return [[float(i), 0.0, 0.0] for i, _ in enumerate(docs, start=1)]
|
||||||
|
|
||||||
|
def embed_query(self, text):
|
||||||
|
return [0.1, 0.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.deleted = []
|
||||||
|
self.upserts = []
|
||||||
|
|
||||||
|
def rebuild_source(self, source, docs, vectors, project_identifier=None):
|
||||||
|
self.deleted.append((source, project_identifier))
|
||||||
|
self.upserts.append((docs, vectors))
|
||||||
|
|
||||||
|
def list_projects(self, source=None, limit=1000):
|
||||||
|
return [
|
||||||
|
{"project_identifier": "customer-service", "document_count": 1684},
|
||||||
|
{"project_identifier": "hiring", "document_count": 409},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRefreshService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "dry_run": dry_run}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
return [SearchResult(id="doc1", score=0.5, text="Snippet", payload={"redmine_url": "http://redmine/issues/1"})]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Snippet"}
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillAndMCPTest(unittest.TestCase):
|
||||||
|
def test_sample_backfill_rebuilds_redmine_source(self):
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=FakeStore())
|
||||||
|
|
||||||
|
result = service.backfill_redmine_sample(limit=2)
|
||||||
|
|
||||||
|
self.assertEqual({"source": "redmine", "issues": 2, "documents": 2}, result)
|
||||||
|
self.assertEqual([("redmine", None)], service.store.deleted)
|
||||||
|
docs, vectors = service.store.upserts[0]
|
||||||
|
self.assertEqual(["redmine:issue:1:chunk:0", "redmine:issue:2:chunk:0"], [doc.id for doc in docs])
|
||||||
|
self.assertEqual(2, len(vectors))
|
||||||
|
|
||||||
|
def test_sample_backfill_rebuilds_only_the_configured_project_scope(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(
|
||||||
|
source=FakeRedmineSource(),
|
||||||
|
embedder=FakeEmbedder(),
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url="", project_identifier="customer-service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
service.backfill_redmine_sample(limit=1)
|
||||||
|
|
||||||
|
self.assertEqual([("redmine", "customer-service")], store.deleted)
|
||||||
|
|
||||||
|
def test_multi_project_backfill_rebuilds_each_project_scope(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_projects(["customer-service", "hiring"], per_project_limit=1)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": 2,
|
||||||
|
"issues": 2,
|
||||||
|
"documents": 2,
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": "customer-service", "issues": 1, "documents": 1},
|
||||||
|
{"project_identifier": "hiring", "issues": 1, "documents": 1},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
self.assertEqual([("redmine", "customer-service"), ("redmine", "hiring")], store.deleted)
|
||||||
|
self.assertEqual("customer-service", store.upserts[0][0][0].payload["project_identifier"])
|
||||||
|
self.assertEqual("hiring", store.upserts[1][0][0].payload["project_identifier"])
|
||||||
|
|
||||||
|
def test_multi_project_backfill_accepts_per_project_limits(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_project_limits({"customer-service": 2, "hiring": 1})
|
||||||
|
|
||||||
|
self.assertEqual(3, result["issues"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
{"project_identifier": "customer-service", "issues": 2, "documents": 2},
|
||||||
|
{"project_identifier": "hiring", "issues": 1, "documents": 1},
|
||||||
|
],
|
||||||
|
result["project_results"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backfill_deduplicates_documents_by_stable_id_before_embedding(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=DuplicateDocumentRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_sample(limit=2)
|
||||||
|
|
||||||
|
self.assertEqual({"source": "redmine", "issues": 2, "documents": 1}, result)
|
||||||
|
docs, vectors = store.upserts[0]
|
||||||
|
self.assertEqual(["redmine:issue:1:chunk:0"], [doc.id for doc in docs])
|
||||||
|
self.assertEqual(1, len(vectors))
|
||||||
|
|
||||||
|
def test_mcp_tools_return_json_ready_results(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
refresh = FakeRefreshService()
|
||||||
|
mcp = SemanticMCP(search_service=search, backfill_service=None, store=FakeStore(), refresh_service=refresh)
|
||||||
|
|
||||||
|
response = mcp.call_tool("semantic_search", {"query": "printer", "source": "redmine", "project_identifier": "hiring", "limit": 3})
|
||||||
|
document = mcp.call_tool("semantic_get_document", {"id": "doc1"})
|
||||||
|
projects = mcp.call_tool("semantic_list_projects", {"source": "redmine"})
|
||||||
|
refresh_response = mcp.call_tool("semantic_refresh_redmine", {"project_identifier": "customer-service", "limit": 5, "dry_run": True})
|
||||||
|
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
self.assertEqual("hiring", response["filters"]["project_identifier"])
|
||||||
|
self.assertEqual("doc1", response["results"][0]["id"])
|
||||||
|
self.assertEqual("http://redmine/issues/1", response["results"][0]["citation"]["url"])
|
||||||
|
self.assertIsInstance(search.queries[0], SearchQuery)
|
||||||
|
self.assertEqual("redmine", search.queries[0].source)
|
||||||
|
self.assertEqual("hiring", search.queries[0].project_identifier)
|
||||||
|
self.assertEqual({"id": "doc1", "text": "Snippet"}, document)
|
||||||
|
self.assertEqual("customer-service", projects["projects"][0]["project_identifier"])
|
||||||
|
self.assertTrue(refresh_response["dry_run"])
|
||||||
|
self.assertEqual(({"customer-service": 5}, True, False, 15), refresh.calls[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexCliTest(unittest.TestCase):
|
||||||
|
def test_help_does_not_require_http_runtime_dependencies(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "semantic_index", "--help"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
self.assertIn("--mcp-stdio", result.stdout)
|
||||||
|
|
||||||
|
def test_settings_load_from_package_env_when_root_env_missing(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
env_path = Path(tmp) / "semantic_index" / ".env"
|
||||||
|
env_path.parent.mkdir()
|
||||||
|
env_path.write_text("QDRANT_URL=http://qdrant.example:6333\nREDMINE_SAMPLE_LIMIT=7\n", encoding="utf-8")
|
||||||
|
|
||||||
|
settings = load_settings(Path(tmp) / ".env")
|
||||||
|
|
||||||
|
self.assertEqual("http://qdrant.example:6333", settings.qdrant_url)
|
||||||
|
self.assertEqual(7, settings.sample_limit)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:issue:1:chunk:0",
|
||||||
|
score=0.7,
|
||||||
|
text="Candidate follow up",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 1,
|
||||||
|
"redmine_url": "http://redmine/issues/1",
|
||||||
|
"source_record_id": "issue:1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Full text", "payload": {"project_identifier": "hiring"}}
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexClientTest(unittest.TestCase):
|
||||||
|
def test_in_process_client_returns_normalized_search_response(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
client = SemanticIndexClient(search_service=search)
|
||||||
|
|
||||||
|
response = client.search("candidate follow up", project_identifier="hiring", limit=3)
|
||||||
|
|
||||||
|
self.assertEqual("candidate follow up", response["query"])
|
||||||
|
self.assertEqual({"project_identifier": "hiring", "limit": 3}, response["filters"])
|
||||||
|
self.assertEqual("redmine:issue:1:chunk:0", response["results"][0]["id"])
|
||||||
|
self.assertEqual("hiring", response["results"][0]["citation"]["project_identifier"])
|
||||||
|
self.assertEqual("hiring", search.queries[0].project_identifier)
|
||||||
|
|
||||||
|
def test_in_process_client_get_document(self):
|
||||||
|
client = SemanticIndexClient(search_service=FakeSearchService())
|
||||||
|
|
||||||
|
document = client.get_document("redmine:issue:1:chunk:0")
|
||||||
|
|
||||||
|
self.assertEqual("Full text", document["text"])
|
||||||
|
|
||||||
|
def test_http_client_sends_auth_header_and_parses_search_response(self):
|
||||||
|
body = json.dumps({"query": "printer", "filters": {}, "results": []}).encode()
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return body
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["authorization"] = request.headers.get("Authorization")
|
||||||
|
captured["body"] = json.loads(request.data.decode())
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", fake_urlopen):
|
||||||
|
client = SemanticIndexClient(base_url="http://semantic.local", api_key="secret")
|
||||||
|
response = client.search("printer", project_identifier="customer-service")
|
||||||
|
|
||||||
|
self.assertEqual("http://semantic.local/search", captured["url"])
|
||||||
|
self.assertEqual("Bearer secret", captured["authorization"])
|
||||||
|
self.assertEqual("customer-service", captured["body"]["project_identifier"])
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineMapperTest(unittest.TestCase):
|
||||||
|
def test_issue_chunks_have_stable_ids_and_metadata(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order ORD-12345 cannot ship",
|
||||||
|
"description": "Customer reports that widget order ORD-12345 is blocked.",
|
||||||
|
"project": {"id": 7, "identifier": "fud-helpdesk"},
|
||||||
|
"contact": {"id": 9, "email": "ada@example.com", "name": "Ada Lovelace"},
|
||||||
|
"created_on": "2026-04-01T10:00:00Z",
|
||||||
|
"updated_on": "2026-04-02T10:00:00Z",
|
||||||
|
"url": "http://redmine.local/issues/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
first = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
second = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual([doc.id for doc in first], [doc.id for doc in second])
|
||||||
|
self.assertEqual("redmine:issue:42:chunk:0", first[0].id)
|
||||||
|
self.assertEqual("issue", first[0].payload["doc_type"])
|
||||||
|
self.assertEqual(42, first[0].payload["issue_id"])
|
||||||
|
self.assertEqual("fud-helpdesk", first[0].payload["project_identifier"])
|
||||||
|
self.assertIsNone(first[0].payload["project_name"])
|
||||||
|
self.assertFalse(first[0].payload["has_helpdesk_ticket"])
|
||||||
|
self.assertEqual("ada@example.com", first[0].payload["contact_email"])
|
||||||
|
self.assertEqual("Ada Lovelace", first[0].payload["contact_name"])
|
||||||
|
self.assertEqual("http://redmine.local/issues/42", first[0].payload["redmine_url"])
|
||||||
|
self.assertIn("source_hash", first[0].payload)
|
||||||
|
|
||||||
|
def test_helpdesk_ticket_contact_is_mapped_to_all_issue_chunks(self):
|
||||||
|
issue = {
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please arrange to return these goods.",
|
||||||
|
"project": {"id": 1, "identifier": "customer-service"},
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"from_address": "callum@safetagtracking.com",
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Callum Mackeonis",
|
||||||
|
"company": "SafeTag Tracking",
|
||||||
|
"email": "callum@safetagtracking.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"journals": [
|
||||||
|
{"id": 71570, "notes": "Hello, yes we can arrange this today.", "created_on": "2026-04-14T14:29:49Z"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
issue_doc = next(doc for doc in docs if doc.payload["doc_type"] == "issue")
|
||||||
|
journal_doc = next(doc for doc in docs if doc.payload["doc_type"] == "journal")
|
||||||
|
contact_doc = next(doc for doc in docs if doc.payload["doc_type"] == "contact")
|
||||||
|
|
||||||
|
for doc in (issue_doc, journal_doc, contact_doc):
|
||||||
|
self.assertEqual(35159, doc.payload["helpdesk_ticket_id"])
|
||||||
|
self.assertTrue(doc.payload["has_helpdesk_ticket"])
|
||||||
|
self.assertEqual(1890, doc.payload["contact_id"])
|
||||||
|
self.assertEqual("Callum Mackeonis", doc.payload["contact_name"])
|
||||||
|
self.assertEqual("SafeTag Tracking", doc.payload["contact_company"])
|
||||||
|
self.assertEqual("callum@safetagtracking.com", doc.payload["contact_email"])
|
||||||
|
self.assertIn("Callum Mackeonis", issue_doc.text)
|
||||||
|
self.assertIn("callum@safetagtracking.com", contact_doc.text)
|
||||||
|
|
||||||
|
def test_configured_project_identifier_is_used_when_issue_payload_omits_identifier(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order",
|
||||||
|
"description": "Body",
|
||||||
|
"project": {"id": 1, "name": "Customer Service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(
|
||||||
|
redmine_url="http://redmine.local",
|
||||||
|
project_identifier="customer-service",
|
||||||
|
).issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual("customer-service", docs[0].payload["project_identifier"])
|
||||||
|
self.assertEqual("Customer Service", docs[0].payload["project_name"])
|
||||||
|
|
||||||
|
def test_internal_non_helpdesk_issue_keeps_project_metadata_without_contact(self):
|
||||||
|
issue = {
|
||||||
|
"id": 55,
|
||||||
|
"subject": "Internal hiring task",
|
||||||
|
"description": "Follow up with candidate.",
|
||||||
|
"project": {"id": 68, "identifier": "hiring", "name": "Hiring"},
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(docs))
|
||||||
|
self.assertEqual("hiring", docs[0].payload["project_identifier"])
|
||||||
|
self.assertEqual("Hiring", docs[0].payload["project_name"])
|
||||||
|
self.assertFalse(docs[0].payload["has_helpdesk_ticket"])
|
||||||
|
self.assertIsNone(docs[0].payload["contact_id"])
|
||||||
|
|
||||||
|
def test_issue_journals_messages_and_contact_are_mapped(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order",
|
||||||
|
"description": "Ticket envelope",
|
||||||
|
"project": {"id": 7, "identifier": "fud-helpdesk"},
|
||||||
|
"contact": {"id": 9, "email": "ada@example.com", "name": "Ada Lovelace"},
|
||||||
|
"journals": [
|
||||||
|
{"id": 5, "notes": "Private escalation note", "private_notes": True, "created_on": "2026-04-03T10:00:00Z"}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
{"id": 6, "body": "Customer reply body", "direction": "incoming", "created_on": "2026-04-03T11:00:00Z"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
ids = {doc.id for doc in docs}
|
||||||
|
types = {doc.payload["doc_type"] for doc in docs}
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:42:journal:5:chunk:0", ids)
|
||||||
|
self.assertIn("redmine:issue:42:message:6:chunk:0", ids)
|
||||||
|
self.assertIn("redmine:contact:9:issue:42:chunk:0", ids)
|
||||||
|
self.assertEqual({"issue", "journal", "message", "contact"}, types)
|
||||||
|
journal = next(doc for doc in docs if doc.payload["doc_type"] == "journal")
|
||||||
|
message = next(doc for doc in docs if doc.payload["doc_type"] == "message")
|
||||||
|
self.assertEqual("private", journal.payload["visibility"])
|
||||||
|
self.assertEqual("incoming", message.payload["direction"])
|
||||||
|
|
||||||
|
def test_empty_documents_are_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
IndexDocument(id="x", text=" ", payload={})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.embeddings import OpenAIEmbedder
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOpenAIClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def create_embeddings(self, model, inputs, dimensions=None):
|
||||||
|
self.calls.append({"model": model, "inputs": list(inputs), "dimensions": dimensions})
|
||||||
|
return [[float(i)] * 3 for i, _ in enumerate(inputs, start=1)]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbedderTest(unittest.TestCase):
|
||||||
|
def test_batches_embedding_requests(self):
|
||||||
|
client = FakeOpenAIClient()
|
||||||
|
embedder = OpenAIEmbedder(client=client, batch_size=2, dimensions=1536)
|
||||||
|
docs = [
|
||||||
|
IndexDocument(id="a", text="alpha", payload={}),
|
||||||
|
IndexDocument(id="b", text="bravo", payload={}),
|
||||||
|
IndexDocument(id="c", text="charlie", payload={}),
|
||||||
|
]
|
||||||
|
|
||||||
|
vectors = embedder.embed_documents(docs)
|
||||||
|
|
||||||
|
self.assertEqual([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [1.0, 1.0, 1.0]], vectors)
|
||||||
|
self.assertEqual(2, len(client.calls))
|
||||||
|
self.assertEqual(["alpha", "bravo"], client.calls[0]["inputs"])
|
||||||
|
self.assertEqual("text-embedding-3-small", client.calls[0]["model"])
|
||||||
|
self.assertEqual(1536, client.calls[0]["dimensions"])
|
||||||
|
|
||||||
|
def test_rejects_empty_or_oversized_chunks_before_api_call(self):
|
||||||
|
client = FakeOpenAIClient()
|
||||||
|
embedder = OpenAIEmbedder(client=client, max_chars=5)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
embedder.embed_texts(["ok", " "])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
embedder.embed_texts(["toolong"])
|
||||||
|
self.assertEqual([], client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.__main__ import main
|
||||||
|
from semantic_index.config import Settings
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
if "missing@example.test" in query.text:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:contact:1890:issue:39779:chunk:0" if "callum" in query.text else "redmine:issue:39779:chunk:0",
|
||||||
|
score=0.58,
|
||||||
|
text="Callum Mackeonis callum@safetagtracking.com SafeTag Tracking",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "contact" if "callum" in query.text else "issue",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {
|
||||||
|
"id": document_id,
|
||||||
|
"text": "Full indexed text",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "journal",
|
||||||
|
"issue_id": 39778,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"redmine_url": "http://redmine/issues/39778",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.list_limits = []
|
||||||
|
|
||||||
|
def count_documents(self, source=None, project_identifier=None, doc_type=None):
|
||||||
|
return 12
|
||||||
|
|
||||||
|
def list_documents(self, limit=10, source=None, project_identifier=None, doc_type=None):
|
||||||
|
self.list_limits.append(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39779:chunk:0",
|
||||||
|
"text": "Issue #39779: Goods return\nPlease return our goods.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "issue-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39779:journal:71570:chunk:0",
|
||||||
|
"text": "Hello, we can arrange this today.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "journal",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "journal-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:contact:1890:issue:39779:chunk:0",
|
||||||
|
"text": "Callum Mackeonis callum@safetagtracking.com SafeTag Tracking",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "contact",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "contact-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39800:chunk:0",
|
||||||
|
"text": "Ordinary issue with no helpdesk contact.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 39800,
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"project_name": "Hiring",
|
||||||
|
"has_helpdesk_ticket": False,
|
||||||
|
"source_hash": "ordinary-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please return our goods.",
|
||||||
|
"project": {"id": 1, "identifier": "customer-service"},
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Callum Mackeonis",
|
||||||
|
"email": "callum@safetagtracking.com",
|
||||||
|
"company": "SafeTag Tracking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def fake_services(store=None, search=None):
|
||||||
|
settings = Settings(
|
||||||
|
openai_api_key="",
|
||||||
|
qdrant_url="http://qdrant",
|
||||||
|
qdrant_api_key=None,
|
||||||
|
qdrant_collection="semantic",
|
||||||
|
redmine_url="http://redmine",
|
||||||
|
redmine_api_key="",
|
||||||
|
redmine_project_identifier="customer-service",
|
||||||
|
sample_limit=50,
|
||||||
|
bind_host="127.0.0.1",
|
||||||
|
bind_port=8787,
|
||||||
|
service_api_key=None,
|
||||||
|
refresh_state_path=Path(".cache/semantic_index/refresh_state.json"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"search": search or FakeSearchService(),
|
||||||
|
"store": store or FakeStore(),
|
||||||
|
"redmine_source": FakeRedmineSource(),
|
||||||
|
"backfill": FakeBackfillService(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackfillService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def backfill_redmine_sample(self, limit):
|
||||||
|
self.calls.append(("sample", limit))
|
||||||
|
return {"source": "redmine", "issues": limit, "documents": limit}
|
||||||
|
|
||||||
|
def backfill_redmine_projects(self, projects, per_project_limit):
|
||||||
|
self.calls.append(("projects", projects, per_project_limit))
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(projects),
|
||||||
|
"issues": len(projects) * per_project_limit,
|
||||||
|
"documents": len(projects) * per_project_limit,
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": project, "issues": per_project_limit, "documents": per_project_limit}
|
||||||
|
for project in projects
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def backfill_redmine_project_limits(self, project_limits):
|
||||||
|
self.calls.append(("project_limits", project_limits))
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"issues": sum(project_limits.values()),
|
||||||
|
"documents": sum(project_limits.values()),
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": project, "issues": limit, "documents": limit}
|
||||||
|
for project, limit in project_limits.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InspectCliTest(unittest.TestCase):
|
||||||
|
def run_cli(self, args):
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(args, service_builder=fake_services)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
def test_no_args_prints_help_without_building_services(self):
|
||||||
|
def broken_services():
|
||||||
|
raise AssertionError("help should not build live services")
|
||||||
|
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main([], service_builder=broken_services)
|
||||||
|
|
||||||
|
self.assertIn("inspect", out.getvalue())
|
||||||
|
|
||||||
|
def test_count_lists_matching_document_count(self):
|
||||||
|
output = self.run_cli(["inspect", "count", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("12", output)
|
||||||
|
|
||||||
|
def test_list_shows_snippet_and_metadata_by_default(self):
|
||||||
|
output = self.run_cli(["inspect", "list", "--limit", "5", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
self.assertIn("issue #39779", output.lower())
|
||||||
|
self.assertIn("customer-service", output)
|
||||||
|
self.assertIn("contact=#1890", output)
|
||||||
|
self.assertIn("Callum Mackeonis", output)
|
||||||
|
self.assertIn("callum@safetagtracking.com", output)
|
||||||
|
self.assertNotIn("Full indexed text", output)
|
||||||
|
|
||||||
|
def test_search_runs_query_and_prints_citation(self):
|
||||||
|
output = self.run_cli(["inspect", "search", "order status", "--limit", "3", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("score=0.5800", output)
|
||||||
|
self.assertIn("http://redmine/issues/39779", output)
|
||||||
|
|
||||||
|
def test_show_prints_full_document_text(self):
|
||||||
|
output = self.run_cli(["inspect", "show", "redmine:issue:39778:chunk:0"])
|
||||||
|
|
||||||
|
self.assertIn("Full indexed text", output)
|
||||||
|
self.assertIn("doc_type=journal", output)
|
||||||
|
|
||||||
|
def test_preview_redmine_maps_documents_without_writing(self):
|
||||||
|
output = self.run_cli(["inspect", "preview-redmine", "--limit", "1", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
self.assertIn("project=customer-service", output)
|
||||||
|
self.assertIn("Please return our goods", output)
|
||||||
|
|
||||||
|
def test_preview_redmine_uses_minimal_service_builder(self):
|
||||||
|
services = []
|
||||||
|
|
||||||
|
def minimal_builder(settings):
|
||||||
|
services.append(settings.redmine_project_identifier)
|
||||||
|
return {"settings": settings, "redmine_source": FakeRedmineSource()}
|
||||||
|
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
["inspect", "preview-redmine", "--limit", "1", "--project", "customer-service"],
|
||||||
|
service_builder=lambda: (_ for _ in ()).throw(AssertionError("full services should not be built")),
|
||||||
|
preview_service_builder=minimal_builder,
|
||||||
|
settings_loader=lambda: fake_services()["settings"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["customer-service"], services)
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", out.getvalue())
|
||||||
|
|
||||||
|
def test_audit_prints_doc_type_counts_contact_coverage_and_attachment_check(self):
|
||||||
|
output = self.run_cli(["inspect", "audit", "--limit", "10", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("documents=4", output)
|
||||||
|
self.assertIn("doc_type issue=2", output)
|
||||||
|
self.assertIn("doc_type journal=1", output)
|
||||||
|
self.assertIn("doc_type contact=1", output)
|
||||||
|
self.assertIn("contact_metadata 3/4", output)
|
||||||
|
self.assertIn("helpdesk_contact_metadata 3/3", output)
|
||||||
|
self.assertIn("project customer-service=3", output)
|
||||||
|
self.assertIn("project hiring=1", output)
|
||||||
|
self.assertIn("attachments=0", output)
|
||||||
|
self.assertNotIn("missing_contact redmine:issue:39800:chunk:0", output)
|
||||||
|
|
||||||
|
def test_audit_json_returns_machine_readable_summary(self):
|
||||||
|
output = self.run_cli(["inspect", "audit", "--limit", "10", "--project", "customer-service", "--json"])
|
||||||
|
payload = json.loads(output)
|
||||||
|
|
||||||
|
self.assertEqual(4, payload["total_documents"])
|
||||||
|
self.assertEqual(2, payload["doc_type_counts"]["issue"])
|
||||||
|
self.assertEqual(3, payload["project_counts"]["customer-service"])
|
||||||
|
self.assertEqual(1, payload["project_counts"]["hiring"])
|
||||||
|
self.assertEqual([], payload["missing_helpdesk_contact_metadata"])
|
||||||
|
|
||||||
|
def test_compare_redmine_reports_missing_stale_and_contact_mismatches(self):
|
||||||
|
output = self.run_cli(["inspect", "compare-redmine", "--limit", "1", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("preview_documents=2", output)
|
||||||
|
self.assertIn("indexed_documents=4", output)
|
||||||
|
self.assertIn("stale", output)
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
|
||||||
|
def test_compare_redmine_fetches_a_large_index_window_to_avoid_false_missing_results(self):
|
||||||
|
store = FakeStore()
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(["inspect", "compare-redmine", "--limit", "3", "--project", "customer-service"], service_builder=lambda: fake_services(store=store))
|
||||||
|
|
||||||
|
self.assertEqual(5000, store.list_limits[0])
|
||||||
|
|
||||||
|
def test_smoke_search_prints_pass_fail_for_known_queries(self):
|
||||||
|
output = self.run_cli(["inspect", "smoke-search", "--project", "customer-service", "--email", "callum@safetagtracking.com", "--issue-id", "39779"])
|
||||||
|
|
||||||
|
self.assertIn("PASS email callum@safetagtracking.com", output)
|
||||||
|
self.assertIn("PASS issue 39779", output)
|
||||||
|
self.assertIn("redmine:contact:1890:issue:39779:chunk:0", output)
|
||||||
|
|
||||||
|
def test_smoke_search_uses_issue_id_filter_for_issue_checks(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(["inspect", "smoke-search", "--project", "customer-service", "--issue-id", "39779"], service_builder=lambda: fake_services(search=search))
|
||||||
|
|
||||||
|
issue_queries = [query for query in search.queries if query.text == "39779"]
|
||||||
|
self.assertEqual(39779, issue_queries[0].issue_id)
|
||||||
|
|
||||||
|
def test_smoke_search_json_returns_check_results(self):
|
||||||
|
output = self.run_cli(["inspect", "smoke-search", "--project", "customer-service", "--email", "missing@example.test", "--json"])
|
||||||
|
payload = json.loads(output)
|
||||||
|
|
||||||
|
self.assertFalse(payload["checks"][0]["passed"])
|
||||||
|
self.assertEqual("email", payload["checks"][0]["kind"])
|
||||||
|
|
||||||
|
def test_backfill_redmine_projects_cli_parses_comma_separated_projects(self):
|
||||||
|
backfill = FakeBackfillService()
|
||||||
|
services = fake_services()
|
||||||
|
services["backfill"] = backfill
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--backfill-redmine-projects",
|
||||||
|
"--projects",
|
||||||
|
"customer-service,hiring",
|
||||||
|
"--per-project-limit",
|
||||||
|
"25",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("projects", ["customer-service", "hiring"], 25), backfill.calls[0])
|
||||||
|
self.assertIn("'projects': 2", out.getvalue())
|
||||||
|
|
||||||
|
def test_backfill_redmine_projects_cli_parses_project_specific_limits(self):
|
||||||
|
backfill = FakeBackfillService()
|
||||||
|
services = fake_services()
|
||||||
|
services["backfill"] = backfill
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--backfill-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=500,hiring=200",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("project_limits", {"customer-service": 500, "hiring": 200}), backfill.calls[0])
|
||||||
|
self.assertIn("'issues': 700", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
INSTALLER = ROOT / "deploy" / "semantic-index" / "install.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexInstallerTest(unittest.TestCase):
|
||||||
|
def run_installer(self, *args, env=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[str(INSTALLER), *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_mode_is_dry_run(self):
|
||||||
|
result = self.run_installer()
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("mode=dry-run", result.stdout)
|
||||||
|
self.assertIn("would run: sudo mkdir -p /opt/semantic-index", result.stdout)
|
||||||
|
self.assertIn("would run: sudo rsync", result.stdout)
|
||||||
|
self.assertNotIn("Semantic Index installed, but deployment is not complete.", result.stdout)
|
||||||
|
|
||||||
|
def test_apply_prints_manual_next_step_warning(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
env = {
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"SEMANTIC_INDEX_INSTALL_DIR": str(tmp_path / "opt" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_ENV_FILE": str(tmp_path / "etc" / "semantic-index.env"),
|
||||||
|
"SEMANTIC_INDEX_STATE_DIR": str(tmp_path / "var" / "lib" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_LOG_DIR": str(tmp_path / "var" / "log" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_SYSTEMD_DIR": str(tmp_path / "etc" / "systemd" / "system"),
|
||||||
|
}
|
||||||
|
result = self.run_installer("--apply", "--no-system", "--skip-deps", env=env)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("Semantic Index installed, but deployment is not complete.", result.stdout)
|
||||||
|
self.assertIn("The refresh timer was NOT enabled automatically.", result.stdout)
|
||||||
|
self.assertIn("Do not use --force-rebuild", result.stdout)
|
||||||
|
|
||||||
|
def test_invalid_argument_fails_with_usage(self):
|
||||||
|
result = self.run_installer("--force-rebuild")
|
||||||
|
|
||||||
|
self.assertEqual(2, result.returncode)
|
||||||
|
self.assertIn("Usage:", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.qdrant_store import QdrantStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMatchValue:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFieldCondition:
|
||||||
|
def __init__(self, key, match=None, range=None):
|
||||||
|
self.key = key
|
||||||
|
self.match = match
|
||||||
|
self.range = range
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFilter:
|
||||||
|
def __init__(self, must):
|
||||||
|
self.must = must
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFilterSelector:
|
||||||
|
def __init__(self, filter):
|
||||||
|
self.filter = filter
|
||||||
|
|
||||||
|
|
||||||
|
class FakePointIdsList:
|
||||||
|
def __init__(self, points):
|
||||||
|
self.points = points
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQModels:
|
||||||
|
MatchValue = FakeMatchValue
|
||||||
|
FieldCondition = FakeFieldCondition
|
||||||
|
Filter = FakeFilter
|
||||||
|
FilterSelector = FakeFilterSelector
|
||||||
|
PointIdsList = FakePointIdsList
|
||||||
|
|
||||||
|
class PointStruct:
|
||||||
|
def __init__(self, id, vector, payload):
|
||||||
|
self.id = id
|
||||||
|
self.vector = vector
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCountResult:
|
||||||
|
count = 7
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRecord:
|
||||||
|
def __init__(self):
|
||||||
|
self.id = "point-id"
|
||||||
|
self.payload = {
|
||||||
|
"document_id": "redmine:issue:1:chunk:0",
|
||||||
|
"text": "Indexed text",
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.count_filter = None
|
||||||
|
self.scroll_filter = None
|
||||||
|
self.delete_filter = None
|
||||||
|
self.delete_selector = None
|
||||||
|
self.upsert_batches = []
|
||||||
|
|
||||||
|
def get_collections(self):
|
||||||
|
collection = type("Collection", (), {"name": "semantic"})()
|
||||||
|
return type("Collections", (), {"collections": [collection]})()
|
||||||
|
|
||||||
|
def count(self, collection_name, count_filter, exact):
|
||||||
|
self.count_filter = count_filter
|
||||||
|
return FakeCountResult()
|
||||||
|
|
||||||
|
def scroll(self, collection_name, scroll_filter, limit, with_payload, with_vectors, offset=None):
|
||||||
|
self.scroll_filter = scroll_filter
|
||||||
|
return [FakeRecord()], None
|
||||||
|
|
||||||
|
def delete(self, collection_name, points_selector):
|
||||||
|
self.delete_selector = points_selector
|
||||||
|
self.delete_filter = getattr(points_selector, "filter", None)
|
||||||
|
|
||||||
|
def upsert(self, collection_name, points):
|
||||||
|
self.upsert_batches.append(points)
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantStoreReadTest(unittest.TestCase):
|
||||||
|
def make_store(self):
|
||||||
|
store = object.__new__(QdrantStore)
|
||||||
|
store.client = FakeClient()
|
||||||
|
store.collection = "semantic"
|
||||||
|
store.vector_size = 1536
|
||||||
|
store.qmodels = FakeQModels
|
||||||
|
store.upsert_batch_size = 2
|
||||||
|
return store
|
||||||
|
|
||||||
|
def test_count_documents_builds_metadata_filter(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
count = store.count_documents(source="redmine", project_identifier="customer-service", doc_type="issue")
|
||||||
|
|
||||||
|
self.assertEqual(7, count)
|
||||||
|
conditions = store.client.count_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier", "doc_type"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual("customer-service", conditions[1].match.value)
|
||||||
|
|
||||||
|
def test_list_documents_strips_internal_payload_fields(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
documents = store.list_documents(limit=5, source="redmine", project_identifier="customer-service")
|
||||||
|
|
||||||
|
self.assertEqual("redmine:issue:1:chunk:0", documents[0]["id"])
|
||||||
|
self.assertEqual("Indexed text", documents[0]["text"])
|
||||||
|
self.assertNotIn("document_id", documents[0]["payload"])
|
||||||
|
self.assertNotIn("text", documents[0]["payload"])
|
||||||
|
|
||||||
|
def test_delete_by_source_can_be_limited_to_project_scope(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.delete_by_source("redmine", project_identifier="customer-service")
|
||||||
|
|
||||||
|
conditions = store.client.delete_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual("redmine", conditions[0].match.value)
|
||||||
|
self.assertEqual("customer-service", conditions[1].match.value)
|
||||||
|
|
||||||
|
def test_list_documents_can_be_limited_to_issue_scope(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.list_documents(limit=5, source="redmine", project_identifier="customer-service", issue_id=39779)
|
||||||
|
|
||||||
|
conditions = store.client.scroll_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier", "issue_id"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual(39779, conditions[2].match.value)
|
||||||
|
|
||||||
|
def test_delete_documents_deletes_stable_document_point_ids(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.delete_documents(["redmine:issue:39779:chunk:0"])
|
||||||
|
|
||||||
|
self.assertEqual(1, len(store.client.delete_selector.points))
|
||||||
|
self.assertNotEqual("redmine:issue:39779:chunk:0", store.client.delete_selector.points[0])
|
||||||
|
|
||||||
|
def test_upsert_sends_points_in_batches(self):
|
||||||
|
store = self.make_store()
|
||||||
|
documents = [
|
||||||
|
IndexDocument(id=f"redmine:issue:{issue_id}:chunk:0", text=f"Issue {issue_id}", payload={"source": "redmine"})
|
||||||
|
for issue_id in range(5)
|
||||||
|
]
|
||||||
|
vectors = [[0.1, 0.2, 0.3] for _ in documents]
|
||||||
|
|
||||||
|
store.upsert(documents, vectors)
|
||||||
|
|
||||||
|
self.assertEqual([2, 2, 1], [len(batch) for batch in store.client.upsert_batches])
|
||||||
|
self.assertEqual("Issue 0", store.client.upsert_batches[0][0].payload["text"])
|
||||||
|
|
||||||
|
def test_list_documents_paginates_qdrant_scroll_until_requested_limit(self):
|
||||||
|
class PagedClient(FakeClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.offsets = []
|
||||||
|
|
||||||
|
def scroll(self, collection_name, scroll_filter, limit, with_payload, with_vectors, offset=None):
|
||||||
|
self.offsets.append(offset)
|
||||||
|
first = FakeRecord()
|
||||||
|
first.payload = {**first.payload, "document_id": f"doc:{len(self.offsets)}a"}
|
||||||
|
second = FakeRecord()
|
||||||
|
second.payload = {**second.payload, "document_id": f"doc:{len(self.offsets)}b"}
|
||||||
|
if offset is None:
|
||||||
|
return [first, second], "next"
|
||||||
|
return [first, second], None
|
||||||
|
|
||||||
|
store = self.make_store()
|
||||||
|
store.client = PagedClient()
|
||||||
|
|
||||||
|
documents = store.list_documents(limit=3, source="redmine")
|
||||||
|
|
||||||
|
self.assertEqual(["doc:1a", "doc:1b", "doc:2a"], [document["id"] for document in documents])
|
||||||
|
self.assertEqual([None, "next"], store.client.offsets)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.redmine import RedmineApiSource
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
self.urls = []
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
self.urls.append(url)
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
return {"issues": [{"id": 39779}]}
|
||||||
|
return {"issue": {"id": 39779, "subject": "Goods return"}}
|
||||||
|
|
||||||
|
|
||||||
|
class PagedRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
self.urls = []
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
self.urls.append(url)
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
query = url.split("?", 1)[1]
|
||||||
|
params = dict(part.split("=", 1) for part in query.split("&"))
|
||||||
|
offset = int(params.get("offset", "0"))
|
||||||
|
limit = int(params.get("limit", "0"))
|
||||||
|
return {"issues": [{"id": issue_id} for issue_id in range(offset + 1, offset + limit + 1)]}
|
||||||
|
issue_id = int(url.split("/issues/", 1)[1].split(".", 1)[0])
|
||||||
|
return {"issue": {"id": issue_id, "subject": f"Issue {issue_id}"}}
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicatePagedRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
query = url.split("?", 1)[1]
|
||||||
|
params = dict(part.split("=", 1) for part in query.split("&"))
|
||||||
|
offset = int(params.get("offset", "0"))
|
||||||
|
if offset == 0:
|
||||||
|
return {"issues": [{"id": 1}, {"id": 2}]}
|
||||||
|
if offset == 2:
|
||||||
|
return {"issues": [{"id": 2}, {"id": 3}]}
|
||||||
|
return {"issues": []}
|
||||||
|
issue_id = int(url.split("/issues/", 1)[1].split(".", 1)[0])
|
||||||
|
return {"issue": {"id": issue_id, "subject": f"Issue {issue_id}"}}
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineApiSourceTest(unittest.TestCase):
|
||||||
|
def test_recent_issue_summaries_do_not_fetch_issue_details(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
summaries = list(source.recent_issue_summaries(limit=1))
|
||||||
|
|
||||||
|
self.assertEqual(39779, summaries[0]["id"])
|
||||||
|
self.assertEqual(1, len(source.urls))
|
||||||
|
self.assertTrue(source.urls[0].startswith("http://redmine.local/issues.json"))
|
||||||
|
|
||||||
|
def test_issue_detail_fetches_journals_and_helpdesk(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
detail = source.issue_detail(39779)
|
||||||
|
|
||||||
|
self.assertEqual(39779, detail["id"])
|
||||||
|
self.assertIn("include=journals%2Chelpdesk", source.urls[0])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_requests_helpdesk_include_with_journals(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=1))
|
||||||
|
|
||||||
|
self.assertEqual(39779, issues[0]["id"])
|
||||||
|
self.assertIn("include=journals%2Chelpdesk", source.urls[1])
|
||||||
|
self.assertIn("subproject_id=%21%2A", source.urls[0])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_paginates_past_redmine_page_limit(self):
|
||||||
|
source = PagedRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=250))
|
||||||
|
|
||||||
|
self.assertEqual(250, len(issues))
|
||||||
|
list_urls = [url for url in source.urls if url.startswith("http://redmine.local/issues.json")]
|
||||||
|
self.assertEqual(3, len(list_urls))
|
||||||
|
self.assertIn("limit=100", list_urls[0])
|
||||||
|
self.assertIn("offset=0", list_urls[0])
|
||||||
|
self.assertIn("offset=100", list_urls[1])
|
||||||
|
self.assertIn("offset=200", list_urls[2])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_skips_duplicate_issue_ids_across_pages(self):
|
||||||
|
source = DuplicatePagedRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=3))
|
||||||
|
|
||||||
|
self.assertEqual([1, 2, 3], [issue["id"] for issue in issues])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.__main__ import main
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.refresh import FileRefreshState, RedmineRefreshService
|
||||||
|
|
||||||
|
|
||||||
|
def issue(updated_on="2026-04-25T12:00:00Z"):
|
||||||
|
return {
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please return our goods.",
|
||||||
|
"updated_on": updated_on,
|
||||||
|
"project": {"id": 1, "identifier": "customer-service", "name": "Customer Service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
project_identifier = None
|
||||||
|
|
||||||
|
def __init__(self, issues=None):
|
||||||
|
self.issues = issues or [issue()]
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
self.calls.append((self.project_identifier, limit))
|
||||||
|
return self.issues[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryDetailRedmineSource(FakeRedmineSource):
|
||||||
|
def __init__(self, summaries, details):
|
||||||
|
super().__init__([])
|
||||||
|
self.summaries = summaries
|
||||||
|
self.details = details
|
||||||
|
self.summary_calls = []
|
||||||
|
self.detail_calls = []
|
||||||
|
|
||||||
|
def recent_issue_summaries(self, limit):
|
||||||
|
self.summary_calls.append((self.project_identifier, limit))
|
||||||
|
return self.summaries[:limit]
|
||||||
|
|
||||||
|
def issue_detail(self, issue_id):
|
||||||
|
self.detail_calls.append(issue_id)
|
||||||
|
return self.details[issue_id]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingEmbedder:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def embed_documents(self, docs):
|
||||||
|
self.calls.append(list(docs))
|
||||||
|
return [[0.1, 0.2, 0.3] for _ in docs]
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshStore:
|
||||||
|
def __init__(self, existing=None):
|
||||||
|
self.existing = existing or {}
|
||||||
|
self.upserts = []
|
||||||
|
self.deleted_ids = []
|
||||||
|
|
||||||
|
def list_documents(self, limit=10, source=None, project_identifier=None, doc_type=None, issue_id=None):
|
||||||
|
return list(self.existing.values())[:limit]
|
||||||
|
|
||||||
|
def upsert(self, docs, vectors):
|
||||||
|
self.upserts.append((list(docs), list(vectors)))
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids):
|
||||||
|
self.deleted_ids.extend(document_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshServiceTest(unittest.TestCase):
|
||||||
|
def test_refresh_skips_embeddings_when_source_hash_matches_existing_document(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {
|
||||||
|
"id": candidate.id,
|
||||||
|
"text": candidate.text,
|
||||||
|
"payload": dict(candidate.payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["unchanged_documents"])
|
||||||
|
self.assertEqual(0, result["embedded_documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
self.assertEqual([], service.store.upserts)
|
||||||
|
|
||||||
|
def test_refresh_embeds_only_changed_and_new_documents(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {
|
||||||
|
"id": candidate.id,
|
||||||
|
"text": "Old text",
|
||||||
|
"payload": {**candidate.payload, "source_hash": "old-hash"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["changed_documents"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
self.assertEqual([[candidate]], embedder.calls)
|
||||||
|
self.assertEqual([candidate.id], [doc.id for doc in service.store.upserts[0][0]])
|
||||||
|
|
||||||
|
def test_refresh_deletes_stale_issue_documents_without_embedding(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {"id": candidate.id, "text": candidate.text, "payload": dict(candidate.payload)}
|
||||||
|
service.store.existing["redmine:issue:39779:journal:1:chunk:0"] = {
|
||||||
|
"id": "redmine:issue:39779:journal:1:chunk:0",
|
||||||
|
"text": "Deleted note",
|
||||||
|
"payload": {"source_hash": "gone", "issue_id": 39779},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["stale_documents"])
|
||||||
|
self.assertEqual(["redmine:issue:39779:journal:1:chunk:0"], service.store.deleted_ids)
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
|
||||||
|
def test_dry_run_reports_planned_embeddings_without_embedding_or_mutating(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, dry_run=True)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["new_documents"])
|
||||||
|
self.assertEqual(1, result["would_embed_documents"])
|
||||||
|
self.assertEqual(0, result["embedded_documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
self.assertEqual([], service.store.upserts)
|
||||||
|
self.assertEqual([], service.store.deleted_ids)
|
||||||
|
|
||||||
|
def test_force_rebuild_embeds_unchanged_documents(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {"id": candidate.id, "text": candidate.text, "payload": dict(candidate.payload)}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, force_rebuild=True)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["force_rebuilt_documents"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
self.assertEqual([[candidate]], embedder.calls)
|
||||||
|
|
||||||
|
def test_force_rebuild_ignores_refresh_state_window_for_fetched_candidates(self):
|
||||||
|
source = FakeRedmineSource([issue(updated_on="2026-04-25T10:00:00Z")])
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, force_rebuild=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(0, result["skipped_issues"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
|
||||||
|
def test_file_refresh_state_updates_only_when_called(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
self.assertEqual({}, state.load())
|
||||||
|
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"projects": {"customer-service": {"last_successful_refresh_at": "2026-04-25T12:00:00Z"}}},
|
||||||
|
json.loads((Path(tmp) / "refresh.json").read_text(encoding="utf-8")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_state_skips_issues_older_than_overlap_window(self):
|
||||||
|
source = FakeRedmineSource([issue(updated_on="2026-04-25T10:00:00Z")])
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, dry_run=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["issues"])
|
||||||
|
self.assertEqual(1, result["skipped_issues"])
|
||||||
|
self.assertEqual(0, result["documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
|
||||||
|
def test_refresh_skips_old_summaries_without_fetching_issue_detail(self):
|
||||||
|
old_summary = {"id": 39779, "updated_on": "2026-04-25T10:00:00Z"}
|
||||||
|
new_summary = {"id": 39780, "updated_on": "2026-04-25T11:50:00Z"}
|
||||||
|
source = SummaryDetailRedmineSource(
|
||||||
|
summaries=[old_summary, new_summary],
|
||||||
|
details={39780: {**issue("2026-04-25T11:50:00Z"), "id": 39780}},
|
||||||
|
)
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 2}, dry_run=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(2, result["scanned_issues"])
|
||||||
|
self.assertEqual(1, result["skipped_issues"])
|
||||||
|
self.assertEqual(1, result["detail_fetched_issues"])
|
||||||
|
self.assertEqual([39780], source.detail_calls)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshCliTest(unittest.TestCase):
|
||||||
|
def test_refresh_redmine_projects_cli_parses_project_limits_and_dry_run(self):
|
||||||
|
class FakeRefresh:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "issues": sum(project_limits.values())}
|
||||||
|
|
||||||
|
refresh = FakeRefresh()
|
||||||
|
services = {"refresh": refresh}
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--refresh-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=5,hiring=2",
|
||||||
|
"--dry-run",
|
||||||
|
"--overlap-minutes",
|
||||||
|
"30",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(({"customer-service": 5, "hiring": 2}, True, False, 30), refresh.calls[0])
|
||||||
|
self.assertIn("'projects': 2", out.getvalue())
|
||||||
|
|
||||||
|
def test_refresh_redmine_projects_cli_can_override_state_path(self):
|
||||||
|
class FakeRefresh:
|
||||||
|
def __init__(self):
|
||||||
|
self.state = None
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
return {"state_path": str(self.state.path)}
|
||||||
|
|
||||||
|
refresh = FakeRefresh()
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--refresh-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=1",
|
||||||
|
"--state-path",
|
||||||
|
"/tmp/semantic-refresh-state.json",
|
||||||
|
],
|
||||||
|
service_builder=lambda: {"refresh": refresh},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("/tmp/semantic-refresh-state.json", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument, SearchQuery, SearchResult
|
||||||
|
from semantic_index.qdrant_store import build_filter, point_id_for_document
|
||||||
|
from semantic_index.search import HybridSearchService, keyword_boost
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedder:
|
||||||
|
def embed_query(self, text):
|
||||||
|
return [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.query = None
|
||||||
|
|
||||||
|
def search(self, vector, query, limit):
|
||||||
|
self.query = query
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="weak",
|
||||||
|
score=0.7,
|
||||||
|
text="general support text",
|
||||||
|
payload={"redmine_url": "http://redmine/issues/1"},
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id="strong",
|
||||||
|
score=0.6,
|
||||||
|
text="Customer ada@example.com asked about ORD-12345",
|
||||||
|
payload={"redmine_url": "http://redmine/issues/2"},
|
||||||
|
),
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchTest(unittest.TestCase):
|
||||||
|
def test_qdrant_point_id_is_deterministic_uuid_for_stable_document_id(self):
|
||||||
|
first = point_id_for_document("redmine:issue:42:journal:5:chunk:0")
|
||||||
|
second = point_id_for_document("redmine:issue:42:journal:5:chunk:0")
|
||||||
|
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertRegex(first, r"^[0-9a-f-]{36}$")
|
||||||
|
|
||||||
|
def test_filter_maps_supported_metadata(self):
|
||||||
|
query = SearchQuery(
|
||||||
|
text="printer",
|
||||||
|
source="redmine",
|
||||||
|
project_identifier="fud-helpdesk",
|
||||||
|
doc_type="message",
|
||||||
|
issue_id=42,
|
||||||
|
contact_email="ada@example.com",
|
||||||
|
date_from="2026-04-01T00:00:00Z",
|
||||||
|
date_to="2026-04-30T23:59:59Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
qfilter = build_filter(query)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
{"key": "source", "match": {"value": "redmine"}},
|
||||||
|
{"key": "project_identifier", "match": {"value": "fud-helpdesk"}},
|
||||||
|
{"key": "doc_type", "match": {"value": "message"}},
|
||||||
|
{"key": "issue_id", "match": {"value": 42}},
|
||||||
|
{"key": "contact_email", "match": {"value": "ada@example.com"}},
|
||||||
|
{"key": "created_on", "range": {"gte": "2026-04-01T00:00:00Z", "lte": "2026-04-30T23:59:59Z"}},
|
||||||
|
],
|
||||||
|
qfilter["must"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_keyword_boost_prioritizes_exact_email_and_order_matches(self):
|
||||||
|
weak = SearchResult(id="weak", score=0.7, text="general support text", payload={})
|
||||||
|
strong = SearchResult(id="strong", score=0.6, text="Customer ada@example.com asked about ORD-12345", payload={})
|
||||||
|
|
||||||
|
self.assertGreater(
|
||||||
|
keyword_boost('ada@example.com "ORD-12345"', strong),
|
||||||
|
keyword_boost('ada@example.com "ORD-12345"', weak),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = HybridSearchService(embedder=FakeEmbedder(), store=FakeStore())
|
||||||
|
results = service.search(SearchQuery(text='ada@example.com "ORD-12345"', limit=2))
|
||||||
|
self.assertEqual("strong", results[0].id)
|
||||||
|
self.assertEqual("http://redmine/issues/2", results[0].citation["url"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REFRESH = ROOT / "semantic_index" / "refresh.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexShellWrapperTest(unittest.TestCase):
|
||||||
|
def test_refresh_wrapper_is_self_locating_when_called_from_another_directory(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHON": "/bin/echo",
|
||||||
|
"SEMANTIC_INDEX_PROJECT_LIMITS": "customer-service=5",
|
||||||
|
"SEMANTIC_INDEX_LOG_DIR": str(tmp_path / "logs"),
|
||||||
|
"SEMANTIC_INDEX_STATE_PATH": str(tmp_path / "state" / "refresh_state.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(REFRESH)],
|
||||||
|
cwd=tmp,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("-m semantic_index --refresh-redmine-projects", result.stdout)
|
||||||
|
self.assertIn("--project-limits customer-service=5", result.stdout)
|
||||||
|
self.assertIn("log_file=", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIGRATION = ROOT / "plugins" / "redmine_event_outbox" / "db" / "migrate" / "001_create_event_outbox_events.rb"
|
||||||
|
|
||||||
|
|
||||||
|
class EventOutboxMigrationTest(unittest.TestCase):
|
||||||
|
def test_create_table_migration_is_idempotent_for_imported_dev_clone(self):
|
||||||
|
source = MIGRATION.read_text()
|
||||||
|
|
||||||
|
self.assertIn("table_exists?(:event_outbox_events)", source)
|
||||||
|
self.assertIn("return if table_exists?(:event_outbox_events)", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from post_import_refresh import AutomationConfig, StepResult, build_steps, write_status
|
||||||
|
|
||||||
|
|
||||||
|
class PostImportRefreshPlanTest(unittest.TestCase):
|
||||||
|
def test_dry_run_is_the_default_and_never_enables_index_writes(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
steps = build_steps(config)
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertFalse(config.apply)
|
||||||
|
self.assertIn("validate semantic index dry-run", [step.name for step in steps])
|
||||||
|
self.assertIn("semantic_index/refresh.sh", commands)
|
||||||
|
self.assertNotIn("semantic_index/refresh.sh --apply", commands)
|
||||||
|
self.assertNotIn("--force-rebuild", commands)
|
||||||
|
self.assertNotIn("systemctl enable --now semantic-index-refresh.timer", commands)
|
||||||
|
|
||||||
|
def test_plugin_reapply_happens_before_migrations_and_helpdesk_reset(self):
|
||||||
|
names = [step.name for step in build_steps(AutomationConfig())]
|
||||||
|
|
||||||
|
self.assertLess(names.index("reapply tracked plugins"), names.index("run plugin migrations"))
|
||||||
|
self.assertLess(names.index("run plugin migrations"), names.index("reset Helpdesk mail settings"))
|
||||||
|
|
||||||
|
def test_expected_plugins_are_reapplied_to_remote_redmine_tree(self):
|
||||||
|
steps = build_steps(AutomationConfig())
|
||||||
|
plugin_step = next(step for step in steps if step.name == "reapply tracked plugins")
|
||||||
|
commands = "\n".join(plugin_step.commands)
|
||||||
|
|
||||||
|
self.assertIn("plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts_helpdesk", commands)
|
||||||
|
self.assertNotIn("plugins/redmine_event_outbox/", commands)
|
||||||
|
self.assertIn("reddev@192.168.50.170:/usr/share/redmine/plugins/", commands)
|
||||||
|
|
||||||
|
def test_apply_mode_runs_mutating_validation_sequence(self):
|
||||||
|
steps = build_steps(AutomationConfig(apply=True))
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
self.assertIn("./reset_helpdesk_mail_settings.py", commands)
|
||||||
|
self.assertIn("touch tmp/restart.txt", commands)
|
||||||
|
self.assertIn("./validate_test_instance.py", commands)
|
||||||
|
|
||||||
|
def test_remote_write_steps_use_sudo_by_default(self):
|
||||||
|
commands = "\n".join(command for step in build_steps(AutomationConfig()) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("--rsync-path 'sudo rsync'", commands)
|
||||||
|
self.assertIn("sudo mkdir -p", commands)
|
||||||
|
self.assertIn("sudo chmod -R g+rwX", commands)
|
||||||
|
|
||||||
|
def test_local_mode_emits_local_commands_without_ssh(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
commands = "\n".join(command for step in build_steps(config) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertNotIn("ssh -i", commands)
|
||||||
|
self.assertNotIn("rsync-path", commands)
|
||||||
|
self.assertIn("reset_helpdesk_mail_settings.py --local", commands)
|
||||||
|
self.assertIn("validate_test_instance.py --local", commands)
|
||||||
|
self.assertNotIn("--composer-bin", commands)
|
||||||
|
self.assertIn("redmine_outbox_worker.py --local --status", commands)
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("/usr/share/redmine/plugins/", commands)
|
||||||
|
self.assertIn("cd /usr/share/redmine && RAILS_ENV=production bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
|
||||||
|
def test_local_semantic_check_is_non_blocking_without_staged_venv(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
semantic_step = next(step for step in build_steps(config) if step.name == "validate semantic index dry-run")
|
||||||
|
command = semantic_step.commands[0]
|
||||||
|
|
||||||
|
self.assertIn("test -x /opt/lanscratch/redmine-post-import/repo/.venv/bin/python", command)
|
||||||
|
self.assertIn("semantic index runtime missing; skipping dry-run", command)
|
||||||
|
self.assertIn("else", command)
|
||||||
|
|
||||||
|
def test_status_paths_default_to_lanscratch(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
|
||||||
|
self.assertEqual(Path("/opt/lanscratch/redmine-post-import/status"), config.status_dir)
|
||||||
|
|
||||||
|
def test_write_status_updates_latest_and_success_only_on_success(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = AutomationConfig(status_dir=Path(tmp))
|
||||||
|
failed = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010000Z",
|
||||||
|
status="failed",
|
||||||
|
results=[StepResult("preflight", "test -d missing", 1)],
|
||||||
|
failed_step="preflight",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((Path(tmp) / "latest.json").exists())
|
||||||
|
self.assertTrue((Path(tmp) / "runs" / "20260428T010000Z.json").exists())
|
||||||
|
self.assertFalse((Path(tmp) / "latest-success.json").exists())
|
||||||
|
self.assertEqual("failed", failed["status"])
|
||||||
|
|
||||||
|
successful = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010100Z",
|
||||||
|
status="success",
|
||||||
|
results=[StepResult("preflight", "test -d plugins", 0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_success = json.loads((Path(tmp) / "latest-success.json").read_text())
|
||||||
|
self.assertEqual(successful["run_id"], latest_success["run_id"])
|
||||||
|
self.assertEqual("success", latest_success["status"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
POST_IMPORT_SCRIPTS = [
|
||||||
|
ROOT / "post_import_refresh.py",
|
||||||
|
ROOT / "stage_post_import_payload.py",
|
||||||
|
ROOT / "reset_helpdesk_mail_settings.py",
|
||||||
|
ROOT / "validate_test_instance.py",
|
||||||
|
ROOT / "redmine_outbox_worker.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Python36CompatTest(unittest.TestCase):
|
||||||
|
def test_post_import_scripts_do_not_use_subprocess_text_keyword(self):
|
||||||
|
for path in POST_IMPORT_SCRIPTS:
|
||||||
|
with self.subTest(path=path.name):
|
||||||
|
self.assertNotIn("text=True", path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SKILL = ROOT / "skills" / "redmine-communicator"
|
||||||
|
SETUP = SKILL / "scripts" / "setup_redmcp.py"
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineCommunicatorSkillTest(unittest.TestCase):
|
||||||
|
def test_skill_files_exist_and_reference_redmcp_safety_rules(self):
|
||||||
|
skill_md = (SKILL / "SKILL.md").read_text()
|
||||||
|
reference = (SKILL / "references" / "redmcp-tools.md").read_text()
|
||||||
|
|
||||||
|
self.assertIn("redmine-communicator", skill_md)
|
||||||
|
self.assertIn("redMCP", skill_md)
|
||||||
|
self.assertIn("send_helpdesk_email=true", skill_md)
|
||||||
|
self.assertIn("redmine_send_helpdesk_response", reference)
|
||||||
|
self.assertIn("customer-visible", reference)
|
||||||
|
|
||||||
|
def test_setup_script_dry_run_prints_stdio_config(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SETUP),
|
||||||
|
"--redmine-url",
|
||||||
|
"http://redmine.example.test",
|
||||||
|
"--redmine-api-key",
|
||||||
|
"secret-key",
|
||||||
|
"--transport",
|
||||||
|
"stdio",
|
||||||
|
],
|
||||||
|
cwd=ROOT,
|
||||||
|
universal_newlines=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("mode=dry-run", result.stdout)
|
||||||
|
self.assertIn("redmcp-server.php", result.stdout)
|
||||||
|
self.assertNotIn("secret-key", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stage_post_import_payload import build_rsync_command
|
||||||
|
|
||||||
|
|
||||||
|
class StagePostImportPayloadTest(unittest.TestCase):
|
||||||
|
def test_stage_command_targets_lanscratch_and_excludes_runtime_files(self):
|
||||||
|
command = build_rsync_command(
|
||||||
|
repo_root=Path("/repo"),
|
||||||
|
target=Path("/opt/lanscratch/redmine-post-import/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/", command)
|
||||||
|
self.assertIn("/repo/plugins", command)
|
||||||
|
self.assertIn("/repo/post_import_refresh.py", command)
|
||||||
|
self.assertIn("/repo/stage_post_import_payload.py", command)
|
||||||
|
self.assertIn("--exclude .env", command)
|
||||||
|
self.assertIn("--exclude .venv", command)
|
||||||
|
self.assertIn("--exclude .cache", command)
|
||||||
|
self.assertIn("--exclude __pycache__/", command)
|
||||||
|
self.assertIn("--exclude '*.tar.gz'", command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import validate_test_instance
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateTestInstanceTest(unittest.TestCase):
|
||||||
|
def test_missing_controlled_projects_are_warn_for_daily_clone(self):
|
||||||
|
results = validate_test_instance.controlled_project_check([])
|
||||||
|
|
||||||
|
self.assertEqual("WARN", results.status)
|
||||||
|
self.assertIn("optional", results.detail)
|
||||||
|
|
||||||
|
def test_composer_validation_is_skipped_when_disabled(self):
|
||||||
|
result = validate_test_instance.check_composer(None)
|
||||||
|
|
||||||
|
self.assertEqual("WARN", result.status)
|
||||||
|
self.assertIn("skipped", result.detail)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Executable
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple Qdrant connectivity and write-path validator.
|
||||||
|
|
||||||
|
Run this from the production host to confirm Qdrant is reachable, auth works,
|
||||||
|
and a minimal create/upsert/read/delete round trip succeeds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_URLS = ("http://127.0.0.1:6333", "http://10.11.0.105:6333")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate Qdrant connectivity and basic operations.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--url",
|
||||||
|
action="append",
|
||||||
|
help=(
|
||||||
|
"Qdrant base URL to test. Repeat for multiple endpoints. "
|
||||||
|
"Defaults to QDRANT_URL if set, otherwise localhost and 10.11.0.105."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
default=os.getenv("QDRANT_API_KEY", ""),
|
||||||
|
help="Qdrant API key. Defaults to QDRANT_API_KEY env var.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-write-test",
|
||||||
|
action="store_true",
|
||||||
|
help="Only verify read-only endpoints and auth.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="HTTP timeout in seconds (default: 5).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
urls = normalized_urls(args.url)
|
||||||
|
api_key = args.api_key.strip()
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
print(f"\n== {url} ==")
|
||||||
|
try:
|
||||||
|
validate_endpoint(url, api_key=api_key, timeout=args.timeout, skip_write_test=args.skip_write_test)
|
||||||
|
print(f"[OK] Endpoint validated: {url}")
|
||||||
|
except ValidationError as exc:
|
||||||
|
failures += 1
|
||||||
|
print(f"[FAIL] {url}: {exc}")
|
||||||
|
|
||||||
|
print(f"\nSummary: {len(urls) - failures} OK, {failures} FAIL")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_urls(values: Optional[List[str]]) -> List[str]:
|
||||||
|
if values:
|
||||||
|
return [v.rstrip("/") for v in values]
|
||||||
|
env_url = os.getenv("QDRANT_URL", "").strip()
|
||||||
|
if env_url:
|
||||||
|
return [env_url.rstrip("/")]
|
||||||
|
return [u.rstrip("/") for u in DEFAULT_URLS]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_endpoint(base_url: str, api_key: str, timeout: float, skip_write_test: bool) -> None:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["api-key"] = api_key
|
||||||
|
|
||||||
|
live_text = http_text("GET", f"{base_url}/livez", headers=headers, timeout=timeout)
|
||||||
|
ensure_health_ok(live_text, "livez")
|
||||||
|
print("[OK] /livez")
|
||||||
|
|
||||||
|
ready_text = http_text("GET", f"{base_url}/readyz", headers=headers, timeout=timeout)
|
||||||
|
ensure_health_ok(ready_text, "readyz")
|
||||||
|
print("[OK] /readyz")
|
||||||
|
|
||||||
|
collections = http_json("GET", f"{base_url}/collections", headers=headers, timeout=timeout)
|
||||||
|
ensure_status_ok(collections, "collections")
|
||||||
|
count = len(collections.get("result", {}).get("collections", []))
|
||||||
|
print(f"[OK] /collections (count={count})")
|
||||||
|
|
||||||
|
if skip_write_test:
|
||||||
|
print("[OK] Write-path test skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
collection = temp_collection_name()
|
||||||
|
created = False
|
||||||
|
try:
|
||||||
|
body = {"vectors": {"size": 4, "distance": "Cosine"}}
|
||||||
|
create_result = http_json(
|
||||||
|
"PUT",
|
||||||
|
f"{base_url}/collections/{collection}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
ensure_status_ok(create_result, "create collection")
|
||||||
|
created = True
|
||||||
|
print(f"[OK] Created temp collection: {collection}")
|
||||||
|
|
||||||
|
point = {"id": 1, "vector": [0.1, 0.2, 0.3, 0.4], "payload": {"check": "qdrant-smoke"}}
|
||||||
|
upsert_result = http_json(
|
||||||
|
"PUT",
|
||||||
|
f"{base_url}/collections/{collection}/points?wait=true",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
body={"points": [point]},
|
||||||
|
)
|
||||||
|
ensure_status_ok(upsert_result, "upsert point")
|
||||||
|
print("[OK] Upserted test point")
|
||||||
|
|
||||||
|
fetch_result = http_json(
|
||||||
|
"POST",
|
||||||
|
f"{base_url}/collections/{collection}/points",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
body={"ids": [1], "with_payload": True, "with_vector": True},
|
||||||
|
)
|
||||||
|
ensure_status_ok(fetch_result, "fetch point")
|
||||||
|
points = fetch_result.get("result", [])
|
||||||
|
if not points:
|
||||||
|
raise ValidationError("fetch point returned empty result")
|
||||||
|
if points[0].get("id") != 1:
|
||||||
|
raise ValidationError(f"unexpected point id in fetch response: {points[0].get('id')!r}")
|
||||||
|
print("[OK] Fetched test point")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if created:
|
||||||
|
try:
|
||||||
|
delete_result = http_json(
|
||||||
|
"DELETE",
|
||||||
|
f"{base_url}/collections/{collection}?timeout=30",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
ensure_status_ok(delete_result, "delete collection")
|
||||||
|
print(f"[OK] Deleted temp collection: {collection}")
|
||||||
|
except ValidationError as exc:
|
||||||
|
print(f"[WARN] Could not delete temp collection {collection}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def temp_collection_name() -> str:
|
||||||
|
stamp = time.strftime("%Y%m%d%H%M%S")
|
||||||
|
host = socket.gethostname().replace("_", "-").replace(".", "-")
|
||||||
|
return f"qdrant_smoke_{host}_{stamp}_{os.getpid()}"
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(method: str, url: str, headers: Dict[str, str], timeout: float, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
request = urllib.request.Request(url=url, method=method, data=data, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
payload = response.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(payload)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValidationError(f"non-JSON response for {method} {url}: {payload[:200]!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def http_text(method: str, url: str, headers: Dict[str, str], timeout: float) -> str:
|
||||||
|
request = urllib.request.Request(url=url, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read().decode("utf-8", errors="replace").strip()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_status_ok(payload: Dict[str, Any], context: str) -> None:
|
||||||
|
if payload.get("status") != "ok":
|
||||||
|
raise ValidationError(f"{context} returned non-ok payload: {payload}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_health_ok(payload_text: str, context: str) -> None:
|
||||||
|
text = payload_text.lower()
|
||||||
|
if "passed" in text or text == "ok" or "ready" in text:
|
||||||
|
return
|
||||||
|
raise ValidationError(f"{context} returned unexpected payload: {payload_text!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+72
-44
@@ -6,15 +6,12 @@ post-import reset steps. It reports whether the test instance looks ready for
|
|||||||
Helpdesk and redMCP testing without changing remote state.
|
Helpdesk and redMCP testing without changing remote state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,20 +23,30 @@ DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
|||||||
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CheckResult:
|
class CheckResult:
|
||||||
status: str
|
def __init__(self, status, name, detail):
|
||||||
name: str
|
self.status = status
|
||||||
detail: str
|
self.name = name
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RemoteRedmine:
|
class RemoteRedmine:
|
||||||
ssh_host: str
|
def __init__(self, ssh_host, ssh_key, remote_redmine, local=False):
|
||||||
ssh_key: Path
|
self.ssh_host = ssh_host
|
||||||
remote_redmine: str
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.local = local
|
||||||
|
|
||||||
def ssh(self, remote_command: str) -> subprocess.CompletedProcess[str]:
|
def ssh(self, remote_command):
|
||||||
|
if self.local:
|
||||||
|
return subprocess.run(
|
||||||
|
remote_command,
|
||||||
|
shell=True,
|
||||||
|
universal_newlines=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[
|
[
|
||||||
"ssh",
|
"ssh",
|
||||||
@@ -50,24 +57,26 @@ class RemoteRedmine:
|
|||||||
self.ssh_host,
|
self.ssh_host,
|
||||||
remote_command,
|
remote_command,
|
||||||
],
|
],
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
|
def mysql_json_lines(self, sql):
|
||||||
result = self.mysql(sql)
|
result = self.mysql(sql)
|
||||||
rows: list[dict[str, Any]] = []
|
rows = []
|
||||||
for line in result.splitlines():
|
for line in result.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
|
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mysql(self, sql: str) -> str:
|
def mysql(self, sql):
|
||||||
result = subprocess.run(
|
command = self._mysql_runner_command()
|
||||||
[
|
shell = True
|
||||||
|
if not self.local:
|
||||||
|
command = [
|
||||||
"ssh",
|
"ssh",
|
||||||
"-i",
|
"-i",
|
||||||
str(self.ssh_key),
|
str(self.ssh_key),
|
||||||
@@ -75,18 +84,22 @@ class RemoteRedmine:
|
|||||||
"IdentitiesOnly=yes",
|
"IdentitiesOnly=yes",
|
||||||
self.ssh_host,
|
self.ssh_host,
|
||||||
self._mysql_runner_command(),
|
self._mysql_runner_command(),
|
||||||
],
|
]
|
||||||
|
shell = False
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
input=sql,
|
input=sql,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
shell=shell,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(result.stderr.strip() or "Remote MySQL command failed.")
|
raise RuntimeError(result.stderr.strip() or "Remote MySQL command failed.")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
def _mysql_runner_command(self) -> str:
|
def _mysql_runner_command(self):
|
||||||
ruby = (
|
ruby = (
|
||||||
"require 'yaml'; "
|
"require 'yaml'; "
|
||||||
"c = YAML.load_file('config/database.yml')['production']; "
|
"c = YAML.load_file('config/database.yml')['production']; "
|
||||||
@@ -99,18 +112,23 @@ class RemoteRedmine:
|
|||||||
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Read-only checks for the Redmine LAN test instance.")
|
parser = argparse.ArgumentParser(description="Read-only checks for the Redmine LAN test instance.")
|
||||||
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
||||||
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
|
||||||
|
parser.add_argument("--local", action="store_true", help="Validate local Redmine paths/database instead of using SSH.")
|
||||||
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
|
||||||
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
||||||
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
||||||
parser.add_argument("--composer-bin", default=os.getenv("COMPOSER_BIN", "composer"))
|
parser.add_argument(
|
||||||
|
"--composer-bin",
|
||||||
|
default=os.getenv("COMPOSER_BIN"),
|
||||||
|
help="Optional Composer binary or composer.phar for redMCP validation.",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
|
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local)
|
||||||
checks: list[CheckResult] = []
|
checks = []
|
||||||
|
|
||||||
checks.extend(check_remote_basics(remote))
|
checks.extend(check_remote_basics(remote))
|
||||||
checks.extend(check_mailpit_connectivity(remote, args.mailpit_host))
|
checks.extend(check_mailpit_connectivity(remote, args.mailpit_host))
|
||||||
@@ -127,8 +145,8 @@ def main() -> int:
|
|||||||
return 1 if failures else 0
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
def check_remote_basics(remote: RemoteRedmine) -> list[CheckResult]:
|
def check_remote_basics(remote):
|
||||||
results: list[CheckResult] = []
|
results = []
|
||||||
result = remote.ssh("printf ok")
|
result = remote.ssh("printf ok")
|
||||||
if result.returncode == 0 and result.stdout == "ok":
|
if result.returncode == 0 and result.stdout == "ok":
|
||||||
results.append(CheckResult("OK", "SSH", f"connected to {remote.ssh_host}"))
|
results.append(CheckResult("OK", "SSH", f"connected to {remote.ssh_host}"))
|
||||||
@@ -155,7 +173,7 @@ def check_remote_basics(remote: RemoteRedmine) -> list[CheckResult]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckResult]:
|
def check_mailpit_connectivity(remote, host):
|
||||||
results = [
|
results = [
|
||||||
tcp_check("Mailpit HTTP from local", host, 8025),
|
tcp_check("Mailpit HTTP from local", host, 8025),
|
||||||
tcp_check("Mailpit SMTP from local", host, 1025),
|
tcp_check("Mailpit SMTP from local", host, 1025),
|
||||||
@@ -181,7 +199,7 @@ def check_mailpit_connectivity(remote: RemoteRedmine, host: str) -> list[CheckRe
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[CheckResult]:
|
def check_files_permissions(remote, files_root):
|
||||||
command = (
|
command = (
|
||||||
"ruby -e "
|
"ruby -e "
|
||||||
+ shell_quote(
|
+ shell_quote(
|
||||||
@@ -208,8 +226,8 @@ def check_files_permissions(remote: RemoteRedmine, files_root: str) -> list[Chec
|
|||||||
return [CheckResult("OK", "Attachment directory permissions", detail)]
|
return [CheckResult("OK", "Attachment directory permissions", detail)]
|
||||||
|
|
||||||
|
|
||||||
def check_database_state(remote: RemoteRedmine, mailpit_host: str) -> list[CheckResult]:
|
def check_database_state(remote, mailpit_host):
|
||||||
results: list[CheckResult] = []
|
results = []
|
||||||
try:
|
try:
|
||||||
projects = remote.mysql_json_lines(
|
projects = remote.mysql_json_lines(
|
||||||
"""
|
"""
|
||||||
@@ -219,12 +237,7 @@ WHERE identifier IN ('fud-helpdesk', 'fud-nohelpdesk')
|
|||||||
ORDER BY identifier;
|
ORDER BY identifier;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
found = {project["identifier"] for project in projects}
|
results.append(controlled_project_check(projects))
|
||||||
missing = {"fud-helpdesk", "fud-nohelpdesk"} - found
|
|
||||||
if missing:
|
|
||||||
results.append(CheckResult("FAIL", "Controlled test projects", "missing " + ", ".join(sorted(missing))))
|
|
||||||
else:
|
|
||||||
results.append(CheckResult("OK", "Controlled test projects", ", ".join(sorted(found))))
|
|
||||||
|
|
||||||
settings_rows = remote.mysql_json_lines(helpdesk_settings_sql())
|
settings_rows = remote.mysql_json_lines(helpdesk_settings_sql())
|
||||||
failures = helpdesk_setting_failures(settings_rows, mailpit_host)
|
failures = helpdesk_setting_failures(settings_rows, mailpit_host)
|
||||||
@@ -237,7 +250,7 @@ ORDER BY identifier;
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def helpdesk_settings_sql() -> str:
|
def helpdesk_settings_sql():
|
||||||
return """
|
return """
|
||||||
SELECT HEX(CAST(JSON_OBJECT(
|
SELECT HEX(CAST(JSON_OBJECT(
|
||||||
'identifier', p.identifier,
|
'identifier', p.identifier,
|
||||||
@@ -264,7 +277,7 @@ ORDER BY p.identifier;
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) -> list[str]:
|
def helpdesk_setting_failures(rows, mailpit_host):
|
||||||
expected = {
|
expected = {
|
||||||
"protocol": "pop3",
|
"protocol": "pop3",
|
||||||
"host": mailpit_host,
|
"host": mailpit_host,
|
||||||
@@ -281,7 +294,7 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
|
|||||||
"smtp_ssl": "0",
|
"smtp_ssl": "0",
|
||||||
"smtp_tls": "0",
|
"smtp_tls": "0",
|
||||||
}
|
}
|
||||||
failures: list[str] = []
|
failures = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
for key, value in expected.items():
|
for key, value in expected.items():
|
||||||
if row.get(key) != value:
|
if row.get(key) != value:
|
||||||
@@ -289,7 +302,22 @@ def helpdesk_setting_failures(rows: list[dict[str, Any]], mailpit_host: str) ->
|
|||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
|
||||||
def check_composer(composer_bin: str) -> CheckResult:
|
def controlled_project_check(projects):
|
||||||
|
found = {project["identifier"] for project in projects}
|
||||||
|
missing = {"fud-helpdesk", "fud-nohelpdesk"} - found
|
||||||
|
if missing:
|
||||||
|
return CheckResult(
|
||||||
|
"WARN",
|
||||||
|
"Controlled test projects",
|
||||||
|
"optional smoke-test project(s) missing after production clone: "
|
||||||
|
+ ", ".join(sorted(missing)),
|
||||||
|
)
|
||||||
|
return CheckResult("OK", "Controlled test projects", ", ".join(sorted(found)))
|
||||||
|
|
||||||
|
|
||||||
|
def check_composer(composer_bin):
|
||||||
|
if not composer_bin:
|
||||||
|
return CheckResult("WARN", "Composer validation", "skipped; pass --composer-bin to enable")
|
||||||
composer_path = Path(composer_bin)
|
composer_path = Path(composer_bin)
|
||||||
composer_on_path = shutil.which(composer_bin)
|
composer_on_path = shutil.which(composer_bin)
|
||||||
if composer_on_path is None and not composer_path.exists():
|
if composer_on_path is None and not composer_path.exists():
|
||||||
@@ -302,7 +330,7 @@ def check_composer(composer_bin: str) -> CheckResult:
|
|||||||
command = [php, composer_bin, "validate", "--working-dir=redMCP"]
|
command = [php, composer_bin, "validate", "--working-dir=redMCP"]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
text=True,
|
universal_newlines=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -312,7 +340,7 @@ def check_composer(composer_bin: str) -> CheckResult:
|
|||||||
return CheckResult("FAIL", "Composer validation", (result.stdout + result.stderr).strip())
|
return CheckResult("FAIL", "Composer validation", (result.stdout + result.stderr).strip())
|
||||||
|
|
||||||
|
|
||||||
def tcp_check(name: str, host: str, port: int) -> CheckResult:
|
def tcp_check(name, host, port):
|
||||||
try:
|
try:
|
||||||
with socket.create_connection((host, port), timeout=5):
|
with socket.create_connection((host, port), timeout=5):
|
||||||
return CheckResult("OK", name, f"{host}:{port}")
|
return CheckResult("OK", name, f"{host}:{port}")
|
||||||
@@ -320,7 +348,7 @@ def tcp_check(name: str, host: str, port: int) -> CheckResult:
|
|||||||
return CheckResult("FAIL", name, f"{host}:{port} {exc.__class__.__name__}: {exc}")
|
return CheckResult("FAIL", name, f"{host}:{port} {exc.__class__.__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def shell_quote(value: str) -> str:
|
def shell_quote(value):
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user