Compare commits

..

16 Commits

Author SHA1 Message Date
Jason Thistlethwaite bd26c8894f Add production rollout tooling and semantic index ops docs
Capture the production plugin rollout workflow and Qdrant validation steps so operations stay repeatable. Also harden redMCP stdio/schema compatibility to keep diverse MCP clients and validators working.
2026-05-06 22:18:02 -04:00
Jason Thistlethwaite 1f4c3d35ef Convert markdown links to repo-relative paths
Replace absolute local filesystem markdown links with repository-relative targets and drop local :line suffixes so links resolve consistently across environments.
2026-05-06 05:06:47 -04:00
Jason Thistlethwaite 38e06da3a6 Update cleanup notes with latest redMCP progress
Record the recent handoff and redMCP commits, refresh intentionally untracked file notes, and capture the latest redMCP lint/test validation commands and results.
2026-05-06 05:02:14 -04:00
Jason Thistlethwaite a7d23cd79a Resolve human project names in MCP project_id args
Auto-resolve project_id values that look like human names to canonical project identifiers when there is a clear match. Return actionable guidance with candidate slugs when ambiguous, and cover the behavior with structure tests and docs updates.
2026-05-06 05:00:45 -04:00
Jason Thistlethwaite 22c8e915e9 Sanitize noisy MCP text fields by default
Clean control and invisible junk from tool result text fields to reduce token waste while preserving readable Unicode. Add an MCP_TEXT_SANITIZATION toggle and regression tests for enabled and disabled behavior.
2026-05-06 02:31:25 -04:00
Jason Thistlethwaite def9084981 Handoff notes for next agent/workflow 2026-05-06 00:59:13 -04:00
Jason Thistlethwaite 42fc8318fa Add redmine-communicator skill docs and setup tooling 2026-05-04 09:50:17 -04:00
Jason Thistlethwaite 4c931bae1a Expand redMCP safe issue operations and HTTP handling 2026-05-04 09:50:11 -04:00
Jason Thistlethwaite b305544f63 Add semantic-index service, deployment assets, and tests 2026-05-04 09:50:03 -04:00
Jason Thistlethwaite faad70872b Automate post-import refresh and validation workflow 2026-05-04 09:49:47 -04:00
Jason Thistlethwaite fba494dada Add Helpdesk issue API include serializer 2026-05-04 09:49:42 -04:00
Jason Thistlethwaite ac284d9dc9 Add README.md to dist folder explaining it is for distribution copies of this project 2026-05-04 08:25:40 -04:00
Jason Thistlethwaite d8f17ff7e7 Add friendly redMCP query options 2026-04-25 04:12:01 +00:00
Jason Thistlethwaite a25361f5fc Add redMCP user and membership tools 2026-04-25 03:13:35 +00:00
Jason Thistlethwaite d54319a5bb Improve redMCP server operations 2026-04-25 02:46:58 +00:00
Jason Thistlethwaite 05c1a4bc97 Add redMCP Streamable HTTP server 2026-04-25 02:23:48 +00:00
93 changed files with 11652 additions and 215 deletions
+2
View File
@@ -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
+11 -11
View File
@@ -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:
+95
View File
@@ -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.
+41 -32
View File
@@ -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
+106
View File
@@ -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.
+183
View File
@@ -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
+449
View File
@@ -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 "$@"
+3
View File
@@ -0,0 +1,3 @@
## Distribution Files
This folder contains packaged copies of the plugins and scripts built by this project for easier distribution.
@@ -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`.
+153
View File
@@ -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.
+78 -8
View File
@@ -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
+353
View File
@@ -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.
+98
View File
@@ -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.
+86
View File
@@ -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
+467
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+369
View File
@@ -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] : [];
}
}
+43
View File
@@ -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);
}
}
+916
View File
@@ -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;
}
}
+82
View File
@@ -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;
}
}
+193
View File
@@ -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,
],
];
}
}
+90
View File
@@ -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
View File
@@ -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
*/ */
+31
View File
@@ -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();
+24
View File
@@ -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);
+188
View File
@@ -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;
}
+15 -1
View File
@@ -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
{ {
+164
View File
@@ -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);
}
+142
View File
@@ -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();
+647
View File
@@ -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();
+6 -1
View File
@@ -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"
+57 -53
View File
@@ -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,24 +50,29 @@ 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 = [ command = self._mysql_runner_command()
"ssh", shell = True
"-i", if not self.local:
str(self.ssh_key), command = [
"-o", "ssh",
"IdentitiesOnly=yes", "-i",
self.ssh_host, str(self.ssh_key),
self._mysql_runner_command(), "-o",
] "IdentitiesOnly=yes",
self.ssh_host,
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]}"
+37 -33
View File
@@ -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,24 +71,29 @@ 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 = [ command = self._mysql_runner_command()
"ssh", shell = True
"-i", if not self.local:
str(self.ssh_key), command = [
"-o", "ssh",
"IdentitiesOnly=yes", "-i",
self.ssh_host, str(self.ssh_key),
self._mysql_runner_command(), "-o",
] "IdentitiesOnly=yes",
self.ssh_host,
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("'", "'\"'\"'") + "'"
+19
View File
@@ -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.
+12
View File
@@ -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
+271
View File
@@ -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`.
+12
View File
@@ -0,0 +1,12 @@
"""Local semantic index service for Redmine and future source adapters."""
__all__ = [
"config",
"embeddings",
"ingest",
"mcp",
"models",
"qdrant_store",
"redmine",
"search",
]
+206
View File
@@ -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()
+153
View File
@@ -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()
+25
View File
@@ -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]
+72
View File
@@ -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
+64
View File
@@ -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")),
)
+64
View File
@@ -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")
+100
View File
@@ -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())
+292
View File
@@ -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() + "..."
+80
View File
@@ -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)
+100
View File
@@ -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],
}
+219
View File
@@ -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}
+243
View File
@@ -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()
+225
View File
@@ -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)
+107
View File
@@ -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"
+61
View 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
+71
View File
@@ -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"
+31
View File
@@ -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 wont 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]'
+78
View File
@@ -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
View File
@@ -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())
+66
View File
@@ -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
+115
View File
@@ -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()
+182
View File
@@ -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()
+37
View File
@@ -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()
+87
View File
@@ -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()
+138
View File
@@ -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()
+46
View File
@@ -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()
+394
View File
@@ -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()
+58
View File
@@ -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()
+187
View File
@@ -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()
+102
View File
@@ -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()
+277
View File
@@ -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()
+85
View File
@@ -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()
+18
View File
@@ -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()
+112
View File
@@ -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()
+23
View File
@@ -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()
+49
View File
@@ -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()
+26
View File
@@ -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()
+21
View File
@@ -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()
+208
View File
@@ -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())
+78 -50
View File
@@ -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,43 +57,49 @@ 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):
command = self._mysql_runner_command()
shell = True
if not self.local:
command = [
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_command(),
]
shell = False
result = subprocess.run( result = subprocess.run(
[ command,
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_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("'", "'\"'\"'") + "'"